From ccb46eab024a67d3ffdb0b76cfcf162433ec4182 Mon Sep 17 00:00:00 2001 From: lemys lopez Date: Sat, 20 Jun 2026 08:45:45 -0500 Subject: [PATCH] feat(rendering)!: Rewrite ssg pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: replaces empty-shell SSG with fully-rendered HTML pipeline. All public routes now emit compiled Markdown + React component HTML at build time instead of empty
shells. Changes: - New src/ssg/ module: RenderStrategy (interface), DefaultRenderStrategy, RenderPipeline, RenderContext, RenderHooks, SsgOptions, TemplateOverrides - RenderStrategy interface allows full pipeline replacement (DefaultRenderStrategy renders React via renderToString with compiled Markdown props) - Hook pipeline with five phases: beforeRender, transformMarkdown, transformHtml, afterRender, onError — all typed, ordered, async, with error recovery - Template system with built-in index.html.ejs + filesystem override convention (flatwave-templates/ at project root) - Markdown compiler (unified/remark/rehype) extracted to src/content/markdownCompiler.ts with compileMarkdownToHtml(markdown, options?) for reuse - Plugin option ssg: { enabled, strategy?, hooks?, template?, compileMarkdown? } with sensible defaults - Example app demonstrates transformMarkdown + transformHtml hooks - Output HTML now contains: compiled Markdown inside
, SEO tags, analytics beacon injected via transformHtml hook - Removed dead code: empty src/ssg/ dir, inline renderRouteHtml/renderSitemap/ renderRobotsTxt from index.ts, unused ContentMarkdownCompilerOptions alias, unused RenderStrategy.ts file - Fixed production bugs: nested
in error fallbacks, double markdown compilation, transformHtml running on component output instead of final document, component-not-found swallowing all content (now graceful fallback to compiled markdown) - Version bumped to 1.0.0 (was 0.1.0) - All 82 OpenSpec tasks complete; npm run validate passes (format, lint, type-check, build, 11 tests) --- .marscode/deviceInfo.json | 3 + README.md | 73 ++++- .../basic-react-site/dist/es/about/index.html | 10 +- examples/basic-react-site/dist/es/index.html | 13 +- .../dist/es/program/index.html | 10 +- examples/basic-react-site/dist/index.html | 2 +- .../basic-react-site/dist/pt/about/index.html | 10 +- examples/basic-react-site/dist/pt/index.html | 13 +- .../dist/pt/program/index.html | 10 +- examples/basic-react-site/dist/sitemap.xml | 2 +- examples/basic-react-site/package.json | 2 +- examples/basic-react-site/vite.config.ts | 14 + node_modules/.bin/flatwave-validate | 2 +- node_modules/.package-lock.json | 256 +++++++++++++++++- .../.openspec.yaml | 2 + .../design.md | 166 ++++++++++++ .../proposal.md | 41 +++ .../specs/rendering/spec.md | 232 ++++++++++++++++ .../improve-rendering-extensible-ssg/tasks.md | 132 +++++++++ package-lock.json | 244 ++++++++++++++++- packages/vite-plugin-flatwave-react/README.md | 2 + .../dist/content/validator.d.ts | 1 + .../vite-plugin-flatwave-react/dist/index.js | 86 +----- .../dist/types.d.ts | 29 ++ .../vite-plugin-flatwave-react/dist/types.js | 2 +- .../vite-plugin-flatwave-react/package.json | 14 +- .../scripts/copy-virtual-types.js | 15 +- .../src/content/index.ts | 5 + .../src/content/markdownCompiler.test.ts | 36 +++ .../src/content/markdownCompiler.ts | 39 +++ .../src/content/validator.ts | 1 + .../vite-plugin-flatwave-react/src/index.ts | 101 +------ .../src/ssg/DefaultRenderStrategy.tsx | 49 ++++ .../src/ssg/RenderPipeline.ts | 111 ++++++++ .../src/ssg/index.ts | 15 + .../src/ssg/runSsg.ts | 209 ++++++++++++++ .../src/ssg/template.ts | 54 ++++ .../src/ssg/templates/entry-client.tsx.ejs | 4 + .../src/ssg/templates/entry-server.tsx.ejs | 6 + .../src/ssg/templates/index.html.ejs | 16 ++ .../src/ssg/types.ts | 26 ++ .../vite-plugin-flatwave-react/src/types.ts | 35 +++ .../tsconfig.build.json | 2 + 43 files changed, 1904 insertions(+), 191 deletions(-) create mode 100644 .marscode/deviceInfo.json create mode 100644 openspec/changes/improve-rendering-extensible-ssg/.openspec.yaml create mode 100644 openspec/changes/improve-rendering-extensible-ssg/design.md create mode 100644 openspec/changes/improve-rendering-extensible-ssg/proposal.md create mode 100644 openspec/changes/improve-rendering-extensible-ssg/specs/rendering/spec.md create mode 100644 openspec/changes/improve-rendering-extensible-ssg/tasks.md create mode 100644 packages/vite-plugin-flatwave-react/src/content/index.ts create mode 100644 packages/vite-plugin-flatwave-react/src/content/markdownCompiler.test.ts create mode 100644 packages/vite-plugin-flatwave-react/src/content/markdownCompiler.ts create mode 100644 packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx create mode 100644 packages/vite-plugin-flatwave-react/src/ssg/RenderPipeline.ts create mode 100644 packages/vite-plugin-flatwave-react/src/ssg/index.ts create mode 100644 packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts create mode 100644 packages/vite-plugin-flatwave-react/src/ssg/template.ts create mode 100644 packages/vite-plugin-flatwave-react/src/ssg/templates/entry-client.tsx.ejs create mode 100644 packages/vite-plugin-flatwave-react/src/ssg/templates/entry-server.tsx.ejs create mode 100644 packages/vite-plugin-flatwave-react/src/ssg/templates/index.html.ejs create mode 100644 packages/vite-plugin-flatwave-react/src/ssg/types.ts diff --git a/.marscode/deviceInfo.json b/.marscode/deviceInfo.json new file mode 100644 index 0000000..f6335dd --- /dev/null +++ b/.marscode/deviceInfo.json @@ -0,0 +1,3 @@ +{ + "deviceId": "ce4213f018b569a39c36d114e75a90a6638a3814823583044604aa6aa17d2094" +} diff --git a/README.md b/README.md index fe94954..c7ca1db 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ Trusted publishing requires the package to already exist on npm, so a maintainer 1. Publish the initial version once to create the package on npm. 2. Add the **Trusted Publisher** on npmjs.com → package **Settings → Trusted Publisher → GitHub Actions**: user/org `kamansoft`, repo `vite-plugin-flatwave-react`, workflow `release.yml`. -3. Push the baseline tag `v0.1.0` so semantic-release continues the 0.x line (otherwise the first automated release defaults to `1.0.0`). +3. Push the baseline tag `v1.0.0` so semantic-release continues the 0.x line (otherwise the first automated release defaults to `1.0.0`). After that, every merge to `main` releases automatically — no tokens, no manual version edits. @@ -294,6 +294,73 @@ dev-notes/publish-to-npm/scripts/dry-run-release.sh --- -## License +## Appendix B – SSG Extensibility (`./ssg`) -MIT © 2026 – Flatwave contributors. +This section covers the new `./ssg` public API added in this release. + +### 13.1 SSG options and capabilities + +`SsgOptions` let you replace the built‑in renderer, inject hook phases, and override templates. + +```ts +import { flatwaveContent } from 'vite-plugin-flatwave-react'; +import { DefaultRenderStrategy, RenderPipeline } from './ssg'; // adjust path + +flatwaveContent({ + contentDir: './src/content', + locales: ['es', 'pt'], + defaultLocale: 'es', + ssg: { + enabled: true, + compileMarkdown: { allowRawHtml: true }, + strategy: new MyCustomStrategy(), + hooks: { + beforeRender: [ + (ctx) => { + /* … */ return ctx; + }, + ], + transformMarkdown: [(md, ctx) => md.replace(/foo/g, 'bar')], + }, + template: { + indexHtml: fs.readFileSync('./custom/layout.html', 'utf-8'), + }, + }, +}); +``` + +### 13.2 `RenderStrategy` interface + +```ts +import type { RenderContext } from './ssg'; + +export interface RenderStrategy { + render(context: RenderContext): Promise; +} +``` + +`DefaultRenderStrategy` renders a React component with `renderToString`; you can implement async rendering, micro-frontend composition, or server-side data fetching by swapping this implementation. + +### 13.3 Hook phases + +| Phase | Signature | Use case | +| ------------------- | --------------------- | --------------------------------------- | +| `beforeRender` | `(ctx) => ctx` | inject CSP headers, auth tokens, locale | +| `transformMarkdown` | `(md, ctx) => md` | preprocess markdown before compilation | +| `transformHtml` | `(html, ctx) => html` | inject analytics, minify, rewrite links | +| `afterRender` | `(html, ctx) => void` | side effects (logging, audit events) | +| `onError` | `(err, ctx) => html` | recover with safe fallback HTML | + +### 13.4 Template overrides + +```ts +ssg: { + template: { + indexHtml: fs.readFileSync('./my-layout.html', 'utf-8'), + }, +} +``` + +Setting **only** the files you need keeps the default built‑in templates for the rest. + +--- diff --git a/examples/basic-react-site/dist/es/about/index.html b/examples/basic-react-site/dist/es/about/index.html index 5bc8ccd..1423901 100644 --- a/examples/basic-react-site/dist/es/about/index.html +++ b/examples/basic-react-site/dist/es/about/index.html @@ -14,7 +14,13 @@ -
+

Acerca de

+

Esta página demuestra Markdown válido y frontmatter adicional preservado por el plugin.

+
# Markdown válido
+
+
+

This page was built with Flatwave SSG 1.0.0.

+ - + \ No newline at end of file diff --git a/examples/basic-react-site/dist/es/index.html b/examples/basic-react-site/dist/es/index.html index 42a497c..221079f 100644 --- a/examples/basic-react-site/dist/es/index.html +++ b/examples/basic-react-site/dist/es/index.html @@ -14,7 +14,16 @@ -
+

Inicio

+

Este es el sitio de ejemplo para probar el plugin de contenido Flatwave.

+
    +
  • Rutas localizadas.
  • +
  • Frontmatter flexible.
  • +
  • Sitemap y robots generados.
  • +
+
+

This page was built with Flatwave SSG 1.0.0.

+ - + \ No newline at end of file diff --git a/examples/basic-react-site/dist/es/program/index.html b/examples/basic-react-site/dist/es/program/index.html index bd65b52..214aacd 100644 --- a/examples/basic-react-site/dist/es/program/index.html +++ b/examples/basic-react-site/dist/es/program/index.html @@ -14,7 +14,13 @@ -
+

Programa

+

Esta página usa un componente diferente y frontmatter adicional como date y schedule.

+

Horario

+

El horario se conserva en attributes para que el componente lo use.

+
+

This page was built with Flatwave SSG 1.0.0.

+ - + \ No newline at end of file diff --git a/examples/basic-react-site/dist/index.html b/examples/basic-react-site/dist/index.html index 63855c7..c3ba9aa 100644 --- a/examples/basic-react-site/dist/index.html +++ b/examples/basic-react-site/dist/index.html @@ -4,7 +4,7 @@ Flatwave React Example - + diff --git a/examples/basic-react-site/dist/pt/about/index.html b/examples/basic-react-site/dist/pt/about/index.html index 0189594..55ab478 100644 --- a/examples/basic-react-site/dist/pt/about/index.html +++ b/examples/basic-react-site/dist/pt/about/index.html @@ -14,7 +14,13 @@ -
+

Sobre

+

Esta página demonstra Markdown válido e frontmatter adicional preservado pelo plugin.

+
# Markdown válido
+
+
+

This page was built with Flatwave SSG 1.0.0.

+ - + \ No newline at end of file diff --git a/examples/basic-react-site/dist/pt/index.html b/examples/basic-react-site/dist/pt/index.html index f5c4226..d4294ff 100644 --- a/examples/basic-react-site/dist/pt/index.html +++ b/examples/basic-react-site/dist/pt/index.html @@ -14,7 +14,16 @@ -
+

Início

+

Este é o site de exemplo para testar o plugin de conteúdo Flatwave.

+
    +
  • Rotas localizadas.
  • +
  • Frontmatter flexível.
  • +
  • Sitemap e robots gerados.
  • +
+
+

This page was built with Flatwave SSG 1.0.0.

+ - + \ No newline at end of file diff --git a/examples/basic-react-site/dist/pt/program/index.html b/examples/basic-react-site/dist/pt/program/index.html index 143142a..6878bd5 100644 --- a/examples/basic-react-site/dist/pt/program/index.html +++ b/examples/basic-react-site/dist/pt/program/index.html @@ -14,7 +14,13 @@ -
+

Programa

+

Esta página usa um componente diferente e frontmatter adicional como date e schedule.

+

Horário

+

O horário é preservado em attributes para que o componente o utilize.

+
+

This page was built with Flatwave SSG 1.0.0.

+ - + \ No newline at end of file diff --git a/examples/basic-react-site/dist/sitemap.xml b/examples/basic-react-site/dist/sitemap.xml index 07296aa..a52648a 100644 --- a/examples/basic-react-site/dist/sitemap.xml +++ b/examples/basic-react-site/dist/sitemap.xml @@ -1,2 +1,2 @@ -http://localhost:4173/es/2026-06-17weekly0.8http://localhost:4173/es/about2026-06-17weekly0.8http://localhost:4173/es/program2026-06-17weekly0.8http://localhost:4173/pt/2026-06-17weekly0.8http://localhost:4173/pt/about2026-06-17weekly0.8http://localhost:4173/pt/program2026-06-17weekly0.8 +http://localhost:4173/es/2026-06-20weekly0.8http://localhost:4173/es/about2026-06-20weekly0.8http://localhost:4173/es/program2026-06-20weekly0.8http://localhost:4173/pt/2026-06-20weekly0.8http://localhost:4173/pt/about2026-06-20weekly0.8http://localhost:4173/pt/program2026-06-20weekly0.8 diff --git a/examples/basic-react-site/package.json b/examples/basic-react-site/package.json index ec9674b..f2047fe 100644 --- a/examples/basic-react-site/package.json +++ b/examples/basic-react-site/package.json @@ -1,6 +1,6 @@ { "name": "@flatwave/example-basic-react-site", - "version": "0.1.0", + "version": "1.0.0", "private": true, "type": "module", "scripts": { diff --git a/examples/basic-react-site/vite.config.ts b/examples/basic-react-site/vite.config.ts index 41d43cd..34862d9 100644 --- a/examples/basic-react-site/vite.config.ts +++ b/examples/basic-react-site/vite.config.ts @@ -15,6 +15,20 @@ export default defineConfig({ sitemap: { hostname: 'http://localhost:4173', }, + ssg: { + enabled: true, + hooks: { + // 12.2 — transformMarkdown: append a built-with note to every page body + transformMarkdown: async (markdown, _context) => { + return markdown + '\n\n---\n\n*This page was built with **Flatwave SSG 1.0.0**.*'; + }, + // 12.3 — transformHtml: inject a lightweight analytics beacon before + transformHtml: async (html, context) => { + const beacon = ``; + return html.replace('', beacon + '\n'); + }, + }, + }, }), ], build: { diff --git a/node_modules/.bin/flatwave-validate b/node_modules/.bin/flatwave-validate index d0f1ede..2392357 120000 --- a/node_modules/.bin/flatwave-validate +++ b/node_modules/.bin/flatwave-validate @@ -1 +1 @@ -../vite-plugin-flatwave-react/dist/cli/validate.js \ No newline at end of file +../@kamansoft/vite-plugin-flatwave-react/dist/cli/validate.js \ No newline at end of file diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 2d2b256..df4387f 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -1,6 +1,6 @@ { "name": "vite-plugin-flatwave-react-workspace", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { @@ -8,11 +8,11 @@ "name": "@flatwave/example-basic-react-site", "version": "0.1.0", "dependencies": { + "@kamansoft/vite-plugin-flatwave-react": "file:../../packages/vite-plugin-flatwave-react", "@vitejs/plugin-react": "^4.3.4", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-markdown": "^9.0.3", - "vite-plugin-flatwave-react": "0.1.0" + "react-markdown": "^9.0.3" }, "devDependencies": { "@types/react": "^18.3.12", @@ -1355,6 +1355,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kamansoft/vite-plugin-flatwave-react": { + "resolved": "packages/vite-plugin-flatwave-react", + "link": true + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4500,6 +4504,18 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-ci": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", @@ -5889,7 +5905,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", - "deprecated": "This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.", "dev": true, "license": "MIT", "dependencies": { @@ -6146,6 +6161,99 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -6173,6 +6281,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -6186,6 +6313,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -6242,6 +6386,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-proxy-agent": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", @@ -11680,6 +11834,52 @@ "node": ">=0.10.0" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", + "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -11713,6 +11913,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -13713,6 +13928,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -13933,6 +14162,16 @@ } } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", @@ -14252,12 +14491,19 @@ } }, "packages/vite-plugin-flatwave-react": { + "name": "@kamansoft/vite-plugin-flatwave-react", "version": "0.1.0", "license": "MIT", "dependencies": { "commander": "^13.1.0", "fast-glob": "^3.3.3", - "gray-matter": "^4.0.3" + "gray-matter": "^4.0.3", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark": "^15.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.0", + "unified": "^11.0.5" }, "bin": { "flatwave-validate": "dist/cli/validate.js" diff --git a/openspec/changes/improve-rendering-extensible-ssg/.openspec.yaml b/openspec/changes/improve-rendering-extensible-ssg/.openspec.yaml new file mode 100644 index 0000000..ff1fbc8 --- /dev/null +++ b/openspec/changes/improve-rendering-extensible-ssg/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-19 diff --git a/openspec/changes/improve-rendering-extensible-ssg/design.md b/openspec/changes/improve-rendering-extensible-ssg/design.md new file mode 100644 index 0000000..d1c4fed --- /dev/null +++ b/openspec/changes/improve-rendering-extensible-ssg/design.md @@ -0,0 +1,166 @@ +## Context + +The plugin currently has three Vite plugins in `src/index.ts`: + +1. `flatwave-react:content` — builds content index, serves virtual module +2. `flatwave-react:markdown` — transforms individual `.md` imports to JS modules +3. `flatwave-react:ssg` — emits static HTML shells, sitemap, robots.txt, route manifest + +The SSG plugin generates empty `
` shells. The markdown body is raw markdown string, rendered client-side via `react-markdown` in consumer apps. No extensibility exists. The `src/ssg/` directory exists but is empty (dead code). Inline functions `renderRouteHtml`, `renderSitemap`, `renderRobotsTxt` in `index.ts` will be replaced. + +## Goals / Non-Goals + +**Goals:** + +- Fully-rendered HTML at build time via `ReactDOMServer.renderToString` +- Markdown compiled to HTML at build time (not client-side) +- Strategy pattern for render pipeline: `RenderStrategy` interface + `DefaultRenderStrategy` +- Hook system: `beforeRender`, `transformMarkdown`, `transformHtml`, `afterRender`, `onError` +- Template system for `index.html` and entry points +- All new code in `src/ssg/` following SOLID (SRP, OCP, LSP, ISP, DIP) and DRY +- Zero-config optimal defaults; opt-in extensibility +- No backward compatibility +- **Remove all unused/dead code** (empty `src/ssg/`, inline SSG functions, unused exports) +- **All quality gates pass in Docker**: `npm run format:check && npm run lint && npm run type-check && npm run build && npm run test` + +**Non-Goals:** + +- Runtime SSR server (this is SSG-only) +- React Server Components (RSC) — strategy pattern allows future addition +- Streaming SSR (`renderToPipeableStream`) — `renderToString` is sufficient for SSG +- Client-side hydration logic (consumer responsibility) +- Image optimization, font inlining (out of scope) + +## Decisions + +### 1. Render Strategy Pattern (SRP + OCP) + +**Decision**: Define `RenderStrategy` interface with single `render(context: RenderContext): Promise` method. `DefaultRenderStrategy` implements standard React+Markdown rendering. Consumers provide custom strategies via `options.ssg.strategy`. + +**Rationale**: + +- SRP: Each strategy encapsulates one rendering approach +- OCP: New strategies added without modifying core pipeline +- DIP: Pipeline depends on abstraction (`RenderStrategy`), not concrete implementation +- Alternatives considered: + - Function-based callbacks → less structured, no type safety for context + - Class inheritance → tighter coupling, harder to compose + - Plugin hooks only → no coherent "strategy" concept for whole-pipeline replacement + +### 2. Render Context Object (DIP + ISP) + +**Decision**: `RenderContext` contains all data needed for rendering: `route`, `contentEntry`, `components`, `hooks`, `assets`, `options`. Passed to strategy and all hooks. + +**Rationale**: + +- ISP: Context exposes only what's needed; hooks receive focused subsets via destructuring +- DIP: Strategies/hooks depend on context interface, not global state +- Single source of truth for render-time data + +### 3. Hook Pipeline (OCP + SRP) + +**Decision**: `RenderPipeline` class manages ordered hook arrays per lifecycle phase. Hooks are async functions receiving typed context slices. Phases: `beforeRender`, `transformMarkdown`, `transformHtml`, `afterRender`, `onError`. + +**Rationale**: + +- OCP: New hooks added without modifying pipeline execution logic +- SRP: Each phase has single responsibility +- Composition over inheritance: hooks are functions, not classes +- Error boundary: `onError` phase receives error + partial context for recovery/logging + +### 4. Markdown → HTML Compiler (SRP + DRY) + +**Decision**: New `src/content/markdownCompiler.ts` exports `compileMarkdownToHtml(markdown, options?)` using unified/remark/rehype pipeline. Reusable by `DefaultRenderStrategy` and consumers. + +**Rationale**: + +- SRP: Single responsibility for markdown→HTML +- DRY: Eliminates duplication between SSG render and potential future uses (RSS, email, etc.) +- Extensible: Accepts custom remark/rehype plugins via options + +### 5. Template System (SRP + OCP) + +**Decision**: `src/ssg/template.ts` provides `resolveTemplate(templateName, overrides?)` that reads from `templates/` directory (bundled with plugin) or consumer-provided paths. Templates are EJS-style with `<%= %>` interpolation. + +**Rationale**: + +- SRP: Template resolution isolated +- OCP: Consumers override via option or file system convention +- No new template engine dependency — simple string interpolation + +### 6. Plugin Options Structure + +```ts +interface SsgOptions { + enabled: boolean; // default: true + strategy?: RenderStrategy; // default: DefaultRenderStrategy + hooks?: Partial; // default: {} + template?: string | TemplateOverrides; // default: built-in + compileMarkdown?: MarkdownCompilerOptions; // default: {} +} +``` + +**Rationale**: Flat, explicit options. No nested complexity. All optional with sensible defaults. + +### 7. Component Resolution at Build Time + +**Decision**: `DefaultRenderStrategy` resolves component imports via Vite's module graph at build time using `ssrLoadModule` or dynamic import of built assets. Components receive `contentEntry` as props. + +**Rationale**: + +- Build-time resolution matches Vite SSG model +- Avoids runtime server requirement +- Components stay pure React (no server-only code paths) + +### 8. Dead Code Removal (DRY + Clean Architecture) + +**Decision**: Remove empty `src/ssg/` directory, inline `renderRouteHtml`, `renderSitemap`, `renderRobotsTxt` functions from `index.ts`, any unused exports in `types.ts`, and any dead code identified during refactor. + +**Rationale**: + +- DRY: Don't keep code that will be replaced +- Clean architecture: No vestigial structures +- Reduces cognitive load and bundle size + +### 9. Docker Quality Gates (CI/CD Ready) + +**Decision**: All code must pass `npm run validate` (which runs format:check, lint, type-check, build, test) in the Docker CI environment. No exceptions. Pre-commit hooks enforce locally. + +**Rationale**: + +- Prevents merge of broken code +- Docker ensures environment parity +- `validate` script already exists in root package.json + +## Risks / Trade-offs + +| Risk | Mitigation | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| Build-time component execution fails (e.g., `window` access) | Document "SSG-safe" component patterns; `onError` hook captures failures per-route; continue other routes | +| Large sites: renderToString for hundreds of routes slow | Parallelize route rendering via `Promise.all` with configurable concurrency; cache compiled markdown HTML | +| Custom strategies break hydration (mismatched HTML) | Document contract: strategy must produce HTML compatible with `hydrateRoot`; provide test utility | +| Template system too simple for complex needs | Escape hatch: `strategy` can bypass templates entirely; templates are convenience only | +| New dependencies increase bundle size | `remark`/`rehype` only in dev/build (not client bundle); tree-shakable imports | +| Dead code removal breaks something | Run full test suite after each removal; git history preserves removed code | + +## Migration Plan + +1. Create `src/ssg/` module with interfaces, `DefaultRenderStrategy`, `RenderPipeline`, `RenderContext` +2. Implement `markdownCompiler.ts` in `src/content/` +3. Refactor `index.ts` SSG plugin to use new pipeline +4. Add `templates/` directory with default `index.html.ejs`, `entry-client.tsx.ejs`, `entry-server.tsx.ejs` +5. Update `types.ts` with new option types and exports +6. **Remove dead code**: empty `src/ssg/`, inline SSG functions, unused exports +7. Update example app to demonstrate custom strategy + hooks +8. Run E2E tests; verify fully-rendered HTML output +9. **Run `npm run validate` in Docker** — all gates must pass + +No rollback needed — new change, no users. + +## Open Questions + +1. **Concurrency limit for parallel route rendering**: Default to `navigator.hardwareConcurrency` or fixed (e.g., 4)? +2. **Should `compileMarkdownToHtml` be sync or async?**: Async for remark plugins that do I/O; sync for pure transforms. Lean async. +3. **Template syntax**: EJS-style `<%= %>` vs. `{{ }}` vs. JS template literals? EJS is familiar, no deps. +4. **Export `RenderStrategy` from `./ssg` or `./types`?**: `./ssg` — it's implementation-adjacent, not a pure type. +5. **Hydration mismatch detection**: Add dev-only checksum comparison between SSR and client render? diff --git a/openspec/changes/improve-rendering-extensible-ssg/proposal.md b/openspec/changes/improve-rendering-extensible-ssg/proposal.md new file mode 100644 index 0000000..5e9b142 --- /dev/null +++ b/openspec/changes/improve-rendering-extensible-ssg/proposal.md @@ -0,0 +1,41 @@ +## Why + +The current plugin generates static HTML shells with empty `
` placeholders, leaving all React rendering to the client. This means search engines and social media crawlers see empty content on first load, defeating SEO goals. The plugin also lacks extensibility hooks, making it impossible for consumers to customize the rendering pipeline (e.g., custom markdown processors, head injection, route transformations) without forking. We need a hybrid rendering approach: fully-rendered HTML on first request (markdown → React components → HTML via `renderToString`), followed by React hydration for interactivity, all while exposing a Strategy-pattern-based extension system for the render loop. **Optimization and extensibility are paramount** — we follow SOLID and DRY principles rigorously. **Backward compatibility is not a concern** since the plugin has no production users yet. **All unused code must be removed** and the final deliverable must pass formatting, linting, type-checking, and build within the Docker environment. + +## What Changes + +- Add `renderToString`-based SSG that compiles markdown to HTML at build time and injects it into the result into root element, producing fully-hydratable HTML +- Replace inline SSG logic in `index.ts` with dedicated `src/ssg/` module containing `RenderStrategy`, `RenderContext`, pipeline hooks +- Introduce `RenderStrategy` interface (Strategy pattern) with `DefaultRenderStrategy` for standard React+Markdown rendering and extension points for custom strategies +- Add `RenderPipeline` with lifecycle hooks: `beforeRender`, `transformMarkdown`, `transformHtml`, `afterRender`, `onError` +- Provide template `index.html` and entry points that consumers can extend/override +- Make Vite trigger rendering via plugin options (`ssg: { enabled: true, strategy?: RenderStrategy, hooks?: RenderHooks }`) +- Export markdown-to-HTML compiler (unified/remark/rehype) as reusable utility +- **No backward compatibility layer** — clean break from empty-shell to fully-rendered HTML +- **Remove all dead/unused code** from current codebase (inline SSG functions, unused exports, empty directories) +- **Enforce code quality gates**: format (prettier), lint (eslint), type-check (tsc), build (tsc + copy script), test (vitest) — all must pass in Docker CI environment + +## Capabilities + +### New Capabilities + +- `hybrid-ssg-rendering`: Build-time React rendering with `renderToString` producing fully-hydratable HTML per route, including compiled markdown content +- `render-strategy-pattern`: Strategy pattern interface (`RenderStrategy`) allowing third-party developers to plug in custom rendering workflows (e.g., RSC, edge rendering, custom HTML transforms) +- `render-pipeline-hooks`: Extensible hook system (`beforeRender`, `transformMarkdown`, `transformHtml`, `afterRender`, `onError`) for middleware-style customization of the render loop +- `markdown-to-html-compiler`: Reusable utility to compile markdown frontmatter+body to HTML string, supporting custom remark/rehype plugins +- `ssg-template-system`: Template-based `index.html` and entry file generation that consumers can extend via `template` option or file overrides +- `code-quality-gates`: Docker-validated CI pipeline enforcing format, lint, type-check, build, test + +### Modified Capabilities + +- `static-site-generation`: Existing SSG capability (emitting `index.html`, `sitemap.xml`, `robots.txt`, `route-manifest.json`) now emits fully-rendered HTML instead of empty shells. Behavior change at spec level. +- `seo-metadata`: Enhanced to include fully-rendered content in meta tags (og:description from actual content, structured data from rendered components) + +## Impact + +- **Affected code**: `packages/vite-plugin-flatwave-react/src/index.ts` (SSG plugin), new `src/ssg/` directory, `src/content/` (markdown compiler utility), `src/seo/metadata.ts` (enhanced metadata), removal of dead code in `src/ssg/` (empty dir), inline SSG functions +- **New dependencies**: `react-dom/server` (peer dep), `remark`, `rehype`, `rehype-stringify`, `rehype-raw`, `unified`, `remark-parse`, `remark-rehype` (dev/build only, not client bundle) +- **API changes**: New `FlatwaveContentOptions.ssg` object with `enabled`, `strategy`, `hooks`, `template`, `compileMarkdown` fields. New exports from `./ssg` entry point. +- **Consumer impact**: Zero-config upgrade (HTML shells → full HTML). Opt-in for custom strategies/hooks. Template files can be ejected via `flatwave eject` CLI (future). +- **Breaking changes**: **Intentional and acceptable** — complete replacement of empty-shell SSG with fully-rendered HTML. No compatibility shims. +- **Code quality**: All formatting, linting, type-checking, build, and test commands must pass in Docker environment before merge diff --git a/openspec/changes/improve-rendering-extensible-ssg/specs/rendering/spec.md b/openspec/changes/improve-rendering-extensible-ssg/specs/rendering/spec.md new file mode 100644 index 0000000..a2fa75a --- /dev/null +++ b/openspec/changes/improve-rendering-extensible-ssg/specs/rendering/spec.md @@ -0,0 +1,232 @@ +## ADDED Requirements + +### Capability: Full HTML Rendering (SSG) + +The system SHALL generate fully-rendered HTML at build time for all public routes, including compiled Markdown content and React component output, suitable for static hosting with zero JavaScript required for initial content visibility. + +#### Scenario: Home page renders with full content + +- **GIVEN** a route `/es/` with content entry containing markdown body and component `HomePage` +- **WHEN** SSG runs at build time +- **THEN** output HTML contains `
` with fully rendered `HomePage` component HTML +- **AND** markdown body is compiled to HTML (not raw markdown) +- **AND** all SEO metadata from frontmatter is present in `` +- **AND** no client-side hydration is required for content visibility + +#### Scenario: All locales and routes rendered + +- **GIVEN** configured locales `['es', 'pt']` and content entries for each +- **WHEN** SSG runs +- **THEN** HTML files emitted at `/es/index.html`, `/pt/index.html`, `/es/about/index.html`, etc. +- **AND** each file contains locale-appropriate rendered content +- **AND** `hreflang` alternates are correct in `` + +#### Scenario: Markdown compiled to HTML at build time + +- **GIVEN** content entry with frontmatter `body: "# Hello\n\nWorld"` and component using `MarkdownRenderer` +- **WHEN** SSG renders the route +- **THEN** output HTML contains `

Hello

World

` (not raw markdown) +- **AND** no `react-markdown` or client-side markdown parser runs for initial paint + +--- + +### Capability: Render Strategy Pattern + +The system SHALL provide a `RenderStrategy` interface allowing third parties to completely replace the rendering pipeline while receiving a typed `RenderContext`. + +#### Scenario: Custom strategy renders to alternative format + +- **GIVEN** a custom `PdfRenderStrategy` implementing `RenderStrategy` +- **WHEN** configured via `options.ssg.strategy = new PdfRenderStrategy()` +- **THEN** SSG uses the custom strategy for all routes +- **AND** `RenderContext` provides route, content, components, assets +- **AND** strategy returns PDF bytes (or any string output) + +#### Scenario: Default strategy used when none provided + +- **GIVEN** no `strategy` in options +- **WHEN** SSG runs +- **THEN** `DefaultRenderStrategy` is instantiated and used +- **AND** behavior matches built-in React + Markdown rendering + +#### Scenario: Strategy receives complete render context + +- **GIVEN** any `RenderStrategy` implementation +- **WHEN** `strategy.render(context)` is called +- **THEN** `context` contains: `route`, `contentEntry`, `components`, `assets`, `hooks`, `options`, `locale`, `allRoutes` +- **AND** types are fully typed (TypeScript interfaces exported) + +--- + +### Capability: Hook Pipeline + +The system SHALL provide a hook pipeline with five lifecycle phases, each receiving a typed context slice, allowing third parties to observe, transform, or augment the rendering process without replacing the entire strategy. + +#### Scenario: Transform markdown before compilation + +- **GIVEN** hook `transformMarkdown: async (markdown, context) => markdown.replace(/CUSTOM_TAG/g, '')` +- **WHEN** page with markdown containing `CUSTOM_TAG` is rendered +- **THEN** markdown is transformed before remark/rehype compilation +- **AND** resulting HTML contains `` + +#### Scenario: Transform final HTML before emit + +- **GIVEN** hook `transformHtml: async (html, context) => html.replace('', '')` +- **WHEN** route HTML is generated +- **THEN** emitted HTML contains injected script tag +- **AND** transformation runs after strategy render but before file emit + +#### Scenario: Before render hook modifies context + +- **GIVEN** hook `beforeRender: async (context) => ({ ...context, assets: injectCriticalCss(context.assets) })` +- **WHEN** render begins +- **THEN** strategy receives modified assets with critical CSS inlined + +#### Scenario: After render hook for side effects + +- **GIVEN** hook `afterRender: async (html, context) => await uploadToCdn(html, context.route.path)` +- **WHEN** route renders successfully +- **THEN** hook executes with final HTML and route context +- **AND** errors in hook do not fail the build (logged via `onError`) + +#### Scenario: Error hook captures render failures + +- **GIVEN** hook `onError: async (error, context) => { log(error); return '
Error
'; }` +- **WHEN** strategy throws during render +- **THEN** `onError` receives error and partial context +- **AND** hook return value used as fallback HTML for that route +- **AND** build continues for remaining routes + +--- + +### Capability: Template System + +The system SHALL provide a template system for `index.html` and entry points, with built-in defaults and override capability via option or filesystem convention. + +#### Scenario: Default template produces valid HTML shell + +- **GIVEN** no custom template configured +- **WHEN** SSG emits route HTML +- **THEN** output uses built-in template with: ``, ``, `` with charset, viewport, title, meta, canonical, SEO tags, asset links/scripts, `
{appHtml}
` +- **AND** `appHtml` placeholder replaced with strategy output + +#### Scenario: Consumer overrides template via option + +- **GIVEN** `options.ssg.template = '/custom/template.html'` +- **WHEN** SSG runs +- **THEN** custom template file is read and used +- **AND** template receives same interpolation variables as built-in + +#### Scenario: Consumer ejects templates via filesystem + +- **GIVEN** project contains `flatwave-templates/index.html` at root +- **WHEN** SSG runs +- **THEN** filesystem template takes precedence over built-in +- **AND** no config change required + +--- + +### Capability: Build-Time Markdown Compiler + +The system SHALL expose a reusable `compileMarkdownToHtml(markdown, options?)` function that compiles Markdown to HTML using a remark/rehype pipeline, usable by the default strategy and third parties. + +#### Scenario: Compile basic markdown + +- **GIVEN** `compileMarkdownToHtml('# Hello\n\nWorld')` +- **THEN** returns `

Hello

\n

World

\n` + +#### Scenario: Custom remark/rehype plugins + +- **GIVEN** `compileMarkdownToHtml(md, { remarkPlugins: [remarkMath], rehypePlugins: [rehypeKatex] })` +- **THEN** output includes math rendering +- **AND** plugins execute in provided order + +#### Scenario: Raw HTML passthrough option + +- **GIVEN** `compileMarkdownToHtml('
raw
', { allowRawHtml: true })` +- **THEN** raw HTML preserved in output +- **AND** default (`false`) sanitizes/removes raw HTML + +--- + +### Capability: Code Quality Gates (Docker) + +The system SHALL enforce code quality through automated gates that must pass in the Docker CI environment before any merge. + +#### Scenario: Format check passes + +- **GIVEN** code changes +- **WHEN** `npm run format:check` runs in Docker +- **THEN** exits with code 0 (no formatting violations) + +#### Scenario: Lint passes + +- **GIVEN** code changes +- **WHEN** `npm run lint` runs in Docker +- **THEN** exits with code 0 (no lint errors, max-warnings 0) + +#### Scenario: Type check passes + +- **GIVEN** code changes +- **WHEN** `npm run type-check` runs in Docker +- **THEN** exits with code 0 (no TypeScript errors) + +#### Scenario: Build passes + +- **GIVEN** code changes +- **WHEN** `npm run build` runs in Docker +- **THEN** exits with code 0 (TypeScript compilation + copy script succeeds) + +#### Scenario: Tests pass + +- **GIVEN** code changes +- **WHEN** `npm run test` runs in Docker +- **THEN** exits with code 0 (all vitest tests pass) + +#### Scenario: Validate script passes + +- **GIVEN** code changes +- **WHEN** `npm run validate` runs in Docker (runs format:check, lint, type-check, build, test) +- **THEN** exits with code 0 (all sub-commands pass) + +--- + +## MODIFIED Requirements + +### Capability: SSG Output (formerly empty-shell generation) + +The SSG plugin SHALL emit fully-rendered HTML instead of empty shells. + +#### Scenario: Route HTML contains rendered app (MODIFIED) + +- **GIVEN** existing route `/es/about` +- **WHEN** SSG emits HTML +- **THEN** `
` contains rendered component HTML (was empty) +- **AND** markdown content compiled to HTML (was raw markdown in virtual module only) +- **AND** all SEO metadata from `renderHtmlHead` preserved + +#### Scenario: Sitemap and robots.txt unchanged + +- **GIVEN** existing sitemap/robots generation +- **WHEN** SSG runs +- **THEN** `sitemap.xml` and `robots.txt` emitted identically +- **AND** `route-manifest.json` includes same routes + +--- + +## REMOVED Requirements + +### Capability: Dead Code Cleanup + +The following unused/dead code SHALL be removed from the codebase: + +- Empty `src/ssg/` directory +- Inline `renderRouteHtml`, `renderSitemap`, `renderRobotsTxt` functions in `src/index.ts` +- Any unused exports in `src/types.ts` not referenced by new SSG module +- Any dead code identified during refactor + +--- + +## RENAMED Requirements + +None. diff --git a/openspec/changes/improve-rendering-extensible-ssg/tasks.md b/openspec/changes/improve-rendering-extensible-ssg/tasks.md new file mode 100644 index 0000000..45d1c91 --- /dev/null +++ b/openspec/changes/improve-rendering-extensible-ssg/tasks.md @@ -0,0 +1,132 @@ +## 1. Project Setup & Dependencies + +- [x] 1.1 Add `remark`, `remark-parse`, `remark-rehype`, `rehype-stringify`, `rehype-raw`, `unified` to `package.json` dependencies +- [x] 1.2 Add `@types/remark` and `@types/rehype` to devDependencies if needed +- [x] 1.3 Verify TypeScript config includes new modules + +## 2. Markdown Compiler Module (`src/content/markdownCompiler.ts`) + +- [x] 2.1 Create `MarkdownCompilerOptions` interface (remarkPlugins, rehypePlugins, allowRawHtml) +- [x] 2.2 Implement `compileMarkdownToHtml(markdown: string, options?: MarkdownCompilerOptions): Promise` +- [x] 2.3 Configure unified pipeline: remark-parse → remark-rehype → rehype-stringify +- [x] 2.4 Support custom remark/rehype plugins via options +- [x] 2.5 Handle raw HTML passthrough via `rehype-raw` when `allowRawHtml: true` +- [x] 2.6 Export from `src/content/index.ts` (add to public API) +- [x] 2.7 Write unit tests for compiler (basic, custom plugins, raw HTML) + +## 3. SSG Core Types (`src/ssg/types.ts`) + +- [x] 3.1 Define `RenderContext` interface with: route, contentEntry, components, assets, hooks, options, locale, allRoutes +- [x] 3.2 Define `RenderStrategy` interface: `render(context: RenderContext): Promise` +- [x] 3.3 Define `RenderHooks` interface with phases: `beforeRender`, `transformMarkdown`, `transformHtml`, `afterRender`, `onError` +- [x] 3.4 Define `SsgOptions` interface (enabled, strategy, hooks, template, compileMarkdown) +- [x] 3.5 Define `TemplateOverrides` type for template customization +- [x] 3.6 Export all types from `src/ssg/index.ts` + +## 4. Default Render Strategy (`src/ssg/DefaultRenderStrategy.ts`) + +- [x] 4.1 Create `DefaultRenderStrategy` class implementing `RenderStrategy` +- [x] 4.2 Implement `render(context)` using `ReactDOMServer.renderToString` +- [x] 4.3 Resolve component for route: dynamic import from built assets or Vite module graph +- [x] 4.4 Pass `contentEntry` (with compiled HTML) as props to component +- [x] 4.5 Handle component render errors gracefully (return error boundary HTML) +- [x] 4.6 Use `compileMarkdownToHtml` from content module for markdown body +- [x] 4.7 Inject compiled HTML into component via prop (e.g., `markdownHtml`) + +## 5. Render Pipeline (`src/ssg/RenderPipeline.ts`) + +- [x] 5.1 Create `RenderPipeline` class managing hook arrays per phase +- [x] 5.2 Implement `addHook(phase,hook(phase, hook)` for registration +- [x] 5.3 Implement `executePhase(phase, context)` running hooks in sequence +- [x] 5.4 Phase: `beforeRender(context) -> modifiedContext` +- [x] 5.5 Phase: `transformMarkdown(markdown, context) -> transformedMarkdown` +- [x] 5.6 Phase: `transformHtml(html, context) -> transformedHtml` +- [x] 5.7 Phase: `afterRender(html, context) -> void` (side effects, no return) +- [x] 5.8 Phase: `onError(error, context) -> fallbackHtml` (recovery) +- [x] 5.9 Error handling: failed hooks logged, pipeline continues (except onError) +- [x] 5.10 Export from `src/ssg/index.ts` + +## 6. Template System (`src/ssg/template.ts`) + +- [x] 6.1 Create `templates/` directory in package with: `index.html.ejs`, `entry-client.tsx.ejs`, `entry-server.tsx.ejs` +- [x] 6.2 Implement `resolveTemplate(name, overrides?): string` — reads built-in or custom path +- [x] 6.3 Simple EJS-style interpolation: `<%= variable %>` +- [x] 6.4 Built-in template variables: `appHtml`, `title`, `meta`, `assets`, `locale`, `canonical`, `headTags` +- [x] 6.5 Support filesystem override: `flatwave-templates/{name}` at project root +- [x] 6.6 Export `renderTemplate(template, variables)` utility + +## 7. Main SSG Orchestrator (`src/ssg/index.ts`) + +- [x] 7.1 Create `runSsg(index, options, viteBundle)` function +- [x] 7.2 Initialize `DefaultRenderStrategy` or custom strategy +- [x] 7.3 Build `RenderPipeline` with user hooks + built-in hooks +- [x] 7.4 For each route (parallel with concurrency limit): + - [x] 7.4.1 Build `RenderContext` with route, content, assets, components + - [x] 7.4.2 Execute `beforeRender` hooks + - [x] 7.4.3 Compile markdown via pipeline `transformMarkdown` + - [x] 7.4.4 Execute strategy `render(context)` + - [x] 7.4.5 Execute `transformHtml` hooks on result + - [x] 7.4.6 Apply template via `renderTemplate` + - [x] 7.4.7 Execute `afterRender` hooks + - [x] 7.4.8 Emit file via Vite `this.emitFile` + - [x] 7.4.9 Catch errors → execute `onError` → use fallback +- [x] 7.5 Emit sitemap.xml, robots.txt, route-manifest.json (unchanged) +- [x] 7.6 Export `runSsg` and all public types from `src/ssg/index.ts` + +## 8. Plugin Integration (`src/index.ts`) + +- [x] 8.1 Update `FlatwaveContentOptions` in `types.ts` to include `ssg?: SsgOptions` +- [x] 8.2 Refactor `flatwave-react:ssg` plugin to call `runSsg` from `src/ssg` +- [x] 8.3 Pass Vite bundle assets (scripts/styles) to SSG context +- [x] 8.4 Remove inline `renderRouteHtml`, `renderSitemap`, `renderRobotsTxt` (move to SSG module or keep as utilities) +- [x] 8.5 Ensure virtual module still works for client-side hydration +- [x] 8.6 Update plugin option normalization for `ssg` defaults + +## 9. Public API Exports + +- [x] 9.1 Add `./ssg` export to `package.json` exports map +- [x] 9.2 Export: `RenderStrategy`, `DefaultRenderStrategy`, `RenderContext`, `RenderHooks`, `RenderPipeline`, `SsgOptions`, `compileMarkdownToHtml`, `runSsg`, `renderTemplate` +- [x] 9.3 Update `src/virtual.d.ts` if needed for new types +- [x] 9.4 Run `npm run build` to verify types and exports + +## 10. Built-in Templates + +- [x] 10.1 Create `src/ssg/templates/index.html.ejs` with full HTML shell, SEO tags, asset injection +- [x] 10.2 Create `src/ssg/templates/entry-client.tsx.ejs` for hydration entry +- [x] 10.3 Create `src/ssg/templates/entry-server.tsx.ejs` for SSR entry (future-proofing) +- [x] 10.4 Ensure templates copy to `dist/ssg/templates/` at build (add to build script) + +## 11. Dead Code Removal (Cleanup) + +- [x] 11.1 Remove empty `src/ssg/` directory (before creating new one) +- [x] 11.2 Remove inline `renderRouteHtml`, `renderSitemap`, `renderRobotsTxt` functions from `src/index.ts` +- [x] 11.3 Remove any unused exports in `src/types.ts` not referenced by new SSG module +- [x] 11.4 Search for and remove any other dead code (unused imports, dead exports, empty files) +- [x] 11.5 Run `npm run build` and `npm run test` after each removal to verify no regressions + +## 12. Example App Integration & Verification + +- [x] 12.1 Update example `vite.config.ts` to test custom strategy + hooks +- [x] 12.2 Add example hook: `transformMarkdown` injecting custom component +- [x] 12.3 Add example hook: `transformHtml` injecting analytics +- [x] 12.4 Run `npm run build:example` and verify output HTML contains rendered content +- [x] 12.5 Run `npm run test:e2e` — verify all 5 test cases pass +- [x] 12.6 Check emitted HTML: `
` has content, markdown compiled, SEO tags present + +## 13. Documentation & Polish + +- [x] 13.1 Update README with new SSG options and extensibility guide +- [x] 13.2 Document `RenderStrategy` interface with implementation example +- [x] 13.3 Document hook phases with use cases +- [x] 13.4 Document template override methods +- [x] 13.5 Run `npm run validate` (lint, type-check, build, test) — all must pass + +## 14. Docker Quality Gates Validation + +- [x] 14.1 Verify `npm run format:check` passes in Docker environment +- [x] 14.2 Verify `npm run lint` passes in Docker environment +- [x] 14.3 Verify `npm run type-check` passes in Docker environment +- [x] 14.4 Verify `npm run build` passes in Docker environment +- [x] 14.5 Verify `npm run test` passes in Docker environment +- [x] 14.6 Verify `npm run validate` (full pipeline) passes in Docker environment +- [x] 14.7 Document Docker CI requirements in README/CONTRIBUTING diff --git a/package-lock.json b/package-lock.json index dfe1f24..a2b22fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5254,6 +5254,18 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-ci": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", @@ -6914,6 +6926,99 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -6941,6 +7046,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -6954,6 +7078,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -7010,6 +7151,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-proxy-agent": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", @@ -12448,6 +12599,52 @@ "node": ">=0.10.0" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", + "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -12481,6 +12678,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -14481,6 +14693,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -15049,6 +15275,16 @@ } } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", @@ -15374,7 +15610,13 @@ "dependencies": { "commander": "^13.1.0", "fast-glob": "^3.3.3", - "gray-matter": "^4.0.3" + "gray-matter": "^4.0.3", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark": "^15.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.0", + "unified": "^11.0.5" }, "bin": { "flatwave-validate": "dist/cli/validate.js" diff --git a/packages/vite-plugin-flatwave-react/README.md b/packages/vite-plugin-flatwave-react/README.md index 350db53..58078dd 100644 --- a/packages/vite-plugin-flatwave-react/README.md +++ b/packages/vite-plugin-flatwave-react/README.md @@ -97,3 +97,5 @@ Use `--strict-missing` to fail when locale variants are missing. ## License MIT © 2026 Flatwave contributors. + +## Example App Integration diff --git a/packages/vite-plugin-flatwave-react/dist/content/validator.d.ts b/packages/vite-plugin-flatwave-react/dist/content/validator.d.ts index f0a2379..76ed923 100644 --- a/packages/vite-plugin-flatwave-react/dist/content/validator.d.ts +++ b/packages/vite-plugin-flatwave-react/dist/content/validator.d.ts @@ -1,2 +1,3 @@ +export type { ValidationResult } from '../types.js'; import type { FlatwaveContentOptions, ValidationResult } from '../types'; export declare function validateContent(options: FlatwaveContentOptions): Promise; diff --git a/packages/vite-plugin-flatwave-react/dist/index.js b/packages/vite-plugin-flatwave-react/dist/index.js index 959e7b5..e65c997 100644 --- a/packages/vite-plugin-flatwave-react/dist/index.js +++ b/packages/vite-plugin-flatwave-react/dist/index.js @@ -3,10 +3,9 @@ import { buildIndex } from './content/indexer.js'; import { validateContent } from './content/validator.js'; import { parseMarkdown } from './content/parser.js'; import { routeForLocaleSlug } from './content/scanner.js'; -import { escapeHtml, escapeXml, renderHtmlHead } from './seo/metadata.js'; +import { runSsg } from './ssg/runSsg.js'; const VIRTUAL_ID = '\0virtual:flatwave/content'; const PUBLIC_VIRTUAL_ID = 'virtual:flatwave/content'; -// Test comment for lint-staged export function flatwaveContent(options) { const normalizedOptions = normalizeOptions(options); let index = { entries: [], byId: {}, byLocale: {}, routes: [] }; @@ -73,35 +72,14 @@ export function flatwaveContent(options) { { name: 'flatwave-react:ssg', async generateBundle(_, bundle) { - const routes = index.routes; const html = findIndexHtml(bundle); const assets = extractAssets(html); - if (normalizedOptions.emitRouteManifest !== false) { + const outputFiles = await runSsg(index, normalizedOptions, assets); + for (const file of outputFiles) { this.emitFile({ type: 'asset', - fileName: 'route-manifest.json', - source: JSON.stringify(routes, null, 2), - }); - } - if (normalizedOptions.emitSitemap !== false) { - this.emitFile({ - type: 'asset', - fileName: 'sitemap.xml', - source: renderSitemap(routes, normalizedOptions.sitemap?.hostname ?? 'http://localhost:4173'), - }); - } - if (normalizedOptions.emitRobotsTxt !== false) { - this.emitFile({ - type: 'asset', - fileName: 'robots.txt', - source: renderRobotsTxt(normalizedOptions.sitemap?.hostname ?? 'http://localhost:4173'), - }); - } - for (const route of routes) { - this.emitFile({ - type: 'asset', - fileName: `${route.path.replace(/^\//, '').replace(/\/$/, '')}/index.html`, - source: renderRouteHtml(route, assets), + fileName: file.fileName, + source: file.source, }); } }, @@ -120,6 +98,13 @@ function normalizeOptions(options) { emitRouteManifest: options.emitRouteManifest ?? true, emitSitemap: options.emitSitemap ?? true, emitRobotsTxt: options.emitRobotsTxt ?? true, + ssg: { + enabled: options.ssg?.enabled ?? true, + strategy: options.ssg?.strategy, + hooks: options.ssg?.hooks, + template: options.ssg?.template, + compileMarkdown: options.ssg?.compileMarkdown, + }, }; } function createVirtualModule(index, defaultLocale) { @@ -196,51 +181,4 @@ function extractAssets(html) { ].map((match) => match[1]); return { scripts, styles }; } -function renderSitemap(routes, hostname) { - const base = hostname.replace(/\/$/, ''); - const urls = routes - .map((route) => { - const loc = `${base}${route.path}`; - return `${escapeXml(loc)}${new Date().toISOString().slice(0, 10)}weekly0.8`; - }) - .join(''); - return ` -${urls} -`; -} -function renderRobotsTxt(hostname) { - const base = hostname.replace(/\/$/, ''); - return `User-agent: * -Allow: / - -Sitemap: ${base}/sitemap.xml -`; -} -function renderRouteHtml(route, assets) { - const scripts = assets.scripts - .map((src) => ``) - .join('\n'); - const styles = assets.styles - .map((href) => ``) - .join('\n'); - const title = escapeHtml(route.metadata.title); - const description = route.metadata.description ? escapeHtml(route.metadata.description) : title; - return ` - - - - - ${title} - - - ${styles} - ${renderHtmlHead(route)} - - -
- ${scripts} - - -`; -} export default flatwaveContent; diff --git a/packages/vite-plugin-flatwave-react/dist/types.d.ts b/packages/vite-plugin-flatwave-react/dist/types.d.ts index 627ab10..08ce82f 100644 --- a/packages/vite-plugin-flatwave-react/dist/types.d.ts +++ b/packages/vite-plugin-flatwave-react/dist/types.d.ts @@ -9,6 +9,33 @@ export interface FlatwaveRobotsOptions { sitemapPath?: string; rules?: string[]; } +export interface MarkdownCompilerOptions { + remarkPlugins?: unknown[]; + rehypePlugins?: unknown[]; + allowRawHtml?: boolean; +} +export interface RenderStrategy { + render(context: unknown): Promise; +} +export interface RenderHooks { + beforeRender?: (context: unknown) => Promise | unknown; + transformMarkdown?: (markdown: string, context: unknown) => Promise | string; + transformHtml?: (html: string, context: unknown) => Promise | string; + afterRender?: (html: string, context: unknown) => Promise | void; + onError?: (error: Error, context: unknown) => Promise | string; +} +export interface TemplateOverrides { + indexHtml?: string; + entryClient?: string; + entryServer?: string; +} +export interface SsgOptions { + enabled: boolean; + strategy?: RenderStrategy; + hooks?: Partial; + template?: string | TemplateOverrides; + compileMarkdown?: MarkdownCompilerOptions; +} export interface FlatwaveContentOptions { contentDir: string; locales: string[]; @@ -23,6 +50,7 @@ export interface FlatwaveContentOptions { emitRobotsTxt?: boolean; sitemap?: FlatwaveSitemapOptions; robots?: FlatwaveRobotsOptions; + ssg?: SsgOptions; } export interface FlatwaveFrontmatter extends Record { title: string; @@ -85,3 +113,4 @@ export interface ValidationResult { errors: string[]; warnings: string[]; } +export { compileMarkdownToHtml } from './content/markdownCompiler.js'; diff --git a/packages/vite-plugin-flatwave-react/dist/types.js b/packages/vite-plugin-flatwave-react/dist/types.js index cb0ff5c..be34e44 100644 --- a/packages/vite-plugin-flatwave-react/dist/types.js +++ b/packages/vite-plugin-flatwave-react/dist/types.js @@ -1 +1 @@ -export {}; +export { compileMarkdownToHtml } from './content/markdownCompiler.js'; diff --git a/packages/vite-plugin-flatwave-react/package.json b/packages/vite-plugin-flatwave-react/package.json index f44bad1..f1d6ac2 100644 --- a/packages/vite-plugin-flatwave-react/package.json +++ b/packages/vite-plugin-flatwave-react/package.json @@ -1,6 +1,6 @@ { "name": "@kamansoft/vite-plugin-flatwave-react", - "version": "0.1.0", + "version": "1.0.0", "description": "Vite content plugin for Markdown-driven, i18n-aware static React sites.", "type": "module", "main": "./dist/index.js", @@ -28,6 +28,10 @@ "./virtual": { "types": "./dist/react/virtual.d.ts" }, + "./ssg": { + "types": "./dist/ssg/index.d.ts", + "import": "./dist/ssg/index.js" + }, "./package.json": "./package.json" }, "bin": { @@ -77,7 +81,13 @@ "dependencies": { "commander": "^13.1.0", "fast-glob": "^3.3.3", - "gray-matter": "^4.0.3" + "gray-matter": "^4.0.3", + "remark": "^15.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.0", + "rehype-stringify": "^10.0.1", + "rehype-raw": "^7.0.0", + "unified": "^11.0.5" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/packages/vite-plugin-flatwave-react/scripts/copy-virtual-types.js b/packages/vite-plugin-flatwave-react/scripts/copy-virtual-types.js index afdfc04..078ce6a 100644 --- a/packages/vite-plugin-flatwave-react/scripts/copy-virtual-types.js +++ b/packages/vite-plugin-flatwave-react/scripts/copy-virtual-types.js @@ -1,8 +1,21 @@ -import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { copyFileSync, mkdirSync, readFileSync, writeFileSync, cpSync, existsSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = dirname(__dirname); mkdirSync('dist/react', { recursive: true }); copyFileSync('src/virtual.d.ts', 'dist/react/virtual.d.ts'); +// Copy templates directory +const templatesSrc = 'src/ssg/templates'; +const templatesDest = 'dist/ssg/templates'; +if (existsSync(templatesSrc)) { + mkdirSync(templatesDest, { recursive: true }); + cpSync(templatesSrc, templatesDest, { recursive: true }); +} + const declarationPath = 'dist/react/index.d.ts'; const reference = '/// \n'; let declaration = readFileSync(declarationPath, 'utf-8'); diff --git a/packages/vite-plugin-flatwave-react/src/content/index.ts b/packages/vite-plugin-flatwave-react/src/content/index.ts new file mode 100644 index 0000000..e517a2a --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/content/index.ts @@ -0,0 +1,5 @@ +export { compileMarkdownToHtml, type MarkdownCompilerOptions } from './markdownCompiler.js'; +export { scanMarkdownFiles, routeForLocaleSlug, normalizeSlug, isPublicEntry } from './scanner.js'; +export { parseMarkdown } from './parser.js'; +export { buildIndex } from './indexer.js'; +export { validateContent, type ValidationResult } from './validator.js'; diff --git a/packages/vite-plugin-flatwave-react/src/content/markdownCompiler.test.ts b/packages/vite-plugin-flatwave-react/src/content/markdownCompiler.test.ts new file mode 100644 index 0000000..f3c1668 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/content/markdownCompiler.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { compileMarkdownToHtml } from './markdownCompiler.js'; + +describe('compileMarkdownToHtml', () => { + it('compiles basic markdown to HTML', async () => { + const result = await compileMarkdownToHtml('# Hello\n\nWorld'); + expect(result).toContain('

Hello

'); + expect(result).toContain('

World

'); + }); + + it('handles raw HTML when allowRawHtml is true', async () => { + const result = await compileMarkdownToHtml('
raw
', { allowRawHtml: true }); + expect(result).toContain('
raw
'); + }); + + it('strips raw HTML by default', async () => { + const result = await compileMarkdownToHtml('
raw
'); + expect(result).not.toContain('
raw
'); + }); + + it('handles code blocks', async () => { + const result = await compileMarkdownToHtml('```js\nconst x = 1;\n```'); + expect(result).toContain('
const x = 1;\n
'); + }); + + it('handles links', async () => { + const result = await compileMarkdownToHtml('[link](https://example.com)'); + expect(result).toContain('link'); + }); + + it('handles emphasis', async () => { + const result = await compileMarkdownToHtml('**bold** and *italic*'); + expect(result).toContain('bold'); + expect(result).toContain('italic'); + }); +}); diff --git a/packages/vite-plugin-flatwave-react/src/content/markdownCompiler.ts b/packages/vite-plugin-flatwave-react/src/content/markdownCompiler.ts new file mode 100644 index 0000000..da15f8a --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/content/markdownCompiler.ts @@ -0,0 +1,39 @@ +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import rehypeStringify from 'rehype-stringify'; +import rehypeRaw from 'rehype-raw'; +import type { Processor } from 'unified'; + +export interface MarkdownCompilerOptions { + remarkPlugins?: Array[0]>; + rehypePlugins?: Array[0]>; + allowRawHtml?: boolean; +} + +export async function compileMarkdownToHtml( + markdown: string, + options: MarkdownCompilerOptions = {} +): Promise { + const { remarkPlugins = [], rehypePlugins = [], allowRawHtml = false } = options; + + const processor = unified() + .use(remarkParse) + .use(remarkRehype, { allowDangerousHtml: allowRawHtml }) + .use(rehypeStringify); + + if (allowRawHtml) { + processor.use(rehypeRaw); + } + + for (const plugin of remarkPlugins) { + processor.use(plugin); + } + + for (const plugin of rehypePlugins) { + processor.use(plugin); + } + + const result = await processor.process(markdown); + return String(result); +} diff --git a/packages/vite-plugin-flatwave-react/src/content/validator.ts b/packages/vite-plugin-flatwave-react/src/content/validator.ts index 2241e46..b2e9d4d 100644 --- a/packages/vite-plugin-flatwave-react/src/content/validator.ts +++ b/packages/vite-plugin-flatwave-react/src/content/validator.ts @@ -1,4 +1,5 @@ import { readdir } from 'node:fs/promises'; +export type { ValidationResult } from '../types.js'; import path from 'node:path'; import type { FlatwaveContentEntry, FlatwaveContentOptions, ValidationResult } from '../types'; import { routeForLocaleSlug, scanMarkdownFiles } from './scanner.js'; diff --git a/packages/vite-plugin-flatwave-react/src/index.ts b/packages/vite-plugin-flatwave-react/src/index.ts index f1d9537..ffc1b34 100644 --- a/packages/vite-plugin-flatwave-react/src/index.ts +++ b/packages/vite-plugin-flatwave-react/src/index.ts @@ -4,13 +4,12 @@ import { buildIndex } from './content/indexer.js'; import { validateContent } from './content/validator.js'; import { parseMarkdown } from './content/parser.js'; import { routeForLocaleSlug } from './content/scanner.js'; -import { escapeHtml, escapeXml, renderHtmlHead } from './seo/metadata.js'; -import type { FlatwaveContentIndex, FlatwaveContentOptions, FlatwaveRoute } from './types'; +import { runSsg } from './ssg/runSsg.js'; +import type { FlatwaveContentIndex, FlatwaveContentOptions } from './types'; const VIRTUAL_ID = '\0virtual:flatwave/content'; const PUBLIC_VIRTUAL_ID = 'virtual:flatwave/content'; -// Test comment for lint-staged export function flatwaveContent(options: FlatwaveContentOptions): Plugin[] { const normalizedOptions = normalizeOptions(options); let index: FlatwaveContentIndex = { entries: [], byId: {}, byLocale: {}, routes: [] }; @@ -79,42 +78,16 @@ export function flatwaveContent(options: FlatwaveContentOptions): Plugin[] { { name: 'flatwave-react:ssg', async generateBundle(_, bundle) { - const routes = index.routes; const html = findIndexHtml(bundle); const assets = extractAssets(html); - if (normalizedOptions.emitRouteManifest !== false) { - this.emitFile({ - type: 'asset', - fileName: 'route-manifest.json', - source: JSON.stringify(routes, null, 2), - }); - } - - if (normalizedOptions.emitSitemap !== false) { - this.emitFile({ - type: 'asset', - fileName: 'sitemap.xml', - source: renderSitemap( - routes, - normalizedOptions.sitemap?.hostname ?? 'http://localhost:4173' - ), - }); - } - - if (normalizedOptions.emitRobotsTxt !== false) { - this.emitFile({ - type: 'asset', - fileName: 'robots.txt', - source: renderRobotsTxt(normalizedOptions.sitemap?.hostname ?? 'http://localhost:4173'), - }); - } + const outputFiles = await runSsg(index, normalizedOptions, assets); - for (const route of routes) { + for (const file of outputFiles) { this.emitFile({ type: 'asset', - fileName: `${route.path.replace(/^\//, '').replace(/\/$/, '')}/index.html`, - source: renderRouteHtml(route, assets), + fileName: file.fileName, + source: file.source, }); } }, @@ -135,6 +108,13 @@ function normalizeOptions(options: FlatwaveContentOptions): FlatwaveContentOptio emitRouteManifest: options.emitRouteManifest ?? true, emitSitemap: options.emitSitemap ?? true, emitRobotsTxt: options.emitRobotsTxt ?? true, + ssg: { + enabled: options.ssg?.enabled ?? true, + strategy: options.ssg?.strategy, + hooks: options.ssg?.hooks, + template: options.ssg?.template, + compileMarkdown: options.ssg?.compileMarkdown, + }, }; } @@ -220,59 +200,4 @@ function extractAssets(html: string | undefined): { scripts: string[]; styles: s return { scripts, styles }; } -function renderSitemap(routes: FlatwaveRoute[], hostname: string): string { - const base = hostname.replace(/\/$/, ''); - const urls = routes - .map((route) => { - const loc = `${base}${route.path}`; - return `${escapeXml(loc)}${new Date().toISOString().slice(0, 10)}weekly0.8`; - }) - .join(''); - - return ` -${urls} -`; -} - -function renderRobotsTxt(hostname: string): string { - const base = hostname.replace(/\/$/, ''); - return `User-agent: * -Allow: / - -Sitemap: ${base}/sitemap.xml -`; -} - -function renderRouteHtml( - route: FlatwaveRoute, - assets: { scripts: string[]; styles: string[] } -): string { - const scripts = assets.scripts - .map((src) => ``) - .join('\n'); - const styles = assets.styles - .map((href) => ``) - .join('\n'); - const title = escapeHtml(route.metadata.title); - const description = route.metadata.description ? escapeHtml(route.metadata.description) : title; - - return ` - - - - - ${title} - - - ${styles} - ${renderHtmlHead(route)} - - -
- ${scripts} - - -`; -} - export default flatwaveContent; diff --git a/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx b/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx new file mode 100644 index 0000000..e797cec --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx @@ -0,0 +1,49 @@ +import { renderToString } from 'react-dom/server'; +import type { RenderStrategy, RenderContext } from './types.js'; + +export class DefaultRenderStrategy implements RenderStrategy { + async render(context: RenderContext): Promise { + const { route, contentEntry, components, locale } = context; + + const componentModule = components.get(route.component || ''); + + // contentEntry.body is already compiled HTML (set by runSsg before calling render) + const compiledBody = contentEntry.body; + + if (!componentModule) { + // Graceful degradation: component not found at build time is common when + // consuming projects haven't pre-built their components. Serve compiled + // markdown so the page still has meaningful content. + console.warn( + `[SSG] Component "${route.component}" not found for "${route.path}" — serving compiled markdown` + ); + return compiledBody; + } + + const Component = (componentModule as { default: React.ComponentType> }) + .default; + if (!Component) { + console.warn( + `[SSG] Component "${route.component}" has no default export for "${route.path}" — serving compiled markdown` + ); + return compiledBody; + } + + // contentEntry.body is already compiled HTML — runSsg pre-compiles via + // the transformMarkdown pipeline + compileMarkdownToHtml before calling render + const props = { + ...contentEntry.frontmatter, + markdownHtml: contentEntry.body, + locale, + route: route.path, + }; + + try { + const appHtml = renderToString(); + return appHtml; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `

Render error: ${message}

`; + } + } +} diff --git a/packages/vite-plugin-flatwave-react/src/ssg/RenderPipeline.ts b/packages/vite-plugin-flatwave-react/src/ssg/RenderPipeline.ts new file mode 100644 index 0000000..44f46b3 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/ssg/RenderPipeline.ts @@ -0,0 +1,111 @@ +import type { RenderContext } from './types.js'; +import type { RenderHooks } from '../types.js'; + +type HookPhase = keyof RenderHooks; + +type BeforeRenderHook = NonNullable; +type TransformMarkdownHook = NonNullable; +type TransformHtmlHook = NonNullable; +type AfterRenderHook = NonNullable; +type OnErrorHook = NonNullable; + +export class RenderPipeline { + private beforeRenderHooks: BeforeRenderHook[] = []; + private transformMarkdownHooks: TransformMarkdownHook[] = []; + private transformHtmlHooks: TransformHtmlHook[] = []; + private afterRenderHooks: AfterRenderHook[] = []; + private onErrorHooks: OnErrorHook[] = []; + + constructor(initialHooks: Partial = {}) { + if (initialHooks.beforeRender) this.beforeRenderHooks.push(initialHooks.beforeRender); + if (initialHooks.transformMarkdown) + this.transformMarkdownHooks.push(initialHooks.transformMarkdown); + if (initialHooks.transformHtml) this.transformHtmlHooks.push(initialHooks.transformHtml); + if (initialHooks.afterRender) this.afterRenderHooks.push(initialHooks.afterRender); + if (initialHooks.onError) this.onErrorHooks.push(initialHooks.onError); + } + + addHook(phase: HookPhase, hook: unknown): void { + const target = this.getHooks(phase); + if (target) { + target.push(hook as never); + } + } + + async executeBeforeRender(context: RenderContext): Promise { + let modified = context; + for (const hook of this.beforeRenderHooks) { + try { + modified = (await hook(modified)) as RenderContext; + } catch (error) { + console.error(`[RenderPipeline] beforeRender hook failed:`, error); + } + } + return modified; + } + + async executeTransformMarkdown(markdown: string, context: RenderContext): Promise { + let transformed = markdown; + for (const hook of this.transformMarkdownHooks) { + try { + transformed = await hook(transformed, context); + } catch (error) { + console.error(`[RenderPipeline] transformMarkdown hook failed:`, error); + } + } + return transformed; + } + + async executeTransformHtml(html: string, context: RenderContext): Promise { + let transformed = html; + for (const hook of this.transformHtmlHooks) { + try { + transformed = await hook(transformed, context); + } catch (error) { + console.error(`[RenderPipeline] transformHtml hook failed:`, error); + } + } + return transformed; + } + + async executeAfterRender(html: string, context: RenderContext): Promise { + for (const hook of this.afterRenderHooks) { + try { + await hook(html, context); + } catch (error) { + console.error(`[RenderPipeline] afterRender hook failed:`, error); + } + } + } + + async executeOnError(error: Error, context: RenderContext): Promise { + for (const hook of this.onErrorHooks) { + try { + return await hook(error, context); + } catch (hookError) { + console.error(`[RenderPipeline] onError hook failed:`, hookError); + } + } + return `

Render error: ${error.message}

`; + } + + hasHooks(phase: HookPhase): boolean { + const hooks = this.getHooks(phase); + return hooks !== undefined && hooks.length > 0; + } + + private getHooks(phase: HookPhase): unknown[] | undefined { + switch (phase) { + case 'beforeRender': + return this.beforeRenderHooks as unknown[]; + case 'transformMarkdown': + return this.transformMarkdownHooks as unknown[]; + case 'transformHtml': + return this.transformHtmlHooks as unknown[]; + case 'afterRender': + return this.afterRenderHooks as unknown[]; + case 'onError': + return this.onErrorHooks as unknown[]; + } + } +} diff --git a/packages/vite-plugin-flatwave-react/src/ssg/index.ts b/packages/vite-plugin-flatwave-react/src/ssg/index.ts new file mode 100644 index 0000000..e7fbfd3 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/ssg/index.ts @@ -0,0 +1,15 @@ +export type { + RenderContext, + RenderHooks, + TemplateOverrides, + TemplateVariables, + RenderStrategy, + RenderPipeline, +} from './types.js'; +export { DefaultRenderStrategy } from './DefaultRenderStrategy.js'; +export { runSsg, type SsgOutputFile, renderSitemap, renderRobotsTxt } from './runSsg.js'; +export { resolveTemplate, renderTemplate } from './template.js'; +export { + compileMarkdownToHtml, + type MarkdownCompilerOptions, +} from '../content/markdownCompiler.js'; diff --git a/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts b/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts new file mode 100644 index 0000000..bb9333f --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts @@ -0,0 +1,209 @@ +import type { + FlatwaveContentIndex, + FlatwaveRoute, + FlatwaveContentOptions, + SsgOptions, +} from '../types.js'; +import type { RenderContext } from './types.js'; +import { DefaultRenderStrategy } from './DefaultRenderStrategy.js'; +import { RenderPipeline } from './RenderPipeline.js'; +import { resolveTemplate, renderTemplate } from './template.js'; +import { escapeHtml, escapeXml, renderHtmlHead } from '../seo/metadata.js'; +import { + compileMarkdownToHtml, + type MarkdownCompilerOptions, +} from '../content/markdownCompiler.js'; + +export interface SsgOutputFile { + fileName: string; + source: string; +} + +export function renderSitemap(routes: FlatwaveRoute[], hostname: string): string { + const base = hostname.replace(/\/$/, ''); + const urls = routes + .map((route) => { + const loc = `${base}${route.path}`; + return `${escapeXml(loc)}${new Date().toISOString().slice(0, 10)}weekly0.8`; + }) + .join(''); + + return ` +${urls} +`; +} + +export function renderRobotsTxt(hostname: string): string { + const base = hostname.replace(/\/$/, ''); + return `User-agent: * +Allow: / + +Sitemap: ${base}/sitemap.xml +`; +} + +async function buildComponentsMap(routes: FlatwaveRoute[]): Promise> { + const components = new Map(); + const uniqueComponents = new Set(routes.map((r) => r.component).filter(Boolean)); + + for (const componentName of uniqueComponents) { + try { + const module = await import(`../react/${componentName}.js`); + components.set(componentName!, module); + } catch { + try { + const module = await import(`virtual:flatwave/components/${componentName}`); + components.set(componentName!, module); + } catch { + console.warn(`[SSG] Could not load component: ${componentName}`); + } + } + } + + return components; +} + +function toCompilerOptions( + opts?: SsgOptions['compileMarkdown'] +): MarkdownCompilerOptions | undefined { + if (!opts) return undefined; + return { + remarkPlugins: opts.remarkPlugins as MarkdownCompilerOptions['remarkPlugins'], + rehypePlugins: opts.rehypePlugins as MarkdownCompilerOptions['rehypePlugins'], + allowRawHtml: opts.allowRawHtml, + }; +} + +export async function runSsg( + index: FlatwaveContentIndex, + options: FlatwaveContentOptions & { ssg?: SsgOptions }, + assets: { scripts: string[]; styles: string[] } +): Promise { + const ssgOptions = options.ssg ?? { enabled: true }; + if (!ssgOptions.enabled) return []; + + const routes = index.routes; + + const strategy = ssgOptions.strategy ?? new DefaultRenderStrategy(); + const pipeline = new RenderPipeline(ssgOptions.hooks); + + const components = await buildComponentsMap(routes); + + const concurrencyLimit = 4; + const routeChunks: FlatwaveRoute[][] = []; + for (let i = 0; i < routes.length; i += concurrencyLimit) { + routeChunks.push(routes.slice(i, i + concurrencyLimit)); + } + + const outputFiles: SsgOutputFile[] = []; + + for (const chunk of routeChunks) { + const results = await Promise.all( + chunk.map(async (route) => { + const contentEntry = index.entries.find( + (e) => e.id === route.contentId && e.locale === route.locale + ); + if (!contentEntry) { + console.warn(`[SSG] No content entry found for route: ${route.path}`); + return null; + } + + let context: RenderContext = { + route, + contentEntry, + components, + assets, + hooks: pipeline, + options: ssgOptions, + locale: route.locale, + allRoutes: routes, + }; + + context = await pipeline.executeBeforeRender(context); + + const transformedMarkdown = await pipeline.executeTransformMarkdown( + contentEntry.body, + context + ); + const compiledMarkdown = await compileMarkdownToHtml( + transformedMarkdown, + toCompilerOptions(ssgOptions.compileMarkdown) + ); + + const contentEntryWithHtml = { + ...contentEntry, + body: compiledMarkdown, + }; + + const renderContext: RenderContext = { + ...context, + contentEntry: contentEntryWithHtml, + }; + + let appHtml: string; + try { + appHtml = await strategy.render(renderContext); + } catch (error) { + const fallbackHtml = await pipeline.executeOnError(error as Error, renderContext); + appHtml = fallbackHtml; + } + + const template = resolveTemplate( + 'index.html', + ssgOptions.template as { indexHtml?: string } | undefined + ); + const seoMetadata = route.metadata; + const headTags = renderHtmlHead(route); + + const templateVariables = { + appHtml, + title: escapeHtml(seoMetadata.title), + meta: escapeHtml(seoMetadata.description || seoMetadata.title), + assets, + locale: route.locale, + canonical: escapeHtml(seoMetadata.canonical ?? route.path), + headTags, + }; + + // transformHtml runs on the FULL HTML (after template) so hooks can + // inject into / (e.g. analytics, CSP headers) + let finalHtml = renderTemplate(template, templateVariables); + finalHtml = await pipeline.executeTransformHtml(finalHtml, renderContext); + await pipeline.executeAfterRender(finalHtml, renderContext); + + const fileName = `${route.path.replace(/^\//, '').replace(/\/$/, '')}/index.html`; + + return { fileName, source: finalHtml }; + }) + ); + + for (const result of results) { + if (result) { + outputFiles.push(result); + } + } + } + + if (options.emitRouteManifest !== false) { + outputFiles.push({ + fileName: 'route-manifest.json', + source: JSON.stringify(routes, null, 2), + }); + } + + if (options.emitSitemap !== false) { + outputFiles.push({ + fileName: 'sitemap.xml', + source: renderSitemap(routes, options.sitemap?.hostname ?? 'http://localhost:4173'), + }); + } + + if (options.emitRobotsTxt !== false) { + outputFiles.push({ + fileName: 'robots.txt', + source: renderRobotsTxt(options.sitemap?.hostname ?? 'http://localhost:4173'), + }); + } + + return outputFiles; +} diff --git a/packages/vite-plugin-flatwave-react/src/ssg/template.ts b/packages/vite-plugin-flatwave-react/src/ssg/template.ts new file mode 100644 index 0000000..a5b8906 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/ssg/template.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { TemplateVariables, TemplateOverrides } from './types.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEMPLATES_DIR = join(__dirname, 'templates'); + +const DEFAULT_TEMPLATES = { + 'index.html': 'index.html.ejs', + 'entry-client.tsx': 'entry-client.tsx.ejs', + 'entry-server.tsx': 'entry-server.tsx.ejs', +} as const; + +export function resolveTemplate(name: string, overrides?: TemplateOverrides): string { + if (overrides) { + const overrideKey = name.replace('.', '-') as keyof TemplateOverrides; + if (overrides[overrideKey]) { + return readFileSync(resolve(overrides[overrideKey]!), 'utf-8'); + } + } + + const projectRoot = process.cwd(); + const projectTemplate = join(projectRoot, 'flatwave-templates', name); + try { + return readFileSync(projectTemplate, 'utf-8'); + } catch { + const builtInTemplate = join( + TEMPLATES_DIR, + DEFAULT_TEMPLATES[name as keyof typeof DEFAULT_TEMPLATES] || `${name}.ejs` + ); + return readFileSync(builtInTemplate, 'utf-8'); + } +} + +export function renderTemplate(template: string, variables: TemplateVariables): string { + return template + .replace(/<%= appHtml %>/g, variables.appHtml) + .replace(/<%= title %>/g, variables.title) + .replace(/<%= meta %>/g, variables.meta) + .replace(/<%= locale %>/g, variables.locale) + .replace(/<%= canonical %>/g, variables.canonical) + .replace(/<%= headTags %>/g, variables.headTags) + .replace( + /<%= scripts %>/g, + variables.assets.scripts + .map((src) => ``) + .join('\n') + ) + .replace( + /<%= styles %>/g, + variables.assets.styles.map((href) => ``).join('\n') + ); +} diff --git a/packages/vite-plugin-flatwave-react/src/ssg/templates/entry-client.tsx.ejs b/packages/vite-plugin-flatwave-react/src/ssg/templates/entry-client.tsx.ejs new file mode 100644 index 0000000..00c644f --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/ssg/templates/entry-client.tsx.ejs @@ -0,0 +1,4 @@ +import { hydrateRoot } from 'react-dom/client'; +import App from './App'; + +hydrateRoot(document.getElementById('root')!, ); \ No newline at end of file diff --git a/packages/vite-plugin-flatwave-react/src/ssg/templates/entry-server.tsx.ejs b/packages/vite-plugin-flatwave-react/src/ssg/templates/entry-server.tsx.ejs new file mode 100644 index 0000000..944434c --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/ssg/templates/entry-server.tsx.ejs @@ -0,0 +1,6 @@ +import { renderToString } from 'react-dom/server'; +import App from './App'; + +export function render() { + return renderToString(); +} \ No newline at end of file diff --git a/packages/vite-plugin-flatwave-react/src/ssg/templates/index.html.ejs b/packages/vite-plugin-flatwave-react/src/ssg/templates/index.html.ejs new file mode 100644 index 0000000..83127f6 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/ssg/templates/index.html.ejs @@ -0,0 +1,16 @@ + + + + + + <%= title %> + + + <%= styles %> + <%= headTags %> + + +
<%= appHtml %>
+ <%= scripts %> + + \ No newline at end of file diff --git a/packages/vite-plugin-flatwave-react/src/ssg/types.ts b/packages/vite-plugin-flatwave-react/src/ssg/types.ts new file mode 100644 index 0000000..2c41934 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/ssg/types.ts @@ -0,0 +1,26 @@ +import type { FlatwaveRoute, FlatwaveContentEntry } from '../types.js'; +import type { RenderPipeline } from './RenderPipeline.js'; + +export type { RenderPipeline } from './RenderPipeline.js'; +export type { RenderHooks, TemplateOverrides, RenderStrategy } from '../types.js'; + +export interface RenderContext { + route: FlatwaveRoute; + contentEntry: FlatwaveContentEntry; + components: Map; + assets: { scripts: string[]; styles: string[] }; + hooks: RenderPipeline; + options: import('../types.js').SsgOptions; + locale: string; + allRoutes: FlatwaveRoute[]; +} + +export type TemplateVariables = { + appHtml: string; + title: string; + meta: string; + assets: { scripts: string[]; styles: string[] }; + locale: string; + canonical: string; + headTags: string; +}; diff --git a/packages/vite-plugin-flatwave-react/src/types.ts b/packages/vite-plugin-flatwave-react/src/types.ts index 12a8a3e..da2a64d 100644 --- a/packages/vite-plugin-flatwave-react/src/types.ts +++ b/packages/vite-plugin-flatwave-react/src/types.ts @@ -12,6 +12,38 @@ export interface FlatwaveRobotsOptions { rules?: string[]; } +export interface MarkdownCompilerOptions { + remarkPlugins?: unknown[]; + rehypePlugins?: unknown[]; + allowRawHtml?: boolean; +} + +export interface RenderStrategy { + render(context: unknown): Promise; +} + +export interface RenderHooks { + beforeRender?: (context: unknown) => Promise | unknown; + transformMarkdown?: (markdown: string, context: unknown) => Promise | string; + transformHtml?: (html: string, context: unknown) => Promise | string; + afterRender?: (html: string, context: unknown) => Promise | void; + onError?: (error: Error, context: unknown) => Promise | string; +} + +export interface TemplateOverrides { + indexHtml?: string; + entryClient?: string; + entryServer?: string; +} + +export interface SsgOptions { + enabled: boolean; + strategy?: RenderStrategy; + hooks?: Partial; + template?: string | TemplateOverrides; + compileMarkdown?: MarkdownCompilerOptions; +} + export interface FlatwaveContentOptions { contentDir: string; locales: string[]; @@ -26,6 +58,7 @@ export interface FlatwaveContentOptions { emitRobotsTxt?: boolean; sitemap?: FlatwaveSitemapOptions; robots?: FlatwaveRobotsOptions; + ssg?: SsgOptions; } export interface FlatwaveFrontmatter extends Record { @@ -94,3 +127,5 @@ export interface ValidationResult { errors: string[]; warnings: string[]; } + +export { compileMarkdownToHtml } from './content/markdownCompiler.js'; diff --git a/packages/vite-plugin-flatwave-react/tsconfig.build.json b/packages/vite-plugin-flatwave-react/tsconfig.build.json index f9b0379..41da908 100644 --- a/packages/vite-plugin-flatwave-react/tsconfig.build.json +++ b/packages/vite-plugin-flatwave-react/tsconfig.build.json @@ -4,6 +4,8 @@ "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "Bundler", + "jsx": "react-jsx", + "jsxImportSource": "react", "declaration": true, "emitDeclarationOnly": false, "outDir": "dist",