: null}
-
- );
-}
diff --git a/examples/basic-react-site/src/content/es/about.md b/examples/basic-react-site/src/content/es/about.md
index d0137e5..bbb9177 100644
--- a/examples/basic-react-site/src/content/es/about.md
+++ b/examples/basic-react-site/src/content/es/about.md
@@ -2,7 +2,6 @@
title: 'Acerca de'
slug: 'about'
id: 'about'
-component: 'SimplePage'
public: true
description: 'Página acerca del ejemplo Flatwave.'
canonical: '/es/about'
diff --git a/examples/basic-react-site/src/content/es/index.md b/examples/basic-react-site/src/content/es/index.md
index 7fe115c..600236d 100644
--- a/examples/basic-react-site/src/content/es/index.md
+++ b/examples/basic-react-site/src/content/es/index.md
@@ -2,7 +2,6 @@
title: 'Inicio'
slug: 'index'
id: 'home'
-component: 'SimplePage'
public: true
description: 'Página de inicio del ejemplo Flatwave.'
canonical: '/es/'
diff --git a/examples/basic-react-site/src/content/es/program.md b/examples/basic-react-site/src/content/es/program.md
index 5ed8d97..c3f50f3 100644
--- a/examples/basic-react-site/src/content/es/program.md
+++ b/examples/basic-react-site/src/content/es/program.md
@@ -2,7 +2,6 @@
title: 'Programa'
slug: 'program'
id: 'program'
-component: 'ProgramPage'
public: true
description: 'Página de programa con frontmatter específico del componente.'
canonical: '/es/program'
diff --git a/examples/basic-react-site/src/content/pt/about.md b/examples/basic-react-site/src/content/pt/about.md
index e08358d..6ec7f40 100644
--- a/examples/basic-react-site/src/content/pt/about.md
+++ b/examples/basic-react-site/src/content/pt/about.md
@@ -2,7 +2,6 @@
title: 'Sobre'
slug: 'about'
id: 'about'
-component: 'SimplePage'
public: true
description: 'Página sobre o exemplo Flatwave.'
canonical: '/pt/about'
diff --git a/examples/basic-react-site/src/content/pt/index.md b/examples/basic-react-site/src/content/pt/index.md
index 3a349f5..31c83db 100644
--- a/examples/basic-react-site/src/content/pt/index.md
+++ b/examples/basic-react-site/src/content/pt/index.md
@@ -2,9 +2,8 @@
title: 'Início'
slug: 'index'
id: 'home'
-component: 'SimplePage'
public: true
-description: 'Página inicial do exemplo Flatwave.'
+description: 'Página de início do exemplo Flatwave.'
canonical: '/pt/'
robots: 'index, follow'
keywords:
diff --git a/examples/basic-react-site/src/content/pt/program.md b/examples/basic-react-site/src/content/pt/program.md
index a446732..84e43cc 100644
--- a/examples/basic-react-site/src/content/pt/program.md
+++ b/examples/basic-react-site/src/content/pt/program.md
@@ -2,7 +2,6 @@
title: 'Programa'
slug: 'program'
id: 'program'
-component: 'ProgramPage'
public: true
description: 'Página de programa com frontmatter específico do componente.'
canonical: '/pt/program'
diff --git a/examples/basic-react-site/vite.config.ts b/examples/basic-react-site/vite.config.ts
index 34862d9..1f867ad 100644
--- a/examples/basic-react-site/vite.config.ts
+++ b/examples/basic-react-site/vite.config.ts
@@ -11,7 +11,6 @@ export default defineConfig({
locales: ['es', 'pt'],
defaultLocale: 'es',
strictMissingLocales: false,
- componentsDir: path.resolve(__dirname, 'src/components'),
sitemap: {
hostname: 'http://localhost:4173',
},
diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json
index df4387f..c50f86a 100644
--- a/node_modules/.package-lock.json
+++ b/node_modules/.package-lock.json
@@ -6,13 +6,15 @@
"packages": {
"examples/basic-react-site": {
"name": "@flatwave/example-basic-react-site",
- "version": "0.1.0",
+ "version": "1.0.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"
+ "react-helmet-async": "^2.0.0",
+ "react-markdown": "^9.0.3",
+ "react-router-dom": "^6.0.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
@@ -21,6 +23,38 @@
"vite": "^6.0.7"
}
},
+ "examples/basic-react-site/node_modules/react-router-dom": {
+ "version": "6.30.4",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
+ "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.3",
+ "react-router": "6.30.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "examples/basic-react-site/node_modules/react-router-dom/node_modules/react-router": {
+ "version": "6.30.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
+ "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
"examples/basic-react-site/node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
@@ -1609,6 +1643,15 @@
"node": ">=12"
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.23.3",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
+ "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -4099,6 +4142,20 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -6585,6 +6642,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/invariant": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -11599,6 +11665,26 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-fast-compare": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
+ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
+ "license": "MIT"
+ },
+ "node_modules/react-helmet-async": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz",
+ "integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "invariant": "^2.2.4",
+ "react-fast-compare": "^3.2.2",
+ "shallowequal": "^1.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.6.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -11642,6 +11728,46 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
+ "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
+ "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/read-package-up": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz",
@@ -12490,6 +12616,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -12539,6 +12672,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/shallowequal": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -14038,10 +14177,6 @@
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/vite-plugin-flatwave-react": {
- "resolved": "packages/vite-plugin-flatwave-react",
- "link": true
- },
"node_modules/vite/node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
@@ -14492,12 +14627,13 @@
},
"packages/vite-plugin-flatwave-react": {
"name": "@kamansoft/vite-plugin-flatwave-react",
- "version": "0.1.0",
+ "version": "1.0.0",
"license": "MIT",
"dependencies": {
"commander": "^13.1.0",
"fast-glob": "^3.3.3",
"gray-matter": "^4.0.3",
+ "react-helmet-async": "^2.0.0",
"rehype-raw": "^7.0.0",
"rehype-stringify": "^10.0.1",
"remark": "^15.0.1",
@@ -14514,6 +14650,8 @@
"@types/react-dom": "^18.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-markdown": "^10.1.0",
+ "react-router-dom": "^7.18.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"
},
@@ -14523,9 +14661,40 @@
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
+ "react-helmet-async": "^2.0.0",
+ "react-markdown": "^10.0.0",
+ "react-router-dom": "^6.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "packages/vite-plugin-flatwave-react/node_modules/react-markdown": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
"packages/vite-plugin-flatwave-react/node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
diff --git a/node_modules/vite-plugin-flatwave-react b/node_modules/vite-plugin-flatwave-react
deleted file mode 120000
index c996fdf..0000000
--- a/node_modules/vite-plugin-flatwave-react
+++ /dev/null
@@ -1 +0,0 @@
-../packages/vite-plugin-flatwave-react
\ No newline at end of file
diff --git a/openspec/changes/archive/2026-06-20-provide-composable-react-components/.openspec.yaml b/openspec/changes/archive/2026-06-20-provide-composable-react-components/.openspec.yaml
new file mode 100644
index 0000000..18edba1
--- /dev/null
+++ b/openspec/changes/archive/2026-06-20-provide-composable-react-components/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-06-20
diff --git a/openspec/changes/archive/2026-06-20-provide-composable-react-components/design.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/design.md
new file mode 100644
index 0000000..dc72c21
--- /dev/null
+++ b/openspec/changes/archive/2026-06-20-provide-composable-react-components/design.md
@@ -0,0 +1,454 @@
+## Context
+
+The plugin currently delivers SSG output (static HTML files) through a closed pipeline. Third-party consumers interact with it only via Vite config options — they cannot reuse, extend, or substitute any of the rendering primitives without reimplementing them from scratch. The `current-working-project-with-features` prototype demonstrates the correct mental model: composable page components (`SimplePage`), language-aware routing (`LanguageRouter`), and a content loader that bridges the virtual module to React components. None of these building blocks are exported by the published package today.
+
+The change introduces seven capabilities:
+
+1. `FlatwaveMDComponent` — base React component for markdown rendering
+2. `FlatwaveMDPageComponent` — full-page extension with SEO head management
+3. `FlatwaveLanguageDetector` — language detection logic only (no routing)
+4. `FlatwaveAppRoutes` — route mapping component with `renderPage` for page rendering
+5. `FlatwaveLanguageRouter` — convenience wrapper combining all routing components
+6. `FlatwaveLanguageSelector` — UI component for language switching (uses context for available languages)
+7. `emitFiles` SSG hook — custom build-time file emission after the render loop
+
+The working project's `LanguageRouter.tsx`, `SimplePage.tsx`, and `contentLoader.ts` are the direct reference implementations that these components generalise and elevate into the package.
+
+---
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Export composable React components that third-party apps can use, extend, or replace.
+- Keep the SSG pipeline fully functional after the refactor — `DefaultRenderStrategy` must still produce correct output.
+- Support both SSG rendering mode (pre-compiled HTML injected as `markdownHtml`) and client-side rendering mode (raw markdown rendered via `react-markdown`).
+- Allow consumers to generate arbitrary build-time output files (JSON, XML, etc.) from the content index after all routes are rendered.
+- Maintain backward compatibility in the Vite plugin API (`flatwaveContent()` options, virtual module API, hooks interface).
+
+**Non-Goals:**
+
+- Providing a complete, opinionated application framework — consumers build their own app shell.
+- Owning i18n library choice — `FlatwaveLanguageRouter` does URL-based language routing but does not import or configure any i18n library.
+- Providing pre-built navigation components — that is the consumer's job, aided by `emitFiles` to generate data (e.g. `navigation.json`).
+- CSS / styling — components accept `className` and `style` props but ship with no styles.
+
+---
+
+## Decisions
+
+### D1: Composition over class inheritance for extensibility
+
+**Decision**: Components are designed for React composition (children, render props, prop drilling, TypeScript generics) rather than class-based inheritance.
+
+**Rationale**: React functional components are not classes. The extensibility model in React is composition — a consumer wraps or overlays a component rather than subclassing it. This is consistent with the React ecosystem and does not require `class` syntax.
+
+**How it looks for consumers**:
+
+```tsx
+// Extend FlatwaveMDComponent by wrapping it
+function MyContent(props: FlatwaveMDComponentProps) {
+ return (
+
+ {(rendered) =>
{rendered}
}
+
+ );
+}
+```
+
+**Alternative considered**: Class components with a `render()` method override. Rejected — incompatible with hooks, goes against modern React patterns.
+
+---
+
+### D2: FlatwaveMDComponent accepts both compiled HTML and raw markdown
+
+**Decision**: The component accepts two mutually exclusive content props:
+
+- `markdownHtml: string` — pre-compiled HTML (used in SSG pipeline)
+- `markdown: string` — raw markdown source (rendered client-side via `react-markdown`)
+
+When `markdownHtml` is provided it uses `dangerouslySetInnerHTML`. When `markdown` is provided it uses `react-markdown`. Consumers should prefer `markdownHtml` in SSG contexts (it is always available via the virtual module's `body` field after compilation) and may use `markdown` for pure client-side rendering.
+
+**Rationale**: In the SSG pipeline, the markdown is compiled to HTML before the component is called (see `runSsg.ts` lines 78–87, where `compileMarkdownToHtml` is called before `strategy.render`). The component receives compiled HTML, not raw markdown. For client-side-only usage (e.g. preview mode), raw markdown is more convenient.
+
+**Alternative considered**: Always use `react-markdown` and re-parse at render time. Rejected — this re-does work already done by the build pipeline and adds a runtime dependency that can be avoided in SSG consumers.
+
+---
+
+### D3: FlatwaveLanguageRouter is split into three exported layers
+
+**Decision**: Export three separate things from the router module:
+
+1. `FlatwaveLanguageDetector` — a renderless component (renders `children`) that detects browser language, syncs with URL prefix, and calls `onLanguageChange`. Can be used inside any existing `BrowserRouter`.
+2. `FlatwaveAppRoutes` — a render-prop component that maps `FlatwaveRoute[]` (from the virtual module) to `react-router-dom` ``, calling a user-supplied `renderPage` function for each route. Does not hardcode any page components.
+3. `FlatwaveLanguageRouter` — a convenience wrapper that combines `BrowserRouter` + `FlatwaveLanguageDetector` + `FlatwaveAppRoutes`. This matches the pattern in the working project's `LanguageRouter.tsx`.
+
+**Rationale**: Consumers who already have a `BrowserRouter` (e.g. from another library) can use just `FlatwaveLanguageDetector`. Consumers who want full control over route rendering use `FlatwaveAppRoutes` with their own `renderPage`. The convenience `FlatwaveLanguageRouter` covers the common case (mirrors the working project).
+
+**Alternative considered**: A single monolithic router component with many props. Rejected — limits extensibility and forces consumers into a fixed structure.
+
+---
+
+### D4: FlatwaveLanguageRouter does not depend on any i18n library
+
+**Decision**: The router does NOT import `i18next`, `react-i18next`, or any other i18n library. It exposes `onLanguageChange(lang: string)` callbacks and reads/writes only the URL pathname for language prefix management. i18n sync is the consumer's responsibility.
+
+**Rationale**: The working project's `LanguageRouter.tsx` imports `i18n` from `../config/i18n` and calls `i18n.changeLanguage()`. This is correct for that app but would lock the plugin to a specific i18n setup. Consumers may use `i18next`, `react-intl`, `lingui`, or none at all.
+
+**How i18n sync works**: The consumer connects `onLanguageChange` to their i18n library:
+
+```tsx
+ i18n.changeLanguage(lang)}
+ renderPage={(route, lang) => }
+/>
+```
+
+---
+
+### D5: emitFiles hook receives the complete content index and returns SsgOutputFile[]
+
+**Decision**: Add an `emitFiles` hook to `RenderHooks`:
+
+```ts
+emitFiles?: (
+ context: EmitFilesContext
+) => Promise | SsgOutputFile[];
+```
+
+Where `EmitFilesContext` contains:
+
+- `routes: FlatwaveRoute[]` — all public routes
+- `contentIndex: FlatwaveContentIndex` — full index with all locales
+- `renderedFiles: SsgOutputFile[]` — all HTML files already emitted
+- `locale: undefined` — not route-specific (this is a post-loop hook)
+
+**Rationale**: This allows consumers to emit derived files. Example — a `navigation.json` generator:
+
+```ts
+hooks: {
+ emitFiles: ({ routes }) => [
+ {
+ fileName: 'navigation.json',
+ source: JSON.stringify(
+ routes.map((r) => ({ url: r.path, publicName: r.metadata.title })),
+ null,
+ 2
+ ),
+ },
+ ];
+}
+```
+
+`RenderPipeline` gets a new `executeEmitFiles` method. `runSsg.ts` calls it once after the rendering loop, before the route manifest emission.
+
+**Alternative considered**: A `afterAllRoutes` hook that receives `emitFile` callback from Vite context. Rejected — the hook pipeline runs inside `runSsg.ts` which already uses Rollup's `this.emitFile`. Returning an array of `SsgOutputFile` objects is simpler and consistent with the existing `SsgOutputFile[]` return type of `runSsg`.
+
+---
+
+### D6: DefaultRenderStrategy uses FlatwaveMDPageComponent internally
+
+**Decision**: `DefaultRenderStrategy.tsx` is refactored to use `FlatwaveMDPageComponent` as its rendering component, replacing the inline `` pattern. This serves as the canonical demonstration of how to use the components in an SSG context.
+
+**Rationale**: This ensures `FlatwaveMDPageComponent` is actually exercised in the default path, preventing the components from becoming untested abstractions. It also simplifies `DefaultRenderStrategy` by removing its own graceful-degradation logic (which moves into `FlatwaveMDPageComponent`).
+
+---
+
+### D7: TypeScript generics for typed frontmatter extension
+
+**Decision**: Components use a generic type parameter for frontmatter:
+
+```ts
+interface FlatwaveMDComponentProps {
+ frontmatter: TFrontmatter;
+ markdownHtml?: string;
+ markdown?: string;
+ locale: string;
+ children?: (rendered: React.ReactNode) => React.ReactNode;
+}
+```
+
+**Rationale**: Consumers who define their own frontmatter schema (e.g. adding `audioUrl: string`) can get full type safety when extending the components.
+
+---
+
+### D8: FlatwaveLanguageSelector is a UI component for language switching
+
+**Decision**: Export a `FlatwaveLanguageSelector` component that renders a language switcher using `FlatwaveLanguageContext` for available languages and current locale. It accepts a `renderOption?: (lang: string, label: string) => React.ReactNode` render prop for customizing the UI and an `onSelect?: (lang: string) => void` callback for post-selection actions (e.g., analytics, additional i18n sync).
+
+**Rationale**: Language switching is a common need. The working project implements this inline in various places. Providing a reusable selector component reduces boilerplate and ensures the selector stays in sync with `supportedLanguages`.
+
+**How it looks for consumers**:
+
+```tsx
+ }
+ onSelect={(lang) => analytics.track('language_change', { lang })}
+/>
+```
+
+**Alternative considered**: A headless hook `useFlatwaveLanguageSwitcher()` that returns `(currentLang, options, selectLang)`. Rejected — the selector component is simple, stateless, and composable. A hook adds indirection without clear benefit.
+
+---
+
+### D9: Dynamic slug route pattern for content-driven pages
+
+**Decision**: `FlatwaveLanguageRouter` SHALL accept an additional `dynamicRoute?: DynamicRouteConfig` prop to handle content-driven pages where the path is not known at build time (e.g., `/{lang}/:slug` for markdown pages). The `DynamicRouteConfig` contains:
+
+```ts
+interface DynamicRouteConfig {
+ path: string; // Route path pattern, e.g., "/:slug"
+ renderPage: (params: { slug: string; lang: string }) => React.ReactNode; // Render function receiving slug param
+}
+```
+
+**Rationale**: The working project's `DynamicSimplePageWrapper` demonstrates a hardcoded `/:slug` route that loads content at runtime. The plugin's static route list from `getRoutes(lang)` does not cover this pattern — consumers need a way to declare dynamic routes that use the virtual module's content lookup.
+
+**How consumers use it**:
+
+```tsx
+ {
+ const content = useFlatwaveContent(slug!, lang);
+ return ;
+ }
+ }}
+/>
+```
+
+**Alternative considered**: Require consumers to wrap their own `` inside `FlatwaveLanguageDetector`. Rejected — this loses the automatic locale injection and the clean declarative API.
+
+---
+
+### D10: layoutWrapper prop for shared page layout
+
+**Decision**: `FlatwaveLanguageRouter` and `FlatwaveAppRoutes` SHALL accept an optional `layoutWrapper?: React.ComponentType<{ children: React.ReactNode; locale: string }>` prop. When provided, all rendered pages SHALL be wrapped inside this layout component, matching the `PagesLayout` pattern in the working project.
+
+**Rationale**: Third-party apps need a shared layout for headers, footers, navigation. React router's `` pattern with a layout route is the standard approach, but our `renderPage` prop doesn't use ``. Instead, we provide the layout as a wrapper.
+
+**How consumers use it**:
+
+```tsx
+ (
+