From ff46df8807c5dd1a853444b1f8a7059e2b792941 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 25 Apr 2026 18:00:55 -0400 Subject: [PATCH 01/17] feature(cli): #1622 get static paths and static params --- packages/cli/src/commands/build.js | 4 +- packages/cli/src/lib/execute-route-module.js | 46 +++- packages/cli/src/lifecycles/bundle.js | 103 ++++++-- packages/cli/src/lifecycles/graph.js | 18 +- packages/cli/src/lifecycles/prerender.js | 220 +++++++++++++----- packages/cli/src/lifecycles/serve.js | 24 +- .../plugins/resource/plugin-standard-html.js | 5 +- ...velop.dynamic-routing-static-paths.spec.js | 115 +++++++++ .../src/pages/blog/[slug].ts | 55 +++++ .../src/services/blog-posts.ts | 20 ++ ...serve.dynamic-routing-static-paths.spec.js | 116 +++++++++ .../src/pages/blog/[slug].ts | 55 +++++ .../src/services/blog-posts.ts | 20 ++ 13 files changed, 707 insertions(+), 94 deletions(-) create mode 100644 packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js create mode 100644 packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/blog/[slug].ts create mode 100644 packages/cli/test/cases/develop.dynamic-routing-static-paths/src/services/blog-posts.ts create mode 100644 packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js create mode 100644 packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/blog/[slug].ts create mode 100644 packages/cli/test/cases/serve.dynamic-routing-static-paths/src/services/blog-posts.ts diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js index 4c8a211d0..970bc06ff 100644 --- a/packages/cli/src/commands/build.js +++ b/packages/cli/src/commands/build.js @@ -15,8 +15,10 @@ const runProductionBuild = async (compilation) => { const adapterPlugin = compilation.config.plugins.find((plugin) => plugin.type === "adapter") ? compilation.config.plugins.find((plugin) => plugin.type === "adapter").provider(compilation) : null; + const pagesWithStaticPaths = compilation.graph.filter((page) => page.staticPaths); - if (prerender) { + // console.log({ prerender, pagesWithStaticPaths }); + if (prerender || pagesWithStaticPaths.length > 0) { // start any of the user's server plugins if needed const servers = [ ...compilation.config.plugins diff --git a/packages/cli/src/lib/execute-route-module.js b/packages/cli/src/lib/execute-route-module.js index a7367ac46..5128493f1 100644 --- a/packages/cli/src/lib/execute-route-module.js +++ b/packages/cli/src/lib/execute-route-module.js @@ -16,6 +16,8 @@ async function executeRouteModule({ body: null, frontmatter: null, html: null, + staticPaths: null, + hasStaticParams: null, }; if (prerender) { @@ -25,7 +27,7 @@ async function executeRouteModule({ data.html = html; } else { const module = await import(moduleUrl).then((module) => module); - const { body, layout, frontmatter } = contentOptions; + const { body, layout, frontmatter, statics } = contentOptions; const { prerender = false, getLayout = null, @@ -34,6 +36,48 @@ async function executeRouteModule({ isolation, } = module; + if (statics && module.getStaticPaths) { + data.staticPaths = await module.getStaticPaths(); + } + + if (statics && module.getStaticParams) { + data.hasStaticParams = true; + } + + console.log("executeRouteModule", { params, page }); + if (params) { + if (page.staticPaths) { + const staticPaths = page.staticPaths ?? []; + + console.log({ staticPaths }); + console.log(staticPaths[0]); + + if (page.hasStaticParams) { + // TODO: does name have to come from the key? I don't think it should be hardcoded here, no? + console.log( + "has static props?", + staticPaths.find((path) => path.params.name === params.name), + ); + const initParams = { + ...params, + ...staticPaths.find((path) => path.params.name === params.name), + }; + console.log({ initParams }); + + const staticParams = module.getStaticParams + ? await module.getStaticParams(initParams) + : {}; + + params = { + ...params, + ...staticParams, + }; + } + } + } + + console.log("final params", { params }); + if (body) { if (module.default) { const { html } = await renderToString(new URL(moduleUrl), false, { diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index f10bd74af..01f470463 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -118,38 +118,89 @@ async function optimizeStaticPages(compilation, plugins) { const pages = compilation.graph.filter( (page) => - !page.isSSR || (page.isSSR && page.prerender) || (page.isSSR && compilation.config.prerender), + !page.isSSR || + (page.isSSR && page.prerender) || + (page.isSSR && compilation.config.prerender) || + page.staticPaths, ); await asyncForEach(pages, async (page) => { - const { route, outputHref } = page; - const outputDirUrl = new URL(outputHref.replace("index.html", "").replace("404.html", "")); - const url = new URL(`http://localhost:${compilation.config.port}${route}`); - const contents = await fs.readFile( - new URL(`./${outputHref.replace(outputDir.href, "")}`, scratchDir), - "utf-8", - ); - const headers = new Headers({ "Content-Type": "text/html" }); - let response = new Response(contents, { headers }); - - if (!(await checkResourceExists(outputDirUrl))) { - await fs.mkdir(outputDirUrl, { - recursive: true, - }); - } + const { outputHref, route, segment, staticPaths } = page; + + if (staticPaths) { + for (const staticPath of staticPaths) { + console.log({ staticPath }); + // TODO: is there a URL util for this? + const staticRoute = route.replace(`[${segment.key}]`, staticPath.params[segment.key]); + const outputDirUrl = new URL( + outputHref + .replace(`[${segment.key}]`, staticPath.params[segment.key]) + .replace("index.html", ""), + ); + const url = new URL(`http://localhost:${compilation.config.port}${route}`); // TODO: keeping placeholder route for optimization looks ups, is this right? + const contents = await fs.readFile( + new URL( + `./${outputHref.replace(outputDir.href, "").replace(`[${segment.key}]`, staticPath.params[segment.key])}`, + scratchDir, + ), + "utf-8", + ); + console.log({ staticRoute, outputDirUrl, url, contents }); + const headers = new Headers({ "Content-Type": "text/html" }); + let response = new Response(contents, { headers }); + + if (!(await checkResourceExists(outputDirUrl))) { + await fs.mkdir(outputDirUrl, { + recursive: true, + }); + } + + for (const plugin of plugins) { + if (plugin.shouldOptimize && (await plugin.shouldOptimize(url, response.clone()))) { + const currentResponse = await plugin.optimize(url, response.clone()); - for (const plugin of plugins) { - if (plugin.shouldOptimize && (await plugin.shouldOptimize(url, response.clone()))) { - const currentResponse = await plugin.optimize(url, response.clone()); + response = mergeResponse(response.clone(), currentResponse.clone()); + } + } + + // clean up optimization markers + const body = (await response.text()).replace(/data-gwd-opt=".*?[a-z]"/g, ""); - response = mergeResponse(response.clone(), currentResponse.clone()); + await fs.writeFile( + new URL(outputHref.replace(`[${segment.key}]`, staticPath.params[segment.key])), + body, + ); } - } + } else { + const { route, outputHref } = page; + const outputDirUrl = new URL(outputHref.replace("index.html", "").replace("404.html", "")); + const url = new URL(`http://localhost:${compilation.config.port}${route}`); + const contents = await fs.readFile( + new URL(`./${outputHref.replace(outputDir.href, "")}`, scratchDir), + "utf-8", + ); + const headers = new Headers({ "Content-Type": "text/html" }); + let response = new Response(contents, { headers }); - // clean up optimization markers - const body = (await response.text()).replace(/data-gwd-opt=".*?[a-z]"/g, ""); + if (!(await checkResourceExists(outputDirUrl))) { + await fs.mkdir(outputDirUrl, { + recursive: true, + }); + } + + for (const plugin of plugins) { + if (plugin.shouldOptimize && (await plugin.shouldOptimize(url, response.clone()))) { + const currentResponse = await plugin.optimize(url, response.clone()); + + response = mergeResponse(response.clone(), currentResponse.clone()); + } + } + + // clean up optimization markers + const body = (await response.text()).replace(/data-gwd-opt=".*?[a-z]"/g, ""); - await fs.writeFile(new URL(outputHref), body); + await fs.writeFile(new URL(outputHref), body); + } }); } @@ -280,7 +331,9 @@ async function bundleApiRoutes(compilation) { async function bundleSsrPages(compilation, optimizePlugins) { const { context, config } = compilation; - const ssrPages = compilation.graph.filter((page) => page.isSSR && !page.prerender); + const ssrPages = compilation.graph.filter( + (page) => page.isSSR && !page.prerender && !page.staticPaths, + ); const ssrPrerenderPagesRouteMapper = {}; const input = []; diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js index 64176d375..92754b47d 100644 --- a/packages/cli/src/lifecycles/graph.js +++ b/packages/cli/src/lifecycles/graph.js @@ -153,6 +153,8 @@ const generateGraph = async (compilation) => { let prerender = true; let isolation = false; let hydration = false; + let staticPaths = null; + let hasStaticParams = false; /* * check if additional nested directories exist to correctly determine route (minus filename) @@ -202,6 +204,14 @@ const generateGraph = async (compilation) => { ssrFrontmatter = result.frontmatter; } + if (result.staticPaths) { + staticPaths = result.staticPaths; + } + + if (result.hasStaticParams) { + hasStaticParams = result.hasStaticParams; + } + resolve(); }); worker.on("error", reject); @@ -224,6 +234,7 @@ const generateGraph = async (compilation) => { request, contentOptions: JSON.stringify({ frontmatter: true, + statics: true, }), }); }); @@ -242,6 +253,7 @@ const generateGraph = async (compilation) => { delete customData[key]; }); + // TODO: document segment, staticPaths, and hasStaticParams /* * Page Properties *---------------------- @@ -269,6 +281,7 @@ const generateGraph = async (compilation) => { basePath, }); + console.log("staticPaths???", { route, staticPaths, hasStaticParams }); const page = { id: decodeURIComponent(getIdFromRelativePathPath(relativePagePath, extension)), label: decodeURIComponent(label), @@ -287,9 +300,12 @@ const generateGraph = async (compilation) => { prerender, isolation, hydration, - servePage: isCustom, + // TODO: this "may" break some things...? validate with testing in Greenwood + servePage: isCustom ? isCustom : isDynamic ? "dynamic" : "static", segment: dynamicRoute.indexOf(":") > 0 ? { key: segmentKey, pathname: dynamicRoute } : null, + staticPaths, + hasStaticParams, }; pages.push(page); diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index 84fcbf611..4f47aeaab 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -68,82 +68,186 @@ function toScratchUrl(outputHref, context) { } async function preRenderCompilationWorker(compilation, workerPrerender) { + // const pages = compilation.graph.filter( + // (page) => + // !page.isSSR && page.servePage !== "static" || (page.isSSR && (page.prerender || page.segment)) || (page.isSSR && compilation.config.prerender), + // ); const pages = compilation.graph.filter( (page) => - !page.isSSR || (page.isSSR && page.prerender) || (page.isSSR && compilation.config.prerender), + !page.isSSR || + (page.isSSR && page.prerender) || + (page.isSSR && compilation.config.prerender) || + page.staticPaths, ); + console.log("@@@@@@@@@", { pages }); const { context, config } = compilation; const plugins = getPluginInstances(compilation); console.info("pages to generate", `\n ${pages.map((page) => page.route).join("\n ")}`); const pool = new WorkerPool( - os.availableParallelism(), + os.cpus().length, new URL("../lib/ssr-route-worker.js", import.meta.url), ); + // TODO: refactor / consolidate await asyncForEach(pages, async (page) => { - const { route, outputHref } = page; - const scratchUrl = toScratchUrl(outputHref, context); - const url = new URL(`http://localhost:${config.port}${route}`); - const request = new Request(url); - let ssrContents; - - // do we negate the worker pool by also running this, outside the pool? - let body = await (await servePage(url, request, plugins)).text(); - body = await (await interceptPage(url, request, plugins, body)).text(); - - // hack to avoid over-rendering SSR content - // https://github.com/ProjectEvergreen/greenwood/issues/1044 - // https://github.com/ProjectEvergreen/greenwood/issues/988#issuecomment-1288168858 - if (page.isSSR) { - const ssrContentsMatch = /(.*.)/s; - const match = body.match(ssrContentsMatch); - - if (match) { - ssrContents = match[0]; - body = body.replace(ssrContents, ""); - - ssrContents = ssrContents - .replace("", "") - .replace("", ""); + console.log("@@@@@ generating page...", { page }); + + if (page.staticPaths) { + console.log("@@@@@ static paths", { staticPaths: page.staticPaths }); + + for (const staticPath of page.staticPaths) { + const { route, outputHref, segment } = page; + // TODO: is there a URL util for this? + const staticRoute = route.replace(`[${segment.key}]`, staticPath.params[segment.key]); + // TODO: base path + const url = new URL(`http://localhost:${config.port}${staticRoute}`); + console.log({ url }); + const request = new Request(url); + const scratchUrl = toScratchUrl( + outputHref.replace(`[${segment.key}]`, staticPath.params[segment.key]), + context, + ); + let ssrContents; + + // do we negate the worker pool by also running this, outside the pool? + console.log("@@@@@ serving page...", { url: url.href }); + let body = await (await servePage(url, request, plugins)).text(); + console.log("@@@@@ served page...", { body }); + body = await (await interceptPage(url, request, plugins, body)).text(); + + // hack to avoid over-rendering SSR content + // https://github.com/ProjectEvergreen/greenwood/issues/1044 + // https://github.com/ProjectEvergreen/greenwood/issues/988#issuecomment-1288168858 + if (page.isSSR) { + const ssrContentsMatch = /(.*.)/s; + const match = body.match(ssrContentsMatch); + + if (match) { + ssrContents = match[0]; + body = body.replace( + ssrContents, + "", + ); + + ssrContents = ssrContents + .replace("", "") + .replace("", ""); + } + } + + const resources = await trackResourcesForRoute(body, compilation, route); + const scripts = resources + .filter((resource) => resource.type === "script") + .map((resource) => resource.sourcePathURL.href); + + body = await new Promise((resolve, reject) => { + pool.runTask( + { + executeModuleUrl: workerPrerender.executeModuleUrl.href, + modulePath: null, + compilation: JSON.stringify(compilation), + page: JSON.stringify(page), + prerender: true, + htmlContents: body, + scripts: JSON.stringify(scripts), + }, + (err, result) => { + if (err) { + return reject(err); + } + + console.log("####", { result }); + + return resolve(result.html); + }, + ); + }); + + if (page.isSSR) { + body = body.replace( + "", + ssrContents, + ); + } + + console.log("@@@@@ prerendered page...", { scratchUrl }); + await createOutputDirectory(new URL(scratchUrl.href.replace("index.html", ""))); + await fs.writeFile(scratchUrl, body); + + console.info("generated static page...", staticRoute, body); + } + } else { + const { route, outputHref } = page; + const scratchUrl = toScratchUrl(outputHref, context); + const url = new URL(`http://localhost:${config.port}${route}`); + const request = new Request(url); + let ssrContents; + + // do we negate the worker pool by also running this, outside the pool? + console.log("@@@@@ serving page...", { url: url.href }); + let body = await (await servePage(url, request, plugins)).text(); + console.log("@@@@@ served page...", { body }); + body = await (await interceptPage(url, request, plugins, body)).text(); + + // hack to avoid over-rendering SSR content + // https://github.com/ProjectEvergreen/greenwood/issues/1044 + // https://github.com/ProjectEvergreen/greenwood/issues/988#issuecomment-1288168858 + if (page.isSSR) { + const ssrContentsMatch = /(.*.)/s; + const match = body.match(ssrContentsMatch); + + if (match) { + ssrContents = match[0]; + body = body.replace( + ssrContents, + "", + ); + + ssrContents = ssrContents + .replace("", "") + .replace("", ""); + } } - } - const resources = await trackResourcesForRoute(body, compilation, route); - const scripts = resources - .filter((resource) => resource.type === "script") - .map((resource) => resource.sourcePathURL.href); - - body = await new Promise((resolve, reject) => { - pool.runTask( - { - executeModuleUrl: workerPrerender.executeModuleUrl.href, - modulePath: null, - compilation: JSON.stringify(compilation), - page: JSON.stringify(page), - prerender: true, - htmlContents: body, - scripts: JSON.stringify(scripts), - }, - (err, result) => { - if (err) { - return reject(err); - } + const resources = await trackResourcesForRoute(body, compilation, route); + const scripts = resources + .filter((resource) => resource.type === "script") + .map((resource) => resource.sourcePathURL.href); + + body = await new Promise((resolve, reject) => { + pool.runTask( + { + executeModuleUrl: workerPrerender.executeModuleUrl.href, + modulePath: null, + compilation: JSON.stringify(compilation), + page: JSON.stringify(page), + prerender: true, + htmlContents: body, + scripts: JSON.stringify(scripts), + }, + (err, result) => { + if (err) { + return reject(err); + } + + console.log("####", { result }); + + return resolve(result.html); + }, + ); + }); + + if (page.isSSR) { + body = body.replace("", ssrContents); + } - return resolve(result.html); - }, - ); - }); + await createOutputDirectory(new URL(scratchUrl.href.replace("index.html", ""))); + await fs.writeFile(scratchUrl, body); - if (page.isSSR) { - body = body.replace("", ssrContents); + console.info("generated page...", route); } - - await createOutputDirectory(new URL(scratchUrl.href.replace("index.html", ""))); - await fs.writeFile(scratchUrl, body); - - console.info("generated page...", route); }); } diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index beff516f1..a15a0de73 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -315,15 +315,19 @@ async function getStaticServer(compilation, composable) { app.use(async (ctx, next) => { try { const url = new URL(`http://localhost:${port}${ctx.url}`); - const matchingRoute = compilation.graph.find((page) => page.route === url.pathname); + const matchingRoute = compilation.graph.find( + (page) => page.staticPaths || page.route === url.pathname, + ); const isSPA = compilation.graph.find((page) => page.isSPA); - const { isSSR } = matchingRoute || {}; + const { isSSR, staticPaths } = matchingRoute || {}; const extension = url.pathname.split(".").pop(); const isStatic = + !!staticPaths || (matchingRoute && !isSSR) || (isSSR && compilation.config.prerender) || (isSSR && matchingRoute.prerender); + console.log({ isStatic, matchingRoute }); if ( ctx.response.status === 404 && ((isSPA && extension === url.pathname) || @@ -332,9 +336,12 @@ async function getStaticServer(compilation, composable) { ) { const outputHref = isSPA ? isSPA.outputHref - : isStatic + : isStatic && !matchingRoute.staticPaths ? matchingRoute.outputHref - : new URL(`.${url.pathname.replace(basePath, "")}`, outputDir).href; + : matchingRoute.staticPaths + ? new URL(`.${url.pathname.replace(basePath, "")}index.html`, outputDir).href + : new URL(`.${url.pathname.replace(basePath, "")}`, outputDir).href; + console.log({ outputHref }); const body = await fs.readFile(new URL(outputHref), "utf-8"); ctx.body = body; @@ -379,7 +386,8 @@ async function getHybridServer(compilation) { if ( !config.prerender && (matchingRoute.isSSR || matchingRouteWithSegment.isSSR) && - !matchingRoute.prerender + !matchingRoute.prerender && + !matchingRouteWithSegment.staticPaths ) { const entryPointUrl = new URL( matchingRoute?.outputHref ?? matchingRouteWithSegment.outputHref, @@ -430,7 +438,10 @@ async function getHybridServer(compilation) { ctx.message = "OK"; ctx.set("Content-Type", "text/html"); ctx.status = 200; - } else if (isApiRoute || matchingApiRouteWithSegment) { + } else if ( + isApiRoute || + (matchingApiRouteWithSegment && !matchingApiRouteWithSegment.staticPaths) + ) { const apiRoute = manifest.apis.get(matchingApiRouteWithSegment ?? pathname); const params = matchingRouteWithSegment && apiRoute.segment @@ -471,6 +482,7 @@ async function getHybridServer(compilation) { }); }); } else { + console.log("3333????"); // @ts-expect-error see https://github.com/microsoft/TypeScript/issues/42866 const { handler } = await import(entryPointUrl); const response = await handler(request, { params }); diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js index 00657ab1f..8d4d4cb90 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-html.js +++ b/packages/cli/src/plugins/resource/plugin-standard-html.js @@ -80,11 +80,12 @@ class StandardHtmlResource { .find((plugin) => plugin.type === "renderer") .provider().executeModuleUrl; const req = await requestAsObject(request); - const params = + let params = matchingRouteWithSegment && matchingRouteWithSegment.segment ? getParamsFromSegment(matchingRouteWithSegment.segment, pathname) : undefined; + console.log("serve plugin???", { matchingRouteWithSegment, pathname, params }); await new Promise((resolve, reject) => { const worker = new Worker(new URL("../../lib/ssr-route-worker.js", import.meta.url)); @@ -106,7 +107,7 @@ class StandardHtmlResource { executeModuleUrl: routeWorkerUrl.href, moduleUrl: routeModuleLocationUrl.href, compilation: JSON.stringify(this.compilation), - page: JSON.stringify(matchingRoute), + page: JSON.stringify(matchingRouteWithSegment ?? matchingRoute), request: req, contentOptions: JSON.stringify({ body: true, diff --git a/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js b/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js new file mode 100644 index 000000000..7a326b709 --- /dev/null +++ b/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js @@ -0,0 +1,115 @@ +/* + * Use Case + * Run Greenwood with SSR routes and API route that use getStaticPaths / getStaticParams file-based routing. + * + * User Result + * Should run the Greenwood dev server can handle static generation for dynamic routes. + * + * User Command + * greenwood develop + * + * User Config + * {} + * + * User Workspace + * src/ + * pages/ + * blog/ + * [slug].ts + * services/ + * blog-posts.ts + */ +import chai from "chai"; +import { JSDOM } from "jsdom"; +import path from "node:path"; +import { getOutputTeardownFiles } from "../../../../../test/utils.js"; +import { Runner } from "gallinago"; +import { fileURLToPath } from "node:url"; + +const expect = chai.expect; + +describe("Develop Greenwood With: ", function () { + const LABEL = "Dynamic Routing for Get Static Paths and Get Static Params"; + const cliPath = path.join(process.cwd(), "packages/cli/src/bin.js"); + const outputPath = fileURLToPath(new URL(".", import.meta.url)); + const hostname = "http://localhost:1984"; + let runner; + + before(function () { + this.context = { + publicDir: path.join(outputPath, "public"), + hostname, + }; + runner = new Runner(); + }); + + describe(LABEL, function () { + before(async function () { + await runner.setup(outputPath); + + await new Promise((resolve, reject) => { + runner + .runCommand(cliPath, "develop", { + onStdOut: (message) => { + if (message.includes(`Started local development server at ${hostname}`)) { + resolve(); + } + }, + }) + .catch(reject); + }); + }); + + describe("An SSR page with a dynamic route segment with default export", function () { + const slug = "first-post"; + let response; + let dom; + let body; + + before(async function () { + response = await fetch(`${hostname}/blog/${slug}/`); + body = await response.clone().text(); + dom = new JSDOM(body); + }); + + it("should have the expected output for the page in the title", function () { + const headings = dom.window.document.querySelectorAll("body > h1"); + + expect(headings.length).to.equal(1); + expect(headings[0].textContent).to.equal("First Post"); + }); + + it("should have the expected output for the page in the content", function () { + const paragraphs = dom.window.document.querySelectorAll("body > p"); + + expect(paragraphs.length).to.equal(1); + expect(paragraphs[0].textContent).to.equal("This is the first post."); + }); + }); + + xdescribe("An SSR page with a dynamic route segment with getBody", function () { + const name = "my-cool-product"; + let response; + let dom; + let body; + + before(async function () { + response = await fetch(`${hostname}/product/${name}/`); + body = await response.clone().text(); + dom = new JSDOM(body); + }); + + it("should have the expected output for the page", function () { + const headings = dom.window.document.querySelectorAll("h1"); + + expect(headings.length).to.equal(1); + expect(headings[0].textContent).to.equal(name); + }); + }); + }); + + after(async function () { + await runner.teardown(getOutputTeardownFiles(outputPath)); + await runner.stopCommand(); + }); +}); diff --git a/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/blog/[slug].ts b/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/blog/[slug].ts new file mode 100644 index 000000000..3af699d17 --- /dev/null +++ b/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/blog/[slug].ts @@ -0,0 +1,55 @@ +import { getBlogPosts, getBlogPostBySlug } from "../../services/blog-posts.ts"; +import type { BlogPost } from "../../services/blog-posts.ts"; + +// TODO: types for all this would be nice: StaticPaths / Params / SSR page / etc? can they be inferred? +interface StaticPaths { + params: { + slug: string; + }; +} + +interface StaticParams { + post: BlogPost; +} + +interface BlogPostPageProps { + params: { + post: BlogPost; + }; +} + +export async function getStaticPaths(): Promise { + const posts = await getBlogPosts(); + + return posts.map((post) => { + return { + params: { + slug: post.slug, + }, + }; + }); +} + +export async function getStaticParams({ params }: StaticPaths): Promise { + const post = (await getBlogPostBySlug(params.slug)) ?? ({} as BlogPost); + + return { post }; +} + +export default class BlogPostPage extends HTMLElement { + #post: BlogPost; + + constructor({ params }: BlogPostPageProps) { + super(); + this.#post = params?.post; + } + + connectedCallback() { + this.innerHTML = ` + +

${this.#post.title}

+

${this.#post.content}

+ + `; + } +} diff --git a/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/services/blog-posts.ts b/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/services/blog-posts.ts new file mode 100644 index 000000000..976e64da4 --- /dev/null +++ b/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/services/blog-posts.ts @@ -0,0 +1,20 @@ +export type BlogPost = { + slug: string; + title: string; + content: string; +}; + +async function getBlogPosts(): Promise { + return [ + { slug: "first-post", title: "First Post", content: "This is the first post." }, + { slug: "second-post", title: "Second Post", content: "This is the second post." }, + { slug: "third-post", title: "Third Post", content: "This is the third post." }, + ]; +} + +async function getBlogPostBySlug(slug: string): Promise { + const posts = await getBlogPosts(); + return posts.find((post) => post.slug === slug); +} + +export { getBlogPosts, getBlogPostBySlug }; diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js b/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js new file mode 100644 index 000000000..695ccba29 --- /dev/null +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js @@ -0,0 +1,116 @@ +/* + * Use Case + * Run Greenwood with SSR routes and API route that use getStaticPaths / getStaticParams file-based routing. + * + * User Result + * Should run a Greenwood build and can handle serving static generation for dynamic routes. + * + * User Command + * greenwood serve + * + * User Config + * {} + * + * User Workspace + * src/ + * pages/ + * blog/ + * [slug].ts + * services/ + * blog-posts.ts + */ +import chai from "chai"; +import { JSDOM } from "jsdom"; +import path from "node:path"; +import { getOutputTeardownFiles } from "../../../../../test/utils.js"; +import { Runner } from "gallinago"; +import { fileURLToPath } from "node:url"; + +const expect = chai.expect; + +describe("Serve Greenwood With: ", function () { + const LABEL = "Dynamic Routing for Get Static Paths and Get Static Params"; + const cliPath = path.join(process.cwd(), "packages/cli/src/bin.js"); + const outputPath = fileURLToPath(new URL(".", import.meta.url)); + const hostname = "http://localhost:8080"; + let runner; + + before(function () { + this.context = { + publicDir: path.join(outputPath, "public"), + hostname, + }; + runner = new Runner(); + }); + + describe(LABEL, function () { + before(async function () { + await runner.setup(outputPath); + await runner.runCommand(cliPath, "build"); + + await new Promise((resolve, reject) => { + runner + .runCommand(cliPath, "serve", { + onStdOut: (message) => { + if (message.includes(`Started server at ${hostname}`)) { + resolve(); + } + }, + }) + .catch(reject); + }); + }); + + describe("An SSR page with a dynamic route segment with default export", function () { + const slug = "first-post"; + let response; + let dom; + let body; + + before(async function () { + response = await fetch(`${hostname}/blog/${slug}/`); + body = await response.clone().text(); + dom = new JSDOM(body); + }); + + it("should have the expected output for the page in the title", function () { + const headings = dom.window.document.querySelectorAll("body > h1"); + + expect(headings.length).to.equal(1); + expect(headings[0].textContent).to.equal("First Post"); + }); + + it("should have the expected output for the page in the content", function () { + const paragraphs = dom.window.document.querySelectorAll("body > p"); + + expect(paragraphs.length).to.equal(1); + expect(paragraphs[0].textContent).to.equal("This is the first post."); + }); + }); + + xdescribe("An SSR page with a dynamic route segment with getBody", function () { + const name = "my-cool-product"; + let response; + let dom; + let body; + + before(async function () { + response = await fetch(`${hostname}/product/${name}/`); + body = await response.clone().text(); + dom = new JSDOM(body); + }); + + it("should have the expected output for the page", function () { + const headings = dom.window.document.querySelectorAll("h1"); + + expect(headings.length).to.equal(1); + expect(headings[0].textContent).to.equal(name); + }); + }); + }); + + after(async function () { + await runner.teardown(getOutputTeardownFiles(outputPath)); + await runner.stopCommand(); + }); +}); diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/blog/[slug].ts b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/blog/[slug].ts new file mode 100644 index 000000000..3af699d17 --- /dev/null +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/blog/[slug].ts @@ -0,0 +1,55 @@ +import { getBlogPosts, getBlogPostBySlug } from "../../services/blog-posts.ts"; +import type { BlogPost } from "../../services/blog-posts.ts"; + +// TODO: types for all this would be nice: StaticPaths / Params / SSR page / etc? can they be inferred? +interface StaticPaths { + params: { + slug: string; + }; +} + +interface StaticParams { + post: BlogPost; +} + +interface BlogPostPageProps { + params: { + post: BlogPost; + }; +} + +export async function getStaticPaths(): Promise { + const posts = await getBlogPosts(); + + return posts.map((post) => { + return { + params: { + slug: post.slug, + }, + }; + }); +} + +export async function getStaticParams({ params }: StaticPaths): Promise { + const post = (await getBlogPostBySlug(params.slug)) ?? ({} as BlogPost); + + return { post }; +} + +export default class BlogPostPage extends HTMLElement { + #post: BlogPost; + + constructor({ params }: BlogPostPageProps) { + super(); + this.#post = params?.post; + } + + connectedCallback() { + this.innerHTML = ` + +

${this.#post.title}

+

${this.#post.content}

+ + `; + } +} diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/services/blog-posts.ts b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/services/blog-posts.ts new file mode 100644 index 000000000..976e64da4 --- /dev/null +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/services/blog-posts.ts @@ -0,0 +1,20 @@ +export type BlogPost = { + slug: string; + title: string; + content: string; +}; + +async function getBlogPosts(): Promise { + return [ + { slug: "first-post", title: "First Post", content: "This is the first post." }, + { slug: "second-post", title: "Second Post", content: "This is the second post." }, + { slug: "third-post", title: "Third Post", content: "This is the third post." }, + ]; +} + +async function getBlogPostBySlug(slug: string): Promise { + const posts = await getBlogPosts(); + return posts.find((post) => post.slug === slug); +} + +export { getBlogPosts, getBlogPostBySlug }; From 7f0a1ffc02b239ace9e8a32fd52f175d464b4208 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 25 Apr 2026 18:32:53 -0400 Subject: [PATCH 02/17] feature(cli): #1622 make sure get static paths usage does not emit any ssr chunks --- .../serve.dynamic-routing-static-paths.spec.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js b/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js index 695ccba29..b8f30bbe8 100644 --- a/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js @@ -20,6 +20,7 @@ * blog-posts.ts */ import chai from "chai"; +import fs from "node:fs/promises"; import { JSDOM } from "jsdom"; import path from "node:path"; import { getOutputTeardownFiles } from "../../../../../test/utils.js"; @@ -61,6 +62,16 @@ describe("Serve Greenwood With: ", function () { }); }); + describe("Build Output", function () { + it("should have no SSR page chunks", async function () { + const files = await Array.fromAsync( + fs.glob("*.js", { cwd: new URL("./public", import.meta.url) }), + ); + + expect(files.length).to.equal(0); + }); + }); + describe("An SSR page with a dynamic route segment with default export", function () { const slug = "first-post"; let response; From c5def15dfba6a4d20efe33127bb441b632326d24 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 25 Apr 2026 18:47:10 -0400 Subject: [PATCH 03/17] feature(cli): #1622 add test cases for get body --- ...velop.dynamic-routing-static-paths.spec.js | 8 +++-- .../src/pages/product/[name].js | 31 +++++++++++++++++++ ...serve.dynamic-routing-static-paths.spec.js | 8 +++-- .../src/pages/product/[name].js | 31 +++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/product/[name].js create mode 100644 packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/product/[name].js diff --git a/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js b/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js index 7a326b709..b56efd18d 100644 --- a/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js +++ b/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js @@ -16,6 +16,8 @@ * pages/ * blog/ * [slug].ts + * product/ + * [name].js * services/ * blog-posts.ts */ @@ -87,14 +89,14 @@ describe("Develop Greenwood With: ", function () { }); }); - xdescribe("An SSR page with a dynamic route segment with getBody", function () { - const name = "my-cool-product"; + describe("An SSR page with a dynamic route segment with getBody", function () { + const name = "My Cool Product"; let response; let dom; let body; before(async function () { - response = await fetch(`${hostname}/product/${name}/`); + response = await fetch(`${hostname}/product/${name.replace(/ /g, "-").toLowerCase()}/`); body = await response.clone().text(); dom = new JSDOM(body); }); diff --git a/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/product/[name].js b/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/product/[name].js new file mode 100644 index 000000000..42bd21d07 --- /dev/null +++ b/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/product/[name].js @@ -0,0 +1,31 @@ +const products = [ + { + id: 1, + name: "My Cool Product", + }, + { + id: 2, + name: "My Other Cool Product", + }, +]; + +export async function getStaticPaths() { + return products.map((product) => { + return { + params: { + id: product.id, + name: product.name.replace(/ /g, "-").toLowerCase(), + }, + }; + }); +} + +export async function getStaticParams({ params }) { + const product = products.find((product) => product.id === params.id); + + return { product }; +} + +export async function getBody(compilation, request, page, params) { + return `

${params.product.name}

`; +} diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js b/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js index b8f30bbe8..43ac37838 100644 --- a/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js @@ -16,6 +16,8 @@ * pages/ * blog/ * [slug].ts + * product/ + * [name].js * services/ * blog-posts.ts */ @@ -99,14 +101,14 @@ describe("Serve Greenwood With: ", function () { }); }); - xdescribe("An SSR page with a dynamic route segment with getBody", function () { - const name = "my-cool-product"; + describe("An SSR page with a dynamic route segment with getBody", function () { + const name = "My Cool Product"; let response; let dom; let body; before(async function () { - response = await fetch(`${hostname}/product/${name}/`); + response = await fetch(`${hostname}/product/${name.replace(/ /g, "-").toLowerCase()}/`); body = await response.clone().text(); dom = new JSDOM(body); }); diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/product/[name].js b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/product/[name].js new file mode 100644 index 000000000..42bd21d07 --- /dev/null +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/product/[name].js @@ -0,0 +1,31 @@ +const products = [ + { + id: 1, + name: "My Cool Product", + }, + { + id: 2, + name: "My Other Cool Product", + }, +]; + +export async function getStaticPaths() { + return products.map((product) => { + return { + params: { + id: product.id, + name: product.name.replace(/ /g, "-").toLowerCase(), + }, + }; + }); +} + +export async function getStaticParams({ params }) { + const product = products.find((product) => product.id === params.id); + + return { product }; +} + +export async function getBody(compilation, request, page, params) { + return `

${params.product.name}

`; +} From aedcc0bcb08dbf791b066fb84bdd3077d6eaf642 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 25 Apr 2026 19:12:25 -0400 Subject: [PATCH 04/17] feature(cli): #1622 use segment key lookup for init static params --- packages/cli/src/lib/execute-route-module.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/lib/execute-route-module.js b/packages/cli/src/lib/execute-route-module.js index 5128493f1..a8ef5427b 100644 --- a/packages/cli/src/lib/execute-route-module.js +++ b/packages/cli/src/lib/execute-route-module.js @@ -53,14 +53,17 @@ async function executeRouteModule({ console.log(staticPaths[0]); if (page.hasStaticParams) { - // TODO: does name have to come from the key? I don't think it should be hardcoded here, no? console.log( "has static props?", - staticPaths.find((path) => path.params.name === params.name), + staticPaths.find( + (staticPath) => staticPath.params[page.segment.key] === params[page.segment.key], + ), ); const initParams = { ...params, - ...staticPaths.find((path) => path.params.name === params.name), + ...staticPaths.find( + (staticPath) => staticPath.params[page.segment.key] === params[page.segment.key], + ), }; console.log({ initParams }); @@ -74,10 +77,9 @@ async function executeRouteModule({ }; } } + console.log("final params", { params }); } - console.log("final params", { params }); - if (body) { if (module.default) { const { html } = await renderToString(new URL(moduleUrl), false, { From 4f9aba65a52b977ae7759da5f324adabd61a084c Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Wed, 27 May 2026 21:16:13 -0400 Subject: [PATCH 05/17] feature(cli): #1622 update specs per rebase --- .../develop.dynamic-routing-static-paths.spec.js | 4 +--- .../serve.dynamic-routing-static-paths.spec.js | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js b/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js index b56efd18d..1b38734bd 100644 --- a/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js +++ b/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js @@ -21,15 +21,13 @@ * services/ * blog-posts.ts */ -import chai from "chai"; +import { expect } from "chai"; import { JSDOM } from "jsdom"; import path from "node:path"; import { getOutputTeardownFiles } from "../../../../../test/utils.js"; import { Runner } from "gallinago"; import { fileURLToPath } from "node:url"; -const expect = chai.expect; - describe("Develop Greenwood With: ", function () { const LABEL = "Dynamic Routing for Get Static Paths and Get Static Params"; const cliPath = path.join(process.cwd(), "packages/cli/src/bin.js"); diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js b/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js index 43ac37838..65017863e 100644 --- a/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js @@ -21,7 +21,7 @@ * services/ * blog-posts.ts */ -import chai from "chai"; +import { expect } from "chai"; import fs from "node:fs/promises"; import { JSDOM } from "jsdom"; import path from "node:path"; @@ -29,8 +29,6 @@ import { getOutputTeardownFiles } from "../../../../../test/utils.js"; import { Runner } from "gallinago"; import { fileURLToPath } from "node:url"; -const expect = chai.expect; - describe("Serve Greenwood With: ", function () { const LABEL = "Dynamic Routing for Get Static Paths and Get Static Params"; const cliPath = path.join(process.cwd(), "packages/cli/src/bin.js"); From bfb5356dd0aee48537e913e29c9a6ebfa5d54d06 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 6 Jun 2026 16:38:46 -0400 Subject: [PATCH 06/17] feature(cli): #1622 fix prod server not correctly matching static path routes --- packages/cli/src/lib/url-utils.js | 2 +- packages/cli/src/lifecycles/serve.js | 5 +++- ...velop.dynamic-routing-static-paths.spec.js | 20 ++++++++++++++ .../src/pages/events/[title].js | 14 ++++++++++ ...serve.dynamic-routing-static-paths.spec.js | 26 +++++++++++++++++-- .../src/pages/events/[title].js | 14 ++++++++++ 6 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/events/[title].js create mode 100644 packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/events/[title].js diff --git a/packages/cli/src/lib/url-utils.js b/packages/cli/src/lib/url-utils.js index a7cbe7e93..06ea771c9 100644 --- a/packages/cli/src/lib/url-utils.js +++ b/packages/cli/src/lib/url-utils.js @@ -34,7 +34,7 @@ function getMatchingDynamicSsrRoute(graph, pathname) { function getParamsFromSegment(segment, pathname) { return new URLPattern({ pathname: segment.pathname }).exec(`https://example.com${pathname}`) - .pathname.groups; + ?.pathname?.groups; } export { diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index a15a0de73..1f0f675e2 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -315,8 +315,11 @@ async function getStaticServer(compilation, composable) { app.use(async (ctx, next) => { try { const url = new URL(`http://localhost:${port}${ctx.url}`); + // TODO: handle base path const matchingRoute = compilation.graph.find( - (page) => page.staticPaths || page.route === url.pathname, + (page) => + (page.staticPaths && getParamsFromSegment(page.segment, url.pathname)) || + page.route === url.pathname, ); const isSPA = compilation.graph.find((page) => page.isSPA); const { isSSR, staticPaths } = matchingRoute || {}; diff --git a/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js b/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js index 1b38734bd..0d3291b6b 100644 --- a/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js +++ b/packages/cli/test/cases/develop.dynamic-routing-static-paths/develop.dynamic-routing-static-paths.spec.js @@ -106,6 +106,26 @@ describe("Develop Greenwood With: ", function () { expect(headings[0].textContent).to.equal(name); }); }); + + describe("An SSR page with a dynamic route segment that NOT be static", function () { + const title = "My Cool Product"; + let response; + let dom; + let body; + + before(async function () { + response = await fetch(`${hostname}/events/${title.replace(/ /g, "-").toLowerCase()}/`); + body = await response.clone().text(); + dom = new JSDOM(body); + }); + + it("should have the expected output for the page", function () { + const headings = dom.window.document.querySelectorAll("h1"); + + expect(headings.length).to.equal(1); + expect(headings[0].textContent).to.equal(title.toUpperCase().replace(/-/g, " ")); + }); + }); }); after(async function () { diff --git a/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/events/[title].js b/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/events/[title].js new file mode 100644 index 000000000..077b7ecb7 --- /dev/null +++ b/packages/cli/test/cases/develop.dynamic-routing-static-paths/src/pages/events/[title].js @@ -0,0 +1,14 @@ +export default class EventDetailPage extends HTMLElement { + #title; + + constructor({ params }) { + super(); + this.#title = params?.title ?? "No Title"; + } + + async connectedCallback() { + this.innerHTML = ` +

${this.#title.toUpperCase().replace(/-/g, " ")}

+ `; + } +} diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js b/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js index 65017863e..0bb051212 100644 --- a/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/serve.dynamic-routing-static-paths.spec.js @@ -16,6 +16,8 @@ * pages/ * blog/ * [slug].ts + * events/ + * [title].js * product/ * [name].js * services/ @@ -63,12 +65,12 @@ describe("Serve Greenwood With: ", function () { }); describe("Build Output", function () { - it("should have no SSR page chunks", async function () { + it("should have only two SSR page chunks (for events page)", async function () { const files = await Array.fromAsync( fs.glob("*.js", { cwd: new URL("./public", import.meta.url) }), ); - expect(files.length).to.equal(0); + expect(files.length).to.equal(2); }); }); @@ -118,6 +120,26 @@ describe("Serve Greenwood With: ", function () { expect(headings[0].textContent).to.equal(name); }); }); + + describe("An SSR page with a dynamic route segment that NOT be static", function () { + const title = "My Cool Product"; + let response; + let dom; + let body; + + before(async function () { + response = await fetch(`${hostname}/events/${title.replace(/ /g, "-").toLowerCase()}/`); + body = await response.clone().text(); + dom = new JSDOM(body); + }); + + it("should have the expected output for the page", function () { + const headings = dom.window.document.querySelectorAll("h1"); + + expect(headings.length).to.equal(1); + expect(headings[0].textContent).to.equal(title.toUpperCase().replace(/-/g, " ")); + }); + }); }); after(async function () { diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/events/[title].js b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/events/[title].js new file mode 100644 index 000000000..077b7ecb7 --- /dev/null +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/events/[title].js @@ -0,0 +1,14 @@ +export default class EventDetailPage extends HTMLElement { + #title; + + constructor({ params }) { + super(); + this.#title = params?.title ?? "No Title"; + } + + async connectedCallback() { + this.innerHTML = ` +

${this.#title.toUpperCase().replace(/-/g, " ")}

+ `; + } +} From 390d35040975f0bcee700f439349c4e103a2bbe4 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sun, 7 Jun 2026 15:33:55 -0400 Subject: [PATCH 07/17] feature(cli): #1622 prerender refactoring to support hybrid rendering --- packages/cli/src/commands/serve.js | 21 +++++-- packages/cli/src/lib/execute-route-module.js | 14 +---- packages/cli/src/lifecycles/bundle.js | 55 +++++++++++++++---- packages/cli/src/lifecycles/graph.js | 5 +- packages/cli/src/lifecycles/prerender.js | 6 +- packages/cli/src/lifecycles/serve.js | 37 +++++++++++-- .../build.default.ssr-prerender.spec.js | 11 ++++ .../greenwood.config.js | 3 + .../src/pages/events/[title].js | 2 + 9 files changed, 116 insertions(+), 38 deletions(-) create mode 100644 packages/cli/test/cases/serve.dynamic-routing-static-paths/greenwood.config.js diff --git a/packages/cli/src/commands/serve.js b/packages/cli/src/commands/serve.js index 6fa0a0829..5bd80a2cd 100644 --- a/packages/cli/src/commands/serve.js +++ b/packages/cli/src/commands/serve.js @@ -5,11 +5,22 @@ const runProdServer = async (compilation) => { const { basePath, port } = compilation.config; const postfixSlash = basePath === "" ? "" : "/"; const hasApisDir = await checkResourceExists(compilation.context.apisDir); - const hasDynamicRoutes = compilation.graph.find((page) => page.isSSR && !page.prerender); - const server = - (hasDynamicRoutes && !compilation.config.prerender) || hasApisDir - ? getHybridServer - : getStaticServer; + // const hasDynamicRoutes = compilation.graph.find((page) => page.isSSR && !page.prerender && !page.staticPaths); + // const hasDynamicRoutes = compilation.graph.find( + + // (page) => page.isSSR && !page.staticPaths && (page.prerender !== true || (!compilation.config.prerender && page.prerender !== false)) + // ); + const hasDynamicRoutes = compilation.graph.find((page) => { + let is = page.isSSR && !page.staticPaths && page.prerender !== true; + + if (is && compilation.config.prerender && page.prerender !== false) { + is = false; + } + + return is; + }); + console.log("*****", { hasDynamicRoutes, hasApisDir, prerender: compilation.config.prerender }); + const server = hasDynamicRoutes || hasApisDir ? getHybridServer : getStaticServer; (await server(compilation)).listen(port, () => { console.info(`Started server at http://localhost:${port}${basePath}${postfixSlash}`); diff --git a/packages/cli/src/lib/execute-route-module.js b/packages/cli/src/lib/execute-route-module.js index a8ef5427b..346f472aa 100644 --- a/packages/cli/src/lib/execute-route-module.js +++ b/packages/cli/src/lib/execute-route-module.js @@ -29,7 +29,7 @@ async function executeRouteModule({ const module = await import(moduleUrl).then((module) => module); const { body, layout, frontmatter, statics } = contentOptions; const { - prerender = false, + prerender = null, getLayout = null, getBody = null, getFrontmatter = null, @@ -44,28 +44,17 @@ async function executeRouteModule({ data.hasStaticParams = true; } - console.log("executeRouteModule", { params, page }); if (params) { if (page.staticPaths) { const staticPaths = page.staticPaths ?? []; - console.log({ staticPaths }); - console.log(staticPaths[0]); - if (page.hasStaticParams) { - console.log( - "has static props?", - staticPaths.find( - (staticPath) => staticPath.params[page.segment.key] === params[page.segment.key], - ), - ); const initParams = { ...params, ...staticPaths.find( (staticPath) => staticPath.params[page.segment.key] === params[page.segment.key], ), }; - console.log({ initParams }); const staticParams = module.getStaticParams ? await module.getStaticParams(initParams) @@ -77,7 +66,6 @@ async function executeRouteModule({ }; } } - console.log("final params", { params }); } if (body) { diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index 01f470463..3761d5fa4 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -116,13 +116,29 @@ async function cleanUpResources(compilation) { async function optimizeStaticPages(compilation, plugins) { const { scratchDir, outputDir } = compilation.context; - const pages = compilation.graph.filter( - (page) => - !page.isSSR || - (page.isSSR && page.prerender) || - (page.isSSR && compilation.config.prerender) || - page.staticPaths, - ); + // let is = page.isSSR && !page.staticPaths && page.prerender !== true; + + // if(is && (config.prerender && page.prerender !== false)) { + // is = false; + // } + + // return is; + const pages = compilation.graph.filter((page) => { + console.log({ page }); + let is = !page.isSSR || page.staticPaths || page.prerender === true; + + console.log("optimizeStaticPages 111", { is }); + if (page.isSSR && compilation.config.prerender && page.prerender !== false) { + is = true; + } + console.log("optimizeStaticPages 222", { is }); + return is; + // return !page.isSSR || + // (page.isSSR && page.prerender === true) || + // (page.isSSR && ((page.prerender !== false) && compilation.config.prerender)) || + // page.staticPaths + }); + console.log("@@@@@@@@@ optimizeStaticPages", { pages }); await asyncForEach(pages, async (page) => { const { outputHref, route, segment, staticPaths } = page; @@ -331,13 +347,29 @@ async function bundleApiRoutes(compilation) { async function bundleSsrPages(compilation, optimizePlugins) { const { context, config } = compilation; - const ssrPages = compilation.graph.filter( - (page) => page.isSSR && !page.prerender && !page.staticPaths, - ); + console.log({ config }); + const ssrPages = compilation.graph.filter((page) => { + let is = page.isSSR && !page.staticPaths && page.prerender !== true; + + if (is && config.prerender && page.prerender !== false) { + is = false; + } + + return is; + // return page.isSSR && !page.staticPaths && page.prerender !== true || (config.prerender && page.prerender === false)); + // // && (config.prerender === true && page.prerender === false ? true : false) + // // if(config.prerender && page.prerender !== false) { + // // is = true; + // // } + // // console.log({ page, is }); + // // return is; + }); + console.log("$$$$$$", { ssrPages }); + console.log("config prerender", config.prerender); const ssrPrerenderPagesRouteMapper = {}; const input = []; - if (!config.prerender && ssrPages.length > 0) { + if (ssrPages.length > 0) { const { executeModuleUrl } = config.plugins .find((plugin) => plugin.type === "renderer") .provider(); @@ -448,6 +480,7 @@ async function bundleSsrPages(compilation, optimizePlugins) { }); const ssrConfigs = await getRollupConfigForSsrPages(compilation, input); + console.log("$$$$$$", { input, ssrConfigs }); if (ssrConfigs.length > 0 && ssrConfigs[0].input !== "") { console.info("bundling dynamic pages..."); diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js index 92754b47d..c445df76c 100644 --- a/packages/cli/src/lifecycles/graph.js +++ b/packages/cli/src/lifecycles/graph.js @@ -150,7 +150,7 @@ const generateGraph = async (compilation) => { let label = getLabelFromRoute(`${route}/`); let imports = []; let customData = {}; - let prerender = true; + let prerender = isStatic === true ? true : null; let isolation = false; let hydration = false; let staticPaths = null; @@ -195,7 +195,8 @@ const generateGraph = async (compilation) => { const worker = new Worker(new URL("../lib/ssr-route-worker.js", import.meta.url)); worker.on("message", (result) => { - prerender = result.prerender ?? false; + prerender = + result.prerender === true || result.prerender === false ? result.prerender : null; isolation = result.isolation ?? isolation; hydration = result.hydration ?? hydration; diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index 4f47aeaab..9de0c4427 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -76,10 +76,12 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { (page) => !page.isSSR || (page.isSSR && page.prerender) || - (page.isSSR && compilation.config.prerender) || + // (page.isSSR && compilation.config.prerender) || + (page.isSSR && page.prerender !== false && compilation.config.prerender) || + // (page.isSSR && page.prerender !== false && page.prerender !== null && compilation.config.prerender) || page.staticPaths, ); - console.log("@@@@@@@@@", { pages }); + console.log("@@@@@@@@@ preRenderCompilationWorker", { pages }); const { context, config } = compilation; const plugins = getPluginInstances(compilation); diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index 1f0f675e2..0cab567ff 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -341,7 +341,7 @@ async function getStaticServer(compilation, composable) { ? isSPA.outputHref : isStatic && !matchingRoute.staticPaths ? matchingRoute.outputHref - : matchingRoute.staticPaths + : matchingRoute?.staticPaths ? new URL(`.${url.pathname.replace(basePath, "")}index.html`, outputDir).href : new URL(`.${url.pathname.replace(basePath, "")}`, outputDir).href; console.log({ outputHref }); @@ -375,6 +375,7 @@ async function getHybridServer(compilation) { app.use(async (ctx) => { try { const url = new URL(`http://localhost:${config.port}${ctx.url}`); + console.log("getHybridServer url???", { url }); const { pathname } = url; const matchingRoute = graph.find((node) => node.route === url.pathname) || { data: {} }; const isApiRoute = manifest.apis.has(url.pathname); @@ -385,13 +386,39 @@ async function getHybridServer(compilation) { ); const matchingRouteWithSegment = getMatchingDynamicSsrRoute(compilation.graph, pathname) || {}; + console.log("!!!!!", { url, matchingRoute, matchingRouteWithSegment }); + let is = + (matchingRoute.isSSR || matchingRouteWithSegment.isSSR) && + !matchingRouteWithSegment.staticPaths; + console.log("1111", { is }); + if ( + (matchingRoute.isSSR && config.prerender === true && matchingRoute.prerender !== false) || + (matchingApiRouteWithSegment?.isSSR && + config.prerender === true && + matchingRouteWithSegment?.prerender !== false) + ) { + is = false; + } + console.log("222", { is }); + // page.isSSR && !page.staticPaths && (page.prerender !== true || !config.prerender && page.prerender !== false) + // let is = page.isSSR && !page.staticPaths && page.prerender !== true; + + // if(is && (config.prerender && page.prerender !== false)) { + // is = false; + // } + + // return is; if ( - !config.prerender && - (matchingRoute.isSSR || matchingRouteWithSegment.isSSR) && - !matchingRoute.prerender && - !matchingRouteWithSegment.staticPaths + is + // (matchingRoute.isSSR || matchingRouteWithSegment.isSSR) && + // !matchingRouteWithSegment.staticPaths && + // (matchingRoute.prerender !== true || (!config.prerender && matchingRoute.prerender !== false)) && + // (matchingRouteWithSegment.prerender !== true || (!config.prerender && matchingRouteWithSegment.prerender !== false)) + // (matchingRoute.prerender !== true && matchingRouteWithSegment.prerender !== true) && + // ((matchingRoute.prerender !== null && matchingRouteWithSegment.prerender !== null) && compilation.config.prerender) ) { + console.log("getHybridServer serving url => ", { url }); const entryPointUrl = new URL( matchingRoute?.outputHref ?? matchingRouteWithSegment.outputHref, ); diff --git a/packages/cli/test/cases/build.default.ssr-prerender/build.default.ssr-prerender.spec.js b/packages/cli/test/cases/build.default.ssr-prerender/build.default.ssr-prerender.spec.js index f3dc95bb0..5f37fd150 100644 --- a/packages/cli/test/cases/build.default.ssr-prerender/build.default.ssr-prerender.spec.js +++ b/packages/cli/test/cases/build.default.ssr-prerender/build.default.ssr-prerender.spec.js @@ -28,6 +28,7 @@ */ import { expect } from "chai"; import { JSDOM } from "jsdom"; +import fs from "node:fs/promises"; import path from "node:path"; import { getOutputTeardownFiles } from "../../../../../test/utils.js"; import { runSmokeTest } from "../../../../../test/smoke-test.js"; @@ -59,6 +60,16 @@ describe("Build Greenwood With: ", function () { runSmokeTest(["public", "index"], LABEL); + describe("Build Output", function () { + it("should not have any ssr page outputs", async function () { + const files = ( + await Array.fromAsync(fs.glob("*.js", { cwd: new URL("./public", import.meta.url) })) + ).filter((file) => file.startsWith("about.route") || file.startsWith("index.route")); + + expect(files.length).to.equal(0); + }); + }); + describe("Build command that prerenders the SSR home page with multiple custom elements", function () { let dom; diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/greenwood.config.js b/packages/cli/test/cases/serve.dynamic-routing-static-paths/greenwood.config.js new file mode 100644 index 000000000..1d1aad744 --- /dev/null +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/greenwood.config.js @@ -0,0 +1,3 @@ +export default { + prerender: true, +}; diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/events/[title].js b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/events/[title].js index 077b7ecb7..a8a3d1a34 100644 --- a/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/events/[title].js +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/events/[title].js @@ -12,3 +12,5 @@ export default class EventDetailPage extends HTMLElement { `; } } + +export const prerender = false; From 9fd946fe0bdc1e8b29fe9822720455a68e654db9 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 13 Jun 2026 17:10:00 -0400 Subject: [PATCH 08/17] feature(cli): #1622 guard adapter plugins from adapting prerendered routes --- packages/cli/src/commands/serve.js | 16 ++------------ packages/cli/src/lib/graph-utils.js | 15 +++++++++++++ packages/cli/src/lifecycles/bundle.js | 21 ++----------------- packages/plugin-adapter-aws/src/index.js | 3 ++- .../build.dynamic-routing.spec.js | 6 ++++-- .../src/pages/event/title.js | 10 +++++++++ packages/plugin-adapter-netlify/src/index.js | 3 ++- .../build.dynamic-routing.spec.js | 2 ++ .../src/pages/event/title.js | 10 +++++++++ packages/plugin-adapter-vercel/src/index.js | 3 ++- .../build.dynamic-routing.spec.js | 2 ++ .../src/pages/event/title.js | 10 +++++++++ 12 files changed, 63 insertions(+), 38 deletions(-) create mode 100644 packages/cli/src/lib/graph-utils.js create mode 100644 packages/plugin-adapter-aws/test/cases/build.dynamic-routing/src/pages/event/title.js create mode 100644 packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/src/pages/event/title.js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/src/pages/event/title.js diff --git a/packages/cli/src/commands/serve.js b/packages/cli/src/commands/serve.js index 5bd80a2cd..46ef7a3ad 100644 --- a/packages/cli/src/commands/serve.js +++ b/packages/cli/src/commands/serve.js @@ -1,24 +1,12 @@ import { getStaticServer, getHybridServer } from "../lifecycles/serve.js"; import { checkResourceExists } from "../lib/resource-utils.js"; +import { getDynamicPages } from "../lib/graph-utils.js"; const runProdServer = async (compilation) => { const { basePath, port } = compilation.config; const postfixSlash = basePath === "" ? "" : "/"; const hasApisDir = await checkResourceExists(compilation.context.apisDir); - // const hasDynamicRoutes = compilation.graph.find((page) => page.isSSR && !page.prerender && !page.staticPaths); - // const hasDynamicRoutes = compilation.graph.find( - - // (page) => page.isSSR && !page.staticPaths && (page.prerender !== true || (!compilation.config.prerender && page.prerender !== false)) - // ); - const hasDynamicRoutes = compilation.graph.find((page) => { - let is = page.isSSR && !page.staticPaths && page.prerender !== true; - - if (is && compilation.config.prerender && page.prerender !== false) { - is = false; - } - - return is; - }); + const hasDynamicRoutes = getDynamicPages(compilation).length > 0; console.log("*****", { hasDynamicRoutes, hasApisDir, prerender: compilation.config.prerender }); const server = hasDynamicRoutes || hasApisDir ? getHybridServer : getStaticServer; diff --git a/packages/cli/src/lib/graph-utils.js b/packages/cli/src/lib/graph-utils.js new file mode 100644 index 000000000..9dd3e7ef2 --- /dev/null +++ b/packages/cli/src/lib/graph-utils.js @@ -0,0 +1,15 @@ +function getDynamicPages(compilation) { + const { config, graph } = compilation; + + return graph.filter((page) => { + let isSsrRoute = page.isSSR && !page.staticPaths && page.prerender !== true; + + if (isSsrRoute && config.prerender && page.prerender !== false) { + isSsrRoute = false; + } + + return isSsrRoute; + }); +} + +export { getDynamicPages }; diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index 3761d5fa4..68fd76b10 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -17,6 +17,7 @@ import path from "node:path"; import { rollup } from "rollup"; import { pruneGraph } from "../lib/content-utils.js"; import { asyncForEach } from "../lib/async-utils.js"; +import { getDynamicPages } from "../lib/graph-utils.js"; async function interceptPage(url, request, plugins, body) { let response = new Response(body, { @@ -347,25 +348,7 @@ async function bundleApiRoutes(compilation) { async function bundleSsrPages(compilation, optimizePlugins) { const { context, config } = compilation; - console.log({ config }); - const ssrPages = compilation.graph.filter((page) => { - let is = page.isSSR && !page.staticPaths && page.prerender !== true; - - if (is && config.prerender && page.prerender !== false) { - is = false; - } - - return is; - // return page.isSSR && !page.staticPaths && page.prerender !== true || (config.prerender && page.prerender === false)); - // // && (config.prerender === true && page.prerender === false ? true : false) - // // if(config.prerender && page.prerender !== false) { - // // is = true; - // // } - // // console.log({ page, is }); - // // return is; - }); - console.log("$$$$$$", { ssrPages }); - console.log("config prerender", config.prerender); + const ssrPages = getDynamicPages(compilation); const ssrPrerenderPagesRouteMapper = {}; const input = []; diff --git a/packages/plugin-adapter-aws/src/index.js b/packages/plugin-adapter-aws/src/index.js index 5c6c2343c..6b87a7124 100644 --- a/packages/plugin-adapter-aws/src/index.js +++ b/packages/plugin-adapter-aws/src/index.js @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { checkResourceExists } from "@greenwood/cli/src/lib/resource-utils.js"; +import { getDynamicPages } from "@greenwood/cli/src/lib/graph-utils.js"; // https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html#apigateway-example-event // https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html @@ -77,7 +78,7 @@ async function awsAdapter(compilation) { const { outputDir, projectDirectory } = compilation.context; const { basePath } = compilation.config; const adapterOutputUrl = new URL("./.aws-output/", projectDirectory); - const ssrPages = compilation.graph.filter((page) => page.isSSR); + const ssrPages = getDynamicPages(compilation); const apiRoutes = compilation.manifest.apis; if (await checkResourceExists(adapterOutputUrl)) { diff --git a/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js b/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js index 70424ece6..855c9e979 100644 --- a/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js +++ b/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js @@ -25,6 +25,8 @@ * [id].ts * blog/ * [slug].ts + * event/ + * title.js # has prerender = true */ import { expect } from "chai"; import fs from "node:fs/promises"; @@ -73,11 +75,11 @@ describe("Build Greenwood With: ", function () { }); it("should output the expected number of serverless function output folders for SSR pages", function () { - expect(functionFolders.length).to.be.equal(1); + expect(routeFolders.length).to.be.equal(1); }); it("should output the expected number of serverless function output folders for API routes", function () { - expect(routeFolders.length).to.be.equal(1); + expect(functionFolders.length).to.be.equal(1); }); it("should output the expected package.json for each serverless function", function () { diff --git a/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/src/pages/event/title.js b/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/src/pages/event/title.js new file mode 100644 index 000000000..e02def26a --- /dev/null +++ b/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/src/pages/event/title.js @@ -0,0 +1,10 @@ +export default class EventDetailsPage extends HTMLElement { + async connectedCallback() { + this.innerHTML = ` +

Events

+ `; + } +} + +// make sure prerendering does not get treated as a serverless function +export const prerender = true; diff --git a/packages/plugin-adapter-netlify/src/index.js b/packages/plugin-adapter-netlify/src/index.js index 0f9fe6e76..91d21b580 100644 --- a/packages/plugin-adapter-netlify/src/index.js +++ b/packages/plugin-adapter-netlify/src/index.js @@ -4,6 +4,7 @@ import { checkResourceExists, normalizePathnameForWindows, } from "@greenwood/cli/src/lib/resource-utils.js"; +import { getDynamicPages } from "@greenwood/cli/src/lib/graph-utils.js"; import { zip } from "zip-a-folder"; // https://docs.netlify.com/functions/create/?fn-language=js @@ -93,7 +94,7 @@ async function netlifyAdapter(compilation) { const { outputDir, projectDirectory, scratchDir } = compilation.context; const adapterOutputUrl = new URL("./netlify/functions/", projectDirectory); const adapterOutputScratchUrl = new URL("./netlify/functions/", scratchDir); - const ssrPages = compilation.graph.filter((page) => page.isSSR); + const ssrPages = getDynamicPages(compilation); const apiRoutes = compilation.manifest.apis; // https://docs.netlify.com/routing/redirects/ // https://docs.netlify.com/routing/redirects/rewrites-proxies/ diff --git a/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js b/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js index 48f61799d..5fd5ad373 100644 --- a/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js +++ b/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js @@ -25,6 +25,8 @@ * [id].ts * blog/ * [slug].ts + *. event/ + * title.js # has prerender = true */ import { expect } from "chai"; import fs from "node:fs/promises"; diff --git a/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/src/pages/event/title.js b/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/src/pages/event/title.js new file mode 100644 index 000000000..e02def26a --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/src/pages/event/title.js @@ -0,0 +1,10 @@ +export default class EventDetailsPage extends HTMLElement { + async connectedCallback() { + this.innerHTML = ` +

Events

+ `; + } +} + +// make sure prerendering does not get treated as a serverless function +export const prerender = true; diff --git a/packages/plugin-adapter-vercel/src/index.js b/packages/plugin-adapter-vercel/src/index.js index 4fcf2cbde..628f3cb26 100644 --- a/packages/plugin-adapter-vercel/src/index.js +++ b/packages/plugin-adapter-vercel/src/index.js @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { checkResourceExists } from "@greenwood/cli/src/lib/resource-utils.js"; +import { getDynamicPages } from "@greenwood/cli/src/lib/graph-utils.js"; const DEFAULT_RUNTIME = "nodejs24.x"; @@ -87,7 +88,7 @@ async function vercelAdapter(compilation, options) { const { runtime = DEFAULT_RUNTIME } = options; const { outputDir, projectDirectory } = compilation.context; const adapterOutputUrl = new URL("./.vercel/output/functions/", projectDirectory); - const ssrPages = compilation.graph.filter((page) => page.isSSR); + const ssrPages = getDynamicPages(compilation); const apiRoutes = compilation.manifest.apis; // https://vercel.com/docs/build-output-api/configuration#routes const dynamicRoutes = []; diff --git a/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js b/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js index 77e06217e..a3c8f26e5 100644 --- a/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js +++ b/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js @@ -19,6 +19,8 @@ * [id].ts * blog/ * [slug].ts + * event/ + * title.js # has prerender = true */ import { expect } from "chai"; import fs from "node:fs/promises"; diff --git a/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/src/pages/event/title.js b/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/src/pages/event/title.js new file mode 100644 index 000000000..e02def26a --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/src/pages/event/title.js @@ -0,0 +1,10 @@ +export default class EventDetailsPage extends HTMLElement { + async connectedCallback() { + this.innerHTML = ` +

Events

+ `; + } +} + +// make sure prerendering does not get treated as a serverless function +export const prerender = true; From 5678cd5f6d60ffd569af555cc24ae48b1e8a178c Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 13 Jun 2026 17:16:04 -0400 Subject: [PATCH 09/17] feature(cli): #1622 add more cases to adapter test suites --- .../build.dynamic-routing.spec.js | 6 ++-- .../src/pages/artists/[name].js | 32 +++++++++++++++++++ .../build.dynamic-routing.spec.js | 8 +++-- .../src/pages/artists/[name].js | 32 +++++++++++++++++++ .../build.dynamic-routing.spec.js | 6 ++-- .../src/pages/artists/[name].js | 32 +++++++++++++++++++ 6 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 packages/plugin-adapter-aws/test/cases/build.dynamic-routing/src/pages/artists/[name].js create mode 100644 packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/src/pages/artists/[name].js create mode 100644 packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/src/pages/artists/[name].js diff --git a/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js b/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js index 855c9e979..7ad3d6125 100644 --- a/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js +++ b/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js @@ -22,9 +22,11 @@ * pages/ * api/ * product/ - * [id].ts + * [id].js + * artists/ + * [name].js # uses getStaticPaths * blog/ - * [slug].ts + * [slug].js * event/ * title.js # has prerender = true */ diff --git a/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/src/pages/artists/[name].js b/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/src/pages/artists/[name].js new file mode 100644 index 000000000..1590e1d1f --- /dev/null +++ b/packages/plugin-adapter-aws/test/cases/build.dynamic-routing/src/pages/artists/[name].js @@ -0,0 +1,32 @@ +export async function getStaticPaths() { + const artists = [ + { + name: "Foo", + }, + { + name: "Bar", + }, + { + name: "Baz", + }, + ]; + + return artists.map((artist) => { + return { + params: { + name: artist.name.toLowerCase().replace(/ /g, "-"), + id: artist.id, + }, + }; + }); +} + +export default class ArtistDetailsPage extends HTMLElement { + connectedCallback() { + this.innerHTML = ` + +

Some Artists Page

+ + `; + } +} diff --git a/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js b/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js index 5fd5ad373..05599b8e9 100644 --- a/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js +++ b/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js @@ -22,10 +22,12 @@ * pages/ * api/ * product/ - * [id].ts + * [id].js + * artists/ + * [name].js # uses getStaticPaths * blog/ - * [slug].ts - *. event/ + * [slug].js + * event/ * title.js # has prerender = true */ import { expect } from "chai"; diff --git a/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/src/pages/artists/[name].js b/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/src/pages/artists/[name].js new file mode 100644 index 000000000..1590e1d1f --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.dynamic-routing/src/pages/artists/[name].js @@ -0,0 +1,32 @@ +export async function getStaticPaths() { + const artists = [ + { + name: "Foo", + }, + { + name: "Bar", + }, + { + name: "Baz", + }, + ]; + + return artists.map((artist) => { + return { + params: { + name: artist.name.toLowerCase().replace(/ /g, "-"), + id: artist.id, + }, + }; + }); +} + +export default class ArtistDetailsPage extends HTMLElement { + connectedCallback() { + this.innerHTML = ` + +

Some Artists Page

+ + `; + } +} diff --git a/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js b/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js index a3c8f26e5..148917f82 100644 --- a/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js +++ b/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/build.dynamic-routing.spec.js @@ -16,9 +16,11 @@ * pages/ * api/ * product/ - * [id].ts + * [id].js + * artists/ + * [name].js # uses getStaticPaths * blog/ - * [slug].ts + * [slug].js * event/ * title.js # has prerender = true */ diff --git a/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/src/pages/artists/[name].js b/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/src/pages/artists/[name].js new file mode 100644 index 000000000..1590e1d1f --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.dynamic-routing/src/pages/artists/[name].js @@ -0,0 +1,32 @@ +export async function getStaticPaths() { + const artists = [ + { + name: "Foo", + }, + { + name: "Bar", + }, + { + name: "Baz", + }, + ]; + + return artists.map((artist) => { + return { + params: { + name: artist.name.toLowerCase().replace(/ /g, "-"), + id: artist.id, + }, + }; + }); +} + +export default class ArtistDetailsPage extends HTMLElement { + connectedCallback() { + this.innerHTML = ` + +

Some Artists Page

+ + `; + } +} From 32bbd6e492c7f0d00df4e8be54210a8d8c4536e7 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 13 Jun 2026 18:57:58 -0400 Subject: [PATCH 10/17] feature(plugins): #1622 support get static paths and props for lit renderer --- packages/cli/src/lib/execute-route-module.js | 2 +- .../src/execute-route-module.js | 64 +++++++++++++++---- packages/plugin-renderer-lit/src/index.js | 15 ++++- .../cases/serve.default/serve.default.spec.js | 54 ++++++++++++++++ .../serve.default/src/pages/blog/[slug].js | 55 ++++++++++++++++ 5 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 packages/plugin-renderer-lit/test/cases/serve.default/src/pages/blog/[slug].js diff --git a/packages/cli/src/lib/execute-route-module.js b/packages/cli/src/lib/execute-route-module.js index 346f472aa..172e65623 100644 --- a/packages/cli/src/lib/execute-route-module.js +++ b/packages/cli/src/lib/execute-route-module.js @@ -8,7 +8,7 @@ async function executeRouteModule({ htmlContents = null, scripts = [], request, - params, + params = {}, contentOptions = {}, }) { const data = { diff --git a/packages/plugin-renderer-lit/src/execute-route-module.js b/packages/plugin-renderer-lit/src/execute-route-module.js index df7a9cab0..921e26825 100644 --- a/packages/plugin-renderer-lit/src/execute-route-module.js +++ b/packages/plugin-renderer-lit/src/execute-route-module.js @@ -8,13 +8,13 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js"; async function executeRouteModule({ moduleUrl, compilation, - page, - prerender, - htmlContents, - scripts, + page = {}, + prerender = false, + htmlContents = null, + scripts = [], request, - contentOptions = {}, params = {}, + contentOptions = {}, }) { const data = { layout: null, @@ -22,6 +22,8 @@ async function executeRouteModule({ frontmatter: null, html: null, hydration: false, + staticPaths: null, + hasStaticParams: null, }; // prerender static content @@ -35,7 +37,7 @@ async function executeRouteModule({ data.html = await collectResult(render(templateResult)); } else { const module = await import(moduleUrl).then((module) => module); - const { body, layout, frontmatter } = contentOptions; + const { body, layout, frontmatter, statics } = contentOptions; const { getLayout = null, getBody = null, @@ -52,6 +54,38 @@ async function executeRouteModule({ data.hydration = true; } + if (statics && module.getStaticPaths) { + data.staticPaths = await module.getStaticPaths(); + } + + if (statics && module.getStaticParams) { + data.hasStaticParams = true; + } + + if (params) { + if (page.staticPaths) { + const staticPaths = page.staticPaths ?? []; + + if (page.hasStaticParams) { + const initParams = { + ...params, + ...staticPaths.find( + (staticPath) => staticPath.params[page.segment.key] === params[page.segment.key], + ), + }; + + const staticParams = module.getStaticParams + ? await module.getStaticParams(initParams) + : {}; + + params = { + ...params, + ...staticParams, + }; + } + } + } + if (body) { if (module.default) { // for the Lit implementation, we render the custom element programmatically @@ -62,7 +96,12 @@ async function executeRouteModule({ const attributes = Object.entries(params).length > 0 ? Object.entries(params) - .map(([key, value]) => `${key}="${value}"`) + // literal object attributes need to be JSON stringified for Lit + // https://github.com/lit/lit/discussions/2714#discussioncomment-2521396 + .map( + ([key, value]) => + `${key}='${typeof value === "object" ? JSON.stringify(value) : value}'`, + ) .join(" ") : ""; const litAttributes = literal`${unsafeStatic(attributes)}`; @@ -96,10 +135,6 @@ async function executeRouteModule({ } } - // TODO: layouts as SSR pages - // TODO: constructor props / dynamic routing - // https://github.com/ProjectEvergreen/greenwood/issues/1248 - if (layout) { // support dynamic layouts that are just custom elements vs calls to getLayout if (!getLayout && !data.body && !page.isSSR && module.default) { @@ -111,7 +146,12 @@ async function executeRouteModule({ const attributes = Object.entries(params).length > 0 ? Object.entries(params) - .map(([key, value]) => `${key}="${value}"`) + // literal object attributes need to be JSON stringified for Lit + // https://github.com/lit/lit/discussions/2714#discussioncomment-2521396 + .map( + ([key, value]) => + `${key}='${typeof value === "object" ? JSON.stringify(value) : value}'`, + ) .join(" ") : ""; const litAttributes = literal`${unsafeStatic(attributes)}`; diff --git a/packages/plugin-renderer-lit/src/index.js b/packages/plugin-renderer-lit/src/index.js index 594e70bbc..6b7477981 100755 --- a/packages/plugin-renderer-lit/src/index.js +++ b/packages/plugin-renderer-lit/src/index.js @@ -1,16 +1,25 @@ +import { getMatchingDynamicSsrRoute } from "@greenwood/cli/src/lib/url-utils.js"; + class LitHydrationResource { constructor(compilation, options) { this.compilation = compilation; this.options = options; } - async shouldIntercept(url) { + async shouldIntercept(url, request, response) { const { pathname } = url; + + if (response.headers?.get("Content-Type")?.indexOf("text/html") < 0) { + return false; + } + + const matchingRouteWithSegment = getMatchingDynamicSsrRoute(this.compilation.graph, pathname); const matchingRoute = this.compilation.graph.find((node) => node.route === pathname); return ( - matchingRoute && - ((matchingRoute.isSSR && matchingRoute.hydration) || this.compilation.config.prerender) + matchingRouteWithSegment || + (matchingRoute && + ((matchingRoute.isSSR && matchingRoute.hydration) || this.compilation.config.prerender)) ); } diff --git a/packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js b/packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js index fa0802132..c7331803d 100644 --- a/packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js +++ b/packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js @@ -28,6 +28,8 @@ * search.js * artist/ * [name].js + * blog/ + * [slug].js # uses getStaticPaths / getStaticParams * product/ * [id].js * artists.js @@ -311,6 +313,58 @@ describe("Serve Greenwood With: ", function () { }); }); + describe("Serve command with HTML route response using LitElement as a default export for the blog post pages using getStaticPaths / getStaticProps", function () { + let blogPostResponse = {}; + let blogPostPageDom; + let blogPostPageHtml; + + before(async function () { + blogPostResponse = await fetch(`${hostname}/blog/third-post/`); + blogPostPageHtml = await blogPostResponse.text(); + blogPostPageDom = new JSDOM(blogPostPageHtml); + }); + + // test exported pages count + it("the response body should be valid HTML from JSDOM", function (done) { + expect(blogPostPageDom).to.not.be.undefined; + done(); + }); + + it("the expected number of static paths generated", async function () { + const files = await Array.fromAsync( + fs.promises.glob("blog/**/*.html", { cwd: new URL("./public", import.meta.url) }), + ); + + expect(files.length).to.equal(3); + }); + + it("should have the expected

text in the ", function () { + const heading = blogPostPageDom.window.document.querySelectorAll("h1"); + + expect(heading.length).to.equal(1); + expect(heading[0].textContent).to.equal("Third Post"); + }); + + it("should have the expected title output", function () { + const title = blogPostPageDom.window.document.querySelectorAll("p"); + + expect(title.length).to.equal(1); + expect(title[0].textContent).to.equal("This is the third post"); + }); + + it("should have the expected lit hydration script in the ", function () { + const scripts = Array.from( + blogPostPageDom.window.document.querySelectorAll("head script"), + ).filter( + (script) => + !script.getAttribute("src") && + script.textContent?.indexOf("globalThis.litElementHydrateSupport") >= 0, + ); + + expect(scripts.length).to.equal(1); + }); + }); + describe("Serve command with HTML route response using LitElement as a default export for a dynamic route with params", function () { const productId = 1; let productDetailsResponse = {}; diff --git a/packages/plugin-renderer-lit/test/cases/serve.default/src/pages/blog/[slug].js b/packages/plugin-renderer-lit/test/cases/serve.default/src/pages/blog/[slug].js new file mode 100644 index 000000000..8e7dcfed5 --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/serve.default/src/pages/blog/[slug].js @@ -0,0 +1,55 @@ +import { LitElement, html } from "lit"; + +function getPosts() { + return [ + { + slug: "first-post", + title: "First Post", + content: "This is the first post", + }, + { + slug: "second-post", + title: "Second Post", + content: "This is the second post", + }, + { + slug: "third-post", + title: "Third Post", + content: "This is the third post", + }, + ]; +} + +export async function getStaticPaths() { + return getPosts().map((post) => { + return { + params: { + slug: post.slug, + }, + }; + }); +} + +export async function getStaticParams({ params }) { + const post = getPosts().find((post) => post.slug === params.slug); + + return { post }; +} + +export default class BlogPostDetailsPage extends LitElement { + static get properties() { + return { + post: { type: Object }, + }; + } + + render() { + const { post } = this; + console.log("render post", { post }); + + return html` +

${post.title}

+

${post.content}

+ `; + } +} From 256960969213188573bcfa1aff8aad0cdeead8ef Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 13 Jun 2026 19:07:26 -0400 Subject: [PATCH 11/17] chore(cli): #1622 update generic adapter test case for dynamic routing --- .../build.config.plugins-adapter.spec.js | 4 +++ .../build.plugins.adapter/generic-adapter.js | 3 +- .../src/pages/artists/[name].js | 32 +++++++++++++++++++ .../src/pages/event/title.js | 10 ++++++ 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/cases/build.plugins.adapter/src/pages/artists/[name].js create mode 100644 packages/cli/test/cases/build.plugins.adapter/src/pages/event/title.js diff --git a/packages/cli/test/cases/build.plugins.adapter/build.config.plugins-adapter.spec.js b/packages/cli/test/cases/build.plugins.adapter/build.config.plugins-adapter.spec.js index be6d7a5be..a01fa109d 100644 --- a/packages/cli/test/cases/build.plugins.adapter/build.config.plugins-adapter.spec.js +++ b/packages/cli/test/cases/build.plugins.adapter/build.config.plugins-adapter.spec.js @@ -29,9 +29,13 @@ * endpoint.js * greeting.js * webhook.js + * artists/ + * [name].js # uses getStaticPaths * blog/ * first-post.js * index.js + * event/ + * title.js # has prerender = true * about.js * index.js */ diff --git a/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js b/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js index cc05cad20..311ec2b22 100644 --- a/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js +++ b/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { checkResourceExists } from "../../../../cli/src/lib/resource-utils.js"; +import { getDynamicPages } from "../../../../cli/src/lib/graph-utils.js"; function generateOutputFormat(id, type) { const path = type === "page" ? `/${id}.route` : `/api/${id}`; @@ -23,7 +24,7 @@ function generateOutputFormat(id, type) { async function genericAdapter(compilation) { const { outputDir } = compilation.context; const adapterOutputUrl = new URL("./adapter-output/", compilation.context.projectDirectory); - const ssrPages = compilation.graph.filter((page) => page.isSSR); + const ssrPages = getDynamicPages(compilation); const apiRoutes = compilation.manifest.apis; if (!(await checkResourceExists(adapterOutputUrl))) { diff --git a/packages/cli/test/cases/build.plugins.adapter/src/pages/artists/[name].js b/packages/cli/test/cases/build.plugins.adapter/src/pages/artists/[name].js new file mode 100644 index 000000000..1590e1d1f --- /dev/null +++ b/packages/cli/test/cases/build.plugins.adapter/src/pages/artists/[name].js @@ -0,0 +1,32 @@ +export async function getStaticPaths() { + const artists = [ + { + name: "Foo", + }, + { + name: "Bar", + }, + { + name: "Baz", + }, + ]; + + return artists.map((artist) => { + return { + params: { + name: artist.name.toLowerCase().replace(/ /g, "-"), + id: artist.id, + }, + }; + }); +} + +export default class ArtistDetailsPage extends HTMLElement { + connectedCallback() { + this.innerHTML = ` + +

Some Artists Page

+ + `; + } +} diff --git a/packages/cli/test/cases/build.plugins.adapter/src/pages/event/title.js b/packages/cli/test/cases/build.plugins.adapter/src/pages/event/title.js new file mode 100644 index 000000000..e02def26a --- /dev/null +++ b/packages/cli/test/cases/build.plugins.adapter/src/pages/event/title.js @@ -0,0 +1,10 @@ +export default class EventDetailsPage extends HTMLElement { + async connectedCallback() { + this.innerHTML = ` +

Events

+ `; + } +} + +// make sure prerendering does not get treated as a serverless function +export const prerender = true; From b14c774376179cdf13f01a39aabddbc3eca97619 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 13 Jun 2026 20:06:53 -0400 Subject: [PATCH 12/17] chore(cli): #1622 console log clean up and refactoring --- packages/cli/src/commands/build.js | 1 - packages/cli/src/commands/serve.js | 1 - packages/cli/src/lib/graph-utils.js | 14 +++++++- packages/cli/src/lifecycles/bundle.js | 32 ++----------------- packages/cli/src/lifecycles/graph.js | 1 - packages/cli/src/lifecycles/prerender.js | 22 +------------ packages/cli/src/lifecycles/serve.js | 31 +++--------------- .../plugins/resource/plugin-standard-html.js | 1 - 8 files changed, 20 insertions(+), 83 deletions(-) diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js index 970bc06ff..c2661f5b9 100644 --- a/packages/cli/src/commands/build.js +++ b/packages/cli/src/commands/build.js @@ -17,7 +17,6 @@ const runProductionBuild = async (compilation) => { : null; const pagesWithStaticPaths = compilation.graph.filter((page) => page.staticPaths); - // console.log({ prerender, pagesWithStaticPaths }); if (prerender || pagesWithStaticPaths.length > 0) { // start any of the user's server plugins if needed const servers = [ diff --git a/packages/cli/src/commands/serve.js b/packages/cli/src/commands/serve.js index 46ef7a3ad..fff7520ca 100644 --- a/packages/cli/src/commands/serve.js +++ b/packages/cli/src/commands/serve.js @@ -7,7 +7,6 @@ const runProdServer = async (compilation) => { const postfixSlash = basePath === "" ? "" : "/"; const hasApisDir = await checkResourceExists(compilation.context.apisDir); const hasDynamicRoutes = getDynamicPages(compilation).length > 0; - console.log("*****", { hasDynamicRoutes, hasApisDir, prerender: compilation.config.prerender }); const server = hasDynamicRoutes || hasApisDir ? getHybridServer : getStaticServer; (await server(compilation)).listen(port, () => { diff --git a/packages/cli/src/lib/graph-utils.js b/packages/cli/src/lib/graph-utils.js index 9dd3e7ef2..94a581a93 100644 --- a/packages/cli/src/lib/graph-utils.js +++ b/packages/cli/src/lib/graph-utils.js @@ -12,4 +12,16 @@ function getDynamicPages(compilation) { }); } -export { getDynamicPages }; +function getStaticPages(compilation) { + return compilation.graph.filter((page) => { + let isStaticRoute = !page.isSSR || page.staticPaths || page.prerender === true; + + if (page.isSSR && compilation.config.prerender && page.prerender !== false) { + isStaticRoute = true; + } + + return isStaticRoute; + }); +} + +export { getDynamicPages, getStaticPages }; diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index 68fd76b10..64e70a360 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -17,7 +17,7 @@ import path from "node:path"; import { rollup } from "rollup"; import { pruneGraph } from "../lib/content-utils.js"; import { asyncForEach } from "../lib/async-utils.js"; -import { getDynamicPages } from "../lib/graph-utils.js"; +import { getDynamicPages, getStaticPages } from "../lib/graph-utils.js"; async function interceptPage(url, request, plugins, body) { let response = new Response(body, { @@ -116,39 +116,13 @@ async function cleanUpResources(compilation) { async function optimizeStaticPages(compilation, plugins) { const { scratchDir, outputDir } = compilation.context; - - // let is = page.isSSR && !page.staticPaths && page.prerender !== true; - - // if(is && (config.prerender && page.prerender !== false)) { - // is = false; - // } - - // return is; - const pages = compilation.graph.filter((page) => { - console.log({ page }); - let is = !page.isSSR || page.staticPaths || page.prerender === true; - - console.log("optimizeStaticPages 111", { is }); - if (page.isSSR && compilation.config.prerender && page.prerender !== false) { - is = true; - } - console.log("optimizeStaticPages 222", { is }); - return is; - // return !page.isSSR || - // (page.isSSR && page.prerender === true) || - // (page.isSSR && ((page.prerender !== false) && compilation.config.prerender)) || - // page.staticPaths - }); - console.log("@@@@@@@@@ optimizeStaticPages", { pages }); + const pages = getStaticPages(compilation); await asyncForEach(pages, async (page) => { const { outputHref, route, segment, staticPaths } = page; if (staticPaths) { for (const staticPath of staticPaths) { - console.log({ staticPath }); - // TODO: is there a URL util for this? - const staticRoute = route.replace(`[${segment.key}]`, staticPath.params[segment.key]); const outputDirUrl = new URL( outputHref .replace(`[${segment.key}]`, staticPath.params[segment.key]) @@ -162,7 +136,6 @@ async function optimizeStaticPages(compilation, plugins) { ), "utf-8", ); - console.log({ staticRoute, outputDirUrl, url, contents }); const headers = new Headers({ "Content-Type": "text/html" }); let response = new Response(contents, { headers }); @@ -463,7 +436,6 @@ async function bundleSsrPages(compilation, optimizePlugins) { }); const ssrConfigs = await getRollupConfigForSsrPages(compilation, input); - console.log("$$$$$$", { input, ssrConfigs }); if (ssrConfigs.length > 0 && ssrConfigs[0].input !== "") { console.info("bundling dynamic pages..."); diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js index c445df76c..3052b5c9e 100644 --- a/packages/cli/src/lifecycles/graph.js +++ b/packages/cli/src/lifecycles/graph.js @@ -282,7 +282,6 @@ const generateGraph = async (compilation) => { basePath, }); - console.log("staticPaths???", { route, staticPaths, hasStaticParams }); const page = { id: decodeURIComponent(getIdFromRelativePathPath(relativePagePath, extension)), label: decodeURIComponent(label), diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index 9de0c4427..e8afd71ce 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -68,10 +68,6 @@ function toScratchUrl(outputHref, context) { } async function preRenderCompilationWorker(compilation, workerPrerender) { - // const pages = compilation.graph.filter( - // (page) => - // !page.isSSR && page.servePage !== "static" || (page.isSSR && (page.prerender || page.segment)) || (page.isSSR && compilation.config.prerender), - // ); const pages = compilation.graph.filter( (page) => !page.isSSR || @@ -81,7 +77,6 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { // (page.isSSR && page.prerender !== false && page.prerender !== null && compilation.config.prerender) || page.staticPaths, ); - console.log("@@@@@@@@@ preRenderCompilationWorker", { pages }); const { context, config } = compilation; const plugins = getPluginInstances(compilation); @@ -94,18 +89,12 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { // TODO: refactor / consolidate await asyncForEach(pages, async (page) => { - console.log("@@@@@ generating page...", { page }); - if (page.staticPaths) { - console.log("@@@@@ static paths", { staticPaths: page.staticPaths }); - for (const staticPath of page.staticPaths) { const { route, outputHref, segment } = page; - // TODO: is there a URL util for this? const staticRoute = route.replace(`[${segment.key}]`, staticPath.params[segment.key]); // TODO: base path const url = new URL(`http://localhost:${config.port}${staticRoute}`); - console.log({ url }); const request = new Request(url); const scratchUrl = toScratchUrl( outputHref.replace(`[${segment.key}]`, staticPath.params[segment.key]), @@ -114,9 +103,7 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { let ssrContents; // do we negate the worker pool by also running this, outside the pool? - console.log("@@@@@ serving page...", { url: url.href }); let body = await (await servePage(url, request, plugins)).text(); - console.log("@@@@@ served page...", { body }); body = await (await interceptPage(url, request, plugins, body)).text(); // hack to avoid over-rendering SSR content @@ -160,8 +147,6 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { return reject(err); } - console.log("####", { result }); - return resolve(result.html); }, ); @@ -174,11 +159,10 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { ); } - console.log("@@@@@ prerendered page...", { scratchUrl }); await createOutputDirectory(new URL(scratchUrl.href.replace("index.html", ""))); await fs.writeFile(scratchUrl, body); - console.info("generated static page...", staticRoute, body); + console.info("generated static path...", staticRoute); } } else { const { route, outputHref } = page; @@ -188,9 +172,7 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { let ssrContents; // do we negate the worker pool by also running this, outside the pool? - console.log("@@@@@ serving page...", { url: url.href }); let body = await (await servePage(url, request, plugins)).text(); - console.log("@@@@@ served page...", { body }); body = await (await interceptPage(url, request, plugins, body)).text(); // hack to avoid over-rendering SSR content @@ -234,8 +216,6 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { return reject(err); } - console.log("####", { result }); - return resolve(result.html); }, ); diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index 0cab567ff..b61b16691 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -330,7 +330,6 @@ async function getStaticServer(compilation, composable) { (isSSR && compilation.config.prerender) || (isSSR && matchingRoute.prerender); - console.log({ isStatic, matchingRoute }); if ( ctx.response.status === 404 && ((isSPA && extension === url.pathname) || @@ -344,7 +343,6 @@ async function getStaticServer(compilation, composable) { : matchingRoute?.staticPaths ? new URL(`.${url.pathname.replace(basePath, "")}index.html`, outputDir).href : new URL(`.${url.pathname.replace(basePath, "")}`, outputDir).href; - console.log({ outputHref }); const body = await fs.readFile(new URL(outputHref), "utf-8"); ctx.body = body; @@ -375,7 +373,6 @@ async function getHybridServer(compilation) { app.use(async (ctx) => { try { const url = new URL(`http://localhost:${config.port}${ctx.url}`); - console.log("getHybridServer url???", { url }); const { pathname } = url; const matchingRoute = graph.find((node) => node.route === url.pathname) || { data: {} }; const isApiRoute = manifest.apis.has(url.pathname); @@ -386,39 +383,20 @@ async function getHybridServer(compilation) { ); const matchingRouteWithSegment = getMatchingDynamicSsrRoute(compilation.graph, pathname) || {}; - console.log("!!!!!", { url, matchingRoute, matchingRouteWithSegment }); - let is = + let isDynamicRoute = (matchingRoute.isSSR || matchingRouteWithSegment.isSSR) && !matchingRouteWithSegment.staticPaths; - console.log("1111", { is }); + if ( (matchingRoute.isSSR && config.prerender === true && matchingRoute.prerender !== false) || (matchingApiRouteWithSegment?.isSSR && config.prerender === true && matchingRouteWithSegment?.prerender !== false) ) { - is = false; + isDynamicRoute = false; } - console.log("222", { is }); - // page.isSSR && !page.staticPaths && (page.prerender !== true || !config.prerender && page.prerender !== false) - - // let is = page.isSSR && !page.staticPaths && page.prerender !== true; - - // if(is && (config.prerender && page.prerender !== false)) { - // is = false; - // } - // return is; - if ( - is - // (matchingRoute.isSSR || matchingRouteWithSegment.isSSR) && - // !matchingRouteWithSegment.staticPaths && - // (matchingRoute.prerender !== true || (!config.prerender && matchingRoute.prerender !== false)) && - // (matchingRouteWithSegment.prerender !== true || (!config.prerender && matchingRouteWithSegment.prerender !== false)) - // (matchingRoute.prerender !== true && matchingRouteWithSegment.prerender !== true) && - // ((matchingRoute.prerender !== null && matchingRouteWithSegment.prerender !== null) && compilation.config.prerender) - ) { - console.log("getHybridServer serving url => ", { url }); + if (isDynamicRoute) { const entryPointUrl = new URL( matchingRoute?.outputHref ?? matchingRouteWithSegment.outputHref, ); @@ -512,7 +490,6 @@ async function getHybridServer(compilation) { }); }); } else { - console.log("3333????"); // @ts-expect-error see https://github.com/microsoft/TypeScript/issues/42866 const { handler } = await import(entryPointUrl); const response = await handler(request, { params }); diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js index 8d4d4cb90..58fd99829 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-html.js +++ b/packages/cli/src/plugins/resource/plugin-standard-html.js @@ -85,7 +85,6 @@ class StandardHtmlResource { ? getParamsFromSegment(matchingRouteWithSegment.segment, pathname) : undefined; - console.log("serve plugin???", { matchingRouteWithSegment, pathname, params }); await new Promise((resolve, reject) => { const worker = new Worker(new URL("../../lib/ssr-route-worker.js", import.meta.url)); From 53e0bdc6328a038e2257d35cf684d8b84a3930c8 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 20 Jun 2026 19:17:20 -0400 Subject: [PATCH 13/17] feature(cli): #1622 support base path routing --- packages/cli/src/lib/graph-utils.js | 42 +++++- packages/cli/src/lib/url-utils.js | 37 ++++-- packages/cli/src/lifecycles/bundle.js | 5 +- packages/cli/src/lifecycles/graph.js | 10 +- packages/cli/src/lifecycles/prerender.js | 18 +-- packages/cli/src/lifecycles/serve.js | 9 +- .../src/plugins/resource/plugin-api-routes.js | 2 +- .../plugins/resource/plugin-standard-html.js | 10 +- .../plugins/resource/plugin-static-router.js | 52 +++++--- .../serve.config.base-path.spec.js | 120 ++++++++++++++---- .../src/pages/blog/[slug].js | 39 ++++++ packages/plugin-renderer-lit/src/index.js | 10 +- 12 files changed, 264 insertions(+), 90 deletions(-) create mode 100644 packages/cli/test/cases/serve.config.base-path/src/pages/blog/[slug].js diff --git a/packages/cli/src/lib/graph-utils.js b/packages/cli/src/lib/graph-utils.js index 94a581a93..a77e03469 100644 --- a/packages/cli/src/lib/graph-utils.js +++ b/packages/cli/src/lib/graph-utils.js @@ -1,6 +1,8 @@ +// pages that are pure SSR function getDynamicPages(compilation) { const { config, graph } = compilation; + // would be nice to do this without the extra conditional (good first issue) return graph.filter((page) => { let isSsrRoute = page.isSSR && !page.staticPaths && page.prerender !== true; @@ -12,16 +14,42 @@ function getDynamicPages(compilation) { }); } +// pages that emit an HTML file function getStaticPages(compilation) { - return compilation.graph.filter((page) => { - let isStaticRoute = !page.isSSR || page.staticPaths || page.prerender === true; + const { config, graph } = compilation; - if (page.isSSR && compilation.config.prerender && page.prerender !== false) { - isStaticRoute = true; - } + return graph.filter( + (page) => + !page.isSSR || + (page.isSSR && page.prerender) || + (page.isSSR && page.prerender !== false && config.prerender) || + page.staticPaths, + ); +} + +// get a page by route; including getStaticPaths or dynamic SSR pages +function getMatchingPageByRoute(compilation, route) { + const { graph, config } = compilation; + + return graph.find((page) => { + return ( + // exact match + page.route === route || + // dynamic route + (page.segment && + new URLPattern({ pathname: `${config.basePath}${page.segment.pathname}` }).test( + `https://example.com${route}`, + )) || + // getStaticPaths + (page.hasStaticParams && + page.staticPaths.find((path) => { + const { segment } = page; + const staticRoute = route.replace(`[${segment.key}]`, path.params[segment.key]); - return isStaticRoute; + return `${config.basePath}${staticRoute}` === route; + })) + ); }); } -export { getDynamicPages, getStaticPages }; +export { getDynamicPages, getStaticPages, getMatchingPageByRoute }; diff --git a/packages/cli/src/lib/url-utils.js b/packages/cli/src/lib/url-utils.js index 06ea771c9..8281a0298 100644 --- a/packages/cli/src/lib/url-utils.js +++ b/packages/cli/src/lib/url-utils.js @@ -1,3 +1,4 @@ +// get the dynamic segments from a dynamic route, e.g. pages/blog/[slug].js function getDynamicSegmentsFromRoute({ route, relativePagePath, extension }) { const dynamicRoute = route.replace("[", ":").replace("]", ""); const segmentKey = relativePagePath @@ -10,31 +11,42 @@ function getDynamicSegmentsFromRoute({ route, relativePagePath, extension }) { return { segmentKey, dynamicRoute }; } -function getMatchingDynamicApiRoute(apis, pathname) { +// all API routes +function getMatchingDynamicApiRoute(apis, route) { return Array.from(apis.keys()).find((key) => { - const route = apis.get(key); + const page = apis.get(key); return ( - route.segment && - new URLPattern({ pathname: `${route.segment.pathname}*` }).test( - `https://example.com${pathname}`, - ) + page.segment && + new URLPattern({ pathname: `${page.segment.pathname}*` }).test(`https://example.com${route}`) ); }); } -function getMatchingDynamicSsrRoute(graph, pathname) { +// pure SSR routes +function getMatchingDynamicSsrRoute(compilation, route) { + const { graph, config } = compilation; + return graph.find((node) => { return ( - (pathname !== "/404/") !== "/404/" && + route !== "/404/" && node.segment && - new URLPattern({ pathname: node.segment.pathname }).test(`https://example.com${pathname}`) + new URLPattern({ pathname: `${config.basePath}${node.segment.pathname}` }).test( + `https://example.com${route}`, + ) ); }); } -function getParamsFromSegment(segment, pathname) { - return new URLPattern({ pathname: segment.pathname }).exec(`https://example.com${pathname}`) - ?.pathname?.groups; +// get params for dynamic routes from URLPattern based segment extraction +function getParamsFromSegment(compilation, segment, route) { + return new URLPattern({ pathname: `${compilation.config.basePath}${segment.pathname}` }).exec( + `https://example.com${route}`, + )?.pathname?.groups; +} + +// get the full route for a static path +function getStaticRouteFromDynamicRoute(basePath, staticPath, segment, route) { + return `${basePath}${route.replace(`[${segment.key}]`, staticPath.params[segment.key])}`; } export { @@ -42,4 +54,5 @@ export { getMatchingDynamicApiRoute, getParamsFromSegment, getMatchingDynamicSsrRoute, + getStaticRouteFromDynamicRoute, }; diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index 64e70a360..92857f1e1 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -18,6 +18,7 @@ import { rollup } from "rollup"; import { pruneGraph } from "../lib/content-utils.js"; import { asyncForEach } from "../lib/async-utils.js"; import { getDynamicPages, getStaticPages } from "../lib/graph-utils.js"; +import { getStaticRouteFromDynamicRoute } from "../lib/url-utils.js"; async function interceptPage(url, request, plugins, body) { let response = new Response(body, { @@ -114,6 +115,7 @@ async function cleanUpResources(compilation) { }); } +// TODO: this could be consolidated async function optimizeStaticPages(compilation, plugins) { const { scratchDir, outputDir } = compilation.context; const pages = getStaticPages(compilation); @@ -123,12 +125,13 @@ async function optimizeStaticPages(compilation, plugins) { if (staticPaths) { for (const staticPath of staticPaths) { + const staticRoute = getStaticRouteFromDynamicRoute("", staticPath, segment, route); const outputDirUrl = new URL( outputHref .replace(`[${segment.key}]`, staticPath.params[segment.key]) .replace("index.html", ""), ); - const url = new URL(`http://localhost:${compilation.config.port}${route}`); // TODO: keeping placeholder route for optimization looks ups, is this right? + const url = new URL(`http://localhost:${compilation.config.port}${staticRoute}`); const contents = await fs.readFile( new URL( `./${outputHref.replace(outputDir.href, "").replace(`[${segment.key}]`, staticPath.params[segment.key])}`, diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js index 3052b5c9e..1187c53f5 100644 --- a/packages/cli/src/lifecycles/graph.js +++ b/packages/cli/src/lifecycles/graph.js @@ -108,8 +108,7 @@ const generateGraph = async (compilation) => { return; } - // TODO: should API routes be run in isolation mode like SSR pages? - // TODO: is there a better way to detect for isolation option? + // is there a better way to detect for isolation export without having to actually import() the module? const contents = await fs.readFile(filenameUrl, "utf8"); const isolation = contents.indexOf("export const isolation = true;") >= 0; const { segmentKey, dynamicRoute } = getDynamicSegmentsFromRoute({ @@ -254,7 +253,6 @@ const generateGraph = async (compilation) => { delete customData[key]; }); - // TODO: document segment, staticPaths, and hasStaticParams /* * Page Properties *---------------------- @@ -273,6 +271,9 @@ const generateGraph = async (compilation) => { * isolation: if this page should be run in isolated mode * hydration: if this page needs hydration support * servePage: signal that this is a custom page file type (static | dynamic) + * segment: key and dynamic pathname for a dynamic routes; the key is what is in the brackets, e.g. [slug].ts + * staticPaths: paths and props returned from getStaticPaths and getStaticProps + * hasStaticParams: if getStaticProps is present on the route */ const { segmentKey, dynamicRoute } = getDynamicSegmentsFromRoute({ @@ -300,7 +301,6 @@ const generateGraph = async (compilation) => { prerender, isolation, hydration, - // TODO: this "may" break some things...? validate with testing in Greenwood servePage: isCustom ? isCustom : isDynamic ? "dynamic" : "static", segment: dynamicRoute.indexOf(":") > 0 ? { key: segmentKey, pathname: dynamicRoute } : null, @@ -311,7 +311,7 @@ const generateGraph = async (compilation) => { pages.push(page); // handle collections - trackCollectionsForPage(page, compilation.collections); // collections; + trackCollectionsForPage(page, compilation.collections); } else { console.warn(`Unsupported format detected for page => ${filename}`); } diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index e8afd71ce..b7b03b584 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -7,6 +7,8 @@ import { import os from "node:os"; import { WorkerPool } from "../lib/threadpool.js"; import { asyncForEach } from "../lib/async-utils.js"; +import { getStaticPages } from "../lib/graph-utils.js"; +import { getParamsFromSegment, getStaticRouteFromDynamicRoute } from "../lib/url-utils.js"; async function createOutputDirectory(outputDir) { // ignore creating directory for 404 pages since they live at the root of the output directory @@ -68,15 +70,7 @@ function toScratchUrl(outputHref, context) { } async function preRenderCompilationWorker(compilation, workerPrerender) { - const pages = compilation.graph.filter( - (page) => - !page.isSSR || - (page.isSSR && page.prerender) || - // (page.isSSR && compilation.config.prerender) || - (page.isSSR && page.prerender !== false && compilation.config.prerender) || - // (page.isSSR && page.prerender !== false && page.prerender !== null && compilation.config.prerender) || - page.staticPaths, - ); + const pages = getStaticPages(compilation); const { context, config } = compilation; const plugins = getPluginInstances(compilation); @@ -92,8 +86,8 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { if (page.staticPaths) { for (const staticPath of page.staticPaths) { const { route, outputHref, segment } = page; - const staticRoute = route.replace(`[${segment.key}]`, staticPath.params[segment.key]); - // TODO: base path + // at this point route will already include the base path + const staticRoute = getStaticRouteFromDynamicRoute("", staticPath, segment, route); const url = new URL(`http://localhost:${config.port}${staticRoute}`); const request = new Request(url); const scratchUrl = toScratchUrl( @@ -101,6 +95,7 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { context, ); let ssrContents; + let params = getParamsFromSegment(compilation, page.segment, staticRoute) ?? {}; // do we negate the worker pool by also running this, outside the pool? let body = await (await servePage(url, request, plugins)).text(); @@ -141,6 +136,7 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { prerender: true, htmlContents: body, scripts: JSON.stringify(scripts), + params: params ? JSON.stringify(params) : params, }, (err, result) => { if (err) { diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index b61b16691..4bdff6b77 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -318,7 +318,7 @@ async function getStaticServer(compilation, composable) { // TODO: handle base path const matchingRoute = compilation.graph.find( (page) => - (page.staticPaths && getParamsFromSegment(page.segment, url.pathname)) || + (page.staticPaths && getParamsFromSegment(compilation, page.segment, url.pathname)) || page.route === url.pathname, ); const isSPA = compilation.graph.find((page) => page.isSPA); @@ -381,8 +381,7 @@ async function getHybridServer(compilation) { compilation.manifest.apis, pathname, ); - const matchingRouteWithSegment = - getMatchingDynamicSsrRoute(compilation.graph, pathname) || {}; + const matchingRouteWithSegment = getMatchingDynamicSsrRoute(compilation, pathname) || {}; let isDynamicRoute = (matchingRoute.isSSR || matchingRouteWithSegment.isSSR) && !matchingRouteWithSegment.staticPaths; @@ -402,7 +401,7 @@ async function getHybridServer(compilation) { ); const params = matchingRouteWithSegment && matchingRouteWithSegment.segment - ? getParamsFromSegment(matchingRouteWithSegment.segment, pathname) + ? getParamsFromSegment(compilation, matchingRouteWithSegment.segment, pathname) : undefined; let html; @@ -453,7 +452,7 @@ async function getHybridServer(compilation) { const apiRoute = manifest.apis.get(matchingApiRouteWithSegment ?? pathname); const params = matchingRouteWithSegment && apiRoute.segment - ? getParamsFromSegment(apiRoute.segment, pathname) + ? getParamsFromSegment(compilation, apiRoute.segment, pathname) : undefined; const entryPointUrl = new URL(apiRoute.outputHref); diff --git a/packages/cli/src/plugins/resource/plugin-api-routes.js b/packages/cli/src/plugins/resource/plugin-api-routes.js index 1d2414cef..8b90d756b 100644 --- a/packages/cli/src/plugins/resource/plugin-api-routes.js +++ b/packages/cli/src/plugins/resource/plugin-api-routes.js @@ -40,7 +40,7 @@ class ApiRoutesResource { const href = apiUrl.href; const params = matchingRouteWithSegment && api.segment - ? getParamsFromSegment(api.segment, pathname) + ? getParamsFromSegment(this.compilation, api.segment, pathname) : undefined; if (process.env.__GWD_COMMAND__ === "develop") { diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js index 58fd99829..3742fae94 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-html.js +++ b/packages/cli/src/plugins/resource/plugin-standard-html.js @@ -8,6 +8,7 @@ import fs from "node:fs/promises"; import { getPageLayout, getAppLayout, getGreenwoodScripts } from "../../lib/layout-utils.js"; import { requestAsObject } from "../../lib/resource-utils.js"; import { getMatchingDynamicSsrRoute, getParamsFromSegment } from "../../lib/url-utils.js"; +import { getMatchingPageByRoute } from "@greenwood/cli/src/lib/graph-utils.js"; import { Worker } from "node:worker_threads"; import { parse } from "node-html-parser"; @@ -22,7 +23,7 @@ class StandardHtmlResource { const { protocol, pathname } = url; const hasMatchingPageRoute = this.compilation.graph.find((node) => node.route === pathname); const isSPA = this.compilation.graph.find((node) => node.isSPA) && pathname.indexOf(".") < 0; - const matchingRouteWithSegment = getMatchingDynamicSsrRoute(this.compilation.graph, pathname); + const matchingRouteWithSegment = getMatchingDynamicSsrRoute(this.compilation, pathname); return ( protocol.startsWith("http") && @@ -38,8 +39,7 @@ class StandardHtmlResource { const { pathname } = url; const isSpaRoute = this.compilation.graph.find((node) => node.isSPA); const matchingRoute = this.compilation.graph.find((node) => node.route === pathname) || {}; - const matchingRouteWithSegment = - getMatchingDynamicSsrRoute(this.compilation.graph, pathname) || {}; + const matchingRouteWithSegment = getMatchingDynamicSsrRoute(this.compilation, pathname) || {}; const { pageHref } = matchingRoute; const filePath = !matchingRoute.external && pageHref @@ -82,7 +82,7 @@ class StandardHtmlResource { const req = await requestAsObject(request); let params = matchingRouteWithSegment && matchingRouteWithSegment.segment - ? getParamsFromSegment(matchingRouteWithSegment.segment, pathname) + ? getParamsFromSegment(this.compilation, matchingRouteWithSegment.segment, pathname) : undefined; await new Promise((resolve, reject) => { @@ -151,7 +151,7 @@ class StandardHtmlResource { async optimize(url, response) { const { optimization, basePath } = this.compilation.config; const { pathname } = url; - const pageResources = this.compilation.graph.find((page) => page.route === pathname).resources; + const pageResources = getMatchingPageByRoute(this.compilation, pathname).resources; let body = await response.text(); const root = parse(body, { diff --git a/packages/cli/src/plugins/resource/plugin-static-router.js b/packages/cli/src/plugins/resource/plugin-static-router.js index dc75fc27d..ca1eeedef 100644 --- a/packages/cli/src/plugins/resource/plugin-static-router.js +++ b/packages/cli/src/plugins/resource/plugin-static-router.js @@ -6,6 +6,8 @@ * */ import { checkResourceExists } from "../../lib/resource-utils.js"; +import { getStaticPages, getMatchingPageByRoute } from "../../lib/graph-utils.js"; +import { getStaticRouteFromDynamicRoute } from "../../lib/url-utils.js"; import fs from "node:fs/promises"; class StaticRouterResource { @@ -65,8 +67,10 @@ class StaticRouterResource { let body = await response.text(); const { basePath } = this.compilation.config; const { pathname } = url; - const isStaticRoute = this.compilation.graph.find( - (page) => page.route === pathname && !page.isSSR, + const staticPages = getStaticPages(this.compilation); + const isStaticRoute = getMatchingPageByRoute( + { graph: staticPages, config: this.compilation.config }, + pathname, ); const { outputDir } = this.compilation.context; const partial = body @@ -81,20 +85,38 @@ class StaticRouterResource { `file://${outputPartialDirUrl.pathname.split("/").slice(0, -1).join("/").concat("/")}`, ); let currentLayout; - - const routeTags = this.compilation.graph - .filter((page) => !page.isSSR && !page.route.endsWith("/404/")) - .map((page) => { - const { layout, route } = page; - const key = - route === "/" ? "" : route.slice(0, route.lastIndexOf("/")).replace(basePath, ""); - - if (pathname === route) { - currentLayout = layout; + let routeTags = []; + + staticPages + .filter((page) => !page.route.endsWith("/404/")) + .forEach((page) => { + const { layout, route, staticPaths, segment } = page; + + if (staticPaths && staticPaths.length > 0) { + staticPaths.forEach((staticPath) => { + const staticRoute = getStaticRouteFromDynamicRoute("", staticPath, segment, route); + const key = staticRoute.slice(0, staticRoute.lastIndexOf("/")).replace(basePath, ""); + + if (pathname === staticRoute) { + currentLayout = layout; + } + + routeTags.push(` + + `); + }); + } else { + const key = + route === "/" ? "" : route.slice(0, route.lastIndexOf("/")).replace(basePath, ""); + + if (pathname === route) { + currentLayout = layout; + } + + routeTags.push(` + + `); } - return ` - - `; }); if (isStaticRoute) { diff --git a/packages/cli/test/cases/serve.config.base-path/serve.config.base-path.spec.js b/packages/cli/test/cases/serve.config.base-path/serve.config.base-path.spec.js index fc1bdef9f..54d18a510 100644 --- a/packages/cli/test/cases/serve.config.base-path/serve.config.base-path.spec.js +++ b/packages/cli/test/cases/serve.config.base-path/serve.config.base-path.spec.js @@ -28,6 +28,8 @@ * pages/ * api/ * greeting.js + * blog/ + * [slug].js * 404.html * about.html * index.html @@ -321,29 +323,29 @@ describe("Serve Greenwood With: ", function () { it("should have expected tags in the for each page", function () { const routeTags = dom.window.document.querySelectorAll("body > greenwood-route"); - expect(routeTags.length).to.be.equal(2); + expect(routeTags.length).to.be.equal(5); }); - it("should have the expected properties for each tag for the about page", function () { - const aboutRouteTag = Array.from( + it("should have the expected properties for each tag for the home page", function () { + const homeRouteTag = Array.from( dom.window.document.querySelectorAll("body > greenwood-route"), - ).filter((tag) => tag.dataset.route === `${basePath}/about/`); - const dataset = aboutRouteTag[0].dataset; + ).filter((tag) => tag.dataset.route === `${basePath}/`); + const dataset = homeRouteTag[0].dataset; - expect(aboutRouteTag.length).to.be.equal(1); - expect(dataset.layout).to.be.equal("test"); - expect(dataset.key).to.be.equal(`${basePath}/_routes/about/index.html`); + expect(homeRouteTag.length).to.be.equal(1); + expect(dataset.layout).to.be.equal("page"); + expect(dataset.key).to.be.equal(`${basePath}/_routes/index.html`); }); - it("should have the expected properties for each tag for the home page", function () { + it("should have the expected properties for each tag for the about page", function () { const aboutRouteTag = Array.from( dom.window.document.querySelectorAll("body > greenwood-route"), - ).filter((tag) => tag.dataset.route === `${basePath}/`); + ).filter((tag) => tag.dataset.route === `${basePath}/about/`); const dataset = aboutRouteTag[0].dataset; expect(aboutRouteTag.length).to.be.equal(1); - expect(dataset.layout).to.be.equal("page"); - expect(dataset.key).to.be.equal(`${basePath}/_routes/index.html`); + expect(dataset.layout).to.be.equal("test"); + expect(dataset.key).to.be.equal(`${basePath}/_routes/about/index.html`); }); // tests to make sure we filter out 404 page from _route partials @@ -352,7 +354,16 @@ describe("Serve Greenwood With: ", function () { }); it("should have the expected number of _route partials in the output directory for each page", function () { - expect(partials.length).to.be.equal(2); + expect(partials.length).to.be.equal(5); // 3 partials comes from src/pages/blog/[slug].js + }); + + it("should have the expected partial output to match the contents of the home page in the tag in the ", function () { + const homePartial = fs.readFileSync(path.join(publicPath, "_routes/index.html"), "utf-8"); + const homeRouterOutlet = dom.window.document.querySelectorAll("body > router-outlet")[0]; + + expect(homeRouterOutlet.innerHTML.replace(/\n/g, "").replace(/ /g, "")).to.contain( + homePartial.replace(/\n/g, "").replace(/ /g, ""), + ); }); it("should have the expected partial output to match the contents of the about page in the tag in the ", function () { @@ -365,15 +376,6 @@ describe("Serve Greenwood With: ", function () { expect(aboutRouterOutlet.innerHTML).to.contain(aboutPartial); }); - - it("should have the expected partial output to match the contents of the home page in the tag in the ", function () { - const homePartial = fs.readFileSync(path.join(publicPath, "_routes/index.html"), "utf-8"); - const homeRouterOutlet = dom.window.document.querySelectorAll("body > router-outlet")[0]; - - expect(homeRouterOutlet.innerHTML.replace(/\n/g, "").replace(/ /g, "")).to.contain( - homePartial.replace(/\n/g, "").replace(/ /g, ""), - ); - }); }); describe("Serve command with dev proxy", function () { @@ -457,6 +459,80 @@ describe("Serve Greenwood With: ", function () { expect(cards.length).to.be.greaterThan(0); }); }); + + describe("Partials from dynamic route with getStaticPaths", function () { + let partials = []; + let dom; + + before(async function () { + const response = await fetch(`${hostname}${basePath}/`); + + dom = new JSDOM(await response.clone().text()); + partials = await Array.fromAsync( + fs.promises.glob("**/*.html", { cwd: new URL("./public/_routes/blog", import.meta.url) }), + ); + }); + + it("should have three partials for static path blog posts", function () { + expect(partials.length).to.equal(3); + }); + + it("should have the expected content for each partial", function () { + const contents = partials.map((partial) => + fs.readFileSync(path.join(publicPath, `_routes/blog/${partial}`), "utf-8"), + ); + let matched = 0; + + // have to loop since filesystems could come back in any order + contents.forEach((content) => { + const trimmed = content.trim().replace(/\n/g, "").replace(/ /, ""); + + if (trimmed.indexOf("First") > 0) { + expect(trimmed).to.equal("

FirstPost

"); + matched++; + } else if (content.indexOf("Second") > 0) { + expect(trimmed).to.equal("

SecondPost

"); + matched++; + } else if (content.indexOf("Third") > 0) { + expect(trimmed).to.equal("

ThirdPost

"); + matched++; + } + }); + + expect(matched).to.equal(partials.length); + }); + + it("should have the expected partial output to match the contents of the getStaticPath routes home page in the tag in the ", function () { + const routeTags = dom.window.document.querySelectorAll("body > greenwood-route"); + const firstPostTag = Array.from(routeTags).find( + (tag) => tag.getAttribute("data-route") === "/my-path/blog/first-post/", + ); + const secondPostTag = Array.from(routeTags).find( + (tag) => tag.getAttribute("data-route") === "/my-path/blog/second-post/", + ); + const thirdPostTag = Array.from(routeTags).find( + (tag) => tag.getAttribute("data-route") === "/my-path/blog/third-post/", + ); + + expect(firstPostTag).to.not.be.undefined; + expect(firstPostTag.getAttribute("data-key")).to.equal( + "/my-path/_routes/blog/first-post/index.html", + ); + expect(firstPostTag.getAttribute("data-layout")).to.equal("page"); + + expect(secondPostTag).to.not.be.undefined; + expect(secondPostTag.getAttribute("data-key")).to.equal( + "/my-path/_routes/blog/second-post/index.html", + ); + expect(secondPostTag.getAttribute("data-layout")).to.equal("page"); + + expect(thirdPostTag).to.not.be.undefined; + expect(thirdPostTag.getAttribute("data-key")).to.equal( + "/my-path/_routes/blog/third-post/index.html", + ); + expect(thirdPostTag.getAttribute("data-layout")).to.equal("page"); + }); + }); }); after(async function () { diff --git a/packages/cli/test/cases/serve.config.base-path/src/pages/blog/[slug].js b/packages/cli/test/cases/serve.config.base-path/src/pages/blog/[slug].js new file mode 100644 index 000000000..9f20eecd6 --- /dev/null +++ b/packages/cli/test/cases/serve.config.base-path/src/pages/blog/[slug].js @@ -0,0 +1,39 @@ +function getPosts() { + return [ + { + slug: "first-post", + title: "First Post", + content: "This is the first post", + }, + { + slug: "second-post", + title: "Second Post", + content: "This is the second post", + }, + { + slug: "third-post", + title: "Third Post", + content: "This is the third post", + }, + ]; +} + +export async function getStaticPaths() { + return getPosts().map((post) => { + return { + params: { + slug: post.slug, + }, + }; + }); +} + +export async function getStaticParams({ params }) { + const post = getPosts().find((post) => post.slug === params.slug); + + return { post }; +} + +export async function getBody(compilation, request, page, params) { + return `

${params.post.title}

`; +} diff --git a/packages/plugin-renderer-lit/src/index.js b/packages/plugin-renderer-lit/src/index.js index 6b7477981..7220b53bd 100755 --- a/packages/plugin-renderer-lit/src/index.js +++ b/packages/plugin-renderer-lit/src/index.js @@ -1,4 +1,4 @@ -import { getMatchingDynamicSsrRoute } from "@greenwood/cli/src/lib/url-utils.js"; +import { getMatchingPageByRoute } from "@greenwood/cli/src/lib/graph-utils.js"; class LitHydrationResource { constructor(compilation, options) { @@ -13,13 +13,11 @@ class LitHydrationResource { return false; } - const matchingRouteWithSegment = getMatchingDynamicSsrRoute(this.compilation.graph, pathname); - const matchingRoute = this.compilation.graph.find((node) => node.route === pathname); + const matchingRoute = getMatchingPageByRoute(this.compilation, pathname); return ( - matchingRouteWithSegment || - (matchingRoute && - ((matchingRoute.isSSR && matchingRoute.hydration) || this.compilation.config.prerender)) + matchingRoute && + ((matchingRoute.isSSR && matchingRoute.hydration) || this.compilation.config.prerender) ); } From a9846dc3a9e7b1f4ab5c30da1efa5e8aa079e732 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 20 Jun 2026 19:29:02 -0400 Subject: [PATCH 14/17] chore(cli): #1622 refactoring --- packages/cli/src/lib/url-utils.js | 4 ++-- packages/cli/src/lifecycles/bundle.js | 2 +- packages/cli/src/lifecycles/prerender.js | 2 +- packages/cli/src/plugins/resource/plugin-static-router.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/lib/url-utils.js b/packages/cli/src/lib/url-utils.js index 8281a0298..ee803d833 100644 --- a/packages/cli/src/lib/url-utils.js +++ b/packages/cli/src/lib/url-utils.js @@ -45,8 +45,8 @@ function getParamsFromSegment(compilation, segment, route) { } // get the full route for a static path -function getStaticRouteFromDynamicRoute(basePath, staticPath, segment, route) { - return `${basePath}${route.replace(`[${segment.key}]`, staticPath.params[segment.key])}`; +function getStaticRouteFromDynamicRoute(dynamicStaticPath, segment, route) { + return `${route.replace(`[${segment.key}]`, dynamicStaticPath.params[segment.key])}`; } export { diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index 92857f1e1..fb0c689fa 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -125,7 +125,7 @@ async function optimizeStaticPages(compilation, plugins) { if (staticPaths) { for (const staticPath of staticPaths) { - const staticRoute = getStaticRouteFromDynamicRoute("", staticPath, segment, route); + const staticRoute = getStaticRouteFromDynamicRoute(staticPath, segment, route); const outputDirUrl = new URL( outputHref .replace(`[${segment.key}]`, staticPath.params[segment.key]) diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index b7b03b584..690e30521 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -87,7 +87,7 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { for (const staticPath of page.staticPaths) { const { route, outputHref, segment } = page; // at this point route will already include the base path - const staticRoute = getStaticRouteFromDynamicRoute("", staticPath, segment, route); + const staticRoute = getStaticRouteFromDynamicRoute(staticPath, segment, route); const url = new URL(`http://localhost:${config.port}${staticRoute}`); const request = new Request(url); const scratchUrl = toScratchUrl( diff --git a/packages/cli/src/plugins/resource/plugin-static-router.js b/packages/cli/src/plugins/resource/plugin-static-router.js index ca1eeedef..1255c1efd 100644 --- a/packages/cli/src/plugins/resource/plugin-static-router.js +++ b/packages/cli/src/plugins/resource/plugin-static-router.js @@ -94,7 +94,7 @@ class StaticRouterResource { if (staticPaths && staticPaths.length > 0) { staticPaths.forEach((staticPath) => { - const staticRoute = getStaticRouteFromDynamicRoute("", staticPath, segment, route); + const staticRoute = getStaticRouteFromDynamicRoute(staticPath, segment, route); const key = staticRoute.slice(0, staticRoute.lastIndexOf("/")).replace(basePath, ""); if (pathname === staticRoute) { From 08d47eba98842a852fa95cc655ff182409ff4082 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 20 Jun 2026 19:49:04 -0400 Subject: [PATCH 15/17] chore(cli): #1622 clean up comments --- packages/cli/src/lifecycles/bundle.js | 2 +- packages/cli/src/lifecycles/prerender.js | 3 +-- packages/cli/src/lifecycles/serve.js | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index fb0c689fa..1c85991b4 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -115,7 +115,7 @@ async function cleanUpResources(compilation) { }); } -// TODO: this could be consolidated +// we could try and refactor / consolidate here some of the duplicate logic async function optimizeStaticPages(compilation, plugins) { const { scratchDir, outputDir } = compilation.context; const pages = getStaticPages(compilation); diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index 690e30521..b1ad9d6a1 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -81,12 +81,11 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { new URL("../lib/ssr-route-worker.js", import.meta.url), ); - // TODO: refactor / consolidate + // we could try and refactor / consolidate here some of the duplicate logic await asyncForEach(pages, async (page) => { if (page.staticPaths) { for (const staticPath of page.staticPaths) { const { route, outputHref, segment } = page; - // at this point route will already include the base path const staticRoute = getStaticRouteFromDynamicRoute(staticPath, segment, route); const url = new URL(`http://localhost:${config.port}${staticRoute}`); const request = new Request(url); diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index 4bdff6b77..65558be8c 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -315,7 +315,6 @@ async function getStaticServer(compilation, composable) { app.use(async (ctx, next) => { try { const url = new URL(`http://localhost:${port}${ctx.url}`); - // TODO: handle base path const matchingRoute = compilation.graph.find( (page) => (page.staticPaths && getParamsFromSegment(compilation, page.segment, url.pathname)) || From cd726b7a01b70a7a24cff260931a9f7881786ae5 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 20 Jun 2026 20:07:58 -0400 Subject: [PATCH 16/17] docs(plugins): #1622 document get static paths caveat for puppeteer plugin --- packages/plugin-renderer-puppeteer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-renderer-puppeteer/README.md b/packages/plugin-renderer-puppeteer/README.md index 2ad029904..b11d816b0 100644 --- a/packages/plugin-renderer-puppeteer/README.md +++ b/packages/plugin-renderer-puppeteer/README.md @@ -55,7 +55,7 @@ import type { PuppeteerRendererPlugin } from '@greenwood/plugin-renderer-puppete ### Limitations -Given this plugin instruments an entire browser, this plugin _only_ works with Greenwood's [`prerender` configuration](https://greenwoodjs.dev/docs/reference/configuration/#prerender) option and so will NOT be viable for any of Greenwood's [SSR or Serverless](https://greenwoodjs.dev/docs/pages/server-rendering/) capabilities. Instead, Greenwood will be focusing on making [**WCC**](https://github.com/ProjectEvergreen/wcc) the default and recommended first-party solution. +Given this plugin instruments an entire browser, this plugin _only_ works with Greenwood's [`prerender` configuration](https://greenwoodjs.dev/docs/reference/configuration/#prerender) option and so will NOT be viable for any of Greenwood's [SSR or Serverless](https://greenwoodjs.dev/docs/pages/server-rendering/) capabilities, including `getStaticPaths` / `getStaticProps`. Instead, Greenwood will be focusing on making [**WCC**](https://github.com/ProjectEvergreen/wcc) the default and recommended first-party solution. In addition, **puppeteer** also leverages npm `postinstall` scripts which in some environments, like [Stackblitz](https://github.com/ProjectEvergreen/greenwood/discussions/639), would be disabled and so [YMMV](https://dictionary.cambridge.org/us/dictionary/english/ymmv). From 5b7611f0f32eef4bc5a5682c26f4c3e79a6f9a79 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sun, 21 Jun 2026 11:54:06 -0400 Subject: [PATCH 17/17] feature(types): #1622 add type for get static paths and params --- packages/cli/src/types/index.d.ts | 15 +++++++- packages/cli/src/types/ssr.d.ts | 13 +++++++ .../src/pages/blog/[slug].ts | 34 +++++++------------ 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/types/index.d.ts b/packages/cli/src/types/index.d.ts index 8a37b316a..f3700d9c3 100644 --- a/packages/cli/src/types/index.d.ts +++ b/packages/cli/src/types/index.d.ts @@ -9,7 +9,16 @@ import type { } from "./content.d.ts"; import type { Compilation, Frontmatter } from "./compilation.d.ts"; import type { ApiRouteHandler } from "./api.d.ts"; -import type { SsrRouteHandler, GetBody, GetLayout, GetFrontmatter } from "./ssr.d.ts"; +import type { + SsrRouteHandler, + GetBody, + GetLayout, + GetFrontmatter, + GetStaticPaths, + GetStaticParams, + InferGetStaticParamsType, + InferGetStaticPropsType, +} from "./ssr.d.ts"; import type { PLUGINS, PLUGIN_TYPES, @@ -56,6 +65,10 @@ export type { GetBody, GetLayout, GetFrontmatter, + GetStaticPaths, + GetStaticParams, + InferGetStaticParamsType, + InferGetStaticPropsType, }; export type CLI_COMMAND = "develop" | "build" | "serve"; diff --git a/packages/cli/src/types/ssr.d.ts b/packages/cli/src/types/ssr.d.ts index 2388873ee..95e35e6d2 100644 --- a/packages/cli/src/types/ssr.d.ts +++ b/packages/cli/src/types/ssr.d.ts @@ -23,10 +23,23 @@ export type GetBody = ( request: Request, params: Params, ) => Promise; + export type GetLayout = ( compilation: Compilation, page: Page, request: Request, params: Params, ) => Promise; + export type GetFrontmatter = (compilation: Compilation, page: Page) => Promise; + +export type StaticPath = { params: object }; +export type StaticParam = Record; + +export type GetStaticPaths = () => Promise; +export type GetStaticParams = ({ params }) => Promise; + +export type InferGetStaticParamsType = T extends () => Promise> + ? P + : never; +export type InferGetStaticPropsType = T extends (...args: any[]) => Promise ? R : never; diff --git a/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/blog/[slug].ts b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/blog/[slug].ts index 3af699d17..4bed0316c 100644 --- a/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/blog/[slug].ts +++ b/packages/cli/test/cases/serve.dynamic-routing-static-paths/src/pages/blog/[slug].ts @@ -1,24 +1,16 @@ import { getBlogPosts, getBlogPostBySlug } from "../../services/blog-posts.ts"; import type { BlogPost } from "../../services/blog-posts.ts"; +import type { + GetStaticPaths, + GetStaticParams, + InferGetStaticParamsType, + InferGetStaticPropsType, +} from "@greenwood/cli"; -// TODO: types for all this would be nice: StaticPaths / Params / SSR page / etc? can they be inferred? -interface StaticPaths { - params: { - slug: string; - }; -} - -interface StaticParams { - post: BlogPost; -} +type Params = InferGetStaticParamsType; +type Props = InferGetStaticPropsType; -interface BlogPostPageProps { - params: { - post: BlogPost; - }; -} - -export async function getStaticPaths(): Promise { +export const getStaticPaths = async function () { const posts = await getBlogPosts(); return posts.map((post) => { @@ -28,18 +20,18 @@ export async function getStaticPaths(): Promise { }, }; }); -} +} satisfies GetStaticPaths; -export async function getStaticParams({ params }: StaticPaths): Promise { +export const getStaticParams = async function ({ params }: { params: Params }) { const post = (await getBlogPostBySlug(params.slug)) ?? ({} as BlogPost); return { post }; -} +} satisfies GetStaticParams; export default class BlogPostPage extends HTMLElement { #post: BlogPost; - constructor({ params }: BlogPostPageProps) { + constructor({ params }: { params: Props }) { super(); this.#post = params?.post; }