Skip to content

Commit

Permalink
rfc/issue 1167 content as data (#1266)
Browse files Browse the repository at this point in the history
* initial implementation of content collections with rich frontmatter support

* add test cases for collections and prerendering

* rename interpolateFrontmatter to activeFrontmatter

* refactor id and lable graph properties

* refactor graph title behavior

* full graph and graphql plugin refactoring

* update website for new graph refactoring

* make sure active frontmatter is enabled for title substition

* restore header nav ordering

* comment cleanup

* eslint ignore

* support multiple import maps

* add id to graph and refactor usage

* refactoring pagePath to pageHref

* update test cases

* rename data/queries.js to client.js

* handle default title from graph and provide default graph content as data in layouts

* handle default title from graph and provide default graph content as data in layouts

* refactor content as data handling to its own plugin

* refactor for better windows interop

* misc refactoring for active frontmatter

* refactor outputPath to outputHref

* add labels for custom side nav output

* refresh content as data and GraphQL docs

* update for new docs redirects

* filter hidden files and improve unsupported page format detection message

* opt-on content as data config and misc refactoring and TODOs

* rename test case

* update docs for content as data config option and patterns

* conslidate content as data into dev server

* misc refactoring

* content as data import map test cases

* fix selectors in test cases

* rename test cases

* consolidate configuration options and update docs

* rename test cases
  • Loading branch information
thescientist13 authored Oct 18, 2024
1 parent 76ff3a1 commit 0a96e8e
Show file tree
Hide file tree
Showing 187 changed files with 2,666 additions and 2,617 deletions.
2 changes: 1 addition & 1 deletion greenwood.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default {
workspace: new URL('./www/', import.meta.url),
optimization: 'inline',
staticRouter: true,
interpolateFrontmatter: true,
activeContent: true,
plugins: [
greenwoodPluginGraphQL(),
greenwoodPluginPolyfills({
Expand Down
17 changes: 16 additions & 1 deletion netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,19 @@

[[redirects]]
from = "/docs/tech-stack/"
to = "/about/tech-stack/"
to = "/about/tech-stack/"

[[redirects]]
from = "/docs/menus/:splat"
to = "/docs/data/"
status = 200

[[redirects]]
from = "/docs/data/#external-sources"
to = "/docs/data/#pages-data"
status = 200

[[redirects]]
from = "/docs/data/#internal-sources"
to = "/docs/data/#pages-data"
status = 200
20 changes: 16 additions & 4 deletions packages/cli/src/commands/build.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { bundleCompilation } from '../lifecycles/bundle.js';
import { checkResourceExists } from '../lib/resource-utils.js';
import { copyAssets } from '../lifecycles/copy.js';
import { getDevServer } from '../lifecycles/serve.js';
import fs from 'fs/promises';
import { preRenderCompilationWorker, preRenderCompilationCustom, staticRenderCompilation } from '../lifecycles/prerender.js';
import { ServerInterface } from '../lib/server-interface.js';
Expand All @@ -10,25 +11,26 @@ const runProductionBuild = async (compilation) => {
return new Promise(async (resolve, reject) => {

try {
const { prerender } = compilation.config;
const { prerender, activeContent, plugins } = compilation.config;
const outputDir = compilation.context.outputDir;
const prerenderPlugin = compilation.config.plugins.find(plugin => plugin.type === 'renderer')
? compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider(compilation)
: {};
const adapterPlugin = compilation.config.plugins.find(plugin => plugin.type === 'adapter')
? compilation.config.plugins.find(plugin => plugin.type === 'adapter').provider(compilation)
: null;
const shouldPrerender = prerender || prerenderPlugin.prerender;

if (!await checkResourceExists(outputDir)) {
await fs.mkdir(outputDir, {
recursive: true
});
}

if (prerender || prerenderPlugin.prerender) {
// start any servers if needed
if (shouldPrerender || (activeContent && shouldPrerender)) {
// start any of the user's server plugins if needed
const servers = [...compilation.config.plugins.filter((plugin) => {
return plugin.type === 'server';
return plugin.type === 'server' && !plugin.isGreenwoodDefaultPlugin;
}).map((plugin) => {
const provider = plugin.provider(compilation);

Expand All @@ -39,6 +41,16 @@ const runProductionBuild = async (compilation) => {
return provider;
})];

if (activeContent) {
(await getDevServer({
...compilation,
// prune for the content as data plugin and start the dev server with only that plugin enabled
plugins: [plugins.find(plugin => plugin.name === 'plugin-active-content')]
})).listen(compilation.config.devServer.port, () => {
console.info('Initializing active content...');
});
}

await Promise.all(servers.map(async (server) => {
await server.start();

Expand Down
43 changes: 20 additions & 23 deletions packages/cli/src/config/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ function greenwoodSyncSsrEntryPointsOutputPaths(compilation) {
name: 'greenwood-sync-ssr-pages-entry-point-output-paths',
generateBundle(options, bundle) {
const { basePath } = compilation.config;
const { scratchDir } = compilation.context;
const { scratchDir, outputDir } = compilation.context;

// map rollup bundle names back to original SSR pages for syncing input <> output bundle names
Object.keys(bundle).forEach((key) => {
Expand All @@ -178,7 +178,7 @@ function greenwoodSyncSsrEntryPointsOutputPaths(compilation) {

compilation.graph.forEach((page, idx) => {
if (page.route === route) {
compilation.graph[idx].outputPath = key;
compilation.graph[idx].outputHref = new URL(`./${key}`, outputDir).href;
}
});
}
Expand All @@ -192,7 +192,7 @@ function greenwoodSyncApiRoutesOutputPath(compilation) {
name: 'greenwood-sync-api-routes-output-paths',
generateBundle(options, bundle) {
const { basePath } = compilation.config;
const { apisDir } = compilation.context;
const { apisDir, outputDir } = compilation.context;

// map rollup bundle names back to original SSR pages for syncing input <> output bundle names
Object.keys(bundle).forEach((key) => {
Expand All @@ -206,7 +206,7 @@ function greenwoodSyncApiRoutesOutputPath(compilation) {

compilation.manifest.apis.set(route, {
...api,
outputPath: `/api/${key}`
outputHref: new URL(`./api/${key}`, outputDir).href
});
}
}
Expand Down Expand Up @@ -353,8 +353,9 @@ function greenwoodImportMetaUrl(compilation) {
if (`${compilation.context.apisDir.pathname}${idAssetName}`.indexOf(normalizedId) >= 0) {
for (const entry of compilation.manifest.apis.keys()) {
const apiRoute = compilation.manifest.apis.get(entry);
const pagePath = apiRoute.pageHref.replace(`${compilation.context.pagesDir}api/`, '');

if (normalizedId.endsWith(apiRoute.path)) {
if (normalizedId.endsWith(pagePath)) {
const assets = apiRoute.assets || [];

assets.push(assetUrl.url.href);
Expand Down Expand Up @@ -643,21 +644,21 @@ const getRollupConfigForBrowserScripts = async (compilation) => {
};

const getRollupConfigForApiRoutes = async (compilation) => {
const { outputDir, pagesDir, apisDir } = compilation.context;
const { outputDir } = compilation.context;

return [...compilation.manifest.apis.values()]
.map(api => normalizePathnameForWindows(new URL(`.${api.path}`, pagesDir)))
.map((filepath) => {
// account for windows pathname shenanigans by "casting" filepath to a URL first
const ext = filepath.split('.').pop();
const entryName = new URL(`file://${filepath}`).pathname.replace(apisDir.pathname, '').replace(/\//g, '-').replace(`.${ext}`, '');
.map((api) => {
const { id, pageHref } = api;

return { id, inputPath: normalizePathnameForWindows(new URL(pageHref)) };
})
.map(({ id, inputPath }) => {
return {
input: filepath,
input: inputPath,
output: {
dir: `${normalizePathnameForWindows(outputDir)}/api`,
entryFileNames: `${entryName}.js`,
chunkFileNames: `${entryName}.[hash].js`
entryFileNames: `${id}.js`,
chunkFileNames: `${id}.[hash].js`
},
plugins: [
greenwoodResourceLoader(compilation),
Expand Down Expand Up @@ -696,20 +697,16 @@ const getRollupConfigForApiRoutes = async (compilation) => {
});
};

const getRollupConfigForSsrPages = async (compilation, input) => {
const getRollupConfigForSsrPages = async (compilation, inputs) => {
const { outputDir } = compilation.context;

return input.map((filepath) => {
const ext = filepath.split('.').pop();
// account for windows pathname shenanigans by "casting" filepath to a URL first
const entryName = new URL(`file://${filepath}`).pathname.replace(compilation.context.scratchDir.pathname, '').replace('/', '-').replace(`.${ext}`, '');

return inputs.map(({ id, inputPath }) => {
return {
input: filepath,
input: inputPath,
output: {
dir: normalizePathnameForWindows(outputDir),
entryFileNames: `${entryName}.route.js`,
chunkFileNames: `${entryName}.route.chunk.[hash].js`
entryFileNames: `${id}.route.js`,
chunkFileNames: `${id}.route.chunk.[hash].js`
},
plugins: [
greenwoodResourceLoader(compilation),
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/data/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const CONTENT_STATE = globalThis.__CONTENT_AS_DATA_STATE__ ?? false; // eslint-disable-line no-underscore-dangle
const PORT = globalThis?.__CONTENT_SERVER__?.PORT ?? 1985; // eslint-disable-line no-underscore-dangle
const BASE_PATH = globalThis?.__GWD_BASE_PATH__ ?? ''; // eslint-disable-line no-underscore-dangle

async function getContentAsData(key = '') {
return CONTENT_STATE
? await fetch(`${window.location.origin}${BASE_PATH}/data-${key.replace(/\//g, '_')}.json`)
.then(resp => resp.json())
: await fetch(`http://localhost:${PORT}${BASE_PATH}/___graph.json`, { headers: { 'X-CONTENT-KEY': key } })
.then(resp => resp.json());
}

async function getContent() {
return await getContentAsData('graph');
}

async function getContentByCollection(collection = '') {
return await getContentAsData(`collection-${collection}`);
}

async function getContentByRoute(route = '') {
return await getContentAsData(`route-${route}`);
}

export { getContent, getContentByCollection, getContentByRoute };
23 changes: 23 additions & 0 deletions packages/cli/src/lib/content-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const activeFrontmatterKeys = ['route', 'label', 'title', 'id'];

function cleanContentCollection(collection = []) {
return collection.map((page) => {
let prunedPage = {};

Object.keys(page).forEach((key) => {
if ([...activeFrontmatterKeys, 'data'].includes(key)) {
prunedPage[key] = page[key];
}
});

return {
...prunedPage,
title: prunedPage.title || prunedPage.label
};
});
}

export {
activeFrontmatterKeys,
cleanContentCollection
};
40 changes: 22 additions & 18 deletions packages/cli/src/lib/layout-utils.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
/* eslint-disable complexity */
import fs from 'fs/promises';
import htmlparser from 'node-html-parser';
import { checkResourceExists } from './resource-utils.js';
import { Worker } from 'worker_threads';

async function getCustomPageLayoutsFromPlugins(compilation, layoutName) {
// TODO confirm context plugins work for SSR
// TODO support context plugins for more than just HTML files
const contextPlugins = compilation.config.plugins.filter((plugin) => {
return plugin.type === 'context';
}).map((plugin) => {
Expand All @@ -30,10 +29,10 @@ async function getCustomPageLayoutsFromPlugins(compilation, layoutName) {
return customLayoutLocations;
}

async function getPageLayout(filePath, compilation, layout) {
async function getPageLayout(pageHref = '', compilation, layout) {
const { config, context } = compilation;
const { layoutsDir, userLayoutsDir, pagesDir, projectDirectory } = context;
const filePathUrl = new URL(`${filePath}`, projectDirectory);
const { layoutsDir, userLayoutsDir, pagesDir } = context;
const filePathUrl = pageHref && pageHref !== '' ? new URL(pageHref) : pageHref;
const customPageFormatPlugins = config.plugins
.filter(plugin => plugin.type === 'resource' && !plugin.isGreenwoodDefaultPlugin)
.map(plugin => plugin.provider(compilation));
Expand All @@ -43,13 +42,13 @@ async function getPageLayout(filePath, compilation, layout) {
&& await customPageFormatPlugins[0].shouldServe(filePathUrl);
const customPluginDefaultPageLayouts = await getCustomPageLayoutsFromPlugins(compilation, 'page');
const customPluginPageLayouts = await getCustomPageLayoutsFromPlugins(compilation, layout);
const extension = filePath.split('.').pop();
const is404Page = filePath.startsWith('404') && extension === 'html';
const extension = pageHref?.split('.')?.pop();
const is404Page = pageHref?.endsWith('404.html') && extension === 'html';
const hasCustomStaticLayout = await checkResourceExists(new URL(`./${layout}.html`, userLayoutsDir));
const hasCustomDynamicLayout = await checkResourceExists(new URL(`./${layout}.js`, userLayoutsDir));
const hasPageLayout = await checkResourceExists(new URL('./page.html', userLayoutsDir));
const hasCustom404Page = await checkResourceExists(new URL('./404.html', pagesDir));
const isHtmlPage = extension === 'html' && await checkResourceExists(new URL(`./${filePath}`, projectDirectory));
const isHtmlPage = extension === 'html' && await checkResourceExists(new URL(pageHref));
let contents;

if (layout && (customPluginPageLayouts.length > 0 || hasCustomStaticLayout)) {
Expand Down Expand Up @@ -108,11 +107,11 @@ async function getPageLayout(filePath, compilation, layout) {
}

/* eslint-disable-next-line complexity */
async function getAppLayout(pageLayoutContents, compilation, customImports = [], frontmatterTitle) {
async function getAppLayout(pageLayoutContents, compilation, customImports = [], matchingRoute) {
const activeFrontmatterTitleKey = '${globalThis.page.title}';
const enableHud = compilation.config.devServer.hud;
const { layoutsDir, userLayoutsDir } = compilation.context;
const userStaticAppLayoutUrl = new URL('./app.html', userLayoutsDir);
// TODO support more than just .js files
const userDynamicAppLayoutUrl = new URL('./app.js', userLayoutsDir);
const userHasStaticAppLayout = await checkResourceExists(userStaticAppLayoutUrl);
const userHasDynamicAppLayout = await checkResourceExists(userDynamicAppLayoutUrl);
Expand Down Expand Up @@ -193,20 +192,25 @@ async function getAppLayout(pageLayoutContents, compilation, customImports = [],
const appBody = appRoot.querySelector('body') ? appRoot.querySelector('body').innerHTML : '';
const pageBody = pageRoot && pageRoot.querySelector('body') ? pageRoot.querySelector('body').innerHTML : '';
const pageTitle = pageRoot && pageRoot.querySelector('head title');
const hasInterpolatedFrontmatter = pageTitle && pageTitle.rawText.indexOf('${globalThis.page.title}') >= 0
|| appTitle && appTitle.rawText.indexOf('${globalThis.page.title}') >= 0;
const hasActiveFrontmatterTitle = compilation.config.activeContent && (pageTitle && pageTitle.rawText.indexOf(activeFrontmatterTitleKey) >= 0
|| appTitle && appTitle.rawText.indexOf(activeFrontmatterTitleKey) >= 0);
let title;

const title = hasInterpolatedFrontmatter // favor frontmatter interpolation first
? pageTitle && pageTitle.rawText
if (hasActiveFrontmatterTitle) {
const text = pageTitle && pageTitle.rawText.indexOf(activeFrontmatterTitleKey) >= 0
? pageTitle.rawText
: appTitle.rawText
: frontmatterTitle // otherwise, work in order of specificity from page -> page layout -> app layout
? frontmatterTitle
: appTitle.rawText;

title = text.replace(activeFrontmatterTitleKey, matchingRoute.title || matchingRoute.label);
} else {
title = matchingRoute.title
? matchingRoute.title
: pageTitle && pageTitle.rawText
? pageTitle.rawText
: appTitle && appTitle.rawText
? appTitle.rawText
: 'My App';
: matchingRoute.label;
}

const mergedHtml = pageRoot && pageRoot.querySelector('html').rawAttrs !== ''
? `<html ${pageRoot.querySelector('html').rawAttrs}>`
Expand Down
36 changes: 26 additions & 10 deletions packages/cli/src/lib/walker-package-ranger.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,17 +215,33 @@ async function walkPackageJson(packageJson = {}) {
return importMap;
}

function mergeImportMap(html = '', map = {}) {
// es-modules-shims breaks on dangling commas in an importMap :/
const danglingComma = html.indexOf('"imports": {}') > 0 ? '' : ',';
const importMap = JSON.stringify(map).replace('}', '').replace('{', '');

const merged = html.replace('"imports": {', `
"imports": {
${importMap}${danglingComma}
`);
function mergeImportMap(html = '', map = {}, shouldShim = false) {
const importMapType = shouldShim ? 'importmap-shim' : 'importmap';
const hasImportMap = html.indexOf(`script type="${importMapType}"`) > 0;
const danglingComma = hasImportMap ? ',' : '';
const importMap = JSON.stringify(map, null, 2).replace('}', '').replace('{', '');

if (Object.entries(map).length === 0) {
return html;
}

return merged;
if (hasImportMap) {
return html.replace('"imports": {', `
"imports": {
${importMap}${danglingComma}
`);
} else {
return html.replace('<head>', `
<head>
<script type="${importMapType}">
{
"imports": {
${importMap}
}
}
</script>
`);
}
}

export {
Expand Down
Loading

0 comments on commit 0a96e8e

Please sign in to comment.