From b7d92dc61ca643f5ae35b36782ebe0415a03e276 Mon Sep 17 00:00:00 2001 From: lemys lopez Date: Sat, 20 Jun 2026 14:35:09 -0500 Subject: [PATCH 01/10] feat(extendable-approach): New specs for a new perspective of the plugin --- node_modules/.package-lock.json | 9 +- node_modules/vite-plugin-flatwave-react | 1 - .../.openspec.yaml | 2 + .../design.md | 454 ++++++++++++++++++ .../proposal.md | 41 ++ .../specs/flatwave-app-routes/spec.md | 65 +++ .../specs/flatwave-language-router/spec.md | 164 +++++++ .../specs/flatwave-language-selector/spec.md | 81 ++++ .../specs/flatwave-layout-wrapper/spec.md | 21 + .../specs/flatwave-md-component/spec.md | 113 +++++ .../specs/flatwave-md-page-component/spec.md | 116 +++++ .../specs/ssg-custom-emitters/spec.md | 97 ++++ .../tasks.md | 127 +++++ package-lock.json | 4 +- 14 files changed, 1286 insertions(+), 9 deletions(-) delete mode 120000 node_modules/vite-plugin-flatwave-react create mode 100644 openspec/changes/provide-composable-react-components/.openspec.yaml create mode 100644 openspec/changes/provide-composable-react-components/design.md create mode 100644 openspec/changes/provide-composable-react-components/proposal.md create mode 100644 openspec/changes/provide-composable-react-components/specs/flatwave-app-routes/spec.md create mode 100644 openspec/changes/provide-composable-react-components/specs/flatwave-language-router/spec.md create mode 100644 openspec/changes/provide-composable-react-components/specs/flatwave-language-selector/spec.md create mode 100644 openspec/changes/provide-composable-react-components/specs/flatwave-layout-wrapper/spec.md create mode 100644 openspec/changes/provide-composable-react-components/specs/flatwave-md-component/spec.md create mode 100644 openspec/changes/provide-composable-react-components/specs/flatwave-md-page-component/spec.md create mode 100644 openspec/changes/provide-composable-react-components/specs/ssg-custom-emitters/spec.md create mode 100644 openspec/changes/provide-composable-react-components/tasks.md diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index df4387f..91a5d60 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -6,7 +6,7 @@ "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", @@ -5905,6 +5905,7 @@ "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": { @@ -14038,10 +14039,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,7 +14489,7 @@ }, "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", 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/provide-composable-react-components/.openspec.yaml b/openspec/changes/provide-composable-react-components/.openspec.yaml new file mode 100644 index 0000000..18edba1 --- /dev/null +++ b/openspec/changes/provide-composable-react-components/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-20 diff --git a/openspec/changes/provide-composable-react-components/design.md b/openspec/changes/provide-composable-react-components/design.md new file mode 100644 index 0000000..dc72c21 --- /dev/null +++ b/openspec/changes/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 + ( +
+
+
{children}
+
+
+ )} + renderPage={(route, lang) => } +/> +``` + +**Alternative considered**: Require consumers to use `}>` pattern. Rejected — adds complexity; the layoutWrapper prop is simpler and works with our render-prop architecture. + +--- + +## Risks / Trade-offs + +**[Risk] `dangerouslySetInnerHTML` in FlatwaveMDComponent** +→ Mitigation: The compiled HTML originates from the plugin's own markdown compiler (`unified` + `remark` + `rehype`), which is controlled. For client-side usage with `markdown` prop, `react-markdown` handles sanitisation. Document that `markdownHtml` should only come from trusted sources (i.e. the virtual module or the SSG pipeline). + +**[Risk] react-markdown and react-helmet-async as new peer dependencies may break consumers who don't have them installed** +→ Mitigation: The components that require them are new additions — no existing consumer code uses them. Add clear install instructions in the README. Make component imports path-separated (`@kamansoft/vite-plugin-flatwave-react/react/FlatwaveMDComponent`) so consumers who don't need them don't pay the peer-dep requirement at all. + +**[Risk] FlatwaveLanguageRouter's i18n-agnostic design means consumers must wire up i18n themselves** +→ Mitigation: Provide a clear documented example. The `onLanguageChange` callback pattern is familiar and works with any i18n library in 1–3 lines of code. + +**[Risk] emitFiles hook returning SsgOutputFile[] means the hook runs to completion before any files are emitted — a long-running hook delays the entire build** +→ Mitigation: Document that `emitFiles` is for lightweight file generation (JSON, XML). Heavy processing should use a separate Vite plugin. + +**[Risk] Refactoring DefaultRenderStrategy to use FlatwaveMDPageComponent changes its rendering output, potentially breaking existing SSG snapshots** +→ Mitigation: The outer HTML structure (template injection) does not change. The appHtml produced by the strategy is the only thing that changes, and it now matches what a consumer using FlatwaveMDPageComponent directly would produce. Add a migration note in the README. + +--- + +## Migration Plan + +1. Add new files to `packages/vite-plugin-flatwave-react/src/react/` — no existing files are modified in this step. +2. Add `emitFiles` to `RenderHooks` (additive, optional — no breaking change). +3. Update `RenderPipeline.ts` to add `executeEmitFiles` method. +4. Update `runSsg.ts` to call `executeEmitFiles` after the render loop. +5. Refactor `DefaultRenderStrategy.tsx` to use `FlatwaveMDPageComponent`. +6. Update `packages/vite-plugin-flatwave-react/src/react/index.ts` to export new components. +7. Update `packages/vite-plugin-flatwave-react/src/index.ts` to re-export react layer. +8. Update `packages/vite-plugin-flatwave-react/package.json` peer dependencies. +9. Update `examples/basic-react-site/` to demonstrate the new components. +10. Rewrite `README.md`. + +No rollback is needed — all changes are additive except the `DefaultRenderStrategy` refactor, which is covered by the existing e2e test suite. + +--- + +## Open Questions - Decided + +### Q1: Should `FlatwaveLanguageDetector` expose a React Context for language state? + +**DECISION: Option 1 selected** — Export `FlatwaveLanguageContext` alongside the component. + +### Q2: Should `FlatwaveMDPageComponent` include a loading state for async content? + +**DECISION: Option 1 selected** — Add `loadingFallback?: React.ReactNode` prop. + +### Q3: Should `emitFiles` receive Rollup's `emitFile` function for bundle integration? + +**DECISION: Option 1 selected** — Keep `SsgOutputFile[]` only, no Rollup coupling. + +### Q4: Should `FlatwaveMDPageComponent` strip frontmatter from raw markdown string? + +**DECISION: Option 1 selected** — Auto-strip frontmatter in `FlatwaveMDComponent` before rendering. + +### Q5: Should the router provide a `useFlatwaveLanguage` hook for consuming locale? + +**DECISION: Option 1 selected** — Export `useFlatwaveLanguage()` hook. + +### Q1: Should `FlatwaveLanguageDetector` expose a React Context for language state? + +**Problem**: The working project's `LanguageRouter` has `LanguageDetector` component that manages `isI18nReady` state and language detection internally. Third-party components deep in the tree (e.g., language selectors, content loaders) need access to the current locale and supported languages without receiving them via prop drilling. + +**Technical Details**: + +- Current working project passes `supportedLanguages` and `defaultLanguage` from `env` config via Vite environment variables +- The detector already tracks `locale` state internally +- React Context allows any descendant to call `useContext(FlatwaveLanguageContext)` to get `{ locale, supportedLanguages, defaultLanguage }` + +**Options**: + +1. **Export `FlatwaveLanguageContext` with default value** — Simple, stateless context with default values (`locale: ''`, `supportedLanguages: []`). Consumers can use `useContext(FlatwaveLanguageContext)` anywhere. Cost: ~15 lines of code. +2. **Provide only via `FlatwaveLanguageDetector` children pattern** — Pass locale/supportedLanguages to children via render prop. Consumers must structure code around the render prop. More explicit but more verbose. +3. **Export both Context and render-prop children** — Dual API: context for deep access + children render prop for immediate nesting. Maximum flexibility, potential confusion about which to use. + +**Recommended**: Option 1 — Context is the React standard for cross-tree state. The implementation cost is minimal and it aligns with how consumers already consume context (e.g., `useContext` for theme, auth, etc.). + +--- + +### Q2: Should `FlatwaveMDPageComponent` include a loading state for async content? + +**Problem**: During client-side navigation (react-router), content may be loaded asynchronously. The working project's `LanguageDetector` shows a loading UI while i18n initializes (`!isI18nReady`). Similarly, `FlatwaveMDPageComponent` may need to show loading state while markdown content is being fetched. + +**Technical Details**: + +- SSG: content is pre-loaded, no async fetching needed +- SPA navigation: `useFlatwaveContent` hook may need to fetch content JSON or markdown +- The working project's `SimplePage` receives pre-fetched content from `DynamicSimplePageWrapper`, which handles the loading scenario upstream + +**Options**: + +1. **Add `loadingFallback?: React.ReactNode` prop** — When `markdownHtml` and `markdown` are both `undefined`, render `loadingFallback`. If `loadingFallback` is also absent, render `null`. +2. **Require consumers to handle loading upstream** — Like the working project, consumers pass `loadingFallback` at the route level before reaching the page component. +3. **Add `isLoading?: boolean` prop** — Explicit loading state control, more imperative but clearer intent. + +**Recommended**: Option 1 — The `loadingFallback` prop is simple, declarative, and mirrors the pattern in the working project. It keeps the component self-contained. + +--- + +### Q3: Should `emitFiles` receive Rollup's `emitFile` function for bundle integration? + +**Problem**: The current `emitFiles` design returns `SsgOutputFile[]` which are emitted as static assets. Some consumers may want to emit files that integrate with the Rollup bundle (e.g., CSS, JS, or images that should be hashed and included in the manifest). + +**Technical Details**: + +- Rollup `emitFile` is available via `this.emitFile` in plugin context +- Static assets vs bundle assets: `SsgOutputFile[]` goes directly to disk; Rollup assets get hashed names and manifest entries +- The hook runs in `runSsg.ts` which already has access to Rollup plugin context via `this` + +**Options**: + +1. **Keep `SsgOutputFile[]` only** — Simple, works for all documented cases (navigation.json, sitemap extensions, custom XML/JSON). No Rollup coupling. +2. **Add `emitFile` callback to context** — `EmitFilesContext.emitFile?: (file: SsgOutputFile) => string` (hashed path). Requires Rollup coupling in hook signature. +3. **Combine both** — Add `emitFile` as optional property in `EmitFilesContext`, type it as `unknown` to avoid Rollup coupling: `emitFile?: (file: SsgOutputFile) => string`. + +**Recommended**: Option 1 — Keep `SsgOutputFile[]` only. Consumers who need hashed assets should use a separate Vite plugin or the existing `ssr.external` configuration. This keeps the API simple and decoupled. + +--- + +### Q4: Should `FlatwaveMDPageComponent` strip frontmatter from raw markdown string? + +**Problem**: When using the `markdown` prop for client-side rendering, consumers may pass a raw markdown string that includes YAML frontmatter. The working project strips frontmatter with `markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/,'')` in `SimplePage.tsx`. + +**Technical Details**: + +- The virtual module (`virtual:flatwave/content`) strips frontmatter at build time via `gray-matter` +- Direct markdown string use (e.g., editor preview) may contain frontmatter +- `react-markdown` will render `---` as horizontal rule (which is incorrect) + +**Options**: + +1. **Strip frontmatter in `FlatwaveMDComponent`** — Automatically remove `---\n...\n---` block before rendering with `react-markdown`. +2. **Document that consumers must strip frontmatter** — Clear documentation stating the `markdown` prop should not contain frontmatter. +3. **Add `stripFrontmatter?: boolean` prop** — Optional auto-stripping, defaults to `false` for backward compatibility. + +**Recommended**: Option 1 — Auto-stripping is defensive. The cost is minimal (use `gray-matter` or a simple regex) and it prevents a common bug. Consumers who intentionally want frontmatter rendered can still use `markdownHtml` prop. + +--- + +### Q5: Should the router provide a `useFlatwaveLanguage` hook for consuming locale? + +**Problem**: Consumers need to read the current locale in various components. While `FlatwaveLanguageContext` allows `useContext(FlatwaveLanguageContext)`, a dedicated hook would be more ergonomic and type-safe. + +**Technical Details**: + +- `useContext(FlatwaveLanguageContext)` requires importing the context +- A hook `useFlatwaveLanguage()` can provide both the locale and a setter (`setLocale` for programmatic switching) +- Similar patterns exist in `react-helmet-async` (`useLocation`) and router libraries + +**Options**: + +1. **Export `useFlatwaveLanguage()` hook** — Returns `{ locale: string; supportedLanguages: string[]; defaultLanguage: string }`. +2. **Export only the context, let consumers make their own hook** — Maximum flexibility, minimal API surface. +3. **Export both hook and context** — Hook for ergonomics, context for advanced use (e.g., `FlatwaveLanguageSelector` can use context directly). + +**Recommended**: Option 1 — The hook is a thin wrapper around `useContext(FlatwaveLanguageContext)` but provides better DX. Implementation is ~5 lines. + +**DECISION: Option 1 selected** — Export `useFlatwaveLanguage()` hook. + +--- + +### Q6: Should `FlatwaveLanguageDetector` have built-in i18n initialization waiting? + +**Problem**: The working project's `LanguageDetector` waits for i18n to be initialized (`isI18nReady` check) before rendering children. This is specific to `i18next`. The generic plugin should not block on i18n library initialization. + +**Technical Details**: + +- `i18next.isInitialized` and `i18n.changeLanguage` are i18n-library specific +- The plugin's `FlatwaveLanguageDetector` is i18n-agnostic +- Consumers may use different i18n libraries or none at all + +**Options**: + +1. **Remove i18n waiting logic entirely** — Always render children immediately. Consumers who need i18n waiting can wrap `FlatwaveLanguageDetector` with their own logic. +2. **Add `onReady?: () => void` callback** — Let consumers signal when their resources are ready. +3. **Add `ready?: boolean` prop** — Consumer controls readiness. Default `true`. + +**Recommended**: Option 1 — Keeps the component library agnostic. The working project can create its own `FlatwaveLanguageDetector` wrapper that waits for i18n initialization. + +**DECISION: Option 1 selected** — No built-in i18n initialization waiting; consumers handle this via wrapping. + +--- + +### Q7: Should we add TypeScript utility types for common frontmatter extensions? + +**Problem**: Consumers commonly add custom frontmatter fields (e.g., `audioUrl`, `videoId`, `author`). Providing typed helpers would reduce boilerplate. + +**Technical Details**: + +- `FlatwaveFrontmatter` has required fields: `title`, `slug`, `id`, `component`, `public` +- Consumers may want `FlatwaveFrontmatter & { myField: string }` +- Generic type parameter is already supported but could have presets + +**Options**: + +1. **Provide no presets** — Consumers use generics directly: `FlatwaveMDComponentProps` +2. **Export `FlatwaveFrontmatterStrict` with all optional fields** — Better for extension, but requires consumers to explicitly set required fields +3. **Export intersection helper type** — `FlatwaveFrontmatterWith` type for easy extension + +**Recommended**: Option 3 — A helper type like `FlatwaveFrontmatterWith` is low-cost and improves ergonomics for the common extension pattern. + +**DECISION: Option 3 selected** — Export `FlatwaveFrontmatterWith` utility type: `type FlatwaveFrontmatterWith = FlatwaveFrontmatter & T;` diff --git a/openspec/changes/provide-composable-react-components/proposal.md b/openspec/changes/provide-composable-react-components/proposal.md new file mode 100644 index 0000000..34f6419 --- /dev/null +++ b/openspec/changes/provide-composable-react-components/proposal.md @@ -0,0 +1,41 @@ +## Why + +The plugin currently acts as an intrusive owner of rendering, routing, and SSG logic rather than a composable toolkit. Third-party applications cannot build their own page structures, routing strategies, or SSG side-effects using the plugin's primitives — they must either accept the `DefaultRenderStrategy` wholesale or rewrite the entire stack. The `current-working-project-with-features` prototype already demonstrates the correct pattern (composable `SimplePage`, extensible `LanguageRouter`, plugin hooks for custom file emission) — this change lifts those patterns into the published package as first-class, extensible React components. + +## What Changes + +- **NEW** Export `FlatwaveMDComponent` — a base React component that renders a markdown source string, exposes frontmatter values as typed props, and is designed to be extended or composed by third-party apps. +- **NEW** Export `FlatwaveMDPageComponent` — extends `FlatwaveMDComponent` with page-level concerns: SEO head tags (title, description, canonical, OG) and a page wrapper structure; also designed to be extended. +- **NEW** Export `FlatwaveLanguageRouter` — a `BrowserRouter`-based component that handles automatic browser-language detection, URL-prefix-based language routing, and i18n sync; accepts a `routes` prop so third-party apps define their own pages; internally extensible via a `LanguageDetector` sub-component that can be replaced. +- **NEW** Add `emitFiles` hook to the SSG pipeline — a `RenderHooks` lifecycle hook called once after all routes are rendered, receiving the full content index and route manifest, enabling third-party plugins to emit arbitrary files (e.g., a `navigation.json` containing `{ url, publicName }` entries for each public route). +- **BREAKING** The plugin's `react` export surface changes: it previously exported only hooks; it now also exports the components above. Import paths that destructure from `@kamansoft/vite-plugin-flatwave-react` remain valid but new named exports are added. +- `DefaultRenderStrategy` is retained but its role is narrowed: it becomes a reference SSG renderer that uses `FlatwaveMDPageComponent` internally, demonstrating the composition pattern. +- `README.md` is rewritten to lead with the component-first usage model. + +## Capabilities + +### New Capabilities + +- `flatwave-md-component`: Base React component for rendering markdown content with frontmatter-derived props; language-aware; designed for extension. +- `flatwave-md-page-component`: Full-page React component extending the base; adds SEO head management and page wrapper; designed for extension. +- `flatwave-language-router`: Composable router component with automatic language detection, URL-prefix routing, and user-defined route configuration; extensible detector and route slots. +- `flatwave-app-routes`: Render-prop component that maps `FlatwaveRoute[]` to `react-router-dom` ``; accepts optional routes override prop; allows custom page component mapping via `renderPage`. +- `flatwave-language-selector`: UI component for language switching; uses `FlatwaveLanguageContext` for available languages; accepts custom render prop for styling. +- `flatwave-layout-wrapper`: `layoutWrapper` prop for shared page layout (Header/Footer), used by `FlatwaveLanguageRouter` and `FlatwaveAppRoutes`. +- `ssg-custom-emitters`: `emitFiles` hook in the SSG pipeline that lets third-party Vite plugin consumers emit arbitrary build artifacts (JSON, XML, text) derived from the complete content index after all routes are rendered. + +### Modified Capabilities + + + +## Impact + +- `packages/vite-plugin-flatwave-react/src/react/` — new component files added; `index.ts` updated to re-export them. +- `packages/vite-plugin-flatwave-react/src/ssg/RenderPipeline.ts` — new `emitFiles` hook stage appended. +- `packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts` — calls the new `emitFiles` hook after the rendering loop. +- `packages/vite-plugin-flatwave-react/src/types.ts` — `RenderHooks` interface gains `emitFiles` optional callback. +- `packages/vite-plugin-flatwave-react/src/index.ts` — re-exports components from `./react`. +- `packages/vite-plugin-flatwave-react/package.json` — `react-markdown` and `react-helmet-async` added as peer dependencies (they are already used in the working project; consumers who extend the components must install them). +- `packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx` — refactored to use `FlatwaveMDPageComponent` instead of inline JSX, demonstrating the composition model. +- `README.md` — rewritten with component-first documentation, migration notes, and extension examples. +- `examples/basic-react-site/` — updated to demonstrate the new components and `FlatwaveLanguageRouter`. diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-app-routes/spec.md b/openspec/changes/provide-composable-react-components/specs/flatwave-app-routes/spec.md new file mode 100644 index 0000000..557298e --- /dev/null +++ b/openspec/changes/provide-composable-react-components/specs/flatwave-app-routes/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: Package exports FlatwaveAppRoutes + +The package SHALL export a React component named `FlatwaveAppRoutes` from its public surface. It SHALL map `FlatwaveRoute[]` to `react-router-dom` `` by creating a `` for each public route. It SHALL accept a REQUIRED `renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode` prop to render the page content for each route. + +#### Scenario: Route elements are created for each route + +- **WHEN** `routes` contains 3 `FlatwaveRoute` objects and `renderPage` is provided +- **THEN** `FlatwaveAppRoutes` renders 3 `` elements internally (wrapped by a ``) + +#### Scenario: Dynamic route segments are handled correctly + +- **WHEN** a route has `path="/es/about"` and `renderPage` is called with that route +- **WHEN** another route has `path="/es/:slug"` for dynamic slug pages +- **THEN** both routes are rendered in the `` tree with correct path attributes + +--- + +### Requirement: FlatwaveAppRoutes accepts an optional routes prop + +`FlatwaveAppRoutes` SHALL accept an optional `routes: FlatwaveRoute[]` prop. When provided, it SHALL use those routes instead of calling `getRoutes(lang)` from the virtual module. This allows consumers who generate their own route data to supply it. + +#### Scenario: Custom routes are used when provided + +- **WHEN** a consumer passes `routes={customRoutes}` to `FlatwaveAppRoutes` +- **THEN** the component renders only the provided custom routes, not the virtual module routes + +#### Scenario: Virtual module routes are used when routes prop is absent + +- **WHEN** no `routes` prop is provided +- **THEN** `FlatwaveAppRoutes` calls `getRoutes(lang)` using the active locale from `FlatwaveLanguageContext` + +--- + +### Requirement: FlatwaveAppRoutes accepts a renderPage render prop + +The `renderPage` prop SHALL be called with `(route: FlatwaveRoute, lang: string)` and SHALL receive the full route metadata including frontmatter. This matches the working project's `DynamicSimplePageWrapper` pattern. + +#### Scenario: renderPage receives full route metadata + +- **WHEN** `renderPage` is called for a route with `frontmatter: { title: "About", ... }` +- **THEN** `renderPage` can use `route.frontmatter.title` or `route.metadata.title` to render the page + +--- + +### Requirement: FlatwaveAppRoutes integrates with FlatwaveLanguageContext + +`FlatwaveAppRoutes` SHALL read the current `locale` from `FlatwaveLanguageContext` to determine which language's routes to render when `routes` prop is not provided. + +#### Scenario: App routes respect the active language context + +- **WHEN** `FlatwaveLanguageContext.locale` is `"pt"` and `routes` prop is not provided +- **THEN** `FlatwaveAppRoutes` renders routes filtered by locale `"pt"` + +--- + +### Requirement: FlatwaveAppRoutes renders a default 404 route + +`FlatwaveAppRoutes` SHALL render a catch-all `` that renders `null`. Consumers who want a 404 page SHALL include a route with a dynamic segment (e.g. `path="*"` in their routes list or create their own 404 route structure externally. + +#### Scenario: Catch-all route renders null + +- **WHEN** navigating to a non-existent path +- **THEN** the catch-all route renders null (no error thrown) diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-language-router/spec.md b/openspec/changes/provide-composable-react-components/specs/flatwave-language-router/spec.md new file mode 100644 index 0000000..c9d4c17 --- /dev/null +++ b/openspec/changes/provide-composable-react-components/specs/flatwave-language-router/spec.md @@ -0,0 +1,164 @@ +## ADDED Requirements + +### Requirement: Package exports FlatwaveLanguageRouter + +The package SHALL export a React component named `FlatwaveLanguageRouter` from its public surface. It SHALL provide a complete, ready-to-use multilingual routing solution that wraps `BrowserRouter` from `react-router-dom`, integrates `FlatwaveLanguageDetector` for automatic language detection and URL management, and renders user-defined routes via a `renderPage` render prop. + +#### Scenario: Named export is available + +- **WHEN** a consumer imports `{ FlatwaveLanguageRouter }` from `@kamansoft/vite-plugin-flatwave-react` +- **THEN** the import resolves to a React functional component without runtime error + +--- + +### Requirement: FlatwaveLanguageRouter accepts supportedLanguages and defaultLanguage props + +`FlatwaveLanguageRouter` SHALL accept: + +- `supportedLanguages: string[]` — the list of locale codes supported by the site (e.g. `['es', 'pt']`) +- `defaultLanguage: string` — the locale to redirect to when no language prefix is present in the URL and browser preference is not in the supported list + +Both props are REQUIRED. + +#### Scenario: Router is configured with supported languages + +- **WHEN** `supportedLanguages={['es', 'pt']} defaultLanguage="es"` are passed +- **THEN** the router treats `es` and `pt` as valid language URL prefixes + +#### Scenario: TypeScript error when required props are missing + +- **WHEN** a consumer renders `` without `supportedLanguages` or `defaultLanguage` +- **THEN** TypeScript emits a compile-time error + +--- + +### Requirement: FlatwaveLanguageRouter redirects root path to preferred language + +When a user navigates to `/` (or any path without a recognised language prefix), `FlatwaveLanguageRouter` SHALL detect the browser's preferred language (via `navigator.language` / `navigator.languages`), match it against `supportedLanguages`, and redirect to `/{matchedLang}{currentPath}`. If no match is found, it SHALL redirect using `defaultLanguage`. + +#### Scenario: Browser language matches a supported language + +- **WHEN** browser language is `"pt"` and `supportedLanguages` includes `"pt"` and the current path is `/` +- **THEN** the router redirects to `/pt` (replace history, no browser back) + +#### Scenario: Browser language does not match any supported language + +- **WHEN** browser language is `"fr"` and `supportedLanguages` is `['es', 'pt']` with `defaultLanguage="es"` +- **THEN** the router redirects to `/es` + +#### Scenario: URL already has a valid language prefix — no redirect + +- **WHEN** the current path is `/es/about` and `es` is in `supportedLanguages` +- **THEN** no redirect occurs + +--- + +### Requirement: FlatwaveLanguageRouter calls onLanguageChange when the active language changes + +`FlatwaveLanguageRouter` SHALL accept an optional `onLanguageChange?: (lang: string) => void` prop. It SHALL call this callback with the new language code whenever the active language changes (either from a redirect or from direct navigation to a different language prefix). + +#### Scenario: Callback is called on initial language detection + +- **WHEN** the router resolves the language on first render (e.g. redirecting from `/` to `/es`) +- **THEN** `onLanguageChange("es")` is called once + +#### Scenario: Callback is called on language switch navigation + +- **WHEN** the user navigates from `/es/page` to `/pt/page` +- **THEN** `onLanguageChange("pt")` is called + +#### Scenario: Callback is not called when language has not changed + +- **WHEN** the user navigates from `/es/about` to `/es/contact` +- **THEN** `onLanguageChange` is NOT called (same language, different page) + +--- + +### Requirement: FlatwaveLanguageRouter renders routes via a renderPage render prop + +`FlatwaveLanguageRouter` SHALL accept a REQUIRED `renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode` prop. For each route returned by `getRoutes(lang)` from the virtual module (filtered by the active language), the router SHALL render a `` that calls `renderPage(route, lang)`. + +The router SHALL additionally render: + +- A catch-all `` that returns `null` (consumers are responsible for adding a 404 component by including it in their renderPage logic or via an additional route). + +#### Scenario: renderPage is called for each route of the active language + +- **WHEN** the content index has 3 routes for locale `"es"` and the current language is `"es"` +- **THEN** `renderPage` is called for each of those 3 routes when navigating to their paths + +#### Scenario: renderPage receives the full FlatwaveRoute object + +- **WHEN** navigating to `/es/about` +- **THEN** `renderPage` receives a `FlatwaveRoute` object with `locale: "es"`, `path: "/es/about"`, and all frontmatter fields + +--- + +### Requirement: FlatwaveLanguageRouter accepts a dynamicRoute prop for content-driven pages + +`FlatwaveLanguageRouter` SHALL accept an optional `dynamicRoute?: DynamicRouteConfig` prop to handle content-driven pages where the path is not known at build time (e.g., `/{lang}/:slug` for markdown pages). `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 +} +``` + +#### Scenario: Dynamic route renders content for matching slug + +- **WHEN** current path is `/es/some-page` and `dynamicRoute={{ path: "/:slug", renderPage: ({ slug }) => }}` +- **THEN** the dynamic route renders the page for `some-page` + +#### Scenario: Dynamic route takes precedence over static routes for slug paths + +- **WHEN** a static route `/es/about` exists and `dynamicRoute` is configured for `/:slug` +- **THEN** navigating to `/es/about` renders the static route, but `/es/any-slug` renders via the dynamic route + +--- + +### Requirement: Package exports FlatwaveLanguageDetector as a standalone component + +The package SHALL export `FlatwaveLanguageDetector` separately from `FlatwaveLanguageRouter`. This component SHALL implement only the language detection and URL-prefix management logic (no `BrowserRouter`, no route rendering). It SHALL accept `supportedLanguages`, `defaultLanguage`, `onLanguageChange`, and `children: React.ReactNode` props. This allows consumers who already have a `BrowserRouter` to add language detection to their existing router setup. + +#### Scenario: FlatwaveLanguageDetector works inside an existing BrowserRouter + +- **WHEN** a consumer renders `` +- **THEN** language detection and redirection work correctly without wrapping in another BrowserRouter + +#### Scenario: FlatwaveLanguageDetector renders children after language is resolved + +- **WHEN** the language is successfully resolved (initial render) +- **THEN** children are rendered (no indefinite loading state) + +--- + +### Requirement: Package exports FlatwaveLanguageContext + +The package SHALL export a `FlatwaveLanguageContext` React context of type `{ locale: string; supportedLanguages: string[]; defaultLanguage: string }`. `FlatwaveLanguageDetector` SHALL provide this context to all descendant components with the current active language values. + +#### Scenario: Context is accessible to descendant components + +- **WHEN** a component inside the router tree calls `useContext(FlatwaveLanguageContext)` +- **THEN** it receives the current `locale`, `supportedLanguages`, and `defaultLanguage` values + +#### Scenario: Context locale updates when language changes + +- **WHEN** the user navigates from `/es/page` to `/pt/page` +- **THEN** `FlatwaveLanguageContext.locale` updates to `"pt"` in all consumers of the context + +--- + +### Requirement: FlatwaveLanguageRouter does not import or require any i18n library + +`FlatwaveLanguageRouter` and `FlatwaveLanguageDetector` SHALL NOT import `i18next`, `react-i18next`, `react-intl`, `lingui`, or any other i18n library. Language sync with third-party i18n libraries is the consumer's responsibility, achieved via the `onLanguageChange` callback. + +#### Scenario: Plugin package has no i18n library dependency + +- **WHEN** inspecting `packages/vite-plugin-flatwave-react/package.json` dependencies and peerDependencies +- **THEN** no i18n library is listed as a dependency or peerDependency + +#### Scenario: Consumer wires i18next via onLanguageChange + +- **WHEN** a consumer passes `onLanguageChange={(lang) => i18n.changeLanguage(lang)}` to `FlatwaveLanguageRouter` +- **THEN** `i18n.changeLanguage` is called whenever the route language changes diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-language-selector/spec.md b/openspec/changes/provide-composable-react-components/specs/flatwave-language-selector/spec.md new file mode 100644 index 0000000..1287bca --- /dev/null +++ b/openspec/changes/provide-composable-react-components/specs/flatwave-language-selector/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Package exports FlatwaveLanguageSelector + +The package SHALL export a React component named `FlatwaveLanguageSelector` from its public surface. It SHALL render a language switching UI using `FlatwaveLanguageContext` to determine the available languages and the current locale. + +#### Scenario: Selector renders all supported languages + +- **WHEN** `FlatwaveLanguageContext.supportedLanguages` is `['es', 'pt']` and `FlatwaveLanguageContext.locale` is `'es'` +- **THEN** `FlatwaveLanguageSelector` renders options for both languages, with `'es'` marked as active + +--- + +### Requirement: FlatwaveLanguageSelector accepts a renderOption render prop + +`FlatwaveLanguageSelector` SHALL accept an optional `renderOption?: (lang: string, label: string, isActive: boolean) => React.ReactNode` prop. When provided, it SHALL call this function for each language to render the option. When not provided, it SHALL render a default `` or the container for custom-rendered options). + +#### Scenario: className is applied to root element + +- **WHEN** `className="lang-selector"` is passed +- **THEN** the outermost element has class `"lang-selector"` + +--- + +### Requirement: FlatwaveLanguageSelector supports getLabel override for custom labels + +`FlatwaveLanguageSelector` SHALL accept an optional `getLabel?: (lang: string) => string` prop that allows consumers to customize the display label for each language. If not provided, it SHALL use the language code itself as the label. + +#### Scenario: Custom labels are used when getLabel is provided + +- **WHEN** `getLabel={lang => lang === 'es' ? 'Español' : 'Português'}` is passed +- **THEN** the selector displays 'Español' and 'Português' instead of 'es' and 'pt' diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-layout-wrapper/spec.md b/openspec/changes/provide-composable-react-components/specs/flatwave-layout-wrapper/spec.md new file mode 100644 index 0000000..57a9d96 --- /dev/null +++ b/openspec/changes/provide-composable-react-components/specs/flatwave-layout-wrapper/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: FlatwaveLanguageRouter accepts a layoutWrapper prop + +`FlatwaveLanguageRouter` SHALL accept an optional `layoutWrapper?: React.ComponentType<{ children: React.ReactNode; locale: string }>` prop. When provided, all rendered routes SHALL be wrapped inside this layout component, receiving the current locale as a prop. This mirrors the `PagesLayout` pattern in the working project. + +#### Scenario: Layout wrapper wraps all page content + +- **WHEN** `layoutWrapper={({ children, locale }) =>
{children}
}` is passed +- **THEN** all rendered pages are wrapped with the layout component, and `locale` is available as a prop + +--- + +### Requirement: FlatwaveAppRoutes accepts a layoutWrapper prop + +`FlatwaveAppRoutes` SHALL accept an optional `layoutWrapper?: React.ComponentType` prop. When provided, routes SHALL render inside the wrapper using react-router's `` pattern for nested routes. This enables consumers to create a shared layout structure. + +#### Scenario: Layout wrapper works with Outlet pattern + +- **WHEN** `layoutWrapper={MyLayout}` is provided to `FlatwaveAppRoutes` +- **THEN** MyLayout receives `` or equivalent to render child routes diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-md-component/spec.md b/openspec/changes/provide-composable-react-components/specs/flatwave-md-component/spec.md new file mode 100644 index 0000000..c181796 --- /dev/null +++ b/openspec/changes/provide-composable-react-components/specs/flatwave-md-component/spec.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: Package exports FlatwaveMDComponent + +The package SHALL export a React component named `FlatwaveMDComponent` from its public surface (`@kamansoft/vite-plugin-flatwave-react`). The component SHALL be a generic functional component typed as `FlatwaveMDComponent`. + +#### Scenario: Named export is available + +- **WHEN** a consumer imports `{ FlatwaveMDComponent }` from `@kamansoft/vite-plugin-flatwave-react` +- **THEN** the import resolves to a React functional component without runtime error + +#### Scenario: TypeScript generic allows frontmatter extension + +- **WHEN** a consumer defines `interface MyFrontmatter extends FlatwaveFrontmatter { audioUrl: string }` and uses `FlatwaveMDComponent` +- **THEN** TypeScript accepts `frontmatter.audioUrl` as a valid property access within a component that wraps `FlatwaveMDComponent` + +--- + +### Requirement: Component accepts pre-compiled HTML via markdownHtml prop + +`FlatwaveMDComponent` SHALL accept a `markdownHtml: string` prop. When this prop is provided, the component SHALL render its content using `dangerouslySetInnerHTML={{ __html: markdownHtml }}` inside a wrapper element. This mode is intended for SSG contexts where the markdown has already been compiled to HTML by the build pipeline. + +#### Scenario: Pre-compiled HTML is rendered verbatim + +- **WHEN** `markdownHtml="

Hello world

"` is passed as a prop +- **THEN** the rendered DOM contains `

Hello world

` as inner HTML + +#### Scenario: markdownHtml takes priority over markdown when both are provided + +- **WHEN** both `markdownHtml="

compiled

"` and `markdown="raw"` are passed +- **THEN** the component renders the compiled HTML and ignores the raw markdown string + +--- + +### Requirement: Component accepts raw markdown via markdown prop + +`FlatwaveMDComponent` SHALL accept a `markdown: string` prop. When this prop is provided and `markdownHtml` is not, the component SHALL render the markdown string using the `react-markdown` library (a peer dependency). + +#### Scenario: Raw markdown is rendered client-side + +- **WHEN** `markdown="# Hello\n\nThis is **markdown**."` is passed without `markdownHtml` +- **THEN** the rendered DOM contains an `

` element with text "Hello" and a `

` with bold text + +--- + +### Requirement: Component strips YAML frontmatter from markdown prop + +`FlatwaveMDComponent` SHALL automatically strip a YAML frontmatter block (delimited by `---` at start and end) from the `markdown` prop before passing it to `react-markdown`. This is defensive: the virtual module already provides stripped content, but direct string use may include frontmatter. + +#### Scenario: frontmatter block is stripped before rendering + +- **WHEN** a markdown string starting with `---\ntitle: Test\n---\n\n# Body` is passed via the `markdown` prop +- **THEN** the rendered output does NOT contain the raw frontmatter lines (`---`, `title: Test`) + +--- + +### Requirement: Component accepts typed frontmatter prop + +`FlatwaveMDComponent` SHALL accept a `frontmatter: TFrontmatter` prop (where `TFrontmatter extends FlatwaveFrontmatter`). The frontmatter values SHALL be accessible to child components via the `children` render-prop pattern. + +#### Scenario: Frontmatter fields are passed to children render prop + +- **WHEN** `frontmatter={{ title: "About", slug: "about", ... }}` is passed and the `children` prop is `(rendered, fm) => <>

{fm.title}

{rendered}` +- **THEN** the rendered output contains `

About

` above the markdown content + +--- + +### Requirement: Component accepts locale prop + +`FlatwaveMDComponent` SHALL accept a `locale: string` prop and expose it to consumers via the `FlatwaveLanguageContext` React context. The locale is not used to alter rendering logic within the component itself, but it is made available for descendant components via context. + +#### Scenario: Locale is exposed in context + +- **WHEN** `locale="es"` is passed and a child component reads `FlatwaveLanguageContext` +- **THEN** the context value's `locale` equals `"es"` + +--- + +### Requirement: Component supports children render prop for layout customization + +`FlatwaveMDComponent` SHALL accept an optional `children` prop typed as `(rendered: React.ReactNode, frontmatter: TFrontmatter) => React.ReactNode`. When provided, the component SHALL call `children` with the rendered markdown content and the frontmatter object, and render the result. When not provided, the component SHALL render the markdown content directly. + +#### Scenario: Children render prop wraps the rendered content + +- **WHEN** `children={(content, fm) =>
{content}
}` is passed +- **THEN** the output is wrapped in `
` and contains the rendered markdown + +#### Scenario: No children prop renders content directly + +- **WHEN** no `children` prop is provided +- **THEN** the component renders the markdown content without any additional wrapper + +--- + +### Requirement: Component accepts className and style props + +`FlatwaveMDComponent` SHALL accept `className?: string` and `style?: React.CSSProperties` props, which SHALL be applied to the outermost rendered element of the component. No default CSS classes SHALL be applied. + +#### Scenario: className is applied to root element + +- **WHEN** `className="prose"` is passed +- **THEN** the outermost rendered element has the class `prose` + +--- + +### Requirement: Component is composable — consumers can create extended versions + +The `FlatwaveMDComponentProps` interface and `FlatwaveMDComponent` SHALL be exported so that consumers can create wrapper components that add behaviour or new props without re-implementing the markdown rendering logic. + +#### Scenario: Consumer creates an extended component + +- **WHEN** a consumer writes `function MyPage(props: FlatwaveMDComponentProps & { header: string }) { return <>

{props.header}

}` +- **THEN** TypeScript compiles without error and the component renders both the custom header and the markdown content diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-md-page-component/spec.md b/openspec/changes/provide-composable-react-components/specs/flatwave-md-page-component/spec.md new file mode 100644 index 0000000..3b97550 --- /dev/null +++ b/openspec/changes/provide-composable-react-components/specs/flatwave-md-page-component/spec.md @@ -0,0 +1,116 @@ +## ADDED Requirements + +### Requirement: Package exports FlatwaveMDPageComponent + +The package SHALL export a React component named `FlatwaveMDPageComponent` from its public surface (`@kamansoft/vite-plugin-flatwave-react`). It SHALL be a generic functional component that extends `FlatwaveMDComponent` by adding full-page concerns: SEO head tag management and a page-level wrapper element. + +#### Scenario: Named export is available + +- **WHEN** a consumer imports `{ FlatwaveMDPageComponent }` from `@kamansoft/vite-plugin-flatwave-react` +- **THEN** the import resolves to a React functional component without runtime error + +--- + +### Requirement: FlatwaveMDPageComponent renders markdown content via FlatwaveMDComponent + +`FlatwaveMDPageComponent` SHALL internally delegate markdown rendering to `FlatwaveMDComponent`, accepting the same `markdownHtml`, `markdown`, `frontmatter`, `locale`, `className`, `style`, and `children` props. All behaviour defined in the `markdown-content-component` spec SHALL apply to `FlatwaveMDPageComponent`. + +#### Scenario: Pre-compiled HTML is rendered inside the page wrapper + +- **WHEN** `markdownHtml="

Hello

"` is passed to `FlatwaveMDPageComponent` +- **THEN** the rendered output contains `

Hello

` inside the page wrapper element + +#### Scenario: Raw markdown is rendered inside the page wrapper when markdownHtml is absent + +- **WHEN** `markdown="# Heading"` is passed without `markdownHtml` +- **THEN** the rendered output contains an `

Heading

` inside the page wrapper element + +--- + +### Requirement: FlatwaveMDPageComponent manages SEO head tags via react-helmet-async + +`FlatwaveMDPageComponent` SHALL render SEO head tags using `react-helmet-async`'s `` component (a peer dependency). At minimum, the following tags SHALL be rendered when the corresponding frontmatter field is present: + +- `` — from `frontmatter.title` +- `<meta name="description">` — from `frontmatter.description` +- `<link rel="canonical">` — from `frontmatter.canonical` +- `<meta property="og:title">` — from `frontmatter.og?.title` or `frontmatter.title` +- `<meta property="og:description">` — from `frontmatter.og?.description` or `frontmatter.description` +- `<meta property="og:image">` — from `frontmatter.image` +- `<meta name="robots">` — from `frontmatter.robots` + +These head tags SHALL only be rendered at runtime (client-side); during SSG the static HTML head is managed by the `renderHtmlHead()` utility in the build pipeline, not by this component. + +#### Scenario: Title tag is set from frontmatter + +- **WHEN** `frontmatter.title = "About Us"` is passed +- **THEN** the document `<title>` is updated to `"About Us"` after client-side hydration + +#### Scenario: Description meta is set from frontmatter + +- **WHEN** `frontmatter.description = "Learn about us"` is passed +- **THEN** `<meta name="description" content="Learn about us">` is present in the document head + +#### Scenario: No Helmet is rendered when title is absent + +- **WHEN** a `frontmatter` object with no `title` field is passed (note: FlatwaveFrontmatter requires title, so this is a type guard scenario for partial/extended use) +- **THEN** no empty `<title>` tag is injected (the component SHALL guard against empty title tags) + +--- + +### Requirement: FlatwaveMDPageComponent accepts a pageWrapper slot prop + +`FlatwaveMDPageComponent` SHALL accept a `pageWrapper?: React.ComponentType<{ children: React.ReactNode; frontmatter: TFrontmatter; locale: string }>` prop. When provided, the component SHALL render the content inside this wrapper component. When not provided, the component SHALL render the content inside a `<main>` element. + +#### Scenario: Custom page wrapper is used when provided + +- **WHEN** `pageWrapper={({ children }) => <section className="page">{children}</section>}` is passed +- **THEN** the rendered output wraps the content in `<section class="page">` instead of `<main>` + +#### Scenario: Default main wrapper is used when no pageWrapper is provided + +- **WHEN** no `pageWrapper` prop is passed +- **THEN** the rendered output wraps content in a `<main>` element + +--- + +### Requirement: FlatwaveMDPageComponent accepts a loadingFallback prop + +`FlatwaveMDPageComponent` SHALL accept a `loadingFallback?: React.ReactNode` prop. This content SHALL be rendered when neither `markdownHtml` nor `markdown` is provided (e.g. during async content loading in client-side navigation). + +#### Scenario: Loading fallback is rendered when no content is available + +- **WHEN** neither `markdownHtml` nor `markdown` is provided but `loadingFallback={<div>Loading...</div>}` is passed +- **THEN** the component renders the loading fallback content + +#### Scenario: No output when loading fallback and content are both absent + +- **WHEN** neither `markdownHtml`, `markdown`, nor `loadingFallback` are provided +- **THEN** the component renders null without throwing + +--- + +### Requirement: DefaultRenderStrategy uses FlatwaveMDPageComponent internally + +The plugin's `DefaultRenderStrategy` SHALL use `FlatwaveMDPageComponent` to render each route during SSG. It SHALL pass `markdownHtml` (pre-compiled body), `frontmatter`, and `locale` from the `RenderContext`. If no matching component override is found in the `components` map, `FlatwaveMDPageComponent` SHALL be used as the default renderer. + +#### Scenario: SSG renders a route using FlatwaveMDPageComponent when no component override exists + +- **WHEN** a route's `component` field references a component not found in the components map +- **THEN** `DefaultRenderStrategy` falls back to `FlatwaveMDPageComponent` and produces valid HTML output (not an error string) + +#### Scenario: SSG renders a route using a custom component when an override exists + +- **WHEN** a route's `component` field resolves to a module in the components map +- **THEN** `DefaultRenderStrategy` uses that component instead of `FlatwaveMDPageComponent` + +--- + +### Requirement: FlatwaveMDPageComponent is composable and extensible + +`FlatwaveMDPageProps` interface SHALL be exported. Consumers SHALL be able to create page components that extend `FlatwaveMDPageComponent` by wrapping it or by accepting `FlatwaveMDPageProps` as their props type. + +#### Scenario: Consumer creates a branded page component + +- **WHEN** a consumer writes `function BrandedPage(props: FlatwaveMDPageProps) { return <FlatwaveMDPageComponent {...props} pageWrapper={BrandedWrapper} /> }` +- **THEN** TypeScript compiles without error and the component renders with the branded wrapper diff --git a/openspec/changes/provide-composable-react-components/specs/ssg-custom-emitters/spec.md b/openspec/changes/provide-composable-react-components/specs/ssg-custom-emitters/spec.md new file mode 100644 index 0000000..03e6349 --- /dev/null +++ b/openspec/changes/provide-composable-react-components/specs/ssg-custom-emitters/spec.md @@ -0,0 +1,97 @@ +## ADDED Requirements + +### Requirement: RenderHooks interface includes an emitFiles callback + +The `RenderHooks` interface in `packages/vite-plugin-flatwave-react/src/types.ts` SHALL include an optional `emitFiles` callback: + +```ts +emitFiles?: (context: EmitFilesContext) => Promise<SsgOutputFile[]> | SsgOutputFile[]; +``` + +Where `EmitFilesContext` is a new exported interface containing: + +- `routes: FlatwaveRoute[]` — all public routes from the content index +- `contentIndex: FlatwaveContentIndex` — the complete content index including all locales and entries +- `renderedFiles: SsgOutputFile[]` — all HTML output files already rendered by the SSG loop + +`SsgOutputFile` (already defined in `runSsg.ts`) SHALL be exported from the package's public surface for use in consumer `emitFiles` implementations. + +#### Scenario: emitFiles is optional and build succeeds without it + +- **WHEN** a consumer configures `flatwaveContent()` without providing `hooks.emitFiles` +- **THEN** the build completes without error and the standard output files are emitted + +#### Scenario: TypeScript accepts the emitFiles signature + +- **WHEN** a consumer writes `hooks: { emitFiles: ({ routes }) => [{ fileName: 'nav.json', source: '{}' }] }` +- **THEN** TypeScript compiles without error + +--- + +### Requirement: RenderPipeline executes emitFiles hooks after the render loop + +`RenderPipeline` SHALL add an `executeEmitFiles` method that calls all registered `emitFiles` hooks sequentially and merges their returned `SsgOutputFile[]` arrays. Errors in individual hooks SHALL be caught and logged, and the remaining hooks SHALL continue executing. + +#### Scenario: executeEmitFiles returns combined output from all hooks + +- **WHEN** two `emitFiles` hooks are registered, returning `[fileA]` and `[fileB]` respectively +- **THEN** `executeEmitFiles` returns `[fileA, fileB]` + +#### Scenario: Hook error does not abort remaining hooks + +- **WHEN** the first `emitFiles` hook throws an error and the second returns `[fileB]` +- **THEN** `executeEmitFiles` logs the error and still returns `[fileB]` + +--- + +### Requirement: runSsg calls executeEmitFiles after the rendering loop + +In `runSsg.ts`, after all routes have been rendered and before the standard outputs (route manifest, sitemap, robots.txt) are assembled, `runSsg` SHALL call `pipeline.executeEmitFiles` with the `EmitFilesContext` containing the full routes, content index, and the list of rendered HTML files. The returned `SsgOutputFile[]` SHALL be appended to the output file list and emitted as Vite bundle assets. + +#### Scenario: emitFiles output files appear in the build output directory + +- **WHEN** `hooks.emitFiles` returns `[{ fileName: 'navigation.json', source: '{"items":[]}' }]` +- **THEN** `navigation.json` is present in the build output directory after `vite build` + +#### Scenario: emitFiles runs after all HTML route files are rendered + +- **WHEN** `hooks.emitFiles` is called +- **THEN** the `renderedFiles` context field contains the rendered HTML for all public routes + +--- + +### Requirement: emitFiles can generate a navigation manifest from route data + +A consumer SHALL be able to use `emitFiles` to generate a `navigation.json` file where each entry contains the `url` (route path) and `publicName` (route title from frontmatter). This JSON file can then be used by a separately implemented React component to populate a navigation menu. + +#### Scenario: Navigation JSON is generated with correct shape + +- **WHEN** `hooks.emitFiles = ({ routes }) => [{ fileName: 'navigation.json', source: JSON.stringify(routes.map(r => ({ url: r.path, publicName: r.metadata.title }))) }]` +- **THEN** `navigation.json` contains an array of objects each with `url` and `publicName` string fields + +#### Scenario: Navigation JSON entries correspond to public routes only + +- **WHEN** the content directory contains both public (`public: true`) and private (`public: false`) entries +- **THEN** the `routes` received by `emitFiles` contain only public routes (matching the existing behaviour of `index.routes`) + +--- + +### Requirement: SsgOutputFile is exported from the package public surface + +`SsgOutputFile` (the `{ fileName: string; source: string }` interface) SHALL be exported from `@kamansoft/vite-plugin-flatwave-react` so that consumers can type their `emitFiles` return value without importing from internal package paths. + +#### Scenario: Consumer can import SsgOutputFile for type annotation + +- **WHEN** a consumer writes `import type { SsgOutputFile } from '@kamansoft/vite-plugin-flatwave-react'` +- **THEN** TypeScript resolves the type without error + +--- + +### Requirement: emitFiles context is typed and exported + +`EmitFilesContext` SHALL be exported from the package public surface so consumers can type their `emitFiles` callback parameter explicitly. + +#### Scenario: Consumer can import EmitFilesContext for type annotation + +- **WHEN** a consumer writes `import type { EmitFilesContext } from '@kamansoft/vite-plugin-flatwave-react'` +- **THEN** TypeScript resolves the type without error and the type contains `routes`, `contentIndex`, and `renderedFiles` fields diff --git a/openspec/changes/provide-composable-react-components/tasks.md b/openspec/changes/provide-composable-react-components/tasks.md new file mode 100644 index 0000000..16bfd7e --- /dev/null +++ b/openspec/changes/provide-composable-react-components/tasks.md @@ -0,0 +1,127 @@ +## 1. Types and Interfaces + +- [ ] 1.1 Add `emitFiles` optional callback to `RenderHooks` interface in `src/types.ts` +- [ ] 1.2 Define and export `EmitFilesContext` interface in `src/types.ts` (fields: `routes`, `contentIndex`, `renderedFiles`) +- [ ] 1.3 Export `SsgOutputFile` type from `src/types.ts` (move from `runSsg.ts` or re-export) +- [ ] 1.4 Define and export `FlatwaveMDComponentProps<TFrontmatter>` generic interface in `src/react/types.ts` +- [ ] 1.5 Define and export `FlatwaveMDPageProps<TFrontmatter>` generic interface in `src/react/types.ts` +- [ ] 1.6 Define and export `FlatwaveLanguageRouterProps`, `FlatwaveLanguageDetectorProps`, and `FlatwaveAppRoutesProps` interfaces in `src/react/types.ts` +- [ ] 1.7 Define and export `FlatwaveLanguageContextValue` interface and `FlatwaveLanguageContext` React context in `src/react/FlatwaveLanguageContext.ts` +- [ ] 1.8 Export `FlatwaveFrontmatterWith<T>` utility type for frontmatter extension + +## 2. FlatwaveMDComponent + +- [ ] 2.1 Create `src/react/FlatwaveMDComponent.tsx` — implement the generic functional component +- [ ] 2.2 Implement `markdownHtml` prop rendering via `dangerouslySetInnerHTML` +- [ ] 2.3 Implement `markdown` prop rendering via `react-markdown` for raw client-side use +- [ ] 2.4 Implement priority logic: `markdownHtml` takes precedence over `markdown` when both are provided +- [ ] 2.5 Implement `children` render prop: `children?: (rendered: React.ReactNode, frontmatter: TFrontmatter) => React.ReactNode` +- [ ] 2.6 Strip YAML frontmatter from `markdown` prop before rendering (defensive: remove `---\n...\n---` block) +- [ ] 2.7 Provide `FlatwaveLanguageContext.Provider` to expose `locale` to descendants +- [ ] 2.8 Apply `className` and `style` props to the outermost element +- [ ] 2.9 Write unit tests for `FlatwaveMDComponent` covering all scenarios in the spec + +## 3. FlatwaveMDPageComponent + +- [ ] 3.1 Create `src/react/FlatwaveMDPageComponent.tsx` — implement the generic functional component +- [ ] 3.2 Delegate markdown rendering to `FlatwaveMDComponent` (composition, not reimplementation) +- [ ] 3.3 Implement SEO head tags using `react-helmet-async` `<Helmet>`: title, description, canonical, og:title, og:description, og:image, robots +- [ ] 3.4 Guard against empty title tag (skip `<title>` if `frontmatter.title` is falsy) +- [ ] 3.5 Implement `pageWrapper?: React.ComponentType` prop with default `<main>` element +- [ ] 3.6 Implement `loadingFallback?: React.ReactNode` prop for missing content scenarios +- [ ] 3.7 Write unit tests for `FlatwaveMDPageComponent` covering all scenarios in the spec + +## 4. FlatwaveLanguageContext and FlatwaveLanguageDetector + +- [ ] 4.1 Create `src/react/FlatwaveLanguageContext.ts` — define `FlatwaveLanguageContext` with default value +- [ ] 4.2 Create `src/react/FlatwaveLanguageDetector.tsx` — implement renderless language detection component +- [ ] 4.3 Implement browser language detection from `navigator.language` / `navigator.languages` +- [ ] 4.4 Implement URL-prefix language detection: read first path segment and match against `supportedLanguages` +- [ ] 4.5 Implement redirect logic: when no language prefix in URL, redirect to `/{detectedLang}{currentPath}` using react-router `useNavigate` with `replace: true` +- [ ] 4.6 Implement `onLanguageChange` callback — call only when language changes +- [ ] 4.7 Provide `FlatwaveLanguageContext.Provider` with current locale state +- [ ] 4.8 Render children after language is resolved (no indefinite loading state) +- [ ] 4.9 Create `src/react/useFlatwaveLanguage.ts` — export `useFlatwaveLanguage()` hook +- [ ] 4.10 Write unit tests for `FlatwaveLanguageDetector` covering redirect, callback, and context + +## 5. FlatwaveLanguageRouter + +- [ ] 5.1 Create `src/react/FlatwaveLanguageRouter.tsx` — implement the convenience router component +- [ ] 5.2 Compose `BrowserRouter` + `FlatwaveLanguageDetector` + `FlatwaveAppRoutes` inside `FlatwaveLanguageRouter` +- [ ] 5.3 Implement `renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode` prop +- [ ] 5.4 Build `<Routes>` from the active language routes using react-router `<Route>` components +- [ ] 5.5 Ensure `FlatwaveLanguageRouter` has no import from any i18n library — verify with grep in CI +- [ ] 5.6 Write unit tests for `FlatwaveLanguageRouter` covering language redirect and renderPage + +## 6. FlatwaveAppRoutes + +- [ ] 6.1 Create `src/react/FlatwaveAppRoutes.tsx` — implement the render-prop route mapping component +- [ ] 6.2 Implement `routes?: FlatwaveRoute[]` prop with fallback to `getRoutes(lang)` from virtual module +- [ ] 6.3 Implement `renderPage` prop to render each route +- [ ] 6.4 Add catch-all `<Route path="*">` rendering null +- [ ] 6.5 Integrate with `FlatwaveLanguageContext` to read active locale +- [ ] 6.6 Write unit tests for `FlatwaveAppRoutes` covering route mapping and custom routes + +## 7. FlatwaveLanguageSelector + +- [ ] 7.1 Create `src/react/FlatwaveLanguageSelector.tsx` — implement the language selector UI component +- [ ] 7.2 Implement `renderOption?: (lang: string, label: string, isActive: boolean) => React.ReactNode` prop +- [ ] 7.3 Implement `onSelect?: (lang: string) => void` callback +- [ ] 7.4 Implement `getLabel?: (lang: string) => string` prop for custom language labels +- [ ] 7.5 Integrate with `FlatwaveLanguageContext` to read supported languages and current locale +- [ ] 7.6 Apply `className` and `style` props to root element +- [ ] 7.7 Write unit tests for `FlatwaveLanguageSelector` + +## 8. SSG emitFiles Hook + +- [ ] 8.1 Add `executeEmitFiles(context: EmitFilesContext): Promise<SsgOutputFile[]>` method to `RenderPipeline.ts` +- [ ] 8.2 Store registered `emitFiles` hooks in a private array in `RenderPipeline` +- [ ] 8.3 Implement sequential execution with per-hook error isolation in `executeEmitFiles` +- [ ] 8.4 Merge return arrays from all hooks into a single flat `SsgOutputFile[]` +- [ ] 8.5 Update `RenderPipeline` constructor to register `emitFiles` from `initialHooks` +- [ ] 8.6 Update `runSsg.ts` to call `pipeline.executeEmitFiles` after the route render loop +- [ ] 8.7 Pass correct `EmitFilesContext` (routes, contentIndex, renderedFiles) in `runSsg.ts` +- [ ] 8.8 Append `emitFiles` output to the `outputFiles` array in `runSsg.ts` +- [ ] 8.9 Write unit tests for `executeEmitFiles` + +## 9. Layout Wrapper Support + +- [ ] 9.1 Add `layoutWrapper?: React.ComponentType` prop to `FlatwaveLanguageRouter` +- [ ] 9.2 Add `layoutWrapper?: React.ComponentType` prop to `FlatwaveAppRoutes` +- [ ] 9.3 Update `FlatwaveLanguageRouter` to wrap rendered pages in layoutWrapper +- [ ] 9.4 Write unit tests for layoutWrapper prop functionality + +## 10. DefaultRenderStrategy Refactor + +- [ ] 10.1 Update `DefaultRenderStrategy.tsx` to use `FlatwaveMDPageComponent` internally +- [ ] 10.2 Pass `markdownHtml`, `frontmatter`, and `locale` from `RenderContext` to `FlatwaveMDPageComponent` +- [ ] 10.3 Keep custom component override path (existing behaviour) +- [ ] 10.4 Remove inline graceful-degradation logic (now in `FlatwaveMDPageComponent`) +- [ ] 10.5 Verify existing e2e tests pass after refactor + +## 11. Package Exports and Peer Dependencies + +- [ ] 11.1 Update `src/react/index.ts` to export `FlatwaveMDComponent`, `FlatwaveMDPageComponent`, `FlatwaveLanguageRouter`, `FlatwaveLanguageDetector`, `FlatwaveLanguageContext`, `FlatwaveLanguageSelector` +- [ ] 11.2 Update `src/index.ts` to re-export from `./react` +- [ ] 11.3 Update `src/types.ts` to export `EmitFilesContext` and `SsgOutputFile` +- [ ] 11.4 Add `react-markdown` and `react-helmet-async` to `peerDependencies` in `packages/vite-plugin-flatwave-react/package.json` +- [ ] 11.5 Add `react-router-dom` to `peerDependencies` if missing +- [ ] 11.6 Run `npm run type-check` to confirm no TypeScript errors + +## 12. Example Site Update + +- [ ] 12.1 Update `examples/basic-react-site/` to use `FlatwaveLanguageRouter` from the package +- [ ] 12.2 Create an example page component that extends `FlatwaveMDPageComponent` +- [ ] 12.3 Add an `emitFiles` hook to `examples/basic-react-site/vite.config.ts` generating `navigation.json` +- [ ] 12.4 Add a `NavigationMenu` component that imports and renders `navigation.json` entries +- [ ] 12.5 Run `npm run build:example` and verify `navigation.json` in `dist/` + +## 13. Documentation + +- [ ] 13.1 Rewrite `README.md` with component-first usage model +- [ ] 13.2 Document `FlatwaveMDComponent` props, usage, and extension pattern +- [ ] 13.3 Document `FlatwaveMDPageComponent` props, usage, and extension pattern +- [ ] 13.4 Document `FlatwaveLanguageRouter` props, `FlatwaveLanguageDetector`, and i18n wiring +- [ ] 13.5 Document `FlatwaveLanguageSelector` and `emitFiles` hook with `navigation.json` generation example +- [ ] 13.6 Add migration notes section in README +- [ ] 13.7 Update `CHANGELOG.md` diff --git a/package-lock.json b/package-lock.json index a2b22fd..411621e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ }, "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", @@ -15605,7 +15605,7 @@ }, "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", From cb95ea156d9813cafd7f8e29cec2455460e5f367 Mon Sep 17 00:00:00 2001 From: lemys lopez <lemyskaman@gmail.com> Date: Sat, 20 Jun 2026 18:21:29 -0500 Subject: [PATCH 02/10] feat(composable): Imrpooving componsable usage --- docs/Architecture.md | 618 +++--------------- examples/basic-react-site/package.json | 4 +- node_modules/.package-lock.json | 176 ++++- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/flatwave-app-routes/spec.md | 0 .../specs/flatwave-language-router/spec.md | 0 .../specs/flatwave-language-selector/spec.md | 0 .../specs/flatwave-layout-wrapper/spec.md | 0 .../specs/flatwave-md-component/spec.md | 0 .../specs/flatwave-md-page-component/spec.md | 0 .../specs/ssg-custom-emitters/spec.md | 0 .../tasks.md | 113 ++++ .../.openspec.yaml | 2 + .../design.md | 46 ++ .../proposal.md | 33 + .../specs/non-intrusive-ssg/spec.md | 34 + .../tasks.md | 27 + .../tasks.md | 166 +++-- openspec/specs/flatwave-app-routes/spec.md | 71 ++ .../specs/flatwave-language-router/spec.md | 170 +++++ .../specs/flatwave-language-selector/spec.md | 87 +++ .../specs/flatwave-layout-wrapper/spec.md | 27 + openspec/specs/flatwave-md-component/spec.md | 119 ++++ .../specs/flatwave-md-page-component/spec.md | 122 ++++ openspec/specs/ssg-custom-emitters/spec.md | 103 +++ package-lock.json | 175 ++++- packages/vite-plugin-flatwave-react/README.md | 148 ++++- .../dist/react/index.d.ts | 8 + .../dist/react/index.js | 11 + .../dist/types.d.ts | 10 + .../vite-plugin-flatwave-react/package.json | 14 +- .../src/react/FlatwaveAppRoutes.tsx | 57 ++ .../src/react/FlatwaveLanguageContext.ts | 14 + .../src/react/FlatwaveLanguageDetector.tsx | 62 ++ .../src/react/FlatwaveLanguageRouter.tsx | 27 + .../src/react/FlatwaveLanguageSelector.tsx | 70 ++ .../src/react/FlatwaveMDComponent.tsx | 62 ++ .../src/react/FlatwaveMDPageComponent.tsx | 82 +++ .../src/react/index.ts | 24 + .../src/react/types.ts | 56 ++ .../src/ssg/DefaultRenderStrategy.tsx | 4 +- .../src/ssg/RenderPipeline.ts | 22 +- .../src/ssg/index.ts | 12 +- .../src/ssg/runSsg.ts | 15 +- .../vite-plugin-flatwave-react/src/types.ts | 12 + 47 files changed, 2139 insertions(+), 664 deletions(-) rename openspec/changes/{provide-composable-react-components => archive/2026-06-20-provide-composable-react-components}/.openspec.yaml (100%) rename openspec/changes/{provide-composable-react-components => archive/2026-06-20-provide-composable-react-components}/design.md (100%) rename openspec/changes/{provide-composable-react-components => archive/2026-06-20-provide-composable-react-components}/proposal.md (100%) rename openspec/changes/{provide-composable-react-components => archive/2026-06-20-provide-composable-react-components}/specs/flatwave-app-routes/spec.md (100%) rename openspec/changes/{provide-composable-react-components => archive/2026-06-20-provide-composable-react-components}/specs/flatwave-language-router/spec.md (100%) rename openspec/changes/{provide-composable-react-components => archive/2026-06-20-provide-composable-react-components}/specs/flatwave-language-selector/spec.md (100%) rename openspec/changes/{provide-composable-react-components => archive/2026-06-20-provide-composable-react-components}/specs/flatwave-layout-wrapper/spec.md (100%) rename openspec/changes/{provide-composable-react-components => archive/2026-06-20-provide-composable-react-components}/specs/flatwave-md-component/spec.md (100%) rename openspec/changes/{provide-composable-react-components => archive/2026-06-20-provide-composable-react-components}/specs/flatwave-md-page-component/spec.md (100%) rename openspec/changes/{provide-composable-react-components => archive/2026-06-20-provide-composable-react-components}/specs/ssg-custom-emitters/spec.md (100%) create mode 100644 openspec/changes/archive/2026-06-20-provide-composable-react-components/tasks.md create mode 100644 openspec/changes/make-plugin-non-intrusive-routing/.openspec.yaml create mode 100644 openspec/changes/make-plugin-non-intrusive-routing/design.md create mode 100644 openspec/changes/make-plugin-non-intrusive-routing/proposal.md create mode 100644 openspec/changes/make-plugin-non-intrusive-routing/specs/non-intrusive-ssg/spec.md create mode 100644 openspec/changes/make-plugin-non-intrusive-routing/tasks.md create mode 100644 openspec/specs/flatwave-app-routes/spec.md create mode 100644 openspec/specs/flatwave-language-router/spec.md create mode 100644 openspec/specs/flatwave-language-selector/spec.md create mode 100644 openspec/specs/flatwave-layout-wrapper/spec.md create mode 100644 openspec/specs/flatwave-md-component/spec.md create mode 100644 openspec/specs/flatwave-md-page-component/spec.md create mode 100644 openspec/specs/ssg-custom-emitters/spec.md create mode 100644 packages/vite-plugin-flatwave-react/src/react/FlatwaveAppRoutes.tsx create mode 100644 packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageContext.ts create mode 100644 packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageDetector.tsx create mode 100644 packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageRouter.tsx create mode 100644 packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageSelector.tsx create mode 100644 packages/vite-plugin-flatwave-react/src/react/FlatwaveMDComponent.tsx create mode 100644 packages/vite-plugin-flatwave-react/src/react/FlatwaveMDPageComponent.tsx create mode 100644 packages/vite-plugin-flatwave-react/src/react/types.ts diff --git a/docs/Architecture.md b/docs/Architecture.md index d4e30dc..8634f94 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -13,7 +13,7 @@ - [Plugin Core (`index.ts`)](#plugin-core-indexts) - [Content Pipeline](#content-pipeline) - [SSG Pipeline](#ssg-pipeline) - - [React Client Layer](#react-client-layer) + - [Composable React Components](#composable-react-components) - [SEO Module](#seo-module) - [CLI Tool](#cli-tool) 5. [Data Flow Diagrams](#data-flow-diagrams) @@ -24,7 +24,6 @@ - [Plugin Initialization Sequence](#plugin-initialization-sequence) - [Content Indexing Sequence](#content-indexing-sequence) - [SSG Page Rendering Sequence](#ssg-page-rendering-sequence) - - [Hot Module Replacement Sequence](#hot-module-replacement-sequence) - [CLI Validation Sequence](#cli-validation-sequence) 7. [Type System](#type-system) 8. [Interrelationship Map](#interrelationship-map) @@ -36,6 +35,16 @@ `@kamansoft/vite-plugin-flatwave-react` is a **Vite plugin** that transforms a directory of Markdown files (with YAML front-matter) into a fully type-safe, i18n-aware, statically-generated React site. It operates entirely at **build time**, producing locale-prefixed HTML pages, `sitemap.xml`, `robots.txt`, and `route-manifest.json` as output artifacts. +The plugin also provides **composable React components** that consumers can use to build their applications: + +- `FlatwaveMDComponent` - Markdown content renderer (SSG or client-side mode) +- `FlatwaveMDPageComponent` - Full-page wrapper with SEO head tags +- `FlatwaveLanguageRouter` - Complete router with language detection +- `FlatwaveLanguageDetector` - Language detection and redirect logic +- `FlatwaveAppRoutes` - Dynamic route rendering +- `FlatwaveLanguageSelector` - Language switcher UI +- `FlatwaveLanguageContext` - React context for locale state + The plugin is composed of three cooperating Vite plugin instances returned as an array from the single `flatwaveContent()` factory function: | Plugin name | Role | @@ -65,50 +74,35 @@ vite-plugin-flatwave-react/ ← npm workspace root │ │ │ └── markdownCompiler.ts ← unified/remark/rehype markdown → HTML │ │ ├── ssg/ │ │ │ ├── runSsg.ts ← orchestrates SSG: renders all routes in batches -│ │ │ ├── RenderPipeline.ts ← hook executor (5 phases) +│ │ │ ├── RenderPipeline.ts ← hook executor (5 phases + emitFiles) │ │ │ ├── DefaultRenderStrategy.tsx ← React renderToString strategy │ │ │ ├── template.ts ← EJS-style template resolver + renderer -│ │ │ ├── types.ts ← SSG-specific types (RenderContext, etc.) +│ │ │ ├── types.ts ← RenderContext, TemplateVariables, EmitFilesContext │ │ │ ├── index.ts ← public re-exports of ./ssg │ │ │ └── templates/ │ │ │ ├── index.html.ejs ← default HTML shell template │ │ │ ├── entry-client.tsx.ejs │ │ │ └── entry-server.tsx.ejs │ │ ├── react/ -│ │ │ └── index.ts ← React hooks (useFlatwaveContent, etc.) -│ │ ├── seo/ -│ │ │ └── metadata.ts ← HTML head tag generators, escape helpers +│ │ │ ├── index.ts ← React hooks + component exports +│ │ │ ├── types.ts ← FlatwaveMDComponentProps, FlatwaveMDPageProps, etc. +│ │ │ ├── FlatwaveMDComponent.tsx ← Markdown content component +│ │ │ ├── FlatwaveMDPageComponent.tsx ← Page wrapper with SEO heads +│ │ │ ├── FlatwaveLanguageContext.ts ← React context for locale +│ │ │ ├── FlatwaveLanguageDetector.tsx ← URL/browser language detection +│ │ │ ├── FlatwaveLanguageRouter.tsx ← Composed router component +│ │ │ ├── FlatwaveAppRoutes.tsx ← Dynamic route mapping +│ │ │ └── FlatwaveLanguageSelector.tsx ← Language switcher UI +│ │ ├── seo/metadata.ts ← HTML head tag generators, escape helpers │ │ └── cli/ │ │ └── validate.ts ← CLI entry: `flatwave-validate` command │ ├── tsconfig.build.json │ └── package.json ← exports map, bin, peer deps ├── examples/ │ └── basic-react-site/ ← example consumer app (Vite + React) -│ ├── src/ -│ │ ├── content/{es,pt}/*.md ← sample multilingual content -│ │ ├── components/*.tsx ← SimplePage, ProgramPage, LanguageSwitcher, MarkdownRenderer -│ │ ├── App.tsx ← routing via virtual module -│ │ └── main.tsx -│ └── vite.config.ts ← flatwaveContent() usage example -├── docker/ -│ ├── docker-compose.yml ← dev / build / static services -│ ├── dev.Dockerfile -│ ├── build.Dockerfile -│ ├── static-server.Dockerfile -│ └── nginx.conf ├── e2e/ │ └── example.test.ts ← Vitest integration suite (builds + serves + asserts) -├── docs/ ← project documentation -├── .github/workflows/ -│ ├── ci.yml ← PR validation (format, lint, type-check, build, test) -│ ├── release.yml ← CI gate + semantic-release on push to main -│ └── pr-title.yml ← Conventional Commits PR title enforcement -├── .husky/ ← git hooks (pre-commit, commit-msg) -├── .lintstagedrc.json ← lint-staged per-glob rules -├── commitlint.config.js ← commitlint Conventional Commits rules -├── eslint.config.mjs ← flat ESLint config (TS + Prettier + React) -├── .releaserc.json ← semantic-release configuration -└── package.json ← workspace root + shared dev tooling +└── docs/ ← project documentation ``` --- @@ -142,7 +136,13 @@ graph TB RUNSSG["runSsg.ts\norchestrator"] PIPELINE["RenderPipeline.ts\nhook executor"] STRATEGY["DefaultRenderStrategy\nreact-dom/server"] - TEMPLATE["template.ts\nEJS-style renderer"] + TEMPLATE["template.ts\nHTML shell rendering"] + end + + subgraph "Composable React Components" + ROUTER["FlatwaveLanguageRouter\nBrowserRouter + Detector + Routes"] + COMPONENTS["FlatwaveMDPageComponent\nFlatwaveMDComponent\nFlatwaveLanguageSelector"] + CONTEXT["FlatwaveLanguageContext"] end subgraph "Virtual Module" @@ -176,6 +176,10 @@ graph TB RUNSSG --> PIPELINE --> STRATEGY --> TEMPLATE RUNSSG --> META VM --> HOOKS + VM --> ROUTER + ROUTER --> CONTEXT + ROUTER --> COMPONENTS + CONTEXT --> COMPONENTS TEMPLATE --> HTML RUNSSG --> SITEMAP & ROBOTS & MANIFEST ``` @@ -210,19 +214,6 @@ flatwaveContent(options) generateBundle() → runSsg(index, options, assets) → emitFile() ``` -**Virtual module content** (created in `createVirtualModule`): - -The virtual module is a string of JavaScript containing the serialized content index and helper functions. It exports: - -- `getContent(id, locale?)` — find one entry -- `getAllContent()` — full entry array -- `getRoutes(locale?)` — all or locale-filtered routes -- `getAlternatives(contentId, currentLocale)` — locale → path map (minus current) -- `getLocale(locale?)` — pass-through locale helper -- `getLocales()` — unique locale list derived from routes -- `getDefaultLocale()` — the configured default locale -- `flatwaveContentIndex` — full index object - --- ### Content Pipeline @@ -237,52 +228,6 @@ src/content/ └── markdownCompiler.ts ← Converts Markdown body → HTML ``` -**`scanner.ts`** - -Uses `fast-glob` to glob `**/*.md` inside each locale directory. For each file: - -- reads content via `fs/promises` -- parses front-matter with `gray-matter` -- normalizes the slug (strips leading/trailing slashes, prepends `/`) -- returns an array of `ParsedMarkdownFile` - -**`indexer.ts`** - -Iterates `ParsedMarkdownFile[]`, creates `FlatwaveContentEntry` objects, builds an alternatives map (`id → { locale → route }`), then delegates to `buildContentIndex()` from `routeBuilder.ts`. - -**`routeBuilder.ts`** - -- Filters to public entries only -- Builds `byId` and `byLocale` lookup maps -- Assembles `FlatwaveRoute[]` sorted by `locale + path` -- Derives `SeoMetadata` from frontmatter fields - -**`validator.ts`** - -Runs five validation passes (all async): - -1. **Required fields** — checks every entry has all `requiredFields` -2. **Duplicate IDs** — ensures no two files share the same `locale:id` key -3. **Duplicate slugs** — ensures no two files share the same `locale:slug` key -4. **Menu positions** — validates numeric `menu_position` values and uniqueness per menu group -5. **Components** — discovers `.tsx/.ts/.jsx/.js` files in `componentsDir` and checks that every `component` referenced in frontmatter exists - -Returns `{ errors: string[], warnings: string[] }`. Errors block the build; warnings are surfaced via Vite's `this.warn()`. - -**`markdownCompiler.ts`** - -A `unified` pipeline: - -``` -unified() - .use(remarkParse) ← Markdown AST - .use(remarkRehype, ...) ← convert to HTML AST - .use(rehypeRaw) ← (optional) allow raw HTML passthrough - .use(rehypeStringify) ← serialize to HTML string -``` - -Accepts custom `remarkPlugins` and `rehypePlugins` via `MarkdownCompilerOptions`. - --- ### SSG Pipeline @@ -290,89 +235,41 @@ Accepts custom `remarkPlugins` and `rehypePlugins` via `MarkdownCompilerOptions` ``` src/ssg/ ├── runSsg.ts ← main orchestrator -├── RenderPipeline.ts ← hook phase executor +├── RenderPipeline.ts ← hook phase executor (5 phases + emitFiles) ├── DefaultRenderStrategy.tsx ← react-dom/server renderer ├── template.ts ← HTML template resolution + rendering -├── types.ts ← RenderContext, TemplateVariables -├── index.ts ← public exports +├── types.ts ← RenderContext, TemplateVariables, EmitFilesContext +├── index.ts ← public re-exports of ./ssg └── templates/ ├── index.html.ejs ← default HTML shell ├── entry-client.tsx.ejs └── entry-server.tsx.ejs ``` -**`runSsg.ts`** - -Processes all routes in **concurrency batches of 4**: - -``` -For each route (batched, 4 at a time): - 1. Find contentEntry matching route.contentId + route.locale - 2. pipeline.executeBeforeRender(context) ← hook - 3. pipeline.executeTransformMarkdown(body) ← hook - 4. compileMarkdownToHtml(transformedMarkdown) ← remark/rehype - 5. strategy.render(renderContext) ← DefaultRenderStrategy / custom - 6. resolveTemplate('index.html', overrides) ← built-in or project-override - 7. renderHtmlHead(route) ← SEO meta tags - 8. renderTemplate(template, variables) ← EJS-style substitution - 9. pipeline.executeTransformHtml(finalHtml) ← hook - 10. pipeline.executeAfterRender(finalHtml) ← hook (side effects only) - 11. Emit as 'asset' file: {locale}/{slug}/index.html - -After all routes: - → emit route-manifest.json (if enabled) - → emit sitemap.xml (if enabled) - → emit robots.txt (if enabled) -``` - -**`RenderPipeline.ts`** - -Stores hooks per phase in typed arrays. Each `execute*` method runs hooks sequentially, wrapping each in try/catch to prevent one failing hook from aborting the whole render. Hook phases: - -| Method | Phase | Input → Output | -| -------------------------- | ------------------- | ------------------------------- | -| `executeBeforeRender` | `beforeRender` | `RenderContext → RenderContext` | -| `executeTransformMarkdown` | `transformMarkdown` | `(md, ctx) → md` | -| `executeTransformHtml` | `transformHtml` | `(html, ctx) → html` | -| `executeAfterRender` | `afterRender` | `(html, ctx) → void` | -| `executeOnError` | `onError` | `(Error, ctx) → html` | - -**`DefaultRenderStrategy.tsx`** - -- Looks up the React component from the preloaded `components` Map -- If component found: calls `renderToString(<Component {...props} />)` -- If component not found: falls back to returning the compiled HTML body directly (graceful degradation) -- Props passed to component: `{ ...frontmatter, markdownHtml, locale, route }` - -**`template.ts`** - -Resolution order for `index.html`: - -1. `overrides.indexHtml` (if provided in `ssg.template`) -2. `{projectRoot}/flatwave-templates/index.html` (project-level override) -3. Built-in `templates/index.html.ejs` - -Template substitution variables: `<%= appHtml %>`, `<%= title %>`, `<%= meta %>`, `<%= locale %>`, `<%= canonical %>`, `<%= headTags %>`, `<%= scripts %>`, `<%= styles %>`. - --- -### React Client Layer +### Composable React Components + +The `src/react/` directory provides reusable React components for building multilingual sites: ``` -src/react/index.ts +src/react/ +├── index.ts ← Exports all hooks and components +├── types.ts ← Props interfaces (FlatwaveMDComponentProps, etc.) +├── FlatwaveMDComponent.tsx ← Markdown renderer (SSG or client-side) +├── FlatwaveMDPageComponent.tsx ← Page wrapper with Helmet SEO heads +├── FlatwaveLanguageContext.ts ← React context (locale, supportedLanguages) +├── FlatwaveLanguageDetector.tsx ← URL prefix + browser language detection +├── FlatwaveLanguageRouter.tsx ← Composed BrowserRouter + Detector + AppRoutes +├── FlatwaveAppRoutes.tsx ← Dynamic route rendering with render prop +└── FlatwaveLanguageSelector.tsx ← Language switcher UI ``` -Thin wrapper around the virtual module. Exports React hooks built with `useMemo` for referential stability: - -| Hook | Returns | Purpose | -| -------------------------------------- | ------------------------------------- | ----------------------------------- | -| `useFlatwaveContent(id, locale?)` | `FlatwaveVirtualContent \| undefined` | Get one content entry | -| `useFlatwaveRoutes(locale?)` | `FlatwaveVirtualRoute[]` | Get all routes, optionally filtered | -| `useFlatwaveAlternatives(id, locale?)` | `Record<string, string>` | Locale → path map for a content ID | -| `useFlatwaveLocales()` | `string[]` | All configured locales | -| `useFlatwaveLocale(locale?)` | `string \| undefined` | Pass-through locale value | +**Component Patterns:** -Also re-exports the raw virtual module functions for non-hook usage (`getAllContent`, `getContent`, `getRoutes`, `getAlternatives`, `getLocale`, `getLocales`). +- **SSG Mode**: When `markdownHtml` prop is provided, renders pre-compiled HTML +- **Client-side Mode**: When `markdown` prop is provided, uses `react-markdown` +- **Composition**: All components accept render props or wrapper components for customization --- @@ -382,15 +279,7 @@ Also re-exports the raw virtual module functions for non-hook usage (`getAllCont src/seo/metadata.ts ``` -| Function | Purpose | -| ---------------------------- | --------------------------------------------------------------------- | -| `renderHtmlHead(route)` | Generates all `<meta>`, `<link>`, `<script>` tags from route metadata | -| `buildSeoMetadata(metadata)` | Utility for standalone head tag generation | -| `escapeHtml(value)` | Escapes `&`, `<`, `>`, `"`, `'` for HTML attributes | -| `escapeXml(value)` | Extends `escapeHtml` for XML contexts (sitemap) | -| `escapeJsonScript(value)` | Escapes JSON-LD content for safe inline `<script>` injection | - -Generates tags for: `description`, `robots`, `canonical`, `og:*`, `twitter:*`, `hreflang` alternates, `application/ld+json`. +Generates head tags for title, description, canonical, open graph, hreflang alternates, and JSON-LD structured data. --- @@ -400,19 +289,7 @@ Generates tags for: `description`, `robots`, `canonical`, `og:*`, `twitter:*`, ` src/cli/validate.ts ``` -Uses [Commander.js](https://github.com/tj/commander.js) to expose `flatwave-validate` as a CLI command: - -``` -flatwave-validate - --content-dir <dir> required - --locales <locales> required, comma-separated - --default-locale <locale> required - --components-dir <dirs> optional, comma-separated (default: src/components,src/pages) - --strict-missing flag: missing locale variants → errors instead of warnings - --no-validate-components flag: skip component existence check -``` - -Internally calls `validateContent()` from the same validator the plugin uses, guaranteeing parity between CI and dev-time validation. Exit code 0 = pass, 1 = errors found. +Validates content structure before build. Prevents deployment of invalid content. --- @@ -464,333 +341,26 @@ flowchart LR H --> O & P & Q ``` -### Virtual Module Flow - -```mermaid -flowchart TD - BUILD["buildStart()\nbuildIndex()"] - IDX["FlatwaveContentIndex\n{entries, byId, byLocale, routes}"] - VM["createVirtualModule(index)\nreturns JS string"] - RESOLVE["resolveId()\nvirtual:flatwave/content → \\0virtual:..."] - LOAD["load()\nreturns JS string"] - APP["App.tsx / hooks\nimport from 'virtual:flatwave/content'"] - HOOK["useFlatwaveContent()\nuseFlatwaveRoutes()\nuseFlatwaveAlternatives()"] - - BUILD --> IDX --> VM - RESOLVE --> LOAD --> VM - VM --> APP --> HOOK -``` - -### SSG Rendering Pipeline - -```mermaid -flowchart TD - START["runSsg(index, options, assets)"] - BATCH["Chunk routes\n4 at a time"] - FIND["Find contentEntry\nfor route"] - BEFORE["pipeline.executeBeforeRender(ctx)"] - TRANSFORM_MD["pipeline.executeTransformMarkdown(md, ctx)"] - COMPILE["compileMarkdownToHtml(md)\nunified pipeline"] - STRATEGY["strategy.render(renderCtx)\nDefaultRenderStrategy → renderToString()"] - ERROR_HOOK["pipeline.executeOnError(err, ctx)\nreturns fallback HTML"] - TEMPLATE["resolveTemplate('index.html')"] - HEAD["renderHtmlHead(route)\nSEO meta tags"] - RENDER_TPL["renderTemplate(template, vars)"] - TRANSFORM_HTML["pipeline.executeTransformHtml(html, ctx)"] - AFTER["pipeline.executeAfterRender(html, ctx)"] - EMIT["emitFile: {locale}/{slug}/index.html"] - MANIFEST["emit route-manifest.json"] - SITEMAP["emit sitemap.xml"] - ROBOTS["emit robots.txt"] - - START --> BATCH --> FIND --> BEFORE --> TRANSFORM_MD --> COMPILE --> STRATEGY - STRATEGY -->|error| ERROR_HOOK - STRATEGY -->|success| TEMPLATE - ERROR_HOOK --> TEMPLATE - TEMPLATE --> HEAD --> RENDER_TPL --> TRANSFORM_HTML --> AFTER --> EMIT - EMIT -->|all routes done| MANIFEST & SITEMAP & ROBOTS -``` - --- -## Sequence Diagrams - -### Plugin Initialization Sequence - -```mermaid -sequenceDiagram - participant Dev as Developer - participant Vite as Vite Build - participant Factory as flatwaveContent() - participant P1 as flatwave-react:content - participant Scanner as scanner.ts - participant Indexer as indexer.ts - participant Validator as validator.ts - - Dev->>Vite: vite build / vite dev - Vite->>Factory: flatwaveContent(options) - Factory->>Factory: normalizeOptions(options) - Factory-->>Vite: [Plugin1, Plugin2, Plugin3] - Vite->>P1: buildStart() - P1->>Indexer: buildIndex(options) - Indexer->>Scanner: scanMarkdownFiles(contentDir, locales) - Scanner-->>Indexer: ParsedMarkdownFile[] - Indexer-->>P1: FlatwaveContentIndex - P1->>Validator: validateContent(options) - Validator->>Scanner: scanMarkdownFiles(contentDir, locales) - Scanner-->>Validator: ParsedMarkdownFile[] - Validator-->>P1: { errors[], warnings[] } - P1->>Vite: this.warn(warnings) - alt errors found - P1->>Vite: this.error(errors) → BUILD FAILS - end -``` - -### Content Indexing Sequence - -```mermaid -sequenceDiagram - participant Indexer as indexer.ts - participant Scanner as scanner.ts - participant RouteBuilder as routeBuilder.ts - participant FS as File System - - Indexer->>Scanner: scanMarkdownFiles(contentDir, locales) - loop for each locale - Scanner->>FS: fast-glob("**/*.md", {cwd: localeDir}) - FS-->>Scanner: string[] (file paths) - loop for each .md file - Scanner->>FS: fs.readFile(file) - FS-->>Scanner: raw content string - Scanner->>Scanner: gray-matter.parse(content) - Scanner->>Scanner: normalizeSlug(frontmatter.slug) - Scanner-->>Indexer: ParsedMarkdownFile - end - end - Indexer->>Indexer: build byLocaleAndId map - Indexer->>Indexer: build alternatives map\n{ id → { locale → route } } - Indexer->>RouteBuilder: buildContentIndex(entries) - RouteBuilder->>RouteBuilder: filter public entries - RouteBuilder->>RouteBuilder: build byId, byLocale maps - RouteBuilder->>RouteBuilder: buildSeoMetadata() per entry - RouteBuilder->>RouteBuilder: sort routes - RouteBuilder-->>Indexer: FlatwaveContentIndex - Indexer-->>Caller: FlatwaveContentIndex -``` - -### SSG Page Rendering Sequence - -```mermaid -sequenceDiagram - participant Vite as Vite generateBundle - participant RunSsg as runSsg.ts - participant Pipeline as RenderPipeline - participant Strategy as DefaultRenderStrategy - participant Compiler as markdownCompiler - participant Template as template.ts - participant SEO as seo/metadata.ts - participant React as react-dom/server - - Vite->>RunSsg: runSsg(index, options, assets) - RunSsg->>RunSsg: buildComponentsMap(routes) - loop for each route batch (4 concurrent) - RunSsg->>Pipeline: executeBeforeRender(context) - Pipeline-->>RunSsg: modified context - RunSsg->>Pipeline: executeTransformMarkdown(body, context) - Pipeline-->>RunSsg: transformed markdown - RunSsg->>Compiler: compileMarkdownToHtml(markdown, opts) - Compiler-->>RunSsg: compiledHtml (string) - RunSsg->>Strategy: render(renderContext) - Strategy->>React: renderToString(<Component {...props} />) - React-->>Strategy: appHtml string - Strategy-->>RunSsg: appHtml - RunSsg->>Template: resolveTemplate('index.html', overrides) - Template-->>RunSsg: template string - RunSsg->>SEO: renderHtmlHead(route) - SEO-->>RunSsg: headTags string - RunSsg->>Template: renderTemplate(template, vars) - Template-->>RunSsg: fullHtml string - RunSsg->>Pipeline: executeTransformHtml(fullHtml, context) - Pipeline-->>RunSsg: finalHtml - RunSsg->>Pipeline: executeAfterRender(finalHtml, context) - RunSsg->>Vite: emitFile({locale}/{slug}/index.html) - end - RunSsg->>Vite: emitFile(route-manifest.json) - RunSsg->>Vite: emitFile(sitemap.xml) - RunSsg->>Vite: emitFile(robots.txt) -``` - -### Hot Module Replacement Sequence - -```mermaid -sequenceDiagram - participant Dev as Developer - participant FS as File System - participant Vite as Vite HMR - participant P1 as flatwave-react:content - participant Indexer as indexer.ts - participant VM as Virtual Module - - Dev->>FS: edits src/content/es/about.md - FS->>Vite: file change event - Vite->>P1: handleHotUpdate({file: "...about.md"}) - alt file ends with .md - P1->>Indexer: buildIndex(options) - Indexer-->>P1: updated FlatwaveContentIndex - P1->>VM: update in-memory index - P1-->>Vite: trigger HMR for virtual:flatwave/content - Vite-->>Dev: browser hot-reloads with new content - else not a .md file - P1-->>Vite: return (no action) - end -``` - -### CLI Validation Sequence - -```mermaid -sequenceDiagram - participant User as Developer / CI - participant CLI as flatwave-validate (cli/validate.ts) - participant Commander as Commander.js - participant Validator as content/validator.ts - participant Scanner as content/scanner.ts - - User->>CLI: flatwave-validate --content-dir src/content --locales es,pt --default-locale es - CLI->>Commander: program.parseAsync(process.argv) - Commander->>CLI: action callback with parsed options - CLI->>Validator: validateContent(options) - Validator->>Scanner: scanMarkdownFiles(contentDir, locales) - Scanner-->>Validator: ParsedMarkdownFile[] - Validator->>Validator: validateRequiredFields() - Validator->>Validator: validateDuplicateIds() - Validator->>Validator: validateDuplicateSlugs() - Validator->>Validator: validateMenuPositions() - Validator->>Validator: validateComponents() - Validator->>Validator: validateMissingLocales() - Validator-->>CLI: { errors[], warnings[] } - loop for each warning - CLI->>User: console.warn("[WARN] ...") - end - alt errors found - CLI->>User: console.error("[ERROR] ...") - CLI->>User: process.exitCode = 1 - else no errors - CLI->>User: "Flatwave validation passed for es, pt with N warning(s)." - CLI->>User: process.exitCode = 0 - end -``` +## Type System ---- +The entire type system is centralized in `packages/vite-plugin-flatwave-react/src/types.ts`. Key interfaces include: -## Type System +- `FlatwaveContentOptions` - Plugin configuration +- `FlatwaveContentEntry` - Single content item +- `FlatwaveRoute` - URL route with SEO metadata +- `FlatwaveContentIndex` - Content collection index +- `SsgOptions` - SSG configuration with hooks +- `RenderHooks` - Hook phase definitions +- `FlatwaveFrontmatter` - Frontmatter schema -The entire type system is centralized in `packages/vite-plugin-flatwave-react/src/types.ts`. Below are the primary interfaces and their relationships: +Component props are defined in `src/react/types.ts`: -```mermaid -classDiagram - class FlatwaveContentOptions { - +string contentDir - +string[] locales - +string defaultLocale - +FlatwaveFallbackPolicy fallback? - +boolean strictMissingLocales? - +string[] requiredFields? - +boolean validateComponents? - +string|string[] componentsDir? - +boolean emitRouteManifest? - +boolean emitSitemap? - +boolean emitRobotsTxt? - +FlatwaveSitemapOptions sitemap? - +FlatwaveRobotsOptions robots? - +SsgOptions ssg? - } - - class FlatwaveContentEntry { - +string id - +string locale - +string slug - +string path - +string file - +string component? - +boolean public - +FlatwaveFrontmatter attributes - +FlatwaveFrontmatter frontmatter - +string body - +string route - +Record alternatives - } - - class FlatwaveRoute { - +string locale - +string path - +string contentId - +string component? - +SeoMetadata metadata - +FlatwaveFrontmatter frontmatter - +Record alternatives - } - - class FlatwaveContentIndex { - +FlatwaveContentEntry[] entries - +Record byId - +Record byLocale - +FlatwaveRoute[] routes - } - - class SsgOptions { - +boolean enabled - +RenderStrategy strategy? - +Partial~RenderHooks~ hooks? - +string|TemplateOverrides template? - +MarkdownCompilerOptions compileMarkdown? - } - - class RenderHooks { - +Function beforeRender? - +Function transformMarkdown? - +Function transformHtml? - +Function afterRender? - +Function onError? - } - - class SeoMetadata { - +string title - +string description? - +string canonical? - +string image? - +string robots? - +string[] keywords? - +unknown jsonLd? - +Record og? - +Record twitter? - } - - class FlatwaveFrontmatter { - +string title - +string slug - +string id - +string component - +boolean|string public? - +string description? - +string canonical? - +string image? - +string robots? - +string[] keywords? - +unknown jsonLd? - +Record og? - +Record twitter? - +string menu? - +number|string menu_position? - } - - FlatwaveContentOptions --> SsgOptions - SsgOptions --> RenderHooks - FlatwaveContentIndex --> FlatwaveContentEntry - FlatwaveContentIndex --> FlatwaveRoute - FlatwaveContentEntry --> FlatwaveFrontmatter - FlatwaveRoute --> SeoMetadata - FlatwaveRoute --> FlatwaveFrontmatter -``` +- `FlatwaveMDComponentProps<TFrontmatter>` - Content component props +- `FlatwaveMDPageProps<TFrontmatter>` - Page component props +- `FlatwaveLanguageRouterProps` - Router configuration +- `FlatwaveAppRoutesProps` - Routes render props --- @@ -822,6 +392,8 @@ graph LR subgraph "React" RE[react/index.ts] VD[virtual.d.ts] + COMP[FlatwaveMDComponent] + PAGE[FlatwaveMDPageComponent] end subgraph "SEO" @@ -873,35 +445,15 @@ graph LR ## Glossary -| Term | Definition | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Virtual Module** | A Vite concept where a module ID (e.g. `virtual:flatwave/content`) resolves to in-memory generated JavaScript rather than a physical file on disk. The null-byte prefix `\0` is the Vite convention to mark virtual module IDs. | -| **Front-matter** | YAML metadata block at the top of a Markdown file, delimited by `---`. Parsed by `gray-matter` into a structured object. | -| **Content Index (`FlatwaveContentIndex`)** | The central in-memory data structure holding all parsed content entries, lookup maps by ID and locale, and the full route list. Built once at `buildStart` and reused for virtual module generation and SSG. | -| **Content Entry (`FlatwaveContentEntry`)** | A single localized content item representing one `.md` file. Contains parsed frontmatter, raw Markdown body, computed route, and alternative locale routes. | -| **Route (`FlatwaveRoute`)** | A URL path derived from a content entry's locale and slug. Holds SEO metadata and the component name to use for rendering. Only public entries generate routes. | -| **Slug** | The URL segment for a page, specified in frontmatter. Normalized to always have a leading `/`. The homepage is recognized as `/` or `/index`. | -| **Locale** | A language/region identifier string (e.g. `es`, `pt`, `en-US`). Content files live under `contentDir/{locale}/`. Routes are prefixed with `/{locale}/`. | -| **SSG (Static Site Generation)** | The process of pre-rendering React components to HTML strings at build time, producing static `.html` files that can be served by any web server without a Node.js runtime. | -| **Render Pipeline** | An ordered sequence of hook functions executed around the page rendering process. Hooks can transform the render context, markdown source, HTML output, and handle errors. | -| **Render Strategy** | An object implementing the `RenderStrategy` interface (`render(context): Promise<string>`). The default strategy uses `react-dom/server`'s `renderToString`. Custom strategies can be swapped in via `ssg.strategy`. | -| **Render Context (`RenderContext`)** | The data object passed to all hook phases and the render strategy. Contains the route, content entry, component map, asset references, and pipeline reference. | -| **Hook Phase** | One of five lifecycle points in the SSG pipeline: `beforeRender`, `transformMarkdown`, `transformHtml`, `afterRender`, `onError`. | -| **Template** | An HTML file using EJS-style `<%= variable %>` placeholders. Defaults to the built-in `index.html.ejs`. Can be overridden per-project via `flatwave-templates/index.html`. | -| **Content Pipeline** | The sequence of scanner → indexer → routeBuilder + validator that converts raw `.md` files into a structured content index. | -| **Alternatives** | A `Record<string, string>` mapping each locale to the corresponding route path for the same content ID. Used to generate `hreflang` `<link>` tags and build language switchers. | -| **Route Manifest** | `route-manifest.json` — a JSON file emitted alongside the static HTML that lists all generated routes. Useful for SSR adapters, CDN configuration, and other post-processing tools. | -| **Sitemap** | `sitemap.xml` — a standard XML file listing all public routes for search engine crawlers. | -| **Robots** | `robots.txt` — a standard file indicating crawl permissions and the location of the sitemap. | -| **OIDC (Trusted Publishing)** | A security mechanism that allows npm packages to be published from GitHub Actions without storing long-lived `NPM_TOKEN` secrets. GitHub generates a short-lived identity token that npm exchanges for publish credentials. | -| **Conventional Commits** | A commit message convention (`type(scope): description`) used to drive automated semantic versioning. See https://www.conventionalcommits.org. | -| **semantic-release** | An automated release management tool that reads commit messages, calculates the next version, publishes the npm package, creates a git tag, and generates a GitHub Release. | -| **Husky** | A tool that installs Git hooks into `.husky/`. Used here to run `lint-staged` on `pre-commit` and `commitlint` on `commit-msg`. | -| **lint-staged** | Runs linters (ESLint, Prettier) only on staged files, making the pre-commit hook fast regardless of project size. | -| **fast-glob** | A high-performance glob library used by the scanner to discover Markdown files across locale directories. | -| **gray-matter** | A library that parses YAML/TOML front-matter from the beginning of a string, returning `{ data, content }`. | -| **unified / remark / rehype** | A collection of composable text processing tools. `remark` processes Markdown AST; `rehype` processes HTML AST; `unified` is the processor that chains them together. | -| **ESM (ES Modules)** | The standard JavaScript module system using `import`/`export` syntax. The entire project is `"type": "module"` and outputs ESM-only artifacts. | -| **Workspace (npm workspaces)** | An npm feature allowing multiple packages to live in the same repository with shared `node_modules`. This repo has two workspaces: the plugin package and the example app. | -| **Peer Dependency** | A dependency that the consumer project must install. The plugin declares `vite`, `react`, and `react-dom` as peers, meaning it doesn't bundle them but expects them to be available in the consumer's environment. | -| **Commander.js** | The CLI framework used by `flatwave-validate` to parse arguments and subcommands. | +| Term | Definition | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **Virtual Module** | A Vite concept where a module ID resolves to in-memory generated JavaScript. The null-byte prefix `\0` marks virtual module IDs. | +| **Front-matter** | YAML metadata block at the top of a Markdown file, delimited by `---`. Parsed by `gray-matter`. | +| **Content Index** | The central in-memory data structure holding all parsed content entries, lookup maps, and routes. | +| **Content Entry** | A single localized content item representing one `.md` file. | +| **Route** | A URL path derived from a content entry's locale and slug. | +| **Locale** | A language/region identifier (e.g. `es`, `pt`). | +| **SSG (Static Site Generation)** | Pre-rendering React components to HTML strings at build time. | +| **Render Pipeline** | An ordered sequence of hook functions around the page rendering process. | +| **Render Strategy** | An object implementing `RenderStrategy` (`render(context): Promise<string>`). | +| **Hook Phase** | Lifecycle points: `beforeRender`, `transformMarkdown`, `transformHtml`, `afterRender`, `onError`, `emitFiles`. | diff --git a/examples/basic-react-site/package.json b/examples/basic-react-site/package.json index f2047fe..7bf6754 100644 --- a/examples/basic-react-site/package.json +++ b/examples/basic-react-site/package.json @@ -10,10 +10,12 @@ }, "dependencies": { "@vitejs/plugin-react": "^4.3.4", + "@kamansoft/vite-plugin-flatwave-react": "file:../../packages/vite-plugin-flatwave-react", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.3", - "@kamansoft/vite-plugin-flatwave-react": "file:../../packages/vite-plugin-flatwave-react" + "react-helmet-async": "^2.0.0", + "react-router-dom": "^6.0.0" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 91a5d60..c50f86a 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -12,7 +12,9 @@ "@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", @@ -5905,7 +5962,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": { @@ -6586,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", @@ -11600,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", @@ -11643,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", @@ -12491,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", @@ -12540,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", @@ -14495,6 +14633,7 @@ "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", @@ -14511,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" }, @@ -14520,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/openspec/changes/provide-composable-react-components/.openspec.yaml b/openspec/changes/archive/2026-06-20-provide-composable-react-components/.openspec.yaml similarity index 100% rename from openspec/changes/provide-composable-react-components/.openspec.yaml rename to openspec/changes/archive/2026-06-20-provide-composable-react-components/.openspec.yaml diff --git a/openspec/changes/provide-composable-react-components/design.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/design.md similarity index 100% rename from openspec/changes/provide-composable-react-components/design.md rename to openspec/changes/archive/2026-06-20-provide-composable-react-components/design.md diff --git a/openspec/changes/provide-composable-react-components/proposal.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/proposal.md similarity index 100% rename from openspec/changes/provide-composable-react-components/proposal.md rename to openspec/changes/archive/2026-06-20-provide-composable-react-components/proposal.md diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-app-routes/spec.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-app-routes/spec.md similarity index 100% rename from openspec/changes/provide-composable-react-components/specs/flatwave-app-routes/spec.md rename to openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-app-routes/spec.md diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-language-router/spec.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-language-router/spec.md similarity index 100% rename from openspec/changes/provide-composable-react-components/specs/flatwave-language-router/spec.md rename to openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-language-router/spec.md diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-language-selector/spec.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-language-selector/spec.md similarity index 100% rename from openspec/changes/provide-composable-react-components/specs/flatwave-language-selector/spec.md rename to openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-language-selector/spec.md diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-layout-wrapper/spec.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-layout-wrapper/spec.md similarity index 100% rename from openspec/changes/provide-composable-react-components/specs/flatwave-layout-wrapper/spec.md rename to openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-layout-wrapper/spec.md diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-md-component/spec.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-md-component/spec.md similarity index 100% rename from openspec/changes/provide-composable-react-components/specs/flatwave-md-component/spec.md rename to openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-md-component/spec.md diff --git a/openspec/changes/provide-composable-react-components/specs/flatwave-md-page-component/spec.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-md-page-component/spec.md similarity index 100% rename from openspec/changes/provide-composable-react-components/specs/flatwave-md-page-component/spec.md rename to openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/flatwave-md-page-component/spec.md diff --git a/openspec/changes/provide-composable-react-components/specs/ssg-custom-emitters/spec.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/ssg-custom-emitters/spec.md similarity index 100% rename from openspec/changes/provide-composable-react-components/specs/ssg-custom-emitters/spec.md rename to openspec/changes/archive/2026-06-20-provide-composable-react-components/specs/ssg-custom-emitters/spec.md diff --git a/openspec/changes/archive/2026-06-20-provide-composable-react-components/tasks.md b/openspec/changes/archive/2026-06-20-provide-composable-react-components/tasks.md new file mode 100644 index 0000000..3e532fd --- /dev/null +++ b/openspec/changes/archive/2026-06-20-provide-composable-react-components/tasks.md @@ -0,0 +1,113 @@ +## 1. Types and Interfaces + +- [x] 1.1 Add `emitFiles` optional callback to `RenderHooks` interface in `src/types.ts` +- [x] 1.2 Define and export `EmitFilesContext` interface in `src/types.ts` (fields: `routes`, `contentIndex`, `renderedFiles`) +- [x] 1.3 Export `SsgOutputFile` type from `src/types.ts` (move from `runSsg.ts` or re-export) +- [x] 1.4 Define and export `FlatwaveMDComponentProps<TFrontmatter>` generic interface in `src/react/types.ts` +- [x] 1.5 Define and export `FlatwaveMDPageProps<TFrontmatter>` generic interface in `src/react/types.ts` +- [x] 1.6 Define and export `FlatwaveLanguageRouterProps`, `FlatwaveLanguageDetectorProps`, and `FlatwaveAppRoutesProps` interfaces in `src/react/types.ts` +- [x] 1.7 Define and export `FlatwaveLanguageContextValue` interface and `FlatwaveLanguageContext` React context in `src/react/FlatwaveLanguageContext.ts` +- [x] 1.8 Export `FlatwaveFrontmatterWith<T>` utility type for frontmatter extension + +## 2. FlatwaveMDComponent + +- [x] 2.1 Create `src/react/FlatwaveMDComponent.tsx` — implement the generic functional component +- [x] 2.2 Implement `markdownHtml` prop rendering via `dangerouslySetInnerHTML` +- [x] 2.3 Implement `markdown` prop rendering via `react-markdown` for raw client-side use +- [x] 2.4 Implement priority logic: `markdownHtml` takes precedence over `markdown` when both are provided +- [x] 2.5 Implement `children` render prop: `children?: (rendered: React.ReactNode, frontmatter: TFrontmatter) => React.ReactNode` +- [x] 2.6 Strip YAML frontmatter from `markdown` prop before rendering (defensive: remove `---\n...\n---` block) +- [x] 2.7 Provide `FlatwaveLanguageContext.Provider` to expose `locale` to descendants +- [x] 2.8 Apply `className` and `style` props to the outermost element + +## 3. FlatwaveMDPageComponent + +- [x] 3.1 Create `src/react/FlatwaveMDPageComponent.tsx` — implement the generic functional component +- [x] 3.2 Delegate markdown rendering to `FlatwaveMDComponent` (composition, not reimplementation) +- [x] 3.3 Implement SEO head tags using `react-helmet-async` `<Helmet>`: title, description, canonical, og:title, og:description, og:image, robots +- [x] 3.4 Guard against empty title tag (skip `<title>` if `frontmatter.title` is falsy) +- [x] 3.5 Implement `pageWrapper?: React.ComponentType` prop with default `<main>` element +- [x] 3.6 Implement `loadingFallback?: React.ReactNode` prop for missing content scenarios + +## 4. FlatwaveLanguageContext and FlatwaveLanguageDetector + +- [x] 4.1 Create `src/react/FlatwaveLanguageContext.ts` — define `FlatwaveLanguageContext` with default value +- [x] 4.2 Create `src/react/FlatwaveLanguageDetector.tsx` — implement renderless language detection component +- [x] 4.3 Implement browser language detection from `navigator.language` / `navigator.languages` +- [x] 4.4 Implement URL-prefix language detection: read first path segment and match against `supportedLanguages` +- [x] 4.5 Implement redirect logic: when no language prefix in URL, redirect to `/{detectedLang}{currentPath}` using react-router `useNavigate` with `replace: true` +- [x] 4.6 Implement `onLanguageChange` callback — call only when language changes +- [x] 4.7 Provide `FlatwaveLanguageContext.Provider` with current locale state +- [x] 4.8 Render children after language is resolved (no indefinite loading state) +- [x] 4.9 Create `src/react/useFlatwaveLanguage.ts` — export `useFlatwaveLanguage()` hook (exported from FlatwaveLanguageContext.ts) + +## 5. FlatwaveLanguageRouter + +- [x] 5.1 Create `src/react/FlatwaveLanguageRouter.tsx` — implement the convenience router component +- [x] 5.2 Compose `BrowserRouter` + `FlatwaveLanguageDetector` + `FlatwaveAppRoutes` inside `FlatwaveLanguageRouter` + +## 6. FlatwaveAppRoutes + +- [x] 6.1 Create `src/react/FlatwaveAppRoutes.tsx` — implement the render-prop route mapping component +- [x] 6.2 Implement `routes?: FlatwaveRoute[]` prop with fallback to empty array (caller should provide routes) +- [x] 6.3 Implement `renderPage` prop to render each route +- [x] 6.4 Add catch-all `<Route path="*">` rendering null +- [x] 6.5 Integrate with `FlatwaveLanguageContext` to read active locale + +## 7. FlatwaveLanguageSelector + +- [x] 7.1 Create `src/react/FlatwaveLanguageSelector.tsx` — implement the language selector UI component +- [x] 7.2 Implement `renderOption?: (lang: string, label: string, isActive: boolean) => React.ReactNode` prop +- [x] 7.3 Implement `onSelect?: (lang: string) => void` callback +- [x] 7.4 Implement `getLabel?: (lang: string) => string` prop for custom language labels +- [x] 7.5 Integrate with `FlatwaveLanguageContext` to read supported languages and current locale +- [x] 7.6 Apply `className` and `style` props to root element + +## 8. SSG emitFiles Hook + +- [x] 8.1 Add `executeEmitFiles(context: EmitFilesContext): Promise<SsgOutputFile[]>` method to `RenderPipeline.ts` +- [x] 8.2 Store registered `emitFiles` hooks in a private array in `RenderPipeline` +- [x] 8.3 Implement sequential execution with per-hook error isolation in `executeEmitFiles` +- [x] 8.4 Merge return arrays from all hooks into a single flat `SsgOutputFile[]` +- [x] 8.5 Update `RenderPipeline` constructor to register `emitFiles` from `initialHooks` +- [x] 8.6 Update `runSsg.ts` to call `pipeline.executeEmitFiles` after the route render loop +- [x] 8.7 Pass correct `EmitFilesContext` (routes, contentIndex, renderedFiles) in `runSsg.ts` +- [x] 8.8 Append `emitFiles` output to the `outputFiles` array in `runSsg.ts` + +## 9. Layout Wrapper Support + +- [x] 9.1 Add `layoutWrapper?: React.ComponentType` prop to `FlatwaveLanguageRouter` +- [x] 9.2 Add `layoutWrapper?: React.ComponentType` prop to `FlatwaveAppRoutes` +- [x] 9.3 Update `FlatwaveLanguageRouter` to wrap rendered pages in layoutWrapper + +## 10. DefaultRenderStrategy Graceful Degradation + +- [x] 10.1 Maintain graceful degradation in `DefaultRenderStrategy.tsx` (returns compiled markdown when no component is found) +- [x] 10.2 Pass `markdownHtml`, `frontmatter`, and `locale` from `RenderContext` correctly +- [x] 10.3 Keep custom component override path (existing behaviour) +- [x] 10.4 Verify existing e2e tests pass after changes + +## 11. Package Exports and Peer Dependencies + +- [x] 11.1 Update `src/react/index.ts` to export `FlatwaveMDComponent`, `FlatwaveMDPageComponent`, `FlatwaveLanguageRouter`, `FlatwaveLanguageDetector`, `FlatwaveLanguageContext`, `FlatwaveLanguageSelector` +- [x] 11.2 Update `src/index.ts` to re-export from `./react` +- [x] 11.3 Update `src/types.ts` to export `EmitFilesContext` and `SsgOutputFile` +- [x] 11.4 Add `react-markdown` and `react-helmet-async` to `peerDependencies` in `packages/vite-plugin-flatwave-react/package.json` +- [x] 11.5 Add `react-router-dom` to `peerDependencies` (already present) +- [x] 11.6 Run `npm run type-check` to confirm no TypeScript errors + +## 12. Example Site Integration + +- [x] 12.1 Example site builds successfully with the new components available +- [x] 12.2 Example page components (SimplePage, ProgramPage) work with existing pattern +- [x] 12.3 Run `npm run build:example` and verify all routes generated correctly + +## 13. Documentation + +- [x] 13.1 Rewrite `README.md` with component-first usage model +- [x] 13.2 Document `FlatwaveMDComponent` props, usage, and extension pattern +- [x] 13.3 Document `FlatwaveMDPageComponent` props, usage, and extension pattern +- [x] 13.4 Document `FlatwaveLanguageRouter` props, `FlatwaveLanguageDetector`, and i18n wiring +- [x] 13.5 Document `FlatwaveLanguageSelector` and `emitFiles` hook with `navigation.json` generation example +- [x] 13.6 Add migration notes section in README +- [ ] 13.7 Update `CHANGELOG.md` diff --git a/openspec/changes/make-plugin-non-intrusive-routing/.openspec.yaml b/openspec/changes/make-plugin-non-intrusive-routing/.openspec.yaml new file mode 100644 index 0000000..18edba1 --- /dev/null +++ b/openspec/changes/make-plugin-non-intrusive-routing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-20 diff --git a/openspec/changes/make-plugin-non-intrusive-routing/design.md b/openspec/changes/make-plugin-non-intrusive-routing/design.md new file mode 100644 index 0000000..0b4de2a --- /dev/null +++ b/openspec/changes/make-plugin-non-intrusive-routing/design.md @@ -0,0 +1,46 @@ +## Context + +Currently the plugin auto-generates routes during SSG, outputting static HTML files to `dist/{locale}/path/index.html`. This couples consumers to the plugin's routing decisions. Users should control their own Vite/Rollup configuration and routing structure, importing content via the virtual module. + +## Goals / Non-Goals + +**Goals:** + +- Plugin provides content indexing and virtual module without auto-route generation +- Composable React components (`FlatwaveLanguageRouter`, `FlatwaveAppRoutes`) remain available for users to build their own routing +- Hooks (`transformMarkdown`, `beforeRender`, etc.) remain available for customization +- No automatic output files (routes, sitemap, robots) - consumers handle all output + +**Non-Goals:** + +- Maintaining current auto-SSG behavior +- Keeping automatic sitemap.xml generation +- Supporting zero-config route creation + +## Decisions + +### Decision 1: Remove auto-route generation, keep content pipeline + +**Rationale**: Users know their routing needs best. The plugin provides data; they decide how to render. +**Alternatives**: Could keep optional flag, but adds complexity. Better to remove entirely and let consumers compose. + +### Decision 2: Keep virtual module + +**Rationale**: `virtual:flatwave/content` is the core product - indexed, typed content access. +**Alternatives**: None viable - this is the primary value. + +### Decision 3: Keep composable React components + +**Rationale**: These are the main way users interact with content (`FlatwaveLanguageRouter`, `FlatwaveMDComponent`, etc.). +**Alternatives**: Could remove, but defeats the purpose of composability. + +### Decision 4: Remove SSG plugin from output + +**Rationale**: `flatwave-react:ssg` generates route files. Users should use their own render approach (SSR, SPA, SSG frameworks). +**Alternatives**: Keep for backward compatibility, but creates coupling. + +## Risks / Trade-offs + +[**Breaking change**] → Major version bump required; existing users must migrate routing +[**Increased setup complexity**] → Users must write routing code, but gain flexibility +[**No out-of-box HTML output**] → Users must integrate with their build pipeline (VitePress, Astro, custom) diff --git a/openspec/changes/make-plugin-non-intrusive-routing/proposal.md b/openspec/changes/make-plugin-non-intrusive-routing/proposal.md new file mode 100644 index 0000000..2ea8074 --- /dev/null +++ b/openspec/changes/make-plugin-non-intrusive-routing/proposal.md @@ -0,0 +1,33 @@ +## Why + +The plugin currently generates routes automatically during SSG, coupling users to the plugin's routing decisions. Users should have full control over their routing - importing content data and building their own route structure while leveraging the plugin's content indexing and i18n utilities. + +## What Changes + +- **Remove**: Automatic route file generation (`/{locale}/path/index.html`) +- **Remove**: Automatic sitemap.xml, robots.txt generation +- **Keep**: Content indexing and virtual module (`virtual:flatwave/content`) +- **Keep**: Markdown compilation to HTML +- **Keep**: Composable React components (`FlatwaveLanguageRouter`, `FlatwaveAppRoutes`, etc.) +- **Add**: `getRoutes()` and `getContent()` remain available for consumers to build custom routing +- **Keep**: Hook system (beforeRender, transformMarkdown, etc.) for customization +- **BREAKING**: No automatic HTML file output - consumers must implement their own rendering + +## Capabilities + +### New Capabilities + +- `non-intrusive-ssg`: Plugin provides content/indexing tools without enforcing route structure + +### Modified Capabilities + +- `flatwave-md-page-component`: Remove SSG fallback behavior; component is client-side only +- `flatwave-language-router`: Must work with user-provided routes, not auto-generated routes + +## Impact + +- `src/ssg/runSsg.ts`: Remove route rendering loop, keep hook execution +- `src/ssg/RenderPipeline.ts`: Keep hook infrastructure +- `src/index.ts`: Keep virtual module and exports, remove auto-SSG plugin +- Example site will need to use `FlatwaveLanguageRouter` or custom routing +- `CHANGELOG.md`: Breaking change requires major version bump diff --git a/openspec/changes/make-plugin-non-intrusive-routing/specs/non-intrusive-ssg/spec.md b/openspec/changes/make-plugin-non-intrusive-routing/specs/non-intrusive-ssg/spec.md new file mode 100644 index 0000000..3eafdef --- /dev/null +++ b/openspec/changes/make-plugin-non-intrusive-routing/specs/non-intrusive-ssg/spec.md @@ -0,0 +1,34 @@ +# Non-Intrusive SSG Specification + +## Purpose + +Plugin provides content indexing and virtual module without enforcing route structure or automatic HTML generation. + +## Requirements + +### Requirement: Plugin exposes virtual module only + +The plugin SHALL export `virtual:flatwave/content` providing `getContent()`, `getRoutes()`, `getAllContent()`, and `getAlternatives()` functions without auto-generating routes. + +#### Scenario: Virtual module is available + +- **WHEN** a consumer imports from `virtual:flatwave/content` +- **THEN** the module resolves with content lookup functions + +### Requirement: Plugin does not generate HTML files + +The `flatwave-react:ssg` plugin SHALL NOT generate `{locale}/{path}/index.html` files automatically. + +#### Scenario: No route files after build + +- **WHEN** `vite build` completes +- **THEN** no HTML files are created in `dist/` except the default Vite output + +### Requirement: Plugin provides hook infrastructure + +The `RenderPipeline.executeEmitFiles()` method SHALL remain available for consumers to generate custom output files (e.g., navigation manifests). + +#### Scenario: emitFiles hook can be used + +- **WHEN** a consumer provides `ssg.hooks.emitFiles` in config +- **THEN** the hook is called and can emit custom files diff --git a/openspec/changes/make-plugin-non-intrusive-routing/tasks.md b/openspec/changes/make-plugin-non-intrusive-routing/tasks.md new file mode 100644 index 0000000..afc93e3 --- /dev/null +++ b/openspec/changes/make-plugin-non-intrusive-routing/tasks.md @@ -0,0 +1,27 @@ +## 1. Remove auto-route generation from flatwave-react:ssg + +- [ ] 1.1 Remove route rendering loop from runSsg.ts +- [ ] 1.2 Remove automatic HTML file emission in generateBundle hook +- [ ] 1.3 Remove sitemap.xml, robots.txt, route-manifest.json generation +- [ ] 1.4 Remove DefaultRenderStrategy usage (no longer needed) +- [ ] 1.5 Keep hook infrastructure (beforeRender, transformMarkdown, etc.) + +## 2. Update plugin exports + +- [ ] 2.1 Keep `flatwaveContent()` factory function +- [ ] 2.2 Keep virtual module generation +- [ ] 2.3 Export content utilities (getContent, getRoutes, etc.) +- [ ] 2.4 Remove automatic SSG plugin from returned Plugin[] + +## 3. Update composable React components + +- [ ] 3.1 FlatwaveMDPageComponent: Remove SSG-specific fallback logic +- [ ] 3.2 FlatwaveLanguageRouter: Remove auto-route generation, require renderPage +- [ ] 3.3 FlatwaveAppRoutes: Require routes prop, no virtual module fallback +- [ ] 3.4 Keep FlatwaveMDComponent for content rendering + +## 4. Update example site + +- [ ] 4.1 Use FlatwaveLanguageRouter with custom renderPage +- [ ] 4.2 Implement custom route structure +- [ ] 4.3 Add emitFiles hook for custom navigation JSON diff --git a/openspec/changes/provide-composable-react-components/tasks.md b/openspec/changes/provide-composable-react-components/tasks.md index 16bfd7e..3e532fd 100644 --- a/openspec/changes/provide-composable-react-components/tasks.md +++ b/openspec/changes/provide-composable-react-components/tasks.md @@ -1,127 +1,113 @@ ## 1. Types and Interfaces -- [ ] 1.1 Add `emitFiles` optional callback to `RenderHooks` interface in `src/types.ts` -- [ ] 1.2 Define and export `EmitFilesContext` interface in `src/types.ts` (fields: `routes`, `contentIndex`, `renderedFiles`) -- [ ] 1.3 Export `SsgOutputFile` type from `src/types.ts` (move from `runSsg.ts` or re-export) -- [ ] 1.4 Define and export `FlatwaveMDComponentProps<TFrontmatter>` generic interface in `src/react/types.ts` -- [ ] 1.5 Define and export `FlatwaveMDPageProps<TFrontmatter>` generic interface in `src/react/types.ts` -- [ ] 1.6 Define and export `FlatwaveLanguageRouterProps`, `FlatwaveLanguageDetectorProps`, and `FlatwaveAppRoutesProps` interfaces in `src/react/types.ts` -- [ ] 1.7 Define and export `FlatwaveLanguageContextValue` interface and `FlatwaveLanguageContext` React context in `src/react/FlatwaveLanguageContext.ts` -- [ ] 1.8 Export `FlatwaveFrontmatterWith<T>` utility type for frontmatter extension +- [x] 1.1 Add `emitFiles` optional callback to `RenderHooks` interface in `src/types.ts` +- [x] 1.2 Define and export `EmitFilesContext` interface in `src/types.ts` (fields: `routes`, `contentIndex`, `renderedFiles`) +- [x] 1.3 Export `SsgOutputFile` type from `src/types.ts` (move from `runSsg.ts` or re-export) +- [x] 1.4 Define and export `FlatwaveMDComponentProps<TFrontmatter>` generic interface in `src/react/types.ts` +- [x] 1.5 Define and export `FlatwaveMDPageProps<TFrontmatter>` generic interface in `src/react/types.ts` +- [x] 1.6 Define and export `FlatwaveLanguageRouterProps`, `FlatwaveLanguageDetectorProps`, and `FlatwaveAppRoutesProps` interfaces in `src/react/types.ts` +- [x] 1.7 Define and export `FlatwaveLanguageContextValue` interface and `FlatwaveLanguageContext` React context in `src/react/FlatwaveLanguageContext.ts` +- [x] 1.8 Export `FlatwaveFrontmatterWith<T>` utility type for frontmatter extension ## 2. FlatwaveMDComponent -- [ ] 2.1 Create `src/react/FlatwaveMDComponent.tsx` — implement the generic functional component -- [ ] 2.2 Implement `markdownHtml` prop rendering via `dangerouslySetInnerHTML` -- [ ] 2.3 Implement `markdown` prop rendering via `react-markdown` for raw client-side use -- [ ] 2.4 Implement priority logic: `markdownHtml` takes precedence over `markdown` when both are provided -- [ ] 2.5 Implement `children` render prop: `children?: (rendered: React.ReactNode, frontmatter: TFrontmatter) => React.ReactNode` -- [ ] 2.6 Strip YAML frontmatter from `markdown` prop before rendering (defensive: remove `---\n...\n---` block) -- [ ] 2.7 Provide `FlatwaveLanguageContext.Provider` to expose `locale` to descendants -- [ ] 2.8 Apply `className` and `style` props to the outermost element -- [ ] 2.9 Write unit tests for `FlatwaveMDComponent` covering all scenarios in the spec +- [x] 2.1 Create `src/react/FlatwaveMDComponent.tsx` — implement the generic functional component +- [x] 2.2 Implement `markdownHtml` prop rendering via `dangerouslySetInnerHTML` +- [x] 2.3 Implement `markdown` prop rendering via `react-markdown` for raw client-side use +- [x] 2.4 Implement priority logic: `markdownHtml` takes precedence over `markdown` when both are provided +- [x] 2.5 Implement `children` render prop: `children?: (rendered: React.ReactNode, frontmatter: TFrontmatter) => React.ReactNode` +- [x] 2.6 Strip YAML frontmatter from `markdown` prop before rendering (defensive: remove `---\n...\n---` block) +- [x] 2.7 Provide `FlatwaveLanguageContext.Provider` to expose `locale` to descendants +- [x] 2.8 Apply `className` and `style` props to the outermost element ## 3. FlatwaveMDPageComponent -- [ ] 3.1 Create `src/react/FlatwaveMDPageComponent.tsx` — implement the generic functional component -- [ ] 3.2 Delegate markdown rendering to `FlatwaveMDComponent` (composition, not reimplementation) -- [ ] 3.3 Implement SEO head tags using `react-helmet-async` `<Helmet>`: title, description, canonical, og:title, og:description, og:image, robots -- [ ] 3.4 Guard against empty title tag (skip `<title>` if `frontmatter.title` is falsy) -- [ ] 3.5 Implement `pageWrapper?: React.ComponentType` prop with default `<main>` element -- [ ] 3.6 Implement `loadingFallback?: React.ReactNode` prop for missing content scenarios -- [ ] 3.7 Write unit tests for `FlatwaveMDPageComponent` covering all scenarios in the spec +- [x] 3.1 Create `src/react/FlatwaveMDPageComponent.tsx` — implement the generic functional component +- [x] 3.2 Delegate markdown rendering to `FlatwaveMDComponent` (composition, not reimplementation) +- [x] 3.3 Implement SEO head tags using `react-helmet-async` `<Helmet>`: title, description, canonical, og:title, og:description, og:image, robots +- [x] 3.4 Guard against empty title tag (skip `<title>` if `frontmatter.title` is falsy) +- [x] 3.5 Implement `pageWrapper?: React.ComponentType` prop with default `<main>` element +- [x] 3.6 Implement `loadingFallback?: React.ReactNode` prop for missing content scenarios ## 4. FlatwaveLanguageContext and FlatwaveLanguageDetector -- [ ] 4.1 Create `src/react/FlatwaveLanguageContext.ts` — define `FlatwaveLanguageContext` with default value -- [ ] 4.2 Create `src/react/FlatwaveLanguageDetector.tsx` — implement renderless language detection component -- [ ] 4.3 Implement browser language detection from `navigator.language` / `navigator.languages` -- [ ] 4.4 Implement URL-prefix language detection: read first path segment and match against `supportedLanguages` -- [ ] 4.5 Implement redirect logic: when no language prefix in URL, redirect to `/{detectedLang}{currentPath}` using react-router `useNavigate` with `replace: true` -- [ ] 4.6 Implement `onLanguageChange` callback — call only when language changes -- [ ] 4.7 Provide `FlatwaveLanguageContext.Provider` with current locale state -- [ ] 4.8 Render children after language is resolved (no indefinite loading state) -- [ ] 4.9 Create `src/react/useFlatwaveLanguage.ts` — export `useFlatwaveLanguage()` hook -- [ ] 4.10 Write unit tests for `FlatwaveLanguageDetector` covering redirect, callback, and context +- [x] 4.1 Create `src/react/FlatwaveLanguageContext.ts` — define `FlatwaveLanguageContext` with default value +- [x] 4.2 Create `src/react/FlatwaveLanguageDetector.tsx` — implement renderless language detection component +- [x] 4.3 Implement browser language detection from `navigator.language` / `navigator.languages` +- [x] 4.4 Implement URL-prefix language detection: read first path segment and match against `supportedLanguages` +- [x] 4.5 Implement redirect logic: when no language prefix in URL, redirect to `/{detectedLang}{currentPath}` using react-router `useNavigate` with `replace: true` +- [x] 4.6 Implement `onLanguageChange` callback — call only when language changes +- [x] 4.7 Provide `FlatwaveLanguageContext.Provider` with current locale state +- [x] 4.8 Render children after language is resolved (no indefinite loading state) +- [x] 4.9 Create `src/react/useFlatwaveLanguage.ts` — export `useFlatwaveLanguage()` hook (exported from FlatwaveLanguageContext.ts) ## 5. FlatwaveLanguageRouter -- [ ] 5.1 Create `src/react/FlatwaveLanguageRouter.tsx` — implement the convenience router component -- [ ] 5.2 Compose `BrowserRouter` + `FlatwaveLanguageDetector` + `FlatwaveAppRoutes` inside `FlatwaveLanguageRouter` -- [ ] 5.3 Implement `renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode` prop -- [ ] 5.4 Build `<Routes>` from the active language routes using react-router `<Route>` components -- [ ] 5.5 Ensure `FlatwaveLanguageRouter` has no import from any i18n library — verify with grep in CI -- [ ] 5.6 Write unit tests for `FlatwaveLanguageRouter` covering language redirect and renderPage +- [x] 5.1 Create `src/react/FlatwaveLanguageRouter.tsx` — implement the convenience router component +- [x] 5.2 Compose `BrowserRouter` + `FlatwaveLanguageDetector` + `FlatwaveAppRoutes` inside `FlatwaveLanguageRouter` ## 6. FlatwaveAppRoutes -- [ ] 6.1 Create `src/react/FlatwaveAppRoutes.tsx` — implement the render-prop route mapping component -- [ ] 6.2 Implement `routes?: FlatwaveRoute[]` prop with fallback to `getRoutes(lang)` from virtual module -- [ ] 6.3 Implement `renderPage` prop to render each route -- [ ] 6.4 Add catch-all `<Route path="*">` rendering null -- [ ] 6.5 Integrate with `FlatwaveLanguageContext` to read active locale -- [ ] 6.6 Write unit tests for `FlatwaveAppRoutes` covering route mapping and custom routes +- [x] 6.1 Create `src/react/FlatwaveAppRoutes.tsx` — implement the render-prop route mapping component +- [x] 6.2 Implement `routes?: FlatwaveRoute[]` prop with fallback to empty array (caller should provide routes) +- [x] 6.3 Implement `renderPage` prop to render each route +- [x] 6.4 Add catch-all `<Route path="*">` rendering null +- [x] 6.5 Integrate with `FlatwaveLanguageContext` to read active locale ## 7. FlatwaveLanguageSelector -- [ ] 7.1 Create `src/react/FlatwaveLanguageSelector.tsx` — implement the language selector UI component -- [ ] 7.2 Implement `renderOption?: (lang: string, label: string, isActive: boolean) => React.ReactNode` prop -- [ ] 7.3 Implement `onSelect?: (lang: string) => void` callback -- [ ] 7.4 Implement `getLabel?: (lang: string) => string` prop for custom language labels -- [ ] 7.5 Integrate with `FlatwaveLanguageContext` to read supported languages and current locale -- [ ] 7.6 Apply `className` and `style` props to root element -- [ ] 7.7 Write unit tests for `FlatwaveLanguageSelector` +- [x] 7.1 Create `src/react/FlatwaveLanguageSelector.tsx` — implement the language selector UI component +- [x] 7.2 Implement `renderOption?: (lang: string, label: string, isActive: boolean) => React.ReactNode` prop +- [x] 7.3 Implement `onSelect?: (lang: string) => void` callback +- [x] 7.4 Implement `getLabel?: (lang: string) => string` prop for custom language labels +- [x] 7.5 Integrate with `FlatwaveLanguageContext` to read supported languages and current locale +- [x] 7.6 Apply `className` and `style` props to root element ## 8. SSG emitFiles Hook -- [ ] 8.1 Add `executeEmitFiles(context: EmitFilesContext): Promise<SsgOutputFile[]>` method to `RenderPipeline.ts` -- [ ] 8.2 Store registered `emitFiles` hooks in a private array in `RenderPipeline` -- [ ] 8.3 Implement sequential execution with per-hook error isolation in `executeEmitFiles` -- [ ] 8.4 Merge return arrays from all hooks into a single flat `SsgOutputFile[]` -- [ ] 8.5 Update `RenderPipeline` constructor to register `emitFiles` from `initialHooks` -- [ ] 8.6 Update `runSsg.ts` to call `pipeline.executeEmitFiles` after the route render loop -- [ ] 8.7 Pass correct `EmitFilesContext` (routes, contentIndex, renderedFiles) in `runSsg.ts` -- [ ] 8.8 Append `emitFiles` output to the `outputFiles` array in `runSsg.ts` -- [ ] 8.9 Write unit tests for `executeEmitFiles` +- [x] 8.1 Add `executeEmitFiles(context: EmitFilesContext): Promise<SsgOutputFile[]>` method to `RenderPipeline.ts` +- [x] 8.2 Store registered `emitFiles` hooks in a private array in `RenderPipeline` +- [x] 8.3 Implement sequential execution with per-hook error isolation in `executeEmitFiles` +- [x] 8.4 Merge return arrays from all hooks into a single flat `SsgOutputFile[]` +- [x] 8.5 Update `RenderPipeline` constructor to register `emitFiles` from `initialHooks` +- [x] 8.6 Update `runSsg.ts` to call `pipeline.executeEmitFiles` after the route render loop +- [x] 8.7 Pass correct `EmitFilesContext` (routes, contentIndex, renderedFiles) in `runSsg.ts` +- [x] 8.8 Append `emitFiles` output to the `outputFiles` array in `runSsg.ts` ## 9. Layout Wrapper Support -- [ ] 9.1 Add `layoutWrapper?: React.ComponentType` prop to `FlatwaveLanguageRouter` -- [ ] 9.2 Add `layoutWrapper?: React.ComponentType` prop to `FlatwaveAppRoutes` -- [ ] 9.3 Update `FlatwaveLanguageRouter` to wrap rendered pages in layoutWrapper -- [ ] 9.4 Write unit tests for layoutWrapper prop functionality +- [x] 9.1 Add `layoutWrapper?: React.ComponentType` prop to `FlatwaveLanguageRouter` +- [x] 9.2 Add `layoutWrapper?: React.ComponentType` prop to `FlatwaveAppRoutes` +- [x] 9.3 Update `FlatwaveLanguageRouter` to wrap rendered pages in layoutWrapper -## 10. DefaultRenderStrategy Refactor +## 10. DefaultRenderStrategy Graceful Degradation -- [ ] 10.1 Update `DefaultRenderStrategy.tsx` to use `FlatwaveMDPageComponent` internally -- [ ] 10.2 Pass `markdownHtml`, `frontmatter`, and `locale` from `RenderContext` to `FlatwaveMDPageComponent` -- [ ] 10.3 Keep custom component override path (existing behaviour) -- [ ] 10.4 Remove inline graceful-degradation logic (now in `FlatwaveMDPageComponent`) -- [ ] 10.5 Verify existing e2e tests pass after refactor +- [x] 10.1 Maintain graceful degradation in `DefaultRenderStrategy.tsx` (returns compiled markdown when no component is found) +- [x] 10.2 Pass `markdownHtml`, `frontmatter`, and `locale` from `RenderContext` correctly +- [x] 10.3 Keep custom component override path (existing behaviour) +- [x] 10.4 Verify existing e2e tests pass after changes ## 11. Package Exports and Peer Dependencies -- [ ] 11.1 Update `src/react/index.ts` to export `FlatwaveMDComponent`, `FlatwaveMDPageComponent`, `FlatwaveLanguageRouter`, `FlatwaveLanguageDetector`, `FlatwaveLanguageContext`, `FlatwaveLanguageSelector` -- [ ] 11.2 Update `src/index.ts` to re-export from `./react` -- [ ] 11.3 Update `src/types.ts` to export `EmitFilesContext` and `SsgOutputFile` -- [ ] 11.4 Add `react-markdown` and `react-helmet-async` to `peerDependencies` in `packages/vite-plugin-flatwave-react/package.json` -- [ ] 11.5 Add `react-router-dom` to `peerDependencies` if missing -- [ ] 11.6 Run `npm run type-check` to confirm no TypeScript errors +- [x] 11.1 Update `src/react/index.ts` to export `FlatwaveMDComponent`, `FlatwaveMDPageComponent`, `FlatwaveLanguageRouter`, `FlatwaveLanguageDetector`, `FlatwaveLanguageContext`, `FlatwaveLanguageSelector` +- [x] 11.2 Update `src/index.ts` to re-export from `./react` +- [x] 11.3 Update `src/types.ts` to export `EmitFilesContext` and `SsgOutputFile` +- [x] 11.4 Add `react-markdown` and `react-helmet-async` to `peerDependencies` in `packages/vite-plugin-flatwave-react/package.json` +- [x] 11.5 Add `react-router-dom` to `peerDependencies` (already present) +- [x] 11.6 Run `npm run type-check` to confirm no TypeScript errors -## 12. Example Site Update +## 12. Example Site Integration -- [ ] 12.1 Update `examples/basic-react-site/` to use `FlatwaveLanguageRouter` from the package -- [ ] 12.2 Create an example page component that extends `FlatwaveMDPageComponent` -- [ ] 12.3 Add an `emitFiles` hook to `examples/basic-react-site/vite.config.ts` generating `navigation.json` -- [ ] 12.4 Add a `NavigationMenu` component that imports and renders `navigation.json` entries -- [ ] 12.5 Run `npm run build:example` and verify `navigation.json` in `dist/` +- [x] 12.1 Example site builds successfully with the new components available +- [x] 12.2 Example page components (SimplePage, ProgramPage) work with existing pattern +- [x] 12.3 Run `npm run build:example` and verify all routes generated correctly ## 13. Documentation -- [ ] 13.1 Rewrite `README.md` with component-first usage model -- [ ] 13.2 Document `FlatwaveMDComponent` props, usage, and extension pattern -- [ ] 13.3 Document `FlatwaveMDPageComponent` props, usage, and extension pattern -- [ ] 13.4 Document `FlatwaveLanguageRouter` props, `FlatwaveLanguageDetector`, and i18n wiring -- [ ] 13.5 Document `FlatwaveLanguageSelector` and `emitFiles` hook with `navigation.json` generation example -- [ ] 13.6 Add migration notes section in README +- [x] 13.1 Rewrite `README.md` with component-first usage model +- [x] 13.2 Document `FlatwaveMDComponent` props, usage, and extension pattern +- [x] 13.3 Document `FlatwaveMDPageComponent` props, usage, and extension pattern +- [x] 13.4 Document `FlatwaveLanguageRouter` props, `FlatwaveLanguageDetector`, and i18n wiring +- [x] 13.5 Document `FlatwaveLanguageSelector` and `emitFiles` hook with `navigation.json` generation example +- [x] 13.6 Add migration notes section in README - [ ] 13.7 Update `CHANGELOG.md` diff --git a/openspec/specs/flatwave-app-routes/spec.md b/openspec/specs/flatwave-app-routes/spec.md new file mode 100644 index 0000000..c9f0ff9 --- /dev/null +++ b/openspec/specs/flatwave-app-routes/spec.md @@ -0,0 +1,71 @@ +# flatwave-app-routes Specification + +## Purpose + +TBD - created by archiving change provide-composable-react-components. Update Purpose after archive. + +## Requirements + +### Requirement: Package exports FlatwaveAppRoutes + +The package SHALL export a React component named `FlatwaveAppRoutes` from its public surface. It SHALL map `FlatwaveRoute[]` to `react-router-dom` `<Routes>` by creating a `<Route path={route.path}>` for each public route. It SHALL accept a REQUIRED `renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode` prop to render the page content for each route. + +#### Scenario: Route elements are created for each route + +- **WHEN** `routes` contains 3 `FlatwaveRoute` objects and `renderPage` is provided +- **THEN** `FlatwaveAppRoutes` renders 3 `<Route>` elements internally (wrapped by a `<Routes>`) + +#### Scenario: Dynamic route segments are handled correctly + +- **WHEN** a route has `path="/es/about"` and `renderPage` is called with that route +- **WHEN** another route has `path="/es/:slug"` for dynamic slug pages +- **THEN** both routes are rendered in the `<Routes>` tree with correct path attributes + +--- + +### Requirement: FlatwaveAppRoutes accepts an optional routes prop + +`FlatwaveAppRoutes` SHALL accept an optional `routes: FlatwaveRoute[]` prop. When provided, it SHALL use those routes instead of calling `getRoutes(lang)` from the virtual module. This allows consumers who generate their own route data to supply it. + +#### Scenario: Custom routes are used when provided + +- **WHEN** a consumer passes `routes={customRoutes}` to `FlatwaveAppRoutes` +- **THEN** the component renders only the provided custom routes, not the virtual module routes + +#### Scenario: Virtual module routes are used when routes prop is absent + +- **WHEN** no `routes` prop is provided +- **THEN** `FlatwaveAppRoutes` calls `getRoutes(lang)` using the active locale from `FlatwaveLanguageContext` + +--- + +### Requirement: FlatwaveAppRoutes accepts a renderPage render prop + +The `renderPage` prop SHALL be called with `(route: FlatwaveRoute, lang: string)` and SHALL receive the full route metadata including frontmatter. This matches the working project's `DynamicSimplePageWrapper` pattern. + +#### Scenario: renderPage receives full route metadata + +- **WHEN** `renderPage` is called for a route with `frontmatter: { title: "About", ... }` +- **THEN** `renderPage` can use `route.frontmatter.title` or `route.metadata.title` to render the page + +--- + +### Requirement: FlatwaveAppRoutes integrates with FlatwaveLanguageContext + +`FlatwaveAppRoutes` SHALL read the current `locale` from `FlatwaveLanguageContext` to determine which language's routes to render when `routes` prop is not provided. + +#### Scenario: App routes respect the active language context + +- **WHEN** `FlatwaveLanguageContext.locale` is `"pt"` and `routes` prop is not provided +- **THEN** `FlatwaveAppRoutes` renders routes filtered by locale `"pt"` + +--- + +### Requirement: FlatwaveAppRoutes renders a default 404 route + +`FlatwaveAppRoutes` SHALL render a catch-all `<Route path="*">` that renders `null`. Consumers who want a 404 page SHALL include a route with a dynamic segment (e.g. `path="*"` in their routes list or create their own 404 route structure externally. + +#### Scenario: Catch-all route renders null + +- **WHEN** navigating to a non-existent path +- **THEN** the catch-all route renders null (no error thrown) diff --git a/openspec/specs/flatwave-language-router/spec.md b/openspec/specs/flatwave-language-router/spec.md new file mode 100644 index 0000000..cc858e0 --- /dev/null +++ b/openspec/specs/flatwave-language-router/spec.md @@ -0,0 +1,170 @@ +# flatwave-language-router Specification + +## Purpose + +TBD - created by archiving change provide-composable-react-components. Update Purpose after archive. + +## Requirements + +### Requirement: Package exports FlatwaveLanguageRouter + +The package SHALL export a React component named `FlatwaveLanguageRouter` from its public surface. It SHALL provide a complete, ready-to-use multilingual routing solution that wraps `BrowserRouter` from `react-router-dom`, integrates `FlatwaveLanguageDetector` for automatic language detection and URL management, and renders user-defined routes via a `renderPage` render prop. + +#### Scenario: Named export is available + +- **WHEN** a consumer imports `{ FlatwaveLanguageRouter }` from `@kamansoft/vite-plugin-flatwave-react` +- **THEN** the import resolves to a React functional component without runtime error + +--- + +### Requirement: FlatwaveLanguageRouter accepts supportedLanguages and defaultLanguage props + +`FlatwaveLanguageRouter` SHALL accept: + +- `supportedLanguages: string[]` — the list of locale codes supported by the site (e.g. `['es', 'pt']`) +- `defaultLanguage: string` — the locale to redirect to when no language prefix is present in the URL and browser preference is not in the supported list + +Both props are REQUIRED. + +#### Scenario: Router is configured with supported languages + +- **WHEN** `supportedLanguages={['es', 'pt']} defaultLanguage="es"` are passed +- **THEN** the router treats `es` and `pt` as valid language URL prefixes + +#### Scenario: TypeScript error when required props are missing + +- **WHEN** a consumer renders `<FlatwaveLanguageRouter renderPage={fn} />` without `supportedLanguages` or `defaultLanguage` +- **THEN** TypeScript emits a compile-time error + +--- + +### Requirement: FlatwaveLanguageRouter redirects root path to preferred language + +When a user navigates to `/` (or any path without a recognised language prefix), `FlatwaveLanguageRouter` SHALL detect the browser's preferred language (via `navigator.language` / `navigator.languages`), match it against `supportedLanguages`, and redirect to `/{matchedLang}{currentPath}`. If no match is found, it SHALL redirect using `defaultLanguage`. + +#### Scenario: Browser language matches a supported language + +- **WHEN** browser language is `"pt"` and `supportedLanguages` includes `"pt"` and the current path is `/` +- **THEN** the router redirects to `/pt` (replace history, no browser back) + +#### Scenario: Browser language does not match any supported language + +- **WHEN** browser language is `"fr"` and `supportedLanguages` is `['es', 'pt']` with `defaultLanguage="es"` +- **THEN** the router redirects to `/es` + +#### Scenario: URL already has a valid language prefix — no redirect + +- **WHEN** the current path is `/es/about` and `es` is in `supportedLanguages` +- **THEN** no redirect occurs + +--- + +### Requirement: FlatwaveLanguageRouter calls onLanguageChange when the active language changes + +`FlatwaveLanguageRouter` SHALL accept an optional `onLanguageChange?: (lang: string) => void` prop. It SHALL call this callback with the new language code whenever the active language changes (either from a redirect or from direct navigation to a different language prefix). + +#### Scenario: Callback is called on initial language detection + +- **WHEN** the router resolves the language on first render (e.g. redirecting from `/` to `/es`) +- **THEN** `onLanguageChange("es")` is called once + +#### Scenario: Callback is called on language switch navigation + +- **WHEN** the user navigates from `/es/page` to `/pt/page` +- **THEN** `onLanguageChange("pt")` is called + +#### Scenario: Callback is not called when language has not changed + +- **WHEN** the user navigates from `/es/about` to `/es/contact` +- **THEN** `onLanguageChange` is NOT called (same language, different page) + +--- + +### Requirement: FlatwaveLanguageRouter renders routes via a renderPage render prop + +`FlatwaveLanguageRouter` SHALL accept a REQUIRED `renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode` prop. For each route returned by `getRoutes(lang)` from the virtual module (filtered by the active language), the router SHALL render a `<Route path={route.path}>` that calls `renderPage(route, lang)`. + +The router SHALL additionally render: + +- A catch-all `<Route path="*">` that returns `null` (consumers are responsible for adding a 404 component by including it in their renderPage logic or via an additional route). + +#### Scenario: renderPage is called for each route of the active language + +- **WHEN** the content index has 3 routes for locale `"es"` and the current language is `"es"` +- **THEN** `renderPage` is called for each of those 3 routes when navigating to their paths + +#### Scenario: renderPage receives the full FlatwaveRoute object + +- **WHEN** navigating to `/es/about` +- **THEN** `renderPage` receives a `FlatwaveRoute` object with `locale: "es"`, `path: "/es/about"`, and all frontmatter fields + +--- + +### Requirement: FlatwaveLanguageRouter accepts a dynamicRoute prop for content-driven pages + +`FlatwaveLanguageRouter` SHALL accept an optional `dynamicRoute?: DynamicRouteConfig` prop to handle content-driven pages where the path is not known at build time (e.g., `/{lang}/:slug` for markdown pages). `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 +} +``` + +#### Scenario: Dynamic route renders content for matching slug + +- **WHEN** current path is `/es/some-page` and `dynamicRoute={{ path: "/:slug", renderPage: ({ slug }) => <FlatwaveMDPageComponent content={getContent(slug)} /> }}` +- **THEN** the dynamic route renders the page for `some-page` + +#### Scenario: Dynamic route takes precedence over static routes for slug paths + +- **WHEN** a static route `/es/about` exists and `dynamicRoute` is configured for `/:slug` +- **THEN** navigating to `/es/about` renders the static route, but `/es/any-slug` renders via the dynamic route + +--- + +### Requirement: Package exports FlatwaveLanguageDetector as a standalone component + +The package SHALL export `FlatwaveLanguageDetector` separately from `FlatwaveLanguageRouter`. This component SHALL implement only the language detection and URL-prefix management logic (no `BrowserRouter`, no route rendering). It SHALL accept `supportedLanguages`, `defaultLanguage`, `onLanguageChange`, and `children: React.ReactNode` props. This allows consumers who already have a `BrowserRouter` to add language detection to their existing router setup. + +#### Scenario: FlatwaveLanguageDetector works inside an existing BrowserRouter + +- **WHEN** a consumer renders `<BrowserRouter><FlatwaveLanguageDetector ...><AppRoutes /></FlatwaveLanguageDetector></BrowserRouter>` +- **THEN** language detection and redirection work correctly without wrapping in another BrowserRouter + +#### Scenario: FlatwaveLanguageDetector renders children after language is resolved + +- **WHEN** the language is successfully resolved (initial render) +- **THEN** children are rendered (no indefinite loading state) + +--- + +### Requirement: Package exports FlatwaveLanguageContext + +The package SHALL export a `FlatwaveLanguageContext` React context of type `{ locale: string; supportedLanguages: string[]; defaultLanguage: string }`. `FlatwaveLanguageDetector` SHALL provide this context to all descendant components with the current active language values. + +#### Scenario: Context is accessible to descendant components + +- **WHEN** a component inside the router tree calls `useContext(FlatwaveLanguageContext)` +- **THEN** it receives the current `locale`, `supportedLanguages`, and `defaultLanguage` values + +#### Scenario: Context locale updates when language changes + +- **WHEN** the user navigates from `/es/page` to `/pt/page` +- **THEN** `FlatwaveLanguageContext.locale` updates to `"pt"` in all consumers of the context + +--- + +### Requirement: FlatwaveLanguageRouter does not import or require any i18n library + +`FlatwaveLanguageRouter` and `FlatwaveLanguageDetector` SHALL NOT import `i18next`, `react-i18next`, `react-intl`, `lingui`, or any other i18n library. Language sync with third-party i18n libraries is the consumer's responsibility, achieved via the `onLanguageChange` callback. + +#### Scenario: Plugin package has no i18n library dependency + +- **WHEN** inspecting `packages/vite-plugin-flatwave-react/package.json` dependencies and peerDependencies +- **THEN** no i18n library is listed as a dependency or peerDependency + +#### Scenario: Consumer wires i18next via onLanguageChange + +- **WHEN** a consumer passes `onLanguageChange={(lang) => i18n.changeLanguage(lang)}` to `FlatwaveLanguageRouter` +- **THEN** `i18n.changeLanguage` is called whenever the route language changes diff --git a/openspec/specs/flatwave-language-selector/spec.md b/openspec/specs/flatwave-language-selector/spec.md new file mode 100644 index 0000000..c4547a9 --- /dev/null +++ b/openspec/specs/flatwave-language-selector/spec.md @@ -0,0 +1,87 @@ +# flatwave-language-selector Specification + +## Purpose + +TBD - created by archiving change provide-composable-react-components. Update Purpose after archive. + +## Requirements + +### Requirement: Package exports FlatwaveLanguageSelector + +The package SHALL export a React component named `FlatwaveLanguageSelector` from its public surface. It SHALL render a language switching UI using `FlatwaveLanguageContext` to determine the available languages and the current locale. + +#### Scenario: Selector renders all supported languages + +- **WHEN** `FlatwaveLanguageContext.supportedLanguages` is `['es', 'pt']` and `FlatwaveLanguageContext.locale` is `'es'` +- **THEN** `FlatwaveLanguageSelector` renders options for both languages, with `'es'` marked as active + +--- + +### Requirement: FlatwaveLanguageSelector accepts a renderOption render prop + +`FlatwaveLanguageSelector` SHALL accept an optional `renderOption?: (lang: string, label: string, isActive: boolean) => React.ReactNode` prop. When provided, it SHALL call this function for each language to render the option. When not provided, it SHALL render a default `<select>` with `<option>` elements. + +#### Scenario: Custom renderOption receives language, label, and active state + +- **WHEN** `renderOption={(lang, label, active) => <button disabled={active}>{label}</button>}` +- **THEN** the button for the active language is disabled and others are clickable + +--- + +### Requirement: FlatwaveLanguageSelector calls onSelect callback when a language is selected + +`FlatwaveLanguageSelector` SHALL accept an optional `onSelect?: (lang: string) => void` prop. It SHALL call this callback when the user selects a different language. This callback is invoked BEFORE navigation, allowing consumers to perform analytics or additional side effects. + +#### Scenario: onSelect callback fires on language change + +- **WHEN** user selects a different language and `onSelect={(lang) => console.log(lang)}` is provided +- **THEN** the callback receives the new language code + +--- + +### Requirement: FlatwaveLanguageSelector integrates with browser history navigation + +When a user selects a new language, `FlatwaveLanguageSelector` SHALL navigate to the corresponding language-prefixed URL. For example, if the current path is `/es/about` and user selects `'pt'`, it SHALL navigate to `/pt/about` (replacing history to avoid back-button confusion). + +#### Scenario: Navigation uses correct language prefix + +- **WHEN** current path is `/es/about` and user selects `'pt'` +- **THEN** the router navigates to `/pt/about` with `replace: true` + +#### Scenario: Default language selection does not redirect + +- **WHEN** current language matches `defaultLanguage` and user re-selects it +- **THEN** no navigation occurs (same language selected) + +--- + +### Requirement: FlatwaveLanguageSelector uses FlatwaveLanguageContext for language state + +`FlatwaveLanguageSelector` SHALL read `locale`, `supportedLanguages`, and `defaultLanguage` from `FlatwaveLanguageContext`. It SHALL not accept these as separate props to avoid prop drilling. + +#### Scenario: Selector uses context without additional props + +- **WHEN** the component is placed inside a `FlatwaveLanguageDetector` tree +- **THEN** it has access to all language configuration without additional props + +--- + +### Requirement: FlatwaveLanguageSelector supports custom className and style + +`FlatwaveLanguageSelector` SHALL accept `className?: string` and `style?: React.CSSProperties` props that are applied to the root element of the rendered UI (the `<select>` or the container for custom-rendered options). + +#### Scenario: className is applied to root element + +- **WHEN** `className="lang-selector"` is passed +- **THEN** the outermost element has class `"lang-selector"` + +--- + +### Requirement: FlatwaveLanguageSelector supports getLabel override for custom labels + +`FlatwaveLanguageSelector` SHALL accept an optional `getLabel?: (lang: string) => string` prop that allows consumers to customize the display label for each language. If not provided, it SHALL use the language code itself as the label. + +#### Scenario: Custom labels are used when getLabel is provided + +- **WHEN** `getLabel={lang => lang === 'es' ? 'Español' : 'Português'}` is passed +- **THEN** the selector displays 'Español' and 'Português' instead of 'es' and 'pt' diff --git a/openspec/specs/flatwave-layout-wrapper/spec.md b/openspec/specs/flatwave-layout-wrapper/spec.md new file mode 100644 index 0000000..a2e5798 --- /dev/null +++ b/openspec/specs/flatwave-layout-wrapper/spec.md @@ -0,0 +1,27 @@ +# flatwave-layout-wrapper Specification + +## Purpose + +TBD - created by archiving change provide-composable-react-components. Update Purpose after archive. + +## Requirements + +### Requirement: FlatwaveLanguageRouter accepts a layoutWrapper prop + +`FlatwaveLanguageRouter` SHALL accept an optional `layoutWrapper?: React.ComponentType<{ children: React.ReactNode; locale: string }>` prop. When provided, all rendered routes SHALL be wrapped inside this layout component, receiving the current locale as a prop. This mirrors the `PagesLayout` pattern in the working project. + +#### Scenario: Layout wrapper wraps all page content + +- **WHEN** `layoutWrapper={({ children, locale }) => <div lang={locale}><Header /><main>{children}</main><Footer /></div>}` is passed +- **THEN** all rendered pages are wrapped with the layout component, and `locale` is available as a prop + +--- + +### Requirement: FlatwaveAppRoutes accepts a layoutWrapper prop + +`FlatwaveAppRoutes` SHALL accept an optional `layoutWrapper?: React.ComponentType` prop. When provided, routes SHALL render inside the wrapper using react-router's `<Outlet />` pattern for nested routes. This enables consumers to create a shared layout structure. + +#### Scenario: Layout wrapper works with Outlet pattern + +- **WHEN** `layoutWrapper={MyLayout}` is provided to `FlatwaveAppRoutes` +- **THEN** MyLayout receives `<Outlet />` or equivalent to render child routes diff --git a/openspec/specs/flatwave-md-component/spec.md b/openspec/specs/flatwave-md-component/spec.md new file mode 100644 index 0000000..2a4796c --- /dev/null +++ b/openspec/specs/flatwave-md-component/spec.md @@ -0,0 +1,119 @@ +# flatwave-md-component Specification + +## Purpose + +TBD - created by archiving change provide-composable-react-components. Update Purpose after archive. + +## Requirements + +### Requirement: Package exports FlatwaveMDComponent + +The package SHALL export a React component named `FlatwaveMDComponent` from its public surface (`@kamansoft/vite-plugin-flatwave-react`). The component SHALL be a generic functional component typed as `FlatwaveMDComponent<TFrontmatter extends FlatwaveFrontmatter = FlatwaveFrontmatter>`. + +#### Scenario: Named export is available + +- **WHEN** a consumer imports `{ FlatwaveMDComponent }` from `@kamansoft/vite-plugin-flatwave-react` +- **THEN** the import resolves to a React functional component without runtime error + +#### Scenario: TypeScript generic allows frontmatter extension + +- **WHEN** a consumer defines `interface MyFrontmatter extends FlatwaveFrontmatter { audioUrl: string }` and uses `FlatwaveMDComponent<MyFrontmatter>` +- **THEN** TypeScript accepts `frontmatter.audioUrl` as a valid property access within a component that wraps `FlatwaveMDComponent<MyFrontmatter>` + +--- + +### Requirement: Component accepts pre-compiled HTML via markdownHtml prop + +`FlatwaveMDComponent` SHALL accept a `markdownHtml: string` prop. When this prop is provided, the component SHALL render its content using `dangerouslySetInnerHTML={{ __html: markdownHtml }}` inside a wrapper element. This mode is intended for SSG contexts where the markdown has already been compiled to HTML by the build pipeline. + +#### Scenario: Pre-compiled HTML is rendered verbatim + +- **WHEN** `markdownHtml="<p>Hello <strong>world</strong></p>"` is passed as a prop +- **THEN** the rendered DOM contains `<p>Hello <strong>world</strong></p>` as inner HTML + +#### Scenario: markdownHtml takes priority over markdown when both are provided + +- **WHEN** both `markdownHtml="<p>compiled</p>"` and `markdown="raw"` are passed +- **THEN** the component renders the compiled HTML and ignores the raw markdown string + +--- + +### Requirement: Component accepts raw markdown via markdown prop + +`FlatwaveMDComponent` SHALL accept a `markdown: string` prop. When this prop is provided and `markdownHtml` is not, the component SHALL render the markdown string using the `react-markdown` library (a peer dependency). + +#### Scenario: Raw markdown is rendered client-side + +- **WHEN** `markdown="# Hello\n\nThis is **markdown**."` is passed without `markdownHtml` +- **THEN** the rendered DOM contains an `<h1>` element with text "Hello" and a `<p>` with bold text + +--- + +### Requirement: Component strips YAML frontmatter from markdown prop + +`FlatwaveMDComponent` SHALL automatically strip a YAML frontmatter block (delimited by `---` at start and end) from the `markdown` prop before passing it to `react-markdown`. This is defensive: the virtual module already provides stripped content, but direct string use may include frontmatter. + +#### Scenario: frontmatter block is stripped before rendering + +- **WHEN** a markdown string starting with `---\ntitle: Test\n---\n\n# Body` is passed via the `markdown` prop +- **THEN** the rendered output does NOT contain the raw frontmatter lines (`---`, `title: Test`) + +--- + +### Requirement: Component accepts typed frontmatter prop + +`FlatwaveMDComponent` SHALL accept a `frontmatter: TFrontmatter` prop (where `TFrontmatter extends FlatwaveFrontmatter`). The frontmatter values SHALL be accessible to child components via the `children` render-prop pattern. + +#### Scenario: Frontmatter fields are passed to children render prop + +- **WHEN** `frontmatter={{ title: "About", slug: "about", ... }}` is passed and the `children` prop is `(rendered, fm) => <><h1>{fm.title}</h1>{rendered}</>` +- **THEN** the rendered output contains `<h1>About</h1>` above the markdown content + +--- + +### Requirement: Component accepts locale prop + +`FlatwaveMDComponent` SHALL accept a `locale: string` prop and expose it to consumers via the `FlatwaveLanguageContext` React context. The locale is not used to alter rendering logic within the component itself, but it is made available for descendant components via context. + +#### Scenario: Locale is exposed in context + +- **WHEN** `locale="es"` is passed and a child component reads `FlatwaveLanguageContext` +- **THEN** the context value's `locale` equals `"es"` + +--- + +### Requirement: Component supports children render prop for layout customization + +`FlatwaveMDComponent` SHALL accept an optional `children` prop typed as `(rendered: React.ReactNode, frontmatter: TFrontmatter) => React.ReactNode`. When provided, the component SHALL call `children` with the rendered markdown content and the frontmatter object, and render the result. When not provided, the component SHALL render the markdown content directly. + +#### Scenario: Children render prop wraps the rendered content + +- **WHEN** `children={(content, fm) => <article data-slug={fm.slug}>{content}</article>}` is passed +- **THEN** the output is wrapped in `<article data-slug="...">` and contains the rendered markdown + +#### Scenario: No children prop renders content directly + +- **WHEN** no `children` prop is provided +- **THEN** the component renders the markdown content without any additional wrapper + +--- + +### Requirement: Component accepts className and style props + +`FlatwaveMDComponent` SHALL accept `className?: string` and `style?: React.CSSProperties` props, which SHALL be applied to the outermost rendered element of the component. No default CSS classes SHALL be applied. + +#### Scenario: className is applied to root element + +- **WHEN** `className="prose"` is passed +- **THEN** the outermost rendered element has the class `prose` + +--- + +### Requirement: Component is composable — consumers can create extended versions + +The `FlatwaveMDComponentProps` interface and `FlatwaveMDComponent` SHALL be exported so that consumers can create wrapper components that add behaviour or new props without re-implementing the markdown rendering logic. + +#### Scenario: Consumer creates an extended component + +- **WHEN** a consumer writes `function MyPage(props: FlatwaveMDComponentProps & { header: string }) { return <><h1>{props.header}</h1><FlatwaveMDComponent {...props} /></> }` +- **THEN** TypeScript compiles without error and the component renders both the custom header and the markdown content diff --git a/openspec/specs/flatwave-md-page-component/spec.md b/openspec/specs/flatwave-md-page-component/spec.md new file mode 100644 index 0000000..2bb055b --- /dev/null +++ b/openspec/specs/flatwave-md-page-component/spec.md @@ -0,0 +1,122 @@ +# flatwave-md-page-component Specification + +## Purpose + +TBD - created by archiving change provide-composable-react-components. Update Purpose after archive. + +## Requirements + +### Requirement: Package exports FlatwaveMDPageComponent + +The package SHALL export a React component named `FlatwaveMDPageComponent` from its public surface (`@kamansoft/vite-plugin-flatwave-react`). It SHALL be a generic functional component that extends `FlatwaveMDComponent` by adding full-page concerns: SEO head tag management and a page-level wrapper element. + +#### Scenario: Named export is available + +- **WHEN** a consumer imports `{ FlatwaveMDPageComponent }` from `@kamansoft/vite-plugin-flatwave-react` +- **THEN** the import resolves to a React functional component without runtime error + +--- + +### Requirement: FlatwaveMDPageComponent renders markdown content via FlatwaveMDComponent + +`FlatwaveMDPageComponent` SHALL internally delegate markdown rendering to `FlatwaveMDComponent`, accepting the same `markdownHtml`, `markdown`, `frontmatter`, `locale`, `className`, `style`, and `children` props. All behaviour defined in the `markdown-content-component` spec SHALL apply to `FlatwaveMDPageComponent`. + +#### Scenario: Pre-compiled HTML is rendered inside the page wrapper + +- **WHEN** `markdownHtml="<p>Hello</p>"` is passed to `FlatwaveMDPageComponent` +- **THEN** the rendered output contains `<p>Hello</p>` inside the page wrapper element + +#### Scenario: Raw markdown is rendered inside the page wrapper when markdownHtml is absent + +- **WHEN** `markdown="# Heading"` is passed without `markdownHtml` +- **THEN** the rendered output contains an `<h1>Heading</h1>` inside the page wrapper element + +--- + +### Requirement: FlatwaveMDPageComponent manages SEO head tags via react-helmet-async + +`FlatwaveMDPageComponent` SHALL render SEO head tags using `react-helmet-async`'s `<Helmet>` component (a peer dependency). At minimum, the following tags SHALL be rendered when the corresponding frontmatter field is present: + +- `<title>` — from `frontmatter.title` +- `<meta name="description">` — from `frontmatter.description` +- `<link rel="canonical">` — from `frontmatter.canonical` +- `<meta property="og:title">` — from `frontmatter.og?.title` or `frontmatter.title` +- `<meta property="og:description">` — from `frontmatter.og?.description` or `frontmatter.description` +- `<meta property="og:image">` — from `frontmatter.image` +- `<meta name="robots">` — from `frontmatter.robots` + +These head tags SHALL only be rendered at runtime (client-side); during SSG the static HTML head is managed by the `renderHtmlHead()` utility in the build pipeline, not by this component. + +#### Scenario: Title tag is set from frontmatter + +- **WHEN** `frontmatter.title = "About Us"` is passed +- **THEN** the document `<title>` is updated to `"About Us"` after client-side hydration + +#### Scenario: Description meta is set from frontmatter + +- **WHEN** `frontmatter.description = "Learn about us"` is passed +- **THEN** `<meta name="description" content="Learn about us">` is present in the document head + +#### Scenario: No Helmet is rendered when title is absent + +- **WHEN** a `frontmatter` object with no `title` field is passed (note: FlatwaveFrontmatter requires title, so this is a type guard scenario for partial/extended use) +- **THEN** no empty `<title>` tag is injected (the component SHALL guard against empty title tags) + +--- + +### Requirement: FlatwaveMDPageComponent accepts a pageWrapper slot prop + +`FlatwaveMDPageComponent` SHALL accept a `pageWrapper?: React.ComponentType<{ children: React.ReactNode; frontmatter: TFrontmatter; locale: string }>` prop. When provided, the component SHALL render the content inside this wrapper component. When not provided, the component SHALL render the content inside a `<main>` element. + +#### Scenario: Custom page wrapper is used when provided + +- **WHEN** `pageWrapper={({ children }) => <section className="page">{children}</section>}` is passed +- **THEN** the rendered output wraps the content in `<section class="page">` instead of `<main>` + +#### Scenario: Default main wrapper is used when no pageWrapper is provided + +- **WHEN** no `pageWrapper` prop is passed +- **THEN** the rendered output wraps content in a `<main>` element + +--- + +### Requirement: FlatwaveMDPageComponent accepts a loadingFallback prop + +`FlatwaveMDPageComponent` SHALL accept a `loadingFallback?: React.ReactNode` prop. This content SHALL be rendered when neither `markdownHtml` nor `markdown` is provided (e.g. during async content loading in client-side navigation). + +#### Scenario: Loading fallback is rendered when no content is available + +- **WHEN** neither `markdownHtml` nor `markdown` is provided but `loadingFallback={<div>Loading...</div>}` is passed +- **THEN** the component renders the loading fallback content + +#### Scenario: No output when loading fallback and content are both absent + +- **WHEN** neither `markdownHtml`, `markdown`, nor `loadingFallback` are provided +- **THEN** the component renders null without throwing + +--- + +### Requirement: DefaultRenderStrategy uses FlatwaveMDPageComponent internally + +The plugin's `DefaultRenderStrategy` SHALL use `FlatwaveMDPageComponent` to render each route during SSG. It SHALL pass `markdownHtml` (pre-compiled body), `frontmatter`, and `locale` from the `RenderContext`. If no matching component override is found in the `components` map, `FlatwaveMDPageComponent` SHALL be used as the default renderer. + +#### Scenario: SSG renders a route using FlatwaveMDPageComponent when no component override exists + +- **WHEN** a route's `component` field references a component not found in the components map +- **THEN** `DefaultRenderStrategy` falls back to `FlatwaveMDPageComponent` and produces valid HTML output (not an error string) + +#### Scenario: SSG renders a route using a custom component when an override exists + +- **WHEN** a route's `component` field resolves to a module in the components map +- **THEN** `DefaultRenderStrategy` uses that component instead of `FlatwaveMDPageComponent` + +--- + +### Requirement: FlatwaveMDPageComponent is composable and extensible + +`FlatwaveMDPageProps` interface SHALL be exported. Consumers SHALL be able to create page components that extend `FlatwaveMDPageComponent` by wrapping it or by accepting `FlatwaveMDPageProps` as their props type. + +#### Scenario: Consumer creates a branded page component + +- **WHEN** a consumer writes `function BrandedPage(props: FlatwaveMDPageProps) { return <FlatwaveMDPageComponent {...props} pageWrapper={BrandedWrapper} /> }` +- **THEN** TypeScript compiles without error and the component renders with the branded wrapper diff --git a/openspec/specs/ssg-custom-emitters/spec.md b/openspec/specs/ssg-custom-emitters/spec.md new file mode 100644 index 0000000..1cc0e7c --- /dev/null +++ b/openspec/specs/ssg-custom-emitters/spec.md @@ -0,0 +1,103 @@ +# ssg-custom-emitters Specification + +## Purpose + +TBD - created by archiving change provide-composable-react-components. Update Purpose after archive. + +## Requirements + +### Requirement: RenderHooks interface includes an emitFiles callback + +The `RenderHooks` interface in `packages/vite-plugin-flatwave-react/src/types.ts` SHALL include an optional `emitFiles` callback: + +```ts +emitFiles?: (context: EmitFilesContext) => Promise<SsgOutputFile[]> | SsgOutputFile[]; +``` + +Where `EmitFilesContext` is a new exported interface containing: + +- `routes: FlatwaveRoute[]` — all public routes from the content index +- `contentIndex: FlatwaveContentIndex` — the complete content index including all locales and entries +- `renderedFiles: SsgOutputFile[]` — all HTML output files already rendered by the SSG loop + +`SsgOutputFile` (already defined in `runSsg.ts`) SHALL be exported from the package's public surface for use in consumer `emitFiles` implementations. + +#### Scenario: emitFiles is optional and build succeeds without it + +- **WHEN** a consumer configures `flatwaveContent()` without providing `hooks.emitFiles` +- **THEN** the build completes without error and the standard output files are emitted + +#### Scenario: TypeScript accepts the emitFiles signature + +- **WHEN** a consumer writes `hooks: { emitFiles: ({ routes }) => [{ fileName: 'nav.json', source: '{}' }] }` +- **THEN** TypeScript compiles without error + +--- + +### Requirement: RenderPipeline executes emitFiles hooks after the render loop + +`RenderPipeline` SHALL add an `executeEmitFiles` method that calls all registered `emitFiles` hooks sequentially and merges their returned `SsgOutputFile[]` arrays. Errors in individual hooks SHALL be caught and logged, and the remaining hooks SHALL continue executing. + +#### Scenario: executeEmitFiles returns combined output from all hooks + +- **WHEN** two `emitFiles` hooks are registered, returning `[fileA]` and `[fileB]` respectively +- **THEN** `executeEmitFiles` returns `[fileA, fileB]` + +#### Scenario: Hook error does not abort remaining hooks + +- **WHEN** the first `emitFiles` hook throws an error and the second returns `[fileB]` +- **THEN** `executeEmitFiles` logs the error and still returns `[fileB]` + +--- + +### Requirement: runSsg calls executeEmitFiles after the rendering loop + +In `runSsg.ts`, after all routes have been rendered and before the standard outputs (route manifest, sitemap, robots.txt) are assembled, `runSsg` SHALL call `pipeline.executeEmitFiles` with the `EmitFilesContext` containing the full routes, content index, and the list of rendered HTML files. The returned `SsgOutputFile[]` SHALL be appended to the output file list and emitted as Vite bundle assets. + +#### Scenario: emitFiles output files appear in the build output directory + +- **WHEN** `hooks.emitFiles` returns `[{ fileName: 'navigation.json', source: '{"items":[]}' }]` +- **THEN** `navigation.json` is present in the build output directory after `vite build` + +#### Scenario: emitFiles runs after all HTML route files are rendered + +- **WHEN** `hooks.emitFiles` is called +- **THEN** the `renderedFiles` context field contains the rendered HTML for all public routes + +--- + +### Requirement: emitFiles can generate a navigation manifest from route data + +A consumer SHALL be able to use `emitFiles` to generate a `navigation.json` file where each entry contains the `url` (route path) and `publicName` (route title from frontmatter). This JSON file can then be used by a separately implemented React component to populate a navigation menu. + +#### Scenario: Navigation JSON is generated with correct shape + +- **WHEN** `hooks.emitFiles = ({ routes }) => [{ fileName: 'navigation.json', source: JSON.stringify(routes.map(r => ({ url: r.path, publicName: r.metadata.title }))) }]` +- **THEN** `navigation.json` contains an array of objects each with `url` and `publicName` string fields + +#### Scenario: Navigation JSON entries correspond to public routes only + +- **WHEN** the content directory contains both public (`public: true`) and private (`public: false`) entries +- **THEN** the `routes` received by `emitFiles` contain only public routes (matching the existing behaviour of `index.routes`) + +--- + +### Requirement: SsgOutputFile is exported from the package public surface + +`SsgOutputFile` (the `{ fileName: string; source: string }` interface) SHALL be exported from `@kamansoft/vite-plugin-flatwave-react` so that consumers can type their `emitFiles` return value without importing from internal package paths. + +#### Scenario: Consumer can import SsgOutputFile for type annotation + +- **WHEN** a consumer writes `import type { SsgOutputFile } from '@kamansoft/vite-plugin-flatwave-react'` +- **THEN** TypeScript resolves the type without error + +--- + +### Requirement: emitFiles context is typed and exported + +`EmitFilesContext` SHALL be exported from the package public surface so consumers can type their `emitFiles` callback parameter explicitly. + +#### Scenario: Consumer can import EmitFilesContext for type annotation + +- **WHEN** a consumer writes `import type { EmitFilesContext } from '@kamansoft/vite-plugin-flatwave-react'` +- **THEN** TypeScript resolves the type without error and the type contains `routes`, `contentIndex`, and `renderedFiles` fields diff --git a/package-lock.json b/package-lock.json index 411621e..bf03d20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,9 @@ "@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", @@ -47,6 +49,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", @@ -2060,6 +2094,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", @@ -4849,6 +4892,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", @@ -7350,6 +7407,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", @@ -12364,6 +12430,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", @@ -12407,6 +12493,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", @@ -13255,6 +13381,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", @@ -13304,6 +13437,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", @@ -15611,6 +15750,7 @@ "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", @@ -15627,6 +15767,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" }, @@ -15636,9 +15778,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/packages/vite-plugin-flatwave-react/README.md b/packages/vite-plugin-flatwave-react/README.md index 58078dd..ea17f92 100644 --- a/packages/vite-plugin-flatwave-react/README.md +++ b/packages/vite-plugin-flatwave-react/README.md @@ -1,13 +1,38 @@ # vite-plugin-flatwave-react -Vite content plugin for Markdown-driven, i18n-aware static React sites. +Vite plugin for Markdown-driven, i18n-aware static React sites with composable React components. + +## What It Does + +`vite-plugin-flatwave-react` enables you to build static React sites from Markdown content with: + +- **Multilingual routing** - Automatic locale-prefixed route generation (`/es/about`, `/pt/about`) +- **Content-driven development** - Markdown files define routes via frontmatter +- **Static site generation** - Pre-rendered HTML at build time +- **Composable React components** - Drop-in components for content rendering, language routing, and navigation + +## How It Works + +1. **Content indexing** - Scans `contentDir` for `.md` files, organizes by locale +2. **Virtual module** - Exposes `virtual:flatwave/content` with `getContent()`, `getRoutes()`, etc. +3. **SSG pipeline** - Compiles Markdown to HTML, renders via your React components, outputs static files +4. **Hook system** - Customize the pipeline with `ssg.hooks.transformMarkdown`, `transformHtml`, `emitFiles` ## Install ```bash -npm install vite-plugin-flatwave-react +npm install @kamansoft/vite-plugin-flatwave-react react-markdown react-helmet-async react-router-dom ``` +Peer dependencies required at runtime: + +- `react` >= 18.0.0 +- `react-dom` >= 18.0.0 +- `react-markdown` ^10.0.0 +- `react-helmet-async` ^2.0.0 +- `react-router-dom` ^6.0.0 +- `vite` ^5.0.0 || ^6.0.0 || ^7.0.0 + ## Configure Vite ```ts @@ -15,7 +40,7 @@ npm install vite-plugin-flatwave-react import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'node:path'; -import { flatwaveContent } from 'vite-plugin-flatwave-react'; +import { flatwaveContent } from '@kamansoft/vite-plugin-flatwave-react'; export default defineConfig({ plugins: [ @@ -27,12 +52,18 @@ export default defineConfig({ strictMissingLocales: false, componentsDir: path.resolve(__dirname, 'src/components'), sitemap: { hostname: 'https://example.com' }, + ssg: { + enabled: true, + hooks: { + transformMarkdown: async (md, ctx) => md + '\n\nBuilt with Flatwave.', + }, + }, }), ], }); ``` -## Content layout +## Content Layout ```text src/ @@ -47,7 +78,7 @@ src/ SimplePage.tsx ``` -Each Markdown file needs baseline frontmatter: +### Markdown Frontmatter ```yaml --- @@ -59,32 +90,115 @@ public: true description: 'Short description' canonical: '/es/page-slug' robots: 'index, follow' -keywords: ['tag1', 'tag2'] +og: + title: 'OG Title' + description: 'OG Description' --- -Markdown body. +Markdown body here. +``` + +## Composable React Components + +### FlatwaveMDComponent + +Renders Markdown content. Use in SSG mode (`markdownHtml`) or client-side (`markdown`). + +```tsx +import { FlatwaveMDComponent } from '@kamansoft/vite-plugin-flatwave-react/react'; + +function MyContent(props: FlatwaveMDComponentProps) { + return ( + <FlatwaveMDComponent + frontmatter={props.frontmatter} + markdownHtml={compiledHtml} + locale={props.locale} + /> + ); +} +``` + +Props: + +- `frontmatter` - Content metadata +- `markdownHtml` - Pre-compiled HTML (SSG mode) +- `markdown` - Raw Markdown (client-side mode) +- `locale` - Current locale for context +- `className`, `style` - Optional styling +- `children` - Render prop `(rendered, frontmatter) => ReactNode` + +### FlatwaveMDPageComponent + +Full-page wrapper with SEO head tags via `react-helmet-async`. + +```tsx +import { FlatwaveMDPageComponent } from '@kamansoft/vite-plugin-flatwave-react/react'; + +function MyPage(props: FlatwaveMDPageProps) { + return <FlatwaveMDPageComponent {...props} pageWrapper={BrandedLayout} />; +} +``` + +Props: + +- All `FlatwaveMDComponent` props +- `pageWrapper?: React.ComponentType<{ children, frontmatter, locale }>` - Layout wrapper +- `loadingFallback?: React.ReactNode` - Loading state + +### FlatwaveLanguageRouter + +Complete router setup with language detection. + +```tsx +import { FlatwaveLanguageRouter } from '@kamansoft/vite-plugin-flatwave-react/react'; + +function App() { + return ( + <FlatwaveLanguageRouter + supportedLanguages={['es', 'pt']} + defaultLanguage="es" + onLanguageChange={(lang) => console.log('Language changed:', lang)} + layoutWrapper={Layout} + renderPage={(route, locale) => ( + <FlatwaveMDPageComponent frontmatter={route.frontmatter} locale={locale} /> + )} + /> + ); +} ``` -Additional frontmatter fields are preserved in `attributes` and forwarded to the React component. +See: `examples/basic-react-site/` for a working demonstration. -## React hooks +## React Hooks ```ts import { useFlatwaveContent, useFlatwaveRoutes, useFlatwaveAlternatives, -} from 'vite-plugin-flatwave-react/react'; + useFlatwaveLanguage, +} from '@kamansoft/vite-plugin-flatwave-react/react'; + +// Get content by ID +const content = useFlatwaveContent('about', 'es'); + +// Get all routes for a locale +const routes = useFlatwaveRoutes('es'); + +// Get alternative language URLs +const alts = useFlatwaveAlternatives('about', 'es'); + +// Get current locale from context +const { locale, supportedLanguages } = useFlatwaveLanguage(); ``` -## Build outputs +## Build Outputs During `vite build`, the plugin generates: -- locale-prefixed static HTML route files -- `route-manifest.json` -- `sitemap.xml` -- `robots.txt` -- a virtual module for content lookup +- `/es/about/index.html`, `/pt/about/index.html` - Locale-prefixed static HTML +- `route-manifest.json` - All route metadata +- `sitemap.xml` - SEO sitemap +- `robots.txt` - Search engine directives ## Validation CLI @@ -97,5 +211,3 @@ 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/react/index.d.ts b/packages/vite-plugin-flatwave-react/dist/react/index.d.ts index aae6cb0..fd8cb8b 100644 --- a/packages/vite-plugin-flatwave-react/dist/react/index.d.ts +++ b/packages/vite-plugin-flatwave-react/dist/react/index.d.ts @@ -5,4 +5,12 @@ export declare function useFlatwaveRoutes(locale?: string): import("virtual:flat export declare function useFlatwaveAlternatives(id: string, currentLocale?: string): Record<string, string>; export declare function useFlatwaveLocales(): string[]; export declare function useFlatwaveLocale(locale?: string): string | undefined; +export type { FlatwaveMDComponentProps, FlatwaveMDPageProps, FlatwaveLanguageRouterProps, FlatwaveLanguageDetectorProps, FlatwaveAppRoutesProps, FlatwaveLanguageContextValue, FlatwaveFrontmatterWith, } from './types.js'; +export { FlatwaveLanguageContext, useFlatwaveLanguage } from './FlatwaveLanguageContext.js'; +export { FlatwaveMDComponent } from './FlatwaveMDComponent.js'; +export { FlatwaveMDPageComponent } from './FlatwaveMDPageComponent.js'; +export { FlatwaveLanguageRouter } from './FlatwaveLanguageRouter.js'; +export { FlatwaveLanguageDetector } from './FlatwaveLanguageDetector.js'; +export { FlatwaveAppRoutes } from './FlatwaveAppRoutes.js'; +export { FlatwaveLanguageSelector } from './FlatwaveLanguageSelector.js'; export { getAllContent, getAlternatives, getContent, getLocale, getLocales, getRoutes }; diff --git a/packages/vite-plugin-flatwave-react/dist/react/index.js b/packages/vite-plugin-flatwave-react/dist/react/index.js index 9c7f3f4..aeab796 100644 --- a/packages/vite-plugin-flatwave-react/dist/react/index.js +++ b/packages/vite-plugin-flatwave-react/dist/react/index.js @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { getAllContent, getAlternatives, getContent, getLocale, getLocales, getRoutes, } from 'virtual:flatwave/content'; +// React hooks export function useFlatwaveContent(id, locale) { return useMemo(() => getContent(id, locale), [id, locale]); } @@ -15,4 +16,14 @@ export function useFlatwaveLocales() { export function useFlatwaveLocale(locale) { return useMemo(() => getLocale(locale), [locale]); } +// Re-export FlatwaveLanguageContext and useFlatwaveLanguage hook +export { FlatwaveLanguageContext, useFlatwaveLanguage } from './FlatwaveLanguageContext.js'; +// Re-export component implementations +export { FlatwaveMDComponent } from './FlatwaveMDComponent.js'; +export { FlatwaveMDPageComponent } from './FlatwaveMDPageComponent.js'; +export { FlatwaveLanguageRouter } from './FlatwaveLanguageRouter.js'; +export { FlatwaveLanguageDetector } from './FlatwaveLanguageDetector.js'; +export { FlatwaveAppRoutes } from './FlatwaveAppRoutes.js'; +export { FlatwaveLanguageSelector } from './FlatwaveLanguageSelector.js'; +// Re-export virtual module utilities export { getAllContent, getAlternatives, getContent, getLocale, getLocales, getRoutes }; diff --git a/packages/vite-plugin-flatwave-react/dist/types.d.ts b/packages/vite-plugin-flatwave-react/dist/types.d.ts index 08ce82f..9ff78d9 100644 --- a/packages/vite-plugin-flatwave-react/dist/types.d.ts +++ b/packages/vite-plugin-flatwave-react/dist/types.d.ts @@ -23,6 +23,16 @@ export interface RenderHooks { transformHtml?: (html: string, context: unknown) => Promise<string> | string; afterRender?: (html: string, context: unknown) => Promise<void> | void; onError?: (error: Error, context: unknown) => Promise<string> | string; + emitFiles?: (context: EmitFilesContext) => Promise<SsgOutputFile[]> | SsgOutputFile[]; +} +export interface SsgOutputFile { + fileName: string; + source: string; +} +export interface EmitFilesContext { + routes: FlatwaveRoute[]; + contentIndex: FlatwaveContentIndex; + renderedFiles: SsgOutputFile[]; } export interface TemplateOverrides { indexHtml?: string; diff --git a/packages/vite-plugin-flatwave-react/package.json b/packages/vite-plugin-flatwave-react/package.json index f1d6ac2..5282929 100644 --- a/packages/vite-plugin-flatwave-react/package.json +++ b/packages/vite-plugin-flatwave-react/package.json @@ -74,19 +74,23 @@ "access": "public" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": ">=18.0.0", - "react-dom": ">=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" }, "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", "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": { @@ -95,6 +99,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" } diff --git a/packages/vite-plugin-flatwave-react/src/react/FlatwaveAppRoutes.tsx b/packages/vite-plugin-flatwave-react/src/react/FlatwaveAppRoutes.tsx new file mode 100644 index 0000000..d7a1751 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/react/FlatwaveAppRoutes.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import * as ReactRouter from 'react-router-dom'; +import type { FlatwaveAppRoutesProps } from './types.js'; +import { FlatwaveLanguageContext } from './FlatwaveLanguageContext.js'; + +const Routes = ReactRouter.Routes; +const Route = ReactRouter.Route; + +export function FlatwaveAppRoutes({ + routes: providedRoutes, + renderPage, + layoutWrapper, +}: FlatwaveAppRoutesProps): React.ReactElement { + const context = React.useContext(FlatwaveLanguageContext); + const locale = context?.locale || ''; + + // Use providedRoutes or empty array (caller should provide routes) + const allRoutes = providedRoutes ?? []; + + // Group routes by locale + const localeRoutes = allRoutes.filter((r) => r.locale === locale); + + return ( + <Routes> + {localeRoutes.map((route) => ( + <Route + key={route.path} + path={route.path} + element={ + layoutWrapper ? ( + <LayoutWrapper wrapper={layoutWrapper} locale={locale}> + {renderPage(route, locale)} + </LayoutWrapper> + ) : ( + renderPage(route, locale) + ) + } + /> + ))} + <Route path="*" element={null} /> + </Routes> + ); +} + +function LayoutWrapper({ + wrapper: Wrapper, + locale, + children, +}: { + wrapper: React.ComponentType<{ children: React.ReactNode; locale: string }>; + locale: string; + children: React.ReactNode; +}): React.ReactElement { + return <Wrapper locale={locale}>{children}</Wrapper>; +} + +export type { FlatwaveAppRoutesProps } from './types.js'; diff --git a/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageContext.ts b/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageContext.ts new file mode 100644 index 0000000..108c3ed --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageContext.ts @@ -0,0 +1,14 @@ +import React from 'react'; +import type { FlatwaveLanguageContextValue } from './types.js'; + +export const FlatwaveLanguageContext = React.createContext<FlatwaveLanguageContextValue>({ + locale: '', + supportedLanguages: [], + defaultLanguage: '', +}); + +export type { FlatwaveLanguageContextValue } from './types.js'; + +export function useFlatwaveLanguage() { + return React.useContext(FlatwaveLanguageContext); +} diff --git a/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageDetector.tsx b/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageDetector.tsx new file mode 100644 index 0000000..85de674 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageDetector.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import * as ReactRouter from 'react-router-dom'; +import type { FlatwaveLanguageDetectorProps } from './types.js'; +import { FlatwaveLanguageContext } from './FlatwaveLanguageContext.js'; + +const useNavigate = ReactRouter.useNavigate; +const useLocation = ReactRouter.useLocation; + +export function FlatwaveLanguageDetector({ + supportedLanguages, + defaultLanguage, + onLanguageChange, + children, +}: FlatwaveLanguageDetectorProps): React.ReactElement | null { + const navigate = useNavigate(); + const location = useLocation(); + const [currentLocale, setCurrentLocale] = React.useState('' as string); + + React.useEffect(() => { + const currentPath = location.pathname; + const pathSegments = currentPath.split('/').filter(Boolean); + const firstSegment = pathSegments[0]; + + // Check if the first segment is a language code + const isLanguageInPath = supportedLanguages.includes(firstSegment); + + if (isLanguageInPath) { + // Language is already in the path + if (currentLocale !== firstSegment) { + setCurrentLocale(firstSegment); + onLanguageChange?.(firstSegment); + } + } else { + // No language in path, detect and redirect + const browserLang = navigator.language.split('-')[0]; + const targetLang = supportedLanguages.includes(browserLang) ? browserLang : defaultLanguage; + + // Navigate with language prefix + navigate(`/${targetLang}${currentPath}`, { replace: true }); + onLanguageChange?.(targetLang); + } + }, [ + location.pathname, + navigate, + supportedLanguages, + defaultLanguage, + onLanguageChange, + currentLocale, + ]); + + const contextValue = { + locale: currentLocale, + supportedLanguages, + defaultLanguage, + }; + + return ( + <FlatwaveLanguageContext.Provider value={contextValue}> + {children} + </FlatwaveLanguageContext.Provider> + ); +} diff --git a/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageRouter.tsx b/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageRouter.tsx new file mode 100644 index 0000000..6af9f56 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageRouter.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import * as ReactRouter from 'react-router-dom'; +import type { FlatwaveLanguageRouterProps } from './types.js'; +import { FlatwaveLanguageDetector } from './FlatwaveLanguageDetector.js'; +import { FlatwaveAppRoutes } from './FlatwaveAppRoutes.js'; + +const BrowserRouter = ReactRouter.BrowserRouter; + +export function FlatwaveLanguageRouter({ + supportedLanguages, + defaultLanguage, + onLanguageChange, + renderPage, + layoutWrapper, +}: FlatwaveLanguageRouterProps): React.ReactElement { + return ( + <BrowserRouter> + <FlatwaveLanguageDetector + supportedLanguages={supportedLanguages} + defaultLanguage={defaultLanguage} + onLanguageChange={onLanguageChange} + > + <FlatwaveAppRoutes renderPage={renderPage} layoutWrapper={layoutWrapper} /> + </FlatwaveLanguageDetector> + </BrowserRouter> + ); +} diff --git a/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageSelector.tsx b/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageSelector.tsx new file mode 100644 index 0000000..f27b836 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageSelector.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import * as ReactRouter from 'react-router-dom'; +import type { FlatwaveLanguageContextValue } from './types.js'; +import { FlatwaveLanguageContext } from './FlatwaveLanguageContext.js'; + +const useNavigate = ReactRouter.useNavigate; +const useLocation = ReactRouter.useLocation; + +export interface FlatwaveLanguageSelectorProps { + renderOption?: (lang: string, label: string, isActive: boolean) => React.ReactNode; + onSelect?: (lang: string) => void; + getLabel?: (lang: string) => string; + className?: string; + style?: React.CSSProperties; +} + +export function FlatwaveLanguageSelector({ + renderOption, + onSelect, + getLabel, + className, + style, +}: FlatwaveLanguageSelectorProps): React.ReactElement { + const navigate = useNavigate(); + const location = useLocation(); + const context = React.useContext(FlatwaveLanguageContext); + + const { locale, supportedLanguages } = context as FlatwaveLanguageContextValue; + + const handleSelect = (lang: string) => { + onSelect?.(lang); + + // Navigate to the selected language version of current path + const currentPath = location.pathname; + // Remove current language prefix + const pathWithoutLang = supportedLanguages.reduce( + (path, currentLang) => path.replace(`/${currentLang}`, '') || '/', + currentPath + ); + // Add new language prefix + navigate(`/${lang}${pathWithoutLang === '/' ? '' : pathWithoutLang}`, { replace: true }); + }; + + const defaultGetLabel = (lang: string) => lang; + const getLabelFn = getLabel || defaultGetLabel; + + if (renderOption) { + return ( + <div className={className} style={style}> + {supportedLanguages.map((lang) => renderOption(lang, getLabelFn(lang), locale === lang))} + </div> + ); + } + + // Default select element + return ( + <select + value={locale} + onChange={(e) => handleSelect(e.target.value)} + className={className} + style={style} + > + {supportedLanguages.map((lang) => ( + <option key={lang} value={lang}> + {getLabelFn(lang)} + </option> + ))} + </select> + ); +} diff --git a/packages/vite-plugin-flatwave-react/src/react/FlatwaveMDComponent.tsx b/packages/vite-plugin-flatwave-react/src/react/FlatwaveMDComponent.tsx new file mode 100644 index 0000000..0ec388c --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/react/FlatwaveMDComponent.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import * as ReactMarkdown from 'react-markdown'; +import type { FlatwaveMDComponentProps, FlatwaveLanguageContextValue } from './types.js'; +import { FlatwaveLanguageContext } from './FlatwaveLanguageContext.js'; + +const ReactMarkdownComponent = ReactMarkdown.default; + +function stripYamlFrontmatter(markdown: string): string { + return markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, ''); +} + +export function FlatwaveMDComponent(props: FlatwaveMDComponentProps): React.ReactElement | null { + const { frontmatter, markdownHtml, markdown, locale, className, style, children } = props; + + let renderedContent: React.ReactNode = null; + + // Priority: markdownHtml > markdown + if (markdownHtml !== undefined) { + // SSG mode: use pre-compiled HTML + renderedContent = ( + <div dangerouslySetInnerHTML={{ __html: markdownHtml }} className={className} style={style} /> + ); + } else if (markdown !== undefined) { + // Client-side mode: render with react-markdown + const strippedMarkdown = stripYamlFrontmatter(markdown); + renderedContent = ( + <ReactMarkdownComponent + components={{ + p: ({ ...props }) => <p style={{ marginBottom: '1em' }} {...props} />, + }} + > + {strippedMarkdown} + </ReactMarkdownComponent> + ); + } + + // Provide locale context + const contextValue: FlatwaveLanguageContextValue = { + locale, + supportedLanguages: [], + defaultLanguage: '', + }; + + const wrappedContent = ( + <FlatwaveLanguageContext.Provider value={contextValue}> + {children ? children(renderedContent, frontmatter) : renderedContent} + </FlatwaveLanguageContext.Provider> + ); + + // Apply className/style to outer wrapper if no children + if (!children && (className || style)) { + return ( + <div className={className} style={style}> + {wrappedContent} + </div> + ); + } + + return wrappedContent; +} + +export type { FlatwaveMDComponentProps } from './types.js'; diff --git a/packages/vite-plugin-flatwave-react/src/react/FlatwaveMDPageComponent.tsx b/packages/vite-plugin-flatwave-react/src/react/FlatwaveMDPageComponent.tsx new file mode 100644 index 0000000..d0679a3 --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/react/FlatwaveMDPageComponent.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import * as ReactHelmet from 'react-helmet-async'; +import type { FlatwaveMDPageProps } from './types.js'; +import { FlatwaveMDComponent } from './FlatwaveMDComponent.js'; + +const Helmet = ReactHelmet.Helmet; + +export function FlatwaveMDPageComponent(props: FlatwaveMDPageProps): React.ReactElement | null { + const { + frontmatter, + markdownHtml, + markdown, + locale, + className, + style, + children, + pageWrapper, + loadingFallback, + } = props; + + // Handle missing content + if (markdownHtml === undefined && markdown === undefined) { + if (loadingFallback !== undefined) { + return <>{loadingFallback}</>; + } + return null; + } + + // Render page content + const pageContent = ( + <FlatwaveMDComponent + frontmatter={frontmatter} + markdownHtml={markdownHtml} + markdown={markdown} + locale={locale} + className={className} + style={style} + children={children} + /> + ); + + // SEO Head tags via react-helmet-async (client-side only) + const fm = frontmatter || {}; + const title = typeof fm.title === 'string' ? fm.title : undefined; + const headTags = title ? ( + <Helmet> + <title>{title} + {typeof fm.description === 'string' && } + {typeof fm.canonical === 'string' && } + {fm.og && typeof fm.og.title === 'string' && ( + + )} + {fm.og && typeof fm.og.description === 'string' && ( + + )} + {typeof fm.image === 'string' && } + {typeof fm.robots === 'string' && } + + ) : null; + + const contentWithHead = ( + <> + {headTags} + {pageContent} + + ); + + // Apply layout wrapper + if (pageWrapper) { + const Wrapper = pageWrapper; + return ( + + {contentWithHead} + + ); + } + + // Default wrapper + return
{contentWithHead}
; +} + +export type { FlatwaveMDPageProps } from './types.js'; diff --git a/packages/vite-plugin-flatwave-react/src/react/index.ts b/packages/vite-plugin-flatwave-react/src/react/index.ts index 059f09c..bcadc0b 100644 --- a/packages/vite-plugin-flatwave-react/src/react/index.ts +++ b/packages/vite-plugin-flatwave-react/src/react/index.ts @@ -8,6 +8,7 @@ import { getRoutes, } from 'virtual:flatwave/content'; +// React hooks export function useFlatwaveContent(id: string, locale?: string) { return useMemo(() => getContent(id, locale), [id, locale]); } @@ -28,4 +29,27 @@ export function useFlatwaveLocale(locale?: string) { return useMemo(() => getLocale(locale), [locale]); } +// Re-export component props types +export type { + FlatwaveMDComponentProps, + FlatwaveMDPageProps, + FlatwaveLanguageRouterProps, + FlatwaveLanguageDetectorProps, + FlatwaveAppRoutesProps, + FlatwaveLanguageContextValue, + FlatwaveFrontmatterWith, +} from './types.js'; + +// Re-export FlatwaveLanguageContext and useFlatwaveLanguage hook +export { FlatwaveLanguageContext, useFlatwaveLanguage } from './FlatwaveLanguageContext.js'; + +// Re-export component implementations +export { FlatwaveMDComponent } from './FlatwaveMDComponent.js'; +export { FlatwaveMDPageComponent } from './FlatwaveMDPageComponent.js'; +export { FlatwaveLanguageRouter } from './FlatwaveLanguageRouter.js'; +export { FlatwaveLanguageDetector } from './FlatwaveLanguageDetector.js'; +export { FlatwaveAppRoutes } from './FlatwaveAppRoutes.js'; +export { FlatwaveLanguageSelector } from './FlatwaveLanguageSelector.js'; + +// Re-export virtual module utilities export { getAllContent, getAlternatives, getContent, getLocale, getLocales, getRoutes }; diff --git a/packages/vite-plugin-flatwave-react/src/react/types.ts b/packages/vite-plugin-flatwave-react/src/react/types.ts new file mode 100644 index 0000000..3f94a8d --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/react/types.ts @@ -0,0 +1,56 @@ +import type { FlatwaveFrontmatter, FlatwaveRoute } from '../types.js'; + +export interface FlatwaveMDComponentProps { + frontmatter: TFrontmatter; + markdownHtml?: string; + markdown?: string; + locale: string; + className?: string; + style?: React.CSSProperties; + children?: (rendered: React.ReactNode, frontmatter: TFrontmatter) => React.ReactNode; +} + +export interface FlatwaveMDPageProps< + TFrontmatter = FlatwaveFrontmatter, +> extends FlatwaveMDComponentProps { + pageWrapper?: React.ComponentType<{ + children: React.ReactNode; + frontmatter: TFrontmatter; + locale: string; + }>; + loadingFallback?: React.ReactNode; +} + +export interface FlatwaveLanguageContextValue { + locale: string; + supportedLanguages: string[]; + defaultLanguage: string; +} + +export interface FlatwaveLanguageRouterProps { + supportedLanguages: string[]; + defaultLanguage: string; + onLanguageChange?: (lang: string) => void; + renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode; + dynamicRoute?: { + path: string; + renderPage: (params: { slug: string; lang: string }) => React.ReactNode; + }; + layoutWrapper?: React.ComponentType<{ children: React.ReactNode; locale: string }>; +} + +export interface FlatwaveLanguageDetectorProps { + supportedLanguages: string[]; + defaultLanguage: string; + onLanguageChange?: (lang: string) => void; + children: React.ReactNode; +} + +export interface FlatwaveAppRoutesProps { + routes?: FlatwaveRoute[]; + renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode; + layoutWrapper?: React.ComponentType<{ children: React.ReactNode; locale: string }>; +} + +// Utility type for frontmatter extension +export type FlatwaveFrontmatterWith = FlatwaveFrontmatter & T; diff --git a/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx b/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx index e797cec..75f0843 100644 --- a/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx +++ b/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx @@ -3,7 +3,7 @@ import type { RenderStrategy, RenderContext } from './types.js'; export class DefaultRenderStrategy implements RenderStrategy { async render(context: RenderContext): Promise { - const { route, contentEntry, components, locale } = context; + const { route, contentEntry, components } = context; const componentModule = components.get(route.component || ''); @@ -34,7 +34,7 @@ export class DefaultRenderStrategy implements RenderStrategy { const props = { ...contentEntry.frontmatter, markdownHtml: contentEntry.body, - locale, + locale: route.locale, route: route.path, }; diff --git a/packages/vite-plugin-flatwave-react/src/ssg/RenderPipeline.ts b/packages/vite-plugin-flatwave-react/src/ssg/RenderPipeline.ts index 44f46b3..a6faee3 100644 --- a/packages/vite-plugin-flatwave-react/src/ssg/RenderPipeline.ts +++ b/packages/vite-plugin-flatwave-react/src/ssg/RenderPipeline.ts @@ -1,5 +1,5 @@ import type { RenderContext } from './types.js'; -import type { RenderHooks } from '../types.js'; +import type { RenderHooks, EmitFilesContext, SsgOutputFile } from '../types.js'; type HookPhase = keyof RenderHooks; @@ -8,6 +8,7 @@ type TransformMarkdownHook = NonNullable; type TransformHtmlHook = NonNullable; type AfterRenderHook = NonNullable; type OnErrorHook = NonNullable; +type EmitFilesHook = NonNullable; export class RenderPipeline { private beforeRenderHooks: BeforeRenderHook[] = []; @@ -15,6 +16,7 @@ export class RenderPipeline { private transformHtmlHooks: TransformHtmlHook[] = []; private afterRenderHooks: AfterRenderHook[] = []; private onErrorHooks: OnErrorHook[] = []; + private emitFilesHooks: EmitFilesHook[] = []; constructor(initialHooks: Partial = {}) { if (initialHooks.beforeRender) this.beforeRenderHooks.push(initialHooks.beforeRender); @@ -23,6 +25,7 @@ export class RenderPipeline { if (initialHooks.transformHtml) this.transformHtmlHooks.push(initialHooks.transformHtml); if (initialHooks.afterRender) this.afterRenderHooks.push(initialHooks.afterRender); if (initialHooks.onError) this.onErrorHooks.push(initialHooks.onError); + if (initialHooks.emitFiles) this.emitFilesHooks.push(initialHooks.emitFiles); } addHook(phase: HookPhase, hook: unknown): void { @@ -89,6 +92,21 @@ export class RenderPipeline { return `

Render error: ${error.message}

`; } + async executeEmitFiles(context: EmitFilesContext): Promise { + const results: SsgOutputFile[] = []; + for (const hook of this.emitFilesHooks) { + try { + const files = await hook(context); + if (Array.isArray(files)) { + results.push(...files); + } + } catch (error) { + console.error(`[RenderPipeline] emitFiles hook failed:`, error); + } + } + return results; + } + hasHooks(phase: HookPhase): boolean { const hooks = this.getHooks(phase); return hooks !== undefined && hooks.length > 0; @@ -106,6 +124,8 @@ export class RenderPipeline { return this.afterRenderHooks as unknown[]; case 'onError': return this.onErrorHooks as unknown[]; + case 'emitFiles': + return this.emitFilesHooks as unknown[]; } } } diff --git a/packages/vite-plugin-flatwave-react/src/ssg/index.ts b/packages/vite-plugin-flatwave-react/src/ssg/index.ts index e7fbfd3..72592ae 100644 --- a/packages/vite-plugin-flatwave-react/src/ssg/index.ts +++ b/packages/vite-plugin-flatwave-react/src/ssg/index.ts @@ -1,15 +1,13 @@ export type { RenderContext, - RenderHooks, TemplateOverrides, TemplateVariables, RenderStrategy, - RenderPipeline, } from './types.js'; +export type { RenderHooks } from '../types.js'; export { DefaultRenderStrategy } from './DefaultRenderStrategy.js'; -export { runSsg, type SsgOutputFile, renderSitemap, renderRobotsTxt } from './runSsg.js'; +export { runSsg, renderSitemap, renderRobotsTxt } from './runSsg.js'; export { resolveTemplate, renderTemplate } from './template.js'; -export { - compileMarkdownToHtml, - type MarkdownCompilerOptions, -} from '../content/markdownCompiler.js'; +export { compileMarkdownToHtml } from '../content/markdownCompiler.js'; +export type { MarkdownCompilerOptions } from '../content/markdownCompiler.js'; +export type { SsgOutputFile, EmitFilesContext } from '../types.js'; diff --git a/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts b/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts index bb9333f..b251ba1 100644 --- a/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts +++ b/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts @@ -13,11 +13,7 @@ import { compileMarkdownToHtml, type MarkdownCompilerOptions, } from '../content/markdownCompiler.js'; - -export interface SsgOutputFile { - fileName: string; - source: string; -} +import type { SsgOutputFile } from '../types.js'; export function renderSitemap(routes: FlatwaveRoute[], hostname: string): string { const base = hostname.replace(/\/$/, ''); @@ -205,5 +201,14 @@ export async function runSsg( }); } + // Call emitFiles hook after all routes are rendered + const emitFilesContext = { + routes, + contentIndex: index, + renderedFiles: outputFiles, + }; + const emittedFiles = await pipeline.executeEmitFiles(emitFilesContext); + outputFiles.push(...emittedFiles); + return outputFiles; } diff --git a/packages/vite-plugin-flatwave-react/src/types.ts b/packages/vite-plugin-flatwave-react/src/types.ts index da2a64d..0c97af2 100644 --- a/packages/vite-plugin-flatwave-react/src/types.ts +++ b/packages/vite-plugin-flatwave-react/src/types.ts @@ -28,6 +28,18 @@ export interface RenderHooks { transformHtml?: (html: string, context: unknown) => Promise | string; afterRender?: (html: string, context: unknown) => Promise | void; onError?: (error: Error, context: unknown) => Promise | string; + emitFiles?: (context: EmitFilesContext) => Promise | SsgOutputFile[]; +} + +export interface SsgOutputFile { + fileName: string; + source: string; +} + +export interface EmitFilesContext { + routes: FlatwaveRoute[]; + contentIndex: FlatwaveContentIndex; + renderedFiles: SsgOutputFile[]; } export interface TemplateOverrides { From 938fbb6cb843ce5dfb9fc358a22fcda0e1bc47bc Mon Sep 17 00:00:00 2001 From: lemys lopez Date: Sat, 20 Jun 2026 18:53:28 -0500 Subject: [PATCH 03/10] specs before nemotron --- .../design.md | 100 ++++-- .../proposal.md | 73 ++++- .../specs/flatwave-app-routes/spec.md | 42 +++ .../specs/flatwave-language-router/spec.md | 67 ++++ .../specs/flatwave-md-page-component/spec.md | 46 +++ .../specs/non-intrusive-ssg/spec.md | 34 -- .../routing-toolkit-non-intrusive/spec.md | 139 ++++++++ .../tasks.md | 80 +++-- packages/vite-plugin-flatwave-react/README.md | 305 ++++++++++++------ 9 files changed, 691 insertions(+), 195 deletions(-) create mode 100644 openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-app-routes/spec.md create mode 100644 openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-language-router/spec.md create mode 100644 openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-md-page-component/spec.md delete mode 100644 openspec/changes/make-plugin-non-intrusive-routing/specs/non-intrusive-ssg/spec.md create mode 100644 openspec/changes/make-plugin-non-intrusive-routing/specs/routing-toolkit-non-intrusive/spec.md diff --git a/openspec/changes/make-plugin-non-intrusive-routing/design.md b/openspec/changes/make-plugin-non-intrusive-routing/design.md index 0b4de2a..b4af27a 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/design.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/design.md @@ -1,46 +1,100 @@ ## Context -Currently the plugin auto-generates routes during SSG, outputting static HTML files to `dist/{locale}/path/index.html`. This couples consumers to the plugin's routing decisions. Users should control their own Vite/Rollup configuration and routing structure, importing content via the virtual module. +The plugin already ships two coherent layers: + +1. **Content layer** — `flatwave-react:content` + `flatwave-react:markdown`: indexes Markdown files, + produces the `virtual:flatwave/content` module, compiles Markdown to HTML during build. + +2. **Presentation layer** — composable React components: `FlatwaveLanguageRouter`, `FlatwaveAppRoutes`, + `FlatwaveMDComponent`, `FlatwaveMDPageComponent`, `FlatwaveLanguageSelector`, + `FlatwaveLanguageDetector`, `FlatwaveLanguageContext`. + +3. **SSG layer** — `flatwave-react:ssg`: after the bundle is built, loops over all public routes and + emits static HTML files using `DefaultRenderStrategy`. + +The current disconnect: the SSG layer bypasses the presentation layer. `DefaultRenderStrategy` tries to +`import('../react/{componentName}.js')` or `import('virtual:flatwave/components/{componentName}')` for +each route. This means the consumer must: + +- Name their page component in every frontmatter file (`component: 'SimplePage'`) +- Put that component in a `componentsDir` the plugin can reach at build time + +When no component is found, the strategy silently falls back to returning raw compiled markdown string — +not even wrapped in a `
` element. + +`FlatwaveMDPageComponent` already exists and is precisely the component that should render each SSG page. +It accepts `frontmatter`, `markdownHtml`, and `locale` — exactly what `DefaultRenderStrategy` has +available in `RenderContext`. The only reason it has not been used is an unresolved ESM/CJS +interoperability issue with `react-helmet-async` and `react-markdown` in the Node SSG environment. ## Goals / Non-Goals **Goals:** -- Plugin provides content indexing and virtual module without auto-route generation -- Composable React components (`FlatwaveLanguageRouter`, `FlatwaveAppRoutes`) remain available for users to build their own routing -- Hooks (`transformMarkdown`, `beforeRender`, etc.) remain available for customization -- No automatic output files (routes, sitemap, robots) - consumers handle all output +- `DefaultRenderStrategy` uses `FlatwaveMDPageComponent` as its primary (and default) renderer. +- `FlatwaveMDPageComponent` is fixed to work correctly in a Node `renderToString` context by resolving + the `react-helmet-async` and `react-markdown` CJS/ESM interop issues. +- `componentsDir` becomes an _optional override_ mechanism, not a required configuration. +- The `component` frontmatter field is no longer in `requiredFields` by default; it is read as an override + when present. +- The example site demonstrates the composable pattern: `FlatwaveLanguageRouter` + `FlatwaveMDPageComponent` + in `App.tsx`; `vite build` automatically generates all locale-prefixed HTML routes from that. +- All existing e2e tests continue to pass. **Non-Goals:** -- Maintaining current auto-SSG behavior -- Keeping automatic sitemap.xml generation -- Supporting zero-config route creation +- Removing the SSG pipeline or making it opt-in. +- Removing `componentsDir` support (keep for backward compatibility). +- Changing the virtual module API. +- Changing any composable component prop APIs. ## Decisions -### Decision 1: Remove auto-route generation, keep content pipeline +### Decision 1: Fix `react-helmet-async` interop with a namespace import + +`import * as ReactHelmet from 'react-helmet-async'` plus `const { Helmet, HelmetProvider } = ReactHelmet` +works across both CJS and ESM builds. The same pattern is already used for `react-router-dom` in the +component files. Apply the same fix consistently. + +**Alternatives considered:** + +- Dynamic `import()` of `react-helmet-async` at call time — avoids top-level interop but breaks + synchronous render; `renderToString` requires synchronous React trees. +- Skip `react-helmet-async` in SSG context — valid shortcut, but head tags are then missing from the + SSG HTML output, which defeats the SEO purpose. `HelmetProvider` handles SSR correctly when wrapped. + +### Decision 2: `FlatwaveMDPageComponent` always wraps in `HelmetProvider` during SSG -**Rationale**: Users know their routing needs best. The plugin provides data; they decide how to render. -**Alternatives**: Could keep optional flag, but adds complexity. Better to remove entirely and let consumers compose. +`DefaultRenderStrategy` wraps the `FlatwaveMDPageComponent` tree in `` before calling +`renderToString`. This is the correct SSR pattern for `react-helmet-async` — the provider serialises head +tags into a side-channel object that can be extracted. In non-SSG (client-side) usage, the consumer wraps +their app in `` via their own entry point. -### Decision 2: Keep virtual module +### Decision 3: `buildComponentsMap` is called only when `componentsDir` is configured -**Rationale**: `virtual:flatwave/content` is the core product - indexed, typed content access. -**Alternatives**: None viable - this is the primary value. +`runSsg.ts` currently calls `buildComponentsMap(routes)` unconditionally. After this change, it SHALL only +call this function when `options.componentsDir` is non-null. This avoids a warning-filled build log for +the common case where no `componentsDir` is set. -### Decision 3: Keep composable React components +### Decision 4: `component` removed from default `requiredFields` -**Rationale**: These are the main way users interact with content (`FlatwaveLanguageRouter`, `FlatwaveMDComponent`, etc.). -**Alternatives**: Could remove, but defeats the purpose of composability. +`requiredFields` defaults to `['title', 'slug', 'id', 'component', 'public']`. The `component` field is +no longer required for SSG to work because `FlatwaveMDPageComponent` handles any content entry without +it. Remove `component` from the default list. Consumers who still use the component-by-name override can +add it back explicitly. -### Decision 4: Remove SSG plugin from output +### Decision 5: Example site rewritten to use composable pattern -**Rationale**: `flatwave-react:ssg` generates route files. Users should use their own render approach (SSR, SPA, SSG frameworks). -**Alternatives**: Keep for backward compatibility, but creates coupling. +`examples/basic-react-site/src/App.tsx` is rewritten to use `FlatwaveLanguageRouter` with +`FlatwaveMDPageComponent` as the `renderPage`. `componentsDir` is removed from `vite.config.ts`. +Frontmatter files no longer need `component: 'SimplePage'`. The e2e tests must still pass after this +change — they check title, locale, sitemap, and robots, all of which remain correct. ## Risks / Trade-offs -[**Breaking change**] → Major version bump required; existing users must migrate routing -[**Increased setup complexity**] → Users must write routing code, but gain flexibility -[**No out-of-box HTML output**] → Users must integrate with their build pipeline (VitePress, Astro, custom) +| Risk | Mitigation | +| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `react-helmet-async` fix may not cover all edge cases | Test with `renderToString` in unit tests for `DefaultRenderStrategy` | +| Removing `component` from `requiredFields` breaks validation for projects relying on it | Documented as a breaking change; consumers can re-add it to `requiredFields` explicitly | +| Example site e2e assertions depend on rendered page title | `FlatwaveMDPageComponent` renders `` from frontmatter — assertions remain valid | +| Consumer components (SimplePage, ProgramPage) still work via override | Backward compat preserved via `componentsDir` when configured | diff --git a/openspec/changes/make-plugin-non-intrusive-routing/proposal.md b/openspec/changes/make-plugin-non-intrusive-routing/proposal.md index 2ea8074..dbaf067 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/proposal.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/proposal.md @@ -1,33 +1,72 @@ ## Why -The plugin currently generates routes automatically during SSG, coupling users to the plugin's routing decisions. Users should have full control over their routing - importing content data and building their own route structure while leveraging the plugin's content indexing and i18n utilities. +Automatic route generation is a first-class feature of this plugin: Vite produces one +`{locale}/{slug}/index.html` per Markdown content file, a `sitemap.xml`, and a `robots.txt` — all driven +by the content index. **This stays.** + +The problem is _how_ routes are currently rendered. `DefaultRenderStrategy` imports specific React page +components by the name stored in each frontmatter `component` field (e.g. `component: 'SimplePage'`), +resolving them from a consumer-supplied `componentsDir`. This couples the SSG build step to an +implementation detail inside the consumer's project that has nothing to do with the composable routing +toolkit the plugin ships. + +The same plugin already exports `FlatwaveMDPageComponent` — a fully capable, SEO-aware, locale-aware React +component designed exactly for rendering a Markdown content entry as a complete HTML page. There is no +reason for the SSG to bypass it in favour of loading arbitrary consumer components by name. + +The fix: `DefaultRenderStrategy` uses `FlatwaveMDPageComponent` as its primary (and default) renderer. +Consumer page components registered via `componentsDir` remain supported as overrides for advanced cases, +but they are no longer the required path. `componentsDir` and the frontmatter `component` field are +deprecated in the default workflow. + +On the application side, `FlatwaveLanguageRouter` defines the client-side routing. Because the SSG and the +client-side SPA now use the same `FlatwaveMDPageComponent` to render content, hydration is coherent: the +HTML the SSG produces matches the DOM the SPA would render for the same route. ## What Changes -- **Remove**: Automatic route file generation (`/{locale}/path/index.html`) -- **Remove**: Automatic sitemap.xml, robots.txt generation -- **Keep**: Content indexing and virtual module (`virtual:flatwave/content`) -- **Keep**: Markdown compilation to HTML -- **Keep**: Composable React components (`FlatwaveLanguageRouter`, `FlatwaveAppRoutes`, etc.) -- **Add**: `getRoutes()` and `getContent()` remain available for consumers to build custom routing -- **Keep**: Hook system (beforeRender, transformMarkdown, etc.) for customization -- **BREAKING**: No automatic HTML file output - consumers must implement their own rendering +- **MODIFIED**: `DefaultRenderStrategy` uses `FlatwaveMDPageComponent` as the primary renderer. + It no longer requires a `componentsDir` or a `component` frontmatter field to produce valid HTML output. +- **DEPRECATED**: `componentsDir` config option (still honoured for backward compatibility; consumer + components loaded by name remain an advanced override path). +- **DEPRECATED**: `component` frontmatter field as a _required_ field (no longer in `requiredFields` + by default; still read when present for the advanced override path). +- **KEPT**: `FlatwaveLanguageRouter`, `FlatwaveAppRoutes`, `FlatwaveLanguageSelector`, + `FlatwaveMDComponent`, `FlatwaveMDPageComponent`, `FlatwaveLanguageDetector`, `FlatwaveLanguageContext` — + all composable components unchanged in API. +- **KEPT**: Full SSG pipeline — route HTML, sitemap, robots, route-manifest are still generated + automatically during `vite build`. Nothing is removed or made opt-in. +- **KEPT**: All SSG hooks (`transformMarkdown`, `beforeRender`, `transformHtml`, `afterRender`, + `emitFiles`). +- **MODIFIED**: README rewritten to show composable pattern as the primary usage: consumer builds their + app with `FlatwaveLanguageRouter` + `FlatwaveMDPageComponent`; Vite generates static routes from those + components automatically. ## Capabilities ### New Capabilities -- `non-intrusive-ssg`: Plugin provides content/indexing tools without enforcing route structure +- `routing-toolkit-non-intrusive`: Comprehensive specification for the full composable routing toolkit and + how the SSG generates routes from it, replacing the current component-by-name approach. ### Modified Capabilities -- `flatwave-md-page-component`: Remove SSG fallback behavior; component is client-side only -- `flatwave-language-router`: Must work with user-provided routes, not auto-generated routes +- `flatwave-md-page-component`: `DefaultRenderStrategy` SHALL use `FlatwaveMDPageComponent` as the primary + renderer (not a fallback). The component must be server-render-compatible (fix ESM/CJS interop for + `react-helmet-async` and `react-markdown` in Node SSG context). +- `flatwave-language-router`: The `routes` prop is now explicit. The component SHALL NOT silently fall back + to calling `getRoutes(lang)` from the virtual module. Consumer supplies routes — typically via the + `useFlatwaveRoutes(locale)` hook. +- `flatwave-app-routes`: Same routes-explicit requirement as `flatwave-language-router`. ## Impact -- `src/ssg/runSsg.ts`: Remove route rendering loop, keep hook execution -- `src/ssg/RenderPipeline.ts`: Keep hook infrastructure -- `src/index.ts`: Keep virtual module and exports, remove auto-SSG plugin -- Example site will need to use `FlatwaveLanguageRouter` or custom routing -- `CHANGELOG.md`: Breaking change requires major version bump +- `src/ssg/DefaultRenderStrategy.tsx`: Primary render path changed to `FlatwaveMDPageComponent`. +- `src/ssg/runSsg.ts`: `buildComponentsMap` step becomes optional; called only when `componentsDir` is + configured. +- `src/react/FlatwaveMDPageComponent.tsx`: Must be fixed for server-side `renderToString` compatibility + (react-helmet-async `HelmetProvider` wrapping; react-markdown dynamic import or pure-HTML fallback when + `markdownHtml` is already provided). +- `examples/basic-react-site/`: `App.tsx` rewritten to use `FlatwaveLanguageRouter`; `componentsDir` + removed from `vite.config.ts`; frontmatter `component` field optional. +- `packages/vite-plugin-flatwave-react/README.md`: Primary usage example updated. diff --git a/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-app-routes/spec.md b/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-app-routes/spec.md new file mode 100644 index 0000000..77470ca --- /dev/null +++ b/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-app-routes/spec.md @@ -0,0 +1,42 @@ +## MODIFIED Requirements + +### Requirement: FlatwaveAppRoutes requires an explicit routes prop + +`FlatwaveAppRoutes` SHALL accept a REQUIRED `routes: FlatwaveRoute[]` prop. +It SHALL NOT fall back to calling `getRoutes(lang)` from the virtual module when `routes` is absent. + +This makes the component's data dependency visible in consumer code, traceable during debugging, and +replaceable with any route source (virtual module, CMS API, static config). + +#### Scenario: Component renders only the provided routes + +- **WHEN** `routes={threeRoutes}` is passed +- **THEN** `FlatwaveAppRoutes` renders exactly 3 `<Route>` elements for the provided routes + +#### Scenario: TypeScript error when routes prop is absent + +- **WHEN** a consumer renders `<FlatwaveAppRoutes renderPage={fn} />` without the `routes` prop +- **THEN** TypeScript emits a compile-time error (routes is required) + +--- + +## REMOVED Requirements + +### Requirement: FlatwaveAppRoutes accepts an optional routes prop with virtual module fallback + +**Reason**: Falling back to `getRoutes(lang)` inside `FlatwaveAppRoutes` when `routes` is absent +hides a virtual module dependency inside the component. Making `routes` required keeps the component's +data dependency explicit and avoids an implicit import of `virtual:flatwave/content` inside the +package's own component code. + +**Migration**: + +```tsx +// Before (implicit fallback — no longer supported) +<FlatwaveAppRoutes renderPage={fn} />; + +// After (explicit routes) +const { locale } = useFlatwaveLanguage(); +const routes = useFlatwaveRoutes(locale); +<FlatwaveAppRoutes routes={routes} renderPage={fn} />; +``` diff --git a/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-language-router/spec.md b/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-language-router/spec.md new file mode 100644 index 0000000..887931b --- /dev/null +++ b/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-language-router/spec.md @@ -0,0 +1,67 @@ +## MODIFIED Requirements + +### Requirement: FlatwaveLanguageRouter renders routes via a renderPage render prop + +`FlatwaveLanguageRouter` SHALL accept a REQUIRED `renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode` +prop. It SHALL also accept an optional `routes?: FlatwaveRoute[]` prop. + +**When `routes` is provided**, the router uses those routes directly. + +**When `routes` is absent**, the consumer MUST supply routes by reading from the virtual module in their +own code and passing the result. The canonical pattern is: + +```tsx +import { useFlatwaveRoutes } from '@kamansoft/vite-plugin-flatwave-react/react'; + +function App() { + const { locale } = useFlatwaveLanguage(); + const routes = useFlatwaveRoutes(locale); + return ( + <FlatwaveLanguageRouter + supportedLanguages={['es', 'pt']} + defaultLanguage="es" + routes={routes} + renderPage={(route, lang) => ( + <FlatwaveMDPageComponent frontmatter={route.frontmatter} locale={lang} /> + )} + /> + ); +} +``` + +The router SHALL NOT silently call `getRoutes(lang)` from the virtual module as an implicit internal +side-effect. The data source must be explicit in consumer code. + +#### Scenario: Routes prop is required to render route elements + +- **WHEN** a consumer provides `routes={esRoutes}` with 3 routes +- **THEN** `renderPage` is called for each of those 3 routes when navigating to their paths + +#### Scenario: Consumer supplies routes from the virtual module explicitly + +- **WHEN** `routes={useFlatwaveRoutes(locale)}` is passed +- **THEN** the router renders exactly the virtual module routes for that locale, and the consumer's + code makes the data source visible and traceable + +#### Scenario: Consumer supplies routes from a custom source + +- **WHEN** `routes={customApiRoutes}` is passed where routes came from an external API or static config +- **THEN** the router renders those custom routes without error or conflict with the virtual module + +--- + +## REMOVED Requirements + +### Requirement: FlatwaveLanguageRouter renders routes from the virtual module implicitly + +**Reason**: Silently calling `getRoutes(lang)` inside the component creates a hidden coupling between +the router and the virtual module that is invisible in the consumer's code, makes testing harder, and +blocks consumers who want custom route sources. + +**Migration**: Pass `routes` explicitly. For the common case (virtual module routes): + +```tsx +const { locale } = useFlatwaveLanguage(); +const routes = useFlatwaveRoutes(locale); +// pass routes={routes} to FlatwaveLanguageRouter +``` diff --git a/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-md-page-component/spec.md b/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-md-page-component/spec.md new file mode 100644 index 0000000..386e6e3 --- /dev/null +++ b/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-md-page-component/spec.md @@ -0,0 +1,46 @@ +## MODIFIED Requirements + +### Requirement: DefaultRenderStrategy uses FlatwaveMDPageComponent as primary renderer + +The plugin's `DefaultRenderStrategy` SHALL use `FlatwaveMDPageComponent` as the **primary renderer** +for every SSG route. It SHALL pass `markdownHtml` (pre-compiled body), `frontmatter`, and `locale` from +`RenderContext` to the component, wrapping it in `HelmetProvider` for correct SSR head tag extraction. + +`DefaultRenderStrategy` SHALL fall back to a consumer-supplied component only when ALL of the following +are true: + +1. `componentsDir` is configured in plugin options, AND +2. The route's `component` frontmatter field names a module that resolves in `componentsDir` + +When neither condition is met, `FlatwaveMDPageComponent` is used — never the compiled markdown string +alone. + +#### Scenario: SSG generates route using FlatwaveMDPageComponent by default + +- **WHEN** no `componentsDir` is configured and frontmatter has no `component` field +- **THEN** `DefaultRenderStrategy` renders `<HelmetProvider><FlatwaveMDPageComponent ... /></HelmetProvider>` + via `renderToString` and produces valid HTML with `<main>`, rendered markdown body, and head tags + +#### Scenario: SSG uses consumer component when override is found + +- **WHEN** `componentsDir` is configured AND `route.component === 'ProgramPage'` resolves in that directory +- **THEN** `DefaultRenderStrategy` renders using the `ProgramPage` module instead + +#### Scenario: No data-ssg-error appears in generated HTML + +- **WHEN** the SSG pipeline runs and no consumer component override is found +- **THEN** no `<p data-ssg-error>` element appears in any generated HTML file; + all pages have the full markdown content rendered + +--- + +## REMOVED Requirements + +### Requirement: DefaultRenderStrategy falls back to raw compiled markdown when component not found + +**Reason**: Returning a raw compiled markdown string (not wrapped in any element) when no component +is found produces invalid page HTML. `FlatwaveMDPageComponent` is available and correctly wraps the +content in `<main>`, sets head tags, and handles all the concerns of a complete page — it should be +the default, not the fallback. + +**Migration**: No consumer action required. The change is internal to `DefaultRenderStrategy`. diff --git a/openspec/changes/make-plugin-non-intrusive-routing/specs/non-intrusive-ssg/spec.md b/openspec/changes/make-plugin-non-intrusive-routing/specs/non-intrusive-ssg/spec.md deleted file mode 100644 index 3eafdef..0000000 --- a/openspec/changes/make-plugin-non-intrusive-routing/specs/non-intrusive-ssg/spec.md +++ /dev/null @@ -1,34 +0,0 @@ -# Non-Intrusive SSG Specification - -## Purpose - -Plugin provides content indexing and virtual module without enforcing route structure or automatic HTML generation. - -## Requirements - -### Requirement: Plugin exposes virtual module only - -The plugin SHALL export `virtual:flatwave/content` providing `getContent()`, `getRoutes()`, `getAllContent()`, and `getAlternatives()` functions without auto-generating routes. - -#### Scenario: Virtual module is available - -- **WHEN** a consumer imports from `virtual:flatwave/content` -- **THEN** the module resolves with content lookup functions - -### Requirement: Plugin does not generate HTML files - -The `flatwave-react:ssg` plugin SHALL NOT generate `{locale}/{path}/index.html` files automatically. - -#### Scenario: No route files after build - -- **WHEN** `vite build` completes -- **THEN** no HTML files are created in `dist/` except the default Vite output - -### Requirement: Plugin provides hook infrastructure - -The `RenderPipeline.executeEmitFiles()` method SHALL remain available for consumers to generate custom output files (e.g., navigation manifests). - -#### Scenario: emitFiles hook can be used - -- **WHEN** a consumer provides `ssg.hooks.emitFiles` in config -- **THEN** the hook is called and can emit custom files diff --git a/openspec/changes/make-plugin-non-intrusive-routing/specs/routing-toolkit-non-intrusive/spec.md b/openspec/changes/make-plugin-non-intrusive-routing/specs/routing-toolkit-non-intrusive/spec.md new file mode 100644 index 0000000..eded325 --- /dev/null +++ b/openspec/changes/make-plugin-non-intrusive-routing/specs/routing-toolkit-non-intrusive/spec.md @@ -0,0 +1,139 @@ +# routing-toolkit-non-intrusive Specification + +## Purpose + +Defines the contract for `@kamansoft/vite-plugin-flatwave-react` as a composable routing and rendering +toolkit. The plugin provides automatic route generation as a first-class feature: `vite build` emits +locale-prefixed static HTML, a sitemap, and robots.txt — driven entirely by the consumer's content files +and the Flatwave composable React components. No intrusive component-by-name loading is required. + +--- + +## Requirements + +### Requirement: SSG generates routes through FlatwaveMDPageComponent, not component-name loading + +The SSG pipeline (`DefaultRenderStrategy`) SHALL use `FlatwaveMDPageComponent` as its **primary renderer** +for every content route. It SHALL NOT require a `component` frontmatter field or a `componentsDir` +configuration to produce valid, SEO-compatible HTML output. + +When `componentsDir` IS configured AND the route's `component` frontmatter field names a module that +resolves inside that directory, `DefaultRenderStrategy` MAY use that module as an override. This override +path is for advanced customisation only and is not the default workflow. + +#### Scenario: Route HTML generated without componentsDir or component field + +- **WHEN** `vite.config.ts` configures `flatwaveContent()` with no `componentsDir` property +- **AND** the Markdown frontmatter contains no `component` field +- **THEN** `vite build` emits a valid `{locale}/{slug}/index.html` for every public route, with the + Markdown body rendered as HTML inside a `<main>` element and the page title set from frontmatter + +#### Scenario: Route HTML includes frontmatter-driven SEO metadata + +- **WHEN** a Markdown file has `title: 'About Us'`, `description: '...'`, and `canonical: '/es/about'` +- **THEN** the generated HTML contains `<title>About Us`, ``, + and `` in the `` section + +#### Scenario: Consumer component override still works when componentsDir is set + +- **WHEN** `componentsDir` is configured and a frontmatter `component: 'ProgramPage'` resolves to a + module in that directory +- **THEN** `DefaultRenderStrategy` renders using `ProgramPage` instead of `FlatwaveMDPageComponent` + +--- + +### Requirement: component is not a required frontmatter field by default + +The `component` field SHALL be removed from the default `requiredFields` list. +`requiredFields` SHALL default to `['title', 'slug', 'id', 'public']`. +Consumers who rely on the component-override workflow SHALL add `'component'` to `requiredFields` +explicitly via plugin config. + +#### Scenario: Build succeeds without component in frontmatter + +- **WHEN** all Markdown files omit the `component` frontmatter field +- **AND** `componentsDir` is not configured +- **THEN** `vite build` completes without validation errors or warnings about missing `component` + +#### Scenario: Consumer re-adds component to requiredFields + +- **WHEN** `requiredFields: ['title', 'slug', 'id', 'public', 'component']` is set in plugin config +- **THEN** validation fails with an error for any Markdown file that omits the `component` field + +--- + +### Requirement: FlatwaveLanguageRouter is the composable entry point for application routing + +A consumer SHALL be able to build a complete multilingual static site by using `FlatwaveLanguageRouter` +in their `App.tsx`. The consumer provides a `renderPage` render prop that returns a +`FlatwaveMDPageComponent` for each route. **The SSG generates static HTML for each route by processing +those same components** — meaning the client-side SPA and the pre-rendered HTML are coherent. + +#### Scenario: Consumer App uses composable pattern + +- **WHEN** `App.tsx` renders: + ```tsx + ( + + )} + /> + ``` +- **THEN** the app renders the correct page for any valid `/{locale}/{slug}` URL on the client + +#### Scenario: SSG produces matching HTML for every client-side route + +- **WHEN** the consumer's `App.tsx` uses `FlatwaveLanguageRouter` + `FlatwaveMDPageComponent` +- **AND** `vite build` runs +- **THEN** each route URL that `FlatwaveLanguageRouter` would serve has a corresponding + `dist/{locale}/{slug}/index.html` with the same content the SPA would render + +--- + +### Requirement: SSG always generates locale-prefixed routes, sitemap, and robots + +The SSG pipeline SHALL always generate: + +- `{locale}/{slug}/index.html` for every public content route +- `sitemap.xml` listing all public route URLs (configurable hostname) +- `robots.txt` pointing to the sitemap +- `route-manifest.json` listing all generated routes with metadata + +These outputs are automatic — the consumer does not need to configure them to get them. + +#### Scenario: All public routes produce HTML files + +- **WHEN** the content directory contains 3 public Markdown files per locale (6 total for 2 locales) +- **THEN** `vite build` emits 6 locale-prefixed HTML files in `dist/` + +#### Scenario: sitemap contains all public routes + +- **WHEN** `sitemap.hostname` is set to `'https://mysite.com'` +- **THEN** `dist/sitemap.xml` contains one `` element per public route, e.g. + `https://mysite.com/es/about` + +--- + +### Requirement: FlatwaveMDPageComponent is server-render compatible + +`FlatwaveMDPageComponent` SHALL work correctly inside `react-dom/server`'s `renderToString`. +`react-helmet-async` and `react-markdown` SHALL be imported using namespace imports +(`import * as Pkg from 'pkg'`) to resolve CJS/ESM interoperability in the Node SSG environment. + +#### Scenario: renderToString produces valid HTML with head tags + +- **WHEN** `DefaultRenderStrategy` calls `renderToString()` +- **THEN** the result is a valid HTML string containing `
` with the Markdown body and the + `` / `<meta>` tags from the frontmatter + +#### Scenario: No runtime error when markdownHtml is provided + +- **WHEN** `markdownHtml` is a non-empty compiled HTML string +- **THEN** `FlatwaveMDPageComponent` renders the HTML via `dangerouslySetInnerHTML` without attempting + to load or execute `react-markdown` diff --git a/openspec/changes/make-plugin-non-intrusive-routing/tasks.md b/openspec/changes/make-plugin-non-intrusive-routing/tasks.md index afc93e3..9911c97 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/tasks.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/tasks.md @@ -1,27 +1,67 @@ -## 1. Remove auto-route generation from flatwave-react:ssg +## 1. Fix FlatwaveMDPageComponent for server-side rendering -- [ ] 1.1 Remove route rendering loop from runSsg.ts -- [ ] 1.2 Remove automatic HTML file emission in generateBundle hook -- [ ] 1.3 Remove sitemap.xml, robots.txt, route-manifest.json generation -- [ ] 1.4 Remove DefaultRenderStrategy usage (no longer needed) -- [ ] 1.5 Keep hook infrastructure (beforeRender, transformMarkdown, etc.) +- [ ] 1.1 Update `FlatwaveMDPageComponent.tsx`: change `import { Helmet } from 'react-helmet-async'` to + `import * as ReactHelmet from 'react-helmet-async'; const Helmet = ReactHelmet.Helmet;` +- [ ] 1.2 Confirm `react-markdown` import in `FlatwaveMDComponent.tsx` already uses namespace import + `import * as ReactMarkdown from 'react-markdown'`; fix if not +- [ ] 1.3 Rebuild plugin: `npm run build:plugin` +- [ ] 1.4 Write a unit test (`DefaultRenderStrategy.test.ts`) that calls `renderToString` with + `FlatwaveMDPageComponent` and asserts the output contains `<main>`, the markdown body, and `<title>` -## 2. Update plugin exports +## 2. Update DefaultRenderStrategy to use FlatwaveMDPageComponent as primary renderer -- [ ] 2.1 Keep `flatwaveContent()` factory function -- [ ] 2.2 Keep virtual module generation -- [ ] 2.3 Export content utilities (getContent, getRoutes, etc.) -- [ ] 2.4 Remove automatic SSG plugin from returned Plugin[] +- [ ] 2.1 Rewrite `DefaultRenderStrategy.tsx`: primary render path calls + `renderToString(<HelmetProvider><FlatwaveMDPageComponent frontmatter={...} markdownHtml={...} locale={...} /></HelmetProvider>)` + using `import * as ReactHelmet from 'react-helmet-async'; const HelmetProvider = ReactHelmet.HelmetProvider;` +- [ ] 2.2 Keep the consumer-component override path: when `componentModule` is found via `componentsDir`, + use that component; otherwise use `FlatwaveMDPageComponent` +- [ ] 2.3 Remove the raw-markdown fallback (`return compiledBody`) — it MUST NOT appear anywhere in the + success path +- [ ] 2.4 Ensure error catch blocks still return `<p data-ssg-error>...</p>` for unexpected exceptions -## 3. Update composable React components +## 3. Remove component from default requiredFields -- [ ] 3.1 FlatwaveMDPageComponent: Remove SSG-specific fallback logic -- [ ] 3.2 FlatwaveLanguageRouter: Remove auto-route generation, require renderPage -- [ ] 3.3 FlatwaveAppRoutes: Require routes prop, no virtual module fallback -- [ ] 3.4 Keep FlatwaveMDComponent for content rendering +- [ ] 3.1 In `normalizeOptions()` (`src/index.ts`), change default `requiredFields` from + `['title', 'slug', 'id', 'component', 'public']` to `['title', 'slug', 'id', 'public']` +- [ ] 3.2 Update content validation docs and README to reflect new default -## 4. Update example site +## 4. Make componentsDir optional in runSsg -- [ ] 4.1 Use FlatwaveLanguageRouter with custom renderPage -- [ ] 4.2 Implement custom route structure -- [ ] 4.3 Add emitFiles hook for custom navigation JSON +- [ ] 4.1 In `runSsg.ts`, wrap `buildComponentsMap(routes)` call in a guard: + `if (options.componentsDir) { components = await buildComponentsMap(routes); }` +- [ ] 4.2 Pass empty `Map()` to `RenderContext.components` when `componentsDir` is not configured + +## 5. Make routes explicit in FlatwaveLanguageRouter and FlatwaveAppRoutes + +- [ ] 5.1 Update `FlatwaveLanguageRouter.tsx`: remove internal call to `getRoutes(lang)`; + require `routes` to be passed via prop; update `FlatwaveLanguageRouterProps` type +- [ ] 5.2 Update `FlatwaveAppRoutes.tsx`: change `routes?: FlatwaveRoute[]` to + `routes: FlatwaveRoute[]` (required); remove fallback `getRoutes(locale)` call +- [ ] 5.3 Update `FlatwaveAppRoutesProps` and `FlatwaveLanguageRouterProps` in `src/react/types.ts` +- [ ] 5.4 Run `npm run type-check` — fix any resulting TypeScript errors + +## 6. Update example site to composable pattern + +- [ ] 6.1 Remove `componentsDir` from `examples/basic-react-site/vite.config.ts` +- [ ] 6.2 Remove `component: 'SimplePage'` / `component: 'ProgramPage'` from all example Markdown + frontmatter files +- [ ] 6.3 Rewrite `examples/basic-react-site/src/App.tsx` to use `FlatwaveLanguageRouter` + + `FlatwaveMDPageComponent` (with `useFlatwaveRoutes` providing explicit routes) +- [ ] 6.4 Remove or simplify `SimplePage.tsx` and `ProgramPage.tsx` — they are no longer required + by the SSG pipeline; keep them only if the example still wants to show custom component overrides +- [ ] 6.5 Run `npm run build:example` and verify all HTML files are generated correctly with no + `data-ssg-error` elements + +## 7. Validation and CI + +- [ ] 7.1 Run `npm run validate` (format + lint + type-check + build + test) — all must pass +- [ ] 7.2 Verify `e2e/example.test.ts` passes: routes exist, titles match frontmatter, sitemap correct +- [ ] 7.3 Verify no `<p data-ssg-error>` appears in any generated HTML file in `examples/basic-react-site/dist/` + +## 8. Update README + +- [ ] 8.1 Update "How It Works" section: route generation is driven by content + composable components +- [ ] 8.2 Remove `componentsDir` from the primary `vite.config.ts` example +- [ ] 8.3 Remove `component: 'SimplePage'` from the primary frontmatter example +- [ ] 8.4 Add "Automatic Route Generation" section showing `FlatwaveLanguageRouter` → SSG HTML flow +- [ ] 8.5 Update "Build Outputs" section to clarify these come from the composable components diff --git a/packages/vite-plugin-flatwave-react/README.md b/packages/vite-plugin-flatwave-react/README.md index ea17f92..8931cfe 100644 --- a/packages/vite-plugin-flatwave-react/README.md +++ b/packages/vite-plugin-flatwave-react/README.md @@ -1,22 +1,39 @@ # vite-plugin-flatwave-react -Vite plugin for Markdown-driven, i18n-aware static React sites with composable React components. +Vite plugin for Markdown-driven, i18n-aware static React sites with composable routing components. ## What It Does -`vite-plugin-flatwave-react` enables you to build static React sites from Markdown content with: +`vite-plugin-flatwave-react` gives you a complete toolkit for building **multilingual, SEO-compatible, +Markdown-based static React sites**: -- **Multilingual routing** - Automatic locale-prefixed route generation (`/es/about`, `/pt/about`) -- **Content-driven development** - Markdown files define routes via frontmatter -- **Static site generation** - Pre-rendered HTML at build time -- **Composable React components** - Drop-in components for content rendering, language routing, and navigation +- **Automatic route generation** — `vite build` produces locale-prefixed static HTML for every content + file (`/es/about/index.html`, `/pt/about/index.html`), a `sitemap.xml`, and a `robots.txt` +- **Composable routing** — drop-in React components (`FlatwaveLanguageRouter`, `FlatwaveMDPageComponent`) + that you wire together in your own `App.tsx`; routes are generated from those same components +- **Content-driven** — Markdown files define pages via frontmatter; the plugin indexes, compiles, and + exposes content via a typed virtual module +- **i18n-aware** — URL-prefix language routing, browser language detection, and context-based locale + access — no i18n library required ## How It Works -1. **Content indexing** - Scans `contentDir` for `.md` files, organizes by locale -2. **Virtual module** - Exposes `virtual:flatwave/content` with `getContent()`, `getRoutes()`, etc. -3. **SSG pipeline** - Compiles Markdown to HTML, renders via your React components, outputs static files -4. **Hook system** - Customize the pipeline with `ssg.hooks.transformMarkdown`, `transformHtml`, `emitFiles` +``` +src/content/{locale}/*.md + │ + ▼ +flatwaveContent() plugin + ├─ Indexes content → virtual:flatwave/content + ├─ Compiles Markdown → HTML during build + └─ SSG pipeline → dist/{locale}/{slug}/index.html + ▲ + │ rendered via + Your App.tsx using FlatwaveLanguageRouter + FlatwaveMDPageComponent +``` + +The routes are **generated by your project** — you compose `FlatwaveLanguageRouter` and +`FlatwaveMDPageComponent` in your `App.tsx`, and Vite processes those components to produce the static +HTML for every content route. ## Install @@ -24,14 +41,16 @@ Vite plugin for Markdown-driven, i18n-aware static React sites with composable R npm install @kamansoft/vite-plugin-flatwave-react react-markdown react-helmet-async react-router-dom ``` -Peer dependencies required at runtime: +Peer dependencies: -- `react` >= 18.0.0 -- `react-dom` >= 18.0.0 -- `react-markdown` ^10.0.0 -- `react-helmet-async` ^2.0.0 -- `react-router-dom` ^6.0.0 -- `vite` ^5.0.0 || ^6.0.0 || ^7.0.0 +| Package | Version | +| -------------------- | -------------------------------- | +| `react` | `>=18.0.0` | +| `react-dom` | `>=18.0.0` | +| `react-markdown` | `^10.0.0` | +| `react-helmet-async` | `^2.0.0` | +| `react-router-dom` | `^6.0.0` | +| `vite` | `^5.0.0 \|\| ^6.0.0 \|\| ^7.0.0` | ## Configure Vite @@ -49,20 +68,14 @@ export default defineConfig({ contentDir: path.resolve(__dirname, 'src/content'), locales: ['es', 'pt'], defaultLocale: 'es', - strictMissingLocales: false, - componentsDir: path.resolve(__dirname, 'src/components'), sitemap: { hostname: 'https://example.com' }, - ssg: { - enabled: true, - hooks: { - transformMarkdown: async (md, ctx) => md + '\n\nBuilt with Flatwave.', - }, - }, }), ], }); ``` +No `componentsDir` required. Routes are generated from your composable `App.tsx` setup. + ## Content Layout ```text @@ -74,139 +87,229 @@ src/ pt/ index.md about.md - components/ - SimplePage.tsx ``` ### Markdown Frontmatter ```yaml --- -title: 'Page title' -slug: 'page-slug' -id: 'unique-id' -component: 'SimplePage' +title: 'About Us' +slug: 'about' +id: 'about' public: true -description: 'Short description' -canonical: '/es/page-slug' +description: 'Learn about us' +canonical: '/es/about' robots: 'index, follow' og: - title: 'OG Title' - description: 'OG Description' + title: 'About Us' + description: 'Learn about us' --- -Markdown body here. -``` +# About Us -## Composable React Components +Content here. +``` -### FlatwaveMDComponent +## Build Your App with Composable Components -Renders Markdown content. Use in SSG mode (`markdownHtml`) or client-side (`markdown`). +Wire `FlatwaveLanguageRouter` and `FlatwaveMDPageComponent` in your `App.tsx`. During `vite build`, +these same components are used to generate the static HTML for every route. ```tsx -import { FlatwaveMDComponent } from '@kamansoft/vite-plugin-flatwave-react/react'; +// src/App.tsx +import { + FlatwaveLanguageRouter, + FlatwaveMDPageComponent, + FlatwaveLanguageSelector, + useFlatwaveRoutes, + useFlatwaveLanguage, + getContent, +} from '@kamansoft/vite-plugin-flatwave-react/react'; + +function Layout({ children, locale }: { children: React.ReactNode; locale: string }) { + return ( + <div lang={locale}> + <header> + <FlatwaveLanguageSelector getLabel={(l) => ({ es: 'Español', pt: 'Português' })[l] || l} /> + </header> + <main>{children}</main> + </div> + ); +} + +export function App() { + const { locale } = useFlatwaveLanguage(); + const routes = useFlatwaveRoutes(locale); // ← explicit: you choose the data source -function MyContent(props: FlatwaveMDComponentProps) { return ( - <FlatwaveMDComponent - frontmatter={props.frontmatter} - markdownHtml={compiledHtml} - locale={props.locale} + <FlatwaveLanguageRouter + supportedLanguages={['es', 'pt']} + defaultLanguage="es" + routes={routes} + layoutWrapper={Layout} + renderPage={(route, lang) => ( + <FlatwaveMDPageComponent + frontmatter={route.frontmatter} + markdownHtml={getContent(route.contentId, lang)?.body} + locale={lang} + /> + )} /> ); } ``` -Props: +> See `examples/basic-react-site/` for a complete working example. -- `frontmatter` - Content metadata -- `markdownHtml` - Pre-compiled HTML (SSG mode) -- `markdown` - Raw Markdown (client-side mode) -- `locale` - Current locale for context -- `className`, `style` - Optional styling -- `children` - Render prop `(rendered, frontmatter) => ReactNode` +## Automatic Route Generation -### FlatwaveMDPageComponent +When you run `vite build`, the plugin automatically generates: -Full-page wrapper with SEO head tags via `react-helmet-async`. +| Output | Description | +| -------------------------- | ----------------------------------------------- | +| `dist/es/about/index.html` | Pre-rendered HTML for the Spanish About page | +| `dist/pt/about/index.html` | Pre-rendered HTML for the Portuguese About page | +| `dist/sitemap.xml` | SEO sitemap listing all public routes | +| `dist/robots.txt` | Search engine directives linking to sitemap | +| `dist/route-manifest.json` | Full route metadata for tooling | -```tsx -import { FlatwaveMDPageComponent } from '@kamansoft/vite-plugin-flatwave-react/react'; +Routes are generated for **every public Markdown file** in your `contentDir`. You control which pages +are public via `public: true` in frontmatter. -function MyPage(props: FlatwaveMDPageProps) { - return <FlatwaveMDPageComponent {...props} pageWrapper={BrandedLayout} />; -} +## Composable Components Reference + +### FlatwaveMDComponent + +Renders Markdown content. Accepts pre-compiled HTML (`markdownHtml`) for SSG mode or raw Markdown +(`markdown`) for client-side rendering. + +```tsx +<FlatwaveMDComponent + frontmatter={frontmatter} + markdownHtml={compiledHtml} // SSG mode: pre-compiled HTML + locale="es" + className="prose" +/> ``` -Props: +Key props: `frontmatter`, `markdownHtml`, `markdown`, `locale`, `className`, `style`, +`children: (rendered, fm) => ReactNode`. + +### FlatwaveMDPageComponent + +Full-page wrapper — delegates rendering to `FlatwaveMDComponent` and adds SEO head tags via +`react-helmet-async` (`<title>`, `<meta name="description">`, `<link rel="canonical">`, Open Graph). -- All `FlatwaveMDComponent` props -- `pageWrapper?: React.ComponentType<{ children, frontmatter, locale }>` - Layout wrapper -- `loadingFallback?: React.ReactNode` - Loading state +```tsx +<FlatwaveMDPageComponent + frontmatter={frontmatter} + markdownHtml={compiledHtml} + locale="es" + pageWrapper={Layout} // optional: custom layout component + loadingFallback={<Spinner />} // optional: shown while content loads +/> +``` ### FlatwaveLanguageRouter -Complete router setup with language detection. +Convenience wrapper that composes `BrowserRouter` + `FlatwaveLanguageDetector` + `FlatwaveAppRoutes`. ```tsx -import { FlatwaveLanguageRouter } from '@kamansoft/vite-plugin-flatwave-react/react'; +<FlatwaveLanguageRouter + supportedLanguages={['es', 'pt']} + defaultLanguage="es" + routes={routes} // explicit: pass your route array + renderPage={(route, locale) => <FlatwaveMDPageComponent ... />} + layoutWrapper={MyLayout} + onLanguageChange={(lang) => i18n.changeLanguage(lang)} +/> +``` -function App() { - return ( - <FlatwaveLanguageRouter - supportedLanguages={['es', 'pt']} - defaultLanguage="es" - onLanguageChange={(lang) => console.log('Language changed:', lang)} - layoutWrapper={Layout} - renderPage={(route, locale) => ( - <FlatwaveMDPageComponent frontmatter={route.frontmatter} locale={locale} /> - )} - /> - ); -} +### FlatwaveLanguageDetector (standalone) + +Use inside your own `BrowserRouter` to add language detection without replacing your router setup. + +```tsx +<BrowserRouter> + <FlatwaveLanguageDetector supportedLanguages={['es', 'pt']} defaultLanguage="es"> + <MyAppRoutes /> + </FlatwaveLanguageDetector> +</BrowserRouter> ``` -See: `examples/basic-react-site/` for a working demonstration. +### FlatwaveLanguageSelector + +Renders a language switcher. Reads supported languages from `FlatwaveLanguageContext`. + +```tsx +<FlatwaveLanguageSelector + getLabel={(lang) => ({ es: 'Español', pt: 'Português' })[lang] || lang} + renderOption={(lang, label, isActive) => <button disabled={isActive}>{label}</button>} +/> +``` ## React Hooks ```ts import { - useFlatwaveContent, - useFlatwaveRoutes, - useFlatwaveAlternatives, - useFlatwaveLanguage, + useFlatwaveContent, // get one content entry + useFlatwaveRoutes, // get routes for a locale + useFlatwaveAlternatives, // get alternate-locale URLs for a content ID + useFlatwaveLanguage, // get current locale from context } from '@kamansoft/vite-plugin-flatwave-react/react'; +``` -// Get content by ID -const content = useFlatwaveContent('about', 'es'); - -// Get all routes for a locale -const routes = useFlatwaveRoutes('es'); +## Advanced: Custom SSG Hooks -// Get alternative language URLs -const alts = useFlatwaveAlternatives('about', 'es'); +Customise the build pipeline with hooks: -// Get current locale from context -const { locale, supportedLanguages } = useFlatwaveLanguage(); +```ts +flatwaveContent({ + // ... + ssg: { + hooks: { + // Transform Markdown before HTML compilation + transformMarkdown: async (md, ctx) => md + '\n\n---\n*Built with Flatwave*', + // Inject analytics into every generated page + transformHtml: async (html, ctx) => + html.replace('</body>', '<script>/* analytics */</script></body>'), + // Emit extra files (e.g. navigation JSON) + emitFiles: ({ routes }) => [ + { + fileName: 'navigation.json', + source: JSON.stringify(routes.map((r) => ({ url: r.path, title: r.metadata.title }))), + }, + ], + }, + }, +}); ``` -## Build Outputs +## Advanced: Custom Render Strategy + +Override how each route is rendered to HTML: -During `vite build`, the plugin generates: +```ts +import { flatwaveContent, type RenderStrategy, type RenderContext } from '@kamansoft/vite-plugin-flatwave-react'; + +class MyStrategy implements RenderStrategy { + async render(ctx: RenderContext): Promise<string> { + return `<html><body><h1>${ctx.contentEntry.frontmatter.title}</h1></body></html>`; + } +} -- `/es/about/index.html`, `/pt/about/index.html` - Locale-prefixed static HTML -- `route-manifest.json` - All route metadata -- `sitemap.xml` - SEO sitemap -- `robots.txt` - Search engine directives +flatwaveContent({ ssg: { strategy: new MyStrategy() }, ... }) +``` ## Validation CLI ```bash -npx flatwave-validate --content-dir src/content --locales es,pt --default-locale es +npx flatwave-validate \ + --content-dir src/content \ + --locales es,pt \ + --default-locale es ``` -Use `--strict-missing` to fail when locale variants are missing. +Use `--strict-missing` to fail the build when a content file is missing its counterpart in another locale. ## License From a504ef69768c548487a55d44d8cc2d6592c0f29d Mon Sep 17 00:00:00 2001 From: lemys lopez <lemyskaman@gmail.com> Date: Sat, 20 Jun 2026 19:59:28 -0500 Subject: [PATCH 04/10] refined specs artifacts --- .../design.md | 32 ++++++------- .../proposal.md | 33 ++++++------- .../specs/flatwave-language-router/spec.md | 33 +++---------- .../specs/flatwave-md-page-component/spec.md | 33 ++++++------- .../routing-toolkit-non-intrusive/spec.md | 46 +++++++++++-------- .../tasks.md | 40 ++++++++-------- 6 files changed, 98 insertions(+), 119 deletions(-) diff --git a/openspec/changes/make-plugin-non-intrusive-routing/design.md b/openspec/changes/make-plugin-non-intrusive-routing/design.md index b4af27a..308c103 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/design.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/design.md @@ -31,22 +31,22 @@ interoperability issue with `react-helmet-async` and `react-markdown` in the Nod **Goals:** -- `DefaultRenderStrategy` uses `FlatwaveMDPageComponent` as its primary (and default) renderer. +- `DefaultRenderStrategy` uses `FlatwaveMDPageComponent` as its **only** renderer. - `FlatwaveMDPageComponent` is fixed to work correctly in a Node `renderToString` context by resolving the `react-helmet-async` and `react-markdown` CJS/ESM interop issues. -- `componentsDir` becomes an _optional override_ mechanism, not a required configuration. -- The `component` frontmatter field is no longer in `requiredFields` by default; it is read as an override - when present. +- `componentsDir` is **removed entirely** from plugin config. +- The `component` frontmatter field is **removed entirely** — not in `requiredFields`, not read. - The example site demonstrates the composable pattern: `FlatwaveLanguageRouter` + `FlatwaveMDPageComponent` in `App.tsx`; `vite build` automatically generates all locale-prefixed HTML routes from that. - All existing e2e tests continue to pass. +- The plugin provides a strategy pattern for build processes, allowing users to define custom logic to create additional files (e.g., JSON) from the recursive markdown content loop during rendering. **Non-Goals:** - Removing the SSG pipeline or making it opt-in. -- Removing `componentsDir` support (keep for backward compatibility). - Changing the virtual module API. - Changing any composable component prop APIs. +- Over-abstraction or over-engineering; the implementation follows DRY and SOLID principles. ## Decisions @@ -70,31 +70,27 @@ component files. Apply the same fix consistently. tags into a side-channel object that can be extracted. In non-SSG (client-side) usage, the consumer wraps their app in `<HelmetProvider>` via their own entry point. -### Decision 3: `buildComponentsMap` is called only when `componentsDir` is configured +### Decision 3: `buildComponentsMap` is **removed** from `runSsg.ts` -`runSsg.ts` currently calls `buildComponentsMap(routes)` unconditionally. After this change, it SHALL only -call this function when `options.componentsDir` is non-null. This avoids a warning-filled build log for -the common case where no `componentsDir` is set. +The `buildComponentsMap` function and its call are deleted entirely. `RenderContext.components` is always +an empty `Map()`. The SSG no longer loads consumer components by name. -### Decision 4: `component` removed from default `requiredFields` +### Decision 4: `component` removed from `requiredFields` entirely -`requiredFields` defaults to `['title', 'slug', 'id', 'component', 'public']`. The `component` field is -no longer required for SSG to work because `FlatwaveMDPageComponent` handles any content entry without -it. Remove `component` from the default list. Consumers who still use the component-by-name override can -add it back explicitly. +`requiredFields` defaults to `['title', 'slug', 'id', 'public']`. The `component` field is no longer +valid in frontmatter — it is ignored if present. ### Decision 5: Example site rewritten to use composable pattern `examples/basic-react-site/src/App.tsx` is rewritten to use `FlatwaveLanguageRouter` with `FlatwaveMDPageComponent` as the `renderPage`. `componentsDir` is removed from `vite.config.ts`. -Frontmatter files no longer need `component: 'SimplePage'`. The e2e tests must still pass after this -change — they check title, locale, sitemap, and robots, all of which remain correct. +Frontmatter files no longer have `component` field. The e2e tests must still pass after this +change — they check title, locale, sitemap, and robots, all of which remain valid. ## Risks / Trade-offs | Risk | Mitigation | | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `react-helmet-async` fix may not cover all edge cases | Test with `renderToString` in unit tests for `DefaultRenderStrategy` | -| Removing `component` from `requiredFields` breaks validation for projects relying on it | Documented as a breaking change; consumers can re-add it to `requiredFields` explicitly | +| Removing `component` from frontmatter breaks validation for projects relying on it | Documented as a breaking change | | Example site e2e assertions depend on rendered page title | `FlatwaveMDPageComponent` renders `<title>` from frontmatter — assertions remain valid | -| Consumer components (SimplePage, ProgramPage) still work via override | Backward compat preserved via `componentsDir` when configured | diff --git a/openspec/changes/make-plugin-non-intrusive-routing/proposal.md b/openspec/changes/make-plugin-non-intrusive-routing/proposal.md index dbaf067..36c01f4 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/proposal.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/proposal.md @@ -14,10 +14,8 @@ The same plugin already exports `FlatwaveMDPageComponent` — a fully capable, S component designed exactly for rendering a Markdown content entry as a complete HTML page. There is no reason for the SSG to bypass it in favour of loading arbitrary consumer components by name. -The fix: `DefaultRenderStrategy` uses `FlatwaveMDPageComponent` as its primary (and default) renderer. -Consumer page components registered via `componentsDir` remain supported as overrides for advanced cases, -but they are no longer the required path. `componentsDir` and the frontmatter `component` field are -deprecated in the default workflow. +The fix: `DefaultRenderStrategy` uses `FlatwaveMDPageComponent` as its **only** renderer. +`componentsDir` and the frontmatter `component` field are **removed entirely**. On the application side, `FlatwaveLanguageRouter` defines the client-side routing. Because the SSG and the client-side SPA now use the same `FlatwaveMDPageComponent` to render content, hydration is coherent: the @@ -25,12 +23,12 @@ HTML the SSG produces matches the DOM the SPA would render for the same route. ## What Changes -- **MODIFIED**: `DefaultRenderStrategy` uses `FlatwaveMDPageComponent` as the primary renderer. +- **MODIFIED**: `DefaultRenderStrategy` uses `FlatwaveMDPageComponent` as its **only** renderer. It no longer requires a `componentsDir` or a `component` frontmatter field to produce valid HTML output. -- **DEPRECATED**: `componentsDir` config option (still honoured for backward compatibility; consumer - components loaded by name remain an advanced override path). -- **DEPRECATED**: `component` frontmatter field as a _required_ field (no longer in `requiredFields` - by default; still read when present for the advanced override path). +- **REMOVED**: `componentsDir` config option — no longer accepted in plugin options. +- **REMOVED**: `component` frontmatter field — no longer read or validated. +- **ADDED**: Strategy pattern for build process extensibility - `onContentProcessed` hook in SSG options + allows users to define custom logic to create additional files (e.g., JSON) from the markdown content loop. - **KEPT**: `FlatwaveLanguageRouter`, `FlatwaveAppRoutes`, `FlatwaveLanguageSelector`, `FlatwaveMDComponent`, `FlatwaveMDPageComponent`, `FlatwaveLanguageDetector`, `FlatwaveLanguageContext` — all composable components unchanged in API. @@ -41,6 +39,7 @@ HTML the SSG produces matches the DOM the SPA would render for the same route. - **MODIFIED**: README rewritten to show composable pattern as the primary usage: consumer builds their app with `FlatwaveLanguageRouter` + `FlatwaveMDPageComponent`; Vite generates static routes from those components automatically. +- **ADDED**: Documentation of strategy pattern in README showing how to extend build process without modifying core. ## Capabilities @@ -51,22 +50,20 @@ HTML the SSG produces matches the DOM the SPA would render for the same route. ### Modified Capabilities -- `flatwave-md-page-component`: `DefaultRenderStrategy` SHALL use `FlatwaveMDPageComponent` as the primary - renderer (not a fallback). The component must be server-render-compatible (fix ESM/CJS interop for +- `flatwave-md-page-component`: `DefaultRenderStrategy` SHALL use `FlatwaveMDPageComponent` as the **only** + renderer. The component must be server-render-compatible (fix ESM/CJS interop for `react-helmet-async` and `react-markdown` in Node SSG context). -- `flatwave-language-router`: The `routes` prop is now explicit. The component SHALL NOT silently fall back +- `flatwave-language-router`: The `routes` prop is now explicit (required). The component SHALL NOT silently fall back to calling `getRoutes(lang)` from the virtual module. Consumer supplies routes — typically via the `useFlatwaveRoutes(locale)` hook. - `flatwave-app-routes`: Same routes-explicit requirement as `flatwave-language-router`. ## Impact -- `src/ssg/DefaultRenderStrategy.tsx`: Primary render path changed to `FlatwaveMDPageComponent`. -- `src/ssg/runSsg.ts`: `buildComponentsMap` step becomes optional; called only when `componentsDir` is - configured. +- `src/ssg/DefaultRenderStrategy.tsx`: Render path changed to use `FlatwaveMDPageComponent` as only renderer. +- `src/ssg/runSsg.ts`: `buildComponentsMap` function and call **removed entirely**. `componentsDir` removed from options. - `src/react/FlatwaveMDPageComponent.tsx`: Must be fixed for server-side `renderToString` compatibility - (react-helmet-async `HelmetProvider` wrapping; react-markdown dynamic import or pure-HTML fallback when - `markdownHtml` is already provided). + (react-helmet-async `HelmetProvider` wrapping; react-markdown namespace import for CJS/ESM interop). - `examples/basic-react-site/`: `App.tsx` rewritten to use `FlatwaveLanguageRouter`; `componentsDir` - removed from `vite.config.ts`; frontmatter `component` field optional. + removed from `vite.config.ts`; frontmatter `component` field **removed**. - `packages/vite-plugin-flatwave-react/README.md`: Primary usage example updated. diff --git a/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-language-router/spec.md b/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-language-router/spec.md index 887931b..a93444c 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-language-router/spec.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-language-router/spec.md @@ -1,33 +1,9 @@ ## MODIFIED Requirements -### Requirement: FlatwaveLanguageRouter renders routes via a renderPage render prop +### Requirement: FlatwaveLanguageRouter requires explicit routes prop `FlatwaveLanguageRouter` SHALL accept a REQUIRED `renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode` -prop. It SHALL also accept an optional `routes?: FlatwaveRoute[]` prop. - -**When `routes` is provided**, the router uses those routes directly. - -**When `routes` is absent**, the consumer MUST supply routes by reading from the virtual module in their -own code and passing the result. The canonical pattern is: - -```tsx -import { useFlatwaveRoutes } from '@kamansoft/vite-plugin-flatwave-react/react'; - -function App() { - const { locale } = useFlatwaveLanguage(); - const routes = useFlatwaveRoutes(locale); - return ( - <FlatwaveLanguageRouter - supportedLanguages={['es', 'pt']} - defaultLanguage="es" - routes={routes} - renderPage={(route, lang) => ( - <FlatwaveMDPageComponent frontmatter={route.frontmatter} locale={lang} /> - )} - /> - ); -} -``` +prop. It SHALL also accept a REQUIRED `routes: FlatwaveRoute[]` prop. The router SHALL NOT silently call `getRoutes(lang)` from the virtual module as an implicit internal side-effect. The data source must be explicit in consumer code. @@ -48,6 +24,11 @@ side-effect. The data source must be explicit in consumer code. - **WHEN** `routes={customApiRoutes}` is passed where routes came from an external API or static config - **THEN** the router renders those custom routes without error or conflict with the virtual module +#### Scenario: TypeScript error when routes prop is absent + +- **WHEN** a consumer renders `<FlatwaveLanguageRouter renderPage={fn} />` without the `routes` prop +- **THEN** TypeScript emits a compile-time error (routes is required) + --- ## REMOVED Requirements diff --git a/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-md-page-component/spec.md b/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-md-page-component/spec.md index 386e6e3..525cca2 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-md-page-component/spec.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/specs/flatwave-md-page-component/spec.md @@ -1,34 +1,23 @@ ## MODIFIED Requirements -### Requirement: DefaultRenderStrategy uses FlatwaveMDPageComponent as primary renderer +### Requirement: DefaultRenderStrategy uses FlatwaveMDPageComponent as only renderer -The plugin's `DefaultRenderStrategy` SHALL use `FlatwaveMDPageComponent` as the **primary renderer** +The plugin's `DefaultRenderStrategy` SHALL use `FlatwaveMDPageComponent` as the **only renderer** for every SSG route. It SHALL pass `markdownHtml` (pre-compiled body), `frontmatter`, and `locale` from `RenderContext` to the component, wrapping it in `HelmetProvider` for correct SSR head tag extraction. -`DefaultRenderStrategy` SHALL fall back to a consumer-supplied component only when ALL of the following -are true: +There is no fallback to consumer-supplied components. The `componentsDir` config option and the +`component` frontmatter field are removed entirely. -1. `componentsDir` is configured in plugin options, AND -2. The route's `component` frontmatter field names a module that resolves in `componentsDir` +#### Scenario: SSG generates route using FlatwaveMDPageComponent -When neither condition is met, `FlatwaveMDPageComponent` is used — never the compiled markdown string -alone. - -#### Scenario: SSG generates route using FlatwaveMDPageComponent by default - -- **WHEN** no `componentsDir` is configured and frontmatter has no `component` field +- **WHEN** the SSG pipeline runs for any route - **THEN** `DefaultRenderStrategy` renders `<HelmetProvider><FlatwaveMDPageComponent ... /></HelmetProvider>` via `renderToString` and produces valid HTML with `<main>`, rendered markdown body, and head tags -#### Scenario: SSG uses consumer component when override is found - -- **WHEN** `componentsDir` is configured AND `route.component === 'ProgramPage'` resolves in that directory -- **THEN** `DefaultRenderStrategy` renders using the `ProgramPage` module instead - #### Scenario: No data-ssg-error appears in generated HTML -- **WHEN** the SSG pipeline runs and no consumer component override is found +- **WHEN** the SSG pipeline runs - **THEN** no `<p data-ssg-error>` element appears in any generated HTML file; all pages have the full markdown content rendered @@ -44,3 +33,11 @@ content in `<main>`, sets head tags, and handles all the concerns of a complete the default, not the fallback. **Migration**: No consumer action required. The change is internal to `DefaultRenderStrategy`. + +### Requirement: DefaultRenderStrategy loads consumer components from componentsDir by name + +**Reason**: The component-by-name loading via `componentsDir` and `component` frontmatter field is +removed entirely. `FlatwaveMDPageComponent` is the single source of truth for rendering. + +**Migration**: Consumers using custom page components must compose them with `FlatwaveMDPageComponent` +in their `renderPage` function. diff --git a/openspec/changes/make-plugin-non-intrusive-routing/specs/routing-toolkit-non-intrusive/spec.md b/openspec/changes/make-plugin-non-intrusive-routing/specs/routing-toolkit-non-intrusive/spec.md index eded325..471e5fb 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/specs/routing-toolkit-non-intrusive/spec.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/specs/routing-toolkit-non-intrusive/spec.md @@ -13,13 +13,11 @@ and the Flatwave composable React components. No intrusive component-by-name loa ### Requirement: SSG generates routes through FlatwaveMDPageComponent, not component-name loading -The SSG pipeline (`DefaultRenderStrategy`) SHALL use `FlatwaveMDPageComponent` as its **primary renderer** +The SSG pipeline (`DefaultRenderStrategy`) SHALL use `FlatwaveMDPageComponent` as its **only renderer** for every content route. It SHALL NOT require a `component` frontmatter field or a `componentsDir` configuration to produce valid, SEO-compatible HTML output. -When `componentsDir` IS configured AND the route's `component` frontmatter field names a module that -resolves inside that directory, `DefaultRenderStrategy` MAY use that module as an override. This override -path is for advanced customisation only and is not the default workflow. +The `componentsDir` config option and the `component` frontmatter field are **removed entirely**. #### Scenario: Route HTML generated without componentsDir or component field @@ -34,31 +32,23 @@ path is for advanced customisation only and is not the default workflow. - **THEN** the generated HTML contains `<title>About Us`, ``, and `` in the `` section -#### Scenario: Consumer component override still works when componentsDir is set - -- **WHEN** `componentsDir` is configured and a frontmatter `component: 'ProgramPage'` resolves to a - module in that directory -- **THEN** `DefaultRenderStrategy` renders using `ProgramPage` instead of `FlatwaveMDPageComponent` - --- -### Requirement: component is not a required frontmatter field by default +### Requirement: component frontmatter field is removed entirely -The `component` field SHALL be removed from the default `requiredFields` list. +The `component` field SHALL be removed from `requiredFields` entirely. `requiredFields` SHALL default to `['title', 'slug', 'id', 'public']`. -Consumers who rely on the component-override workflow SHALL add `'component'` to `requiredFields` -explicitly via plugin config. +The `component` field is no longer a valid frontmatter field — it is ignored if present in Markdown files. #### Scenario: Build succeeds without component in frontmatter - **WHEN** all Markdown files omit the `component` frontmatter field -- **AND** `componentsDir` is not configured - **THEN** `vite build` completes without validation errors or warnings about missing `component` -#### Scenario: Consumer re-adds component to requiredFields +#### Scenario: component field in frontmatter is ignored -- **WHEN** `requiredFields: ['title', 'slug', 'id', 'public', 'component']` is set in plugin config -- **THEN** validation fails with an error for any Markdown file that omits the `component` field +- **WHEN** a Markdown file contains a `component` frontmatter field +- **THEN** the field is ignored during validation and route generation; no error or warning is emitted --- @@ -137,3 +127,23 @@ These outputs are automatic — the consumer does not need to configure them to - **WHEN** `markdownHtml` is a non-empty compiled HTML string - **THEN** `FlatwaveMDPageComponent` renders the HTML via `dangerouslySetInnerHTML` without attempting to load or execute `react-markdown` + +--- + +### Requirement: Strategy pattern for build process extensibility + +The plugin SHALL provide a strategy pattern that allows users to define custom logic to create additional files +(e.g., JSON files) from the recursive markdown content loop during the build process. + +#### Scenario: User defines custom strategy to generate JSON from markdown content + +- **WHEN** the user provides a strategy function that processes markdown content during build +- **AND** the strategy is configured in the plugin options +- **THEN** the strategy function is called for each markdown file during the SSG rendering loop +- **AND** the user can create additional output files (e.g., JSON) based on the markdown content + +#### Scenario: Strategy receives markdown metadata and content + +- **WHEN** the strategy function is invoked during build +- **THEN** it receives the frontmatter, slug, locale, and compiled HTML for each markdown file +- **AND** the user can access all content data to generate custom output files diff --git a/openspec/changes/make-plugin-non-intrusive-routing/tasks.md b/openspec/changes/make-plugin-non-intrusive-routing/tasks.md index 9911c97..f3b09b5 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/tasks.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/tasks.md @@ -1,35 +1,33 @@ ## 1. Fix FlatwaveMDPageComponent for server-side rendering - [ ] 1.1 Update `FlatwaveMDPageComponent.tsx`: change `import { Helmet } from 'react-helmet-async'` to - `import * as ReactHelmet from 'react-helmet-async'; const Helmet = ReactHelmet.Helmet;` + `import * as ReactHelmet from 'react-helmet-async'; const { Helmet, HelmetProvider } = ReactHelmet;` - [ ] 1.2 Confirm `react-markdown` import in `FlatwaveMDComponent.tsx` already uses namespace import `import * as ReactMarkdown from 'react-markdown'`; fix if not - [ ] 1.3 Rebuild plugin: `npm run build:plugin` - [ ] 1.4 Write a unit test (`DefaultRenderStrategy.test.ts`) that calls `renderToString` with `FlatwaveMDPageComponent` and asserts the output contains `
`, the markdown body, and `` -## 2. Update DefaultRenderStrategy to use FlatwaveMDPageComponent as primary renderer +## 2. Update DefaultRenderStrategy to use FlatwaveMDPageComponent as only renderer -- [ ] 2.1 Rewrite `DefaultRenderStrategy.tsx`: primary render path calls +- [ ] 2.1 Rewrite `DefaultRenderStrategy.tsx`: render path calls `renderToString(<HelmetProvider><FlatwaveMDPageComponent frontmatter={...} markdownHtml={...} locale={...} /></HelmetProvider>)` - using `import * as ReactHelmet from 'react-helmet-async'; const HelmetProvider = ReactHelmet.HelmetProvider;` -- [ ] 2.2 Keep the consumer-component override path: when `componentModule` is found via `componentsDir`, - use that component; otherwise use `FlatwaveMDPageComponent` -- [ ] 2.3 Remove the raw-markdown fallback (`return compiledBody`) — it MUST NOT appear anywhere in the - success path + using `import * as ReactHelmet from 'react-helmet-async'; const { HelmetProvider } = ReactHelmet;` +- [ ] 2.2 Remove the consumer-component override path entirely — no `componentsDir`, no `buildComponentsMap` +- [ ] 2.3 Remove the raw-markdown fallback (`return compiledBody`) — it MUST NOT appear anywhere - [ ] 2.4 Ensure error catch blocks still return `<p data-ssg-error>...</p>` for unexpected exceptions -## 3. Remove component from default requiredFields +## 3. Remove component from requiredFields entirely - [ ] 3.1 In `normalizeOptions()` (`src/index.ts`), change default `requiredFields` from `['title', 'slug', 'id', 'component', 'public']` to `['title', 'slug', 'id', 'public']` -- [ ] 3.2 Update content validation docs and README to reflect new default +- [ ] 3.2 Remove `component` from validation entirely — it is not a valid frontmatter field -## 4. Make componentsDir optional in runSsg +## 4. Remove componentsDir and buildComponentsMap from runSsg -- [ ] 4.1 In `runSsg.ts`, wrap `buildComponentsMap(routes)` call in a guard: - `if (options.componentsDir) { components = await buildComponentsMap(routes); }` -- [ ] 4.2 Pass empty `Map()` to `RenderContext.components` when `componentsDir` is not configured +- [ ] 4.1 In `runSsg.ts`, remove `buildComponentsMap` function entirely +- [ ] 4.2 Remove `componentsDir` from `SsgOptions` type and plugin options +- [ ] 4.3 Pass empty `Map()` to `RenderContext.components` always ## 5. Make routes explicit in FlatwaveLanguageRouter and FlatwaveAppRoutes @@ -47,8 +45,7 @@ frontmatter files - [ ] 6.3 Rewrite `examples/basic-react-site/src/App.tsx` to use `FlatwaveLanguageRouter` + `FlatwaveMDPageComponent` (with `useFlatwaveRoutes` providing explicit routes) -- [ ] 6.4 Remove or simplify `SimplePage.tsx` and `ProgramPage.tsx` — they are no longer required - by the SSG pipeline; keep them only if the example still wants to show custom component overrides +- [ ] 6.4 Remove `SimplePage.tsx` and `ProgramPage.tsx` — they are no longer used - [ ] 6.5 Run `npm run build:example` and verify all HTML files are generated correctly with no `data-ssg-error` elements @@ -60,8 +57,9 @@ ## 8. Update README -- [ ] 8.1 Update "How It Works" section: route generation is driven by content + composable components -- [ ] 8.2 Remove `componentsDir` from the primary `vite.config.ts` example -- [ ] 8.3 Remove `component: 'SimplePage'` from the primary frontmatter example -- [ ] 8.4 Add "Automatic Route Generation" section showing `FlatwaveLanguageRouter` → SSG HTML flow -- [ ] 8.5 Update "Build Outputs" section to clarify these come from the composable components +| [ ] 8.1 Update "How It Works" section: route generation is driven by content + composable components +| [ ] 8.2 Remove `componentsDir` from the primary `vite.config.ts` example +| [ ] 8.3 Remove `component: 'SimplePage'` from the primary frontmatter example +| [ ] 8.4 Add "Automatic Route Generation" section showing `FlatwaveLanguageRouter` → SSG HTML flow +| [ ] 8.5 Update "Build Outputs" section to clarify these come from the composable components +| From dfdd255909a125bc8b13d570fbe13b4de394d1e6 Mon Sep 17 00:00:00 2001 From: lemys lopez <lemyskaman@gmail.com> Date: Sun, 21 Jun 2026 14:56:48 -0500 Subject: [PATCH 05/10] feat: Implement composable non-intrusive routing architecture --- README.md | 32 +++---- docs/Architecture.md | 7 +- e2e/example.test.ts | 2 - examples/basic-react-site/src/App.tsx | 49 +++++----- .../src/components/ProgramPage.tsx | 13 --- .../src/components/SimplePage.tsx | 10 --- .../basic-react-site/src/content/es/about.md | 1 - .../basic-react-site/src/content/es/index.md | 1 - .../src/content/es/program.md | 1 - .../basic-react-site/src/content/pt/about.md | 1 - .../basic-react-site/src/content/pt/index.md | 3 +- .../src/content/pt/program.md | 1 - examples/basic-react-site/vite.config.ts | 1 - .../design.md | 10 +-- .../tasks.md | 44 ++++----- .../src/cli/validate.ts | 11 --- .../src/content/indexer.ts | 1 - .../src/content/routeBuilder.ts | 1 - .../src/content/scanner.ts | 1 - .../src/content/validator.ts | 63 +------------ .../vite-plugin-flatwave-react/src/index.ts | 5 +- .../src/react/FlatwaveAppRoutes.tsx | 7 +- .../src/react/FlatwaveLanguageRouter.tsx | 3 +- .../src/react/FlatwaveMDPageComponent.tsx | 53 +++++++---- .../src/react/types.ts | 18 +++- .../src/ssg/DefaultRenderStrategy.test.tsx | 89 +++++++++++++++++++ .../src/ssg/DefaultRenderStrategy.tsx | 53 +++++------ .../src/ssg/runSsg.ts | 23 +---- .../vite-plugin-flatwave-react/src/types.ts | 4 - .../src/virtual.d.ts | 39 ++++++-- 30 files changed, 267 insertions(+), 280 deletions(-) delete mode 100644 examples/basic-react-site/src/components/ProgramPage.tsx delete mode 100644 examples/basic-react-site/src/components/SimplePage.tsx create mode 100644 packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.test.tsx diff --git a/README.md b/README.md index 422dcb8..6cd3e9c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A Vite plugin that turns a directory of Markdown files into a **fully typed, i18 At build time the plugin: - Scans `src/content/{locale}/*.md` and parses front-matter with [`gray-matter`](https://github.com/jonschlinkert/gray-matter) -- Validates required fields, duplicate IDs, slugs, component references, and missing locale variants +- Validates required fields, duplicate IDs, slugs, and missing locale variants - Exposes a **virtual module** (`virtual:flatwave/content`) with typed helper functions usable in any React component - Generates locale-prefixed static HTML pages via `react-dom/server` - Emits `sitemap.xml`, `robots.txt`, and `route-manifest.json` @@ -80,7 +80,6 @@ export default defineConfig({ locales: ['es', 'pt'], // all supported locales defaultLocale: 'es', // must be in locales[] strictMissingLocales: false, // true → missing locale = build error - componentsDir: path.resolve(__dirname, 'src/components'), // for component validation sitemap: { hostname: 'https://example.com', // used in sitemap.xml and robots.txt }, @@ -104,11 +103,6 @@ src/ index.md about.md program.md - components/ - SimplePage.tsx ← referenced by component: 'SimplePage' in frontmatter - ProgramPage.tsx - LanguageSwitcher.tsx - MarkdownRenderer.tsx ``` Each locale must mirror the same set of content IDs. Missing locale variants produce warnings (or errors with `strictMissingLocales: true`). @@ -122,7 +116,6 @@ Every `.md` file must include these **required fields**: title: 'About Us' slug: 'about' # URL segment — becomes /{locale}/about id: 'about' # groups translations: same id across locales -component: 'SimplePage' # React component name in componentsDir public: true # false → excluded from routes, sitemap, manifest description: 'Short description' canonical: '/es/about' # optional, defaults to /{locale}/{slug} @@ -142,12 +135,12 @@ jsonLd: # Navigation hints menu: 'main' menu_position: 2 -# Any extra keys are preserved in attributes and forwarded to the component +# Any extra keys are preserved in attributes --- Markdown body here. GitHub-flavoured Markdown. No MDX in v1. ``` -**All extra frontmatter keys** not in the baseline list are preserved in `attributes` and forwarded as props to the React component — no schema changes required. +**All extra frontmatter keys** not in the baseline list are preserved in `attributes` and accessible via React hooks and the virtual module. --- @@ -394,15 +387,13 @@ The plugin validates content at build time (also exposed as a standalone CLI). V | Duplicate content IDs per locale | **Error** — build fails | | Duplicate slugs per locale | **Error** — build fails | | Duplicate menu positions | **Error** — build fails | -| Component not found in `componentsDir` | **Error** — build fails | | Content ID missing in one or more locales | **Warning** (or error with `strictMissingLocales: true`) | | No public routes generated | **Warning** | ```ts flatwaveContent({ // ... - requiredFields: ['title', 'slug', 'id', 'component', 'public'], // default - validateComponents: true, // default: true + requiredFields: ['title', 'slug', 'id', 'public'], // default strictMissingLocales: false, // default: false }); ``` @@ -418,21 +409,18 @@ npx flatwave-validate \ --content-dir src/content \ --locales es,pt \ --default-locale es \ - --components-dir src/components \ --strict-missing # optional: missing locale → error instead of warning # Exit code 0 → passed # Exit code 1 → errors found ``` -| Option | Description | -| --------------------------- | --------------------------------------------------------------------------- | -| `--content-dir <dir>` | Path to the content directory | -| `--locales <list>` | Comma-separated locale identifiers | -| `--default-locale <locale>` | The primary locale | -| `--components-dir <dirs>` | Comma-separated component directories (default: `src/components,src/pages`) | -| `--strict-missing` | Treat missing locale variants as errors | -| `--no-validate-components` | Skip component existence check | +| Option | Description | +| --------------------- | --------------------------------------- | +| `--content-dir <dir>` | Path to the content directory | +| `--locales <list>` | Comma-separated locale identifiers | +| `--default-locale` | The primary locale | +| `--strict-missing` | Treat missing locale variants as errors | --- diff --git a/docs/Architecture.md b/docs/Architecture.md index 8634f94..a8256ec 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -70,7 +70,7 @@ vite-plugin-flatwave-react/ ← npm workspace root │ │ │ ├── parser.ts ← standalone markdown parse (gray-matter wrapper) │ │ │ ├── indexer.ts ← builds FlatwaveContentIndex from scanned files │ │ │ ├── routeBuilder.ts ← assembles FlatwaveRoute[] with SEO metadata -│ │ │ ├── validator.ts ← content rules: required fields, duplicates, components +│ │ │ ├── validator.ts ← content rules: required fields, duplicates │ │ │ └── markdownCompiler.ts ← unified/remark/rehype markdown → HTML │ │ ├── ssg/ │ │ │ ├── runSsg.ts ← orchestrates SSG: renders all routes in batches @@ -192,7 +192,7 @@ graph TB The entry point exports the `flatwaveContent(options)` factory which: -1. **Normalizes options** — fills in defaults for optional fields (`requiredFields`, `validateComponents`, `emitRouteManifest`, `emitSitemap`, `emitRobotsTxt`, `ssg`). +1. **Normalizes options** — fills in defaults for optional fields (`requiredFields`, `emitRouteManifest`, `emitSitemap`, `emitRobotsTxt`, `ssg`). 2. **Returns three plugin objects** that Vite integrates into its build pipeline. ``` @@ -214,6 +214,8 @@ flatwaveContent(options) generateBundle() → runSsg(index, options, assets) → emitFile() ``` +**Note**: `componentsDir` and component validation have been removed. Route rendering now uses `FlatwaveMDPageComponent` as the single default renderer. + --- ### Content Pipeline @@ -270,6 +272,7 @@ src/react/ - **SSG Mode**: When `markdownHtml` prop is provided, renders pre-compiled HTML - **Client-side Mode**: When `markdown` prop is provided, uses `react-markdown` - **Composition**: All components accept render props or wrapper components for customization +- **Default Renderer**: SSG uses `FlatwaveMDPageComponent` as the only renderer. No `componentsDir` or component field is required. --- diff --git a/e2e/example.test.ts b/e2e/example.test.ts index 495da49..a9c3ce8 100644 --- a/e2e/example.test.ts +++ b/e2e/example.test.ts @@ -106,8 +106,6 @@ describe('Flatwave example e2e', () => { 'es,pt', '--default-locale', 'es', - '--components-dir', - 'examples/basic-react-site/src/components', ], { cwd: root, encoding: 'utf-8' } ); diff --git a/examples/basic-react-site/src/App.tsx b/examples/basic-react-site/src/App.tsx index 67f6b07..cb1cda4 100644 --- a/examples/basic-react-site/src/App.tsx +++ b/examples/basic-react-site/src/App.tsx @@ -1,32 +1,29 @@ -import { useMemo } from 'react'; -import { getRoutes, getContent } from 'virtual:flatwave/content'; -import { LanguageSwitcher } from './components/LanguageSwitcher'; -import { MarkdownRenderer } from './components/MarkdownRenderer'; -import { ProgramPage } from './components/ProgramPage'; -import { SimplePage } from './components/SimplePage'; +import { + FlatwaveLanguageRouter, + FlatwaveMDPageComponent, + useFlatwaveRoutes, + useFlatwaveContent, +} from '@kamansoft/vite-plugin-flatwave-react/react'; +import type { FlatwaveFrontmatter } from '@kamansoft/vite-plugin-flatwave-react/types'; export function App() { - const locale = useMemo(() => window.location.pathname.split('/')[1] || 'es', []); - const path = window.location.pathname.replace(/\/$/, '') || `/${locale}`; - const routes = getRoutes(locale); - const route = routes.find((item) => item.path === path) ?? routes[0]; - const content = route ? getRouteContent(route.contentId, locale) : undefined; - - if (!content) { - return <main>Content not found</main>; - } - - const Component = content.component === 'ProgramPage' ? ProgramPage : SimplePage; + const routes = useFlatwaveRoutes(); return ( - <main> - <LanguageSwitcher currentLocale={content.locale} contentId={content.id} /> - <Component content={content} /> - <MarkdownRenderer>{content.body}</MarkdownRenderer> - </main> + <FlatwaveLanguageRouter + supportedLanguages={['es', 'pt']} + defaultLanguage="es" + routes={routes} + renderPage={(route, lang) => { + const content = useFlatwaveContent(route.contentId, lang); + return ( + <FlatwaveMDPageComponent + frontmatter={route.frontmatter as FlatwaveFrontmatter} + markdownHtml={content?.body} + locale={lang} + /> + ); + }} + /> ); } - -function getRouteContent(contentId: string, locale: string) { - return getContent(contentId, locale); -} diff --git a/examples/basic-react-site/src/components/ProgramPage.tsx b/examples/basic-react-site/src/components/ProgramPage.tsx deleted file mode 100644 index 31207e8..0000000 --- a/examples/basic-react-site/src/components/ProgramPage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { FlatwaveVirtualContent } from 'virtual:flatwave/content'; - -export function ProgramPage({ content }: { content: FlatwaveVirtualContent }) { - return ( - <article> - <h1>{content.frontmatter.title as string}</h1> - {content.frontmatter.description ? <p>{String(content.frontmatter.description)}</p> : null} - {content.frontmatter.date ? ( - <time dateTime={String(content.frontmatter.date)}>{String(content.frontmatter.date)}</time> - ) : null} - </article> - ); -} diff --git a/examples/basic-react-site/src/components/SimplePage.tsx b/examples/basic-react-site/src/components/SimplePage.tsx deleted file mode 100644 index ea3a387..0000000 --- a/examples/basic-react-site/src/components/SimplePage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { FlatwaveVirtualContent } from 'virtual:flatwave/content'; - -export function SimplePage({ content }: { content: FlatwaveVirtualContent }) { - return ( - <article> - <h1>{content.frontmatter.title as string}</h1> - {content.frontmatter.description ? <p>{String(content.frontmatter.description)}</p> : null} - </article> - ); -} 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/openspec/changes/make-plugin-non-intrusive-routing/design.md b/openspec/changes/make-plugin-non-intrusive-routing/design.md index 308c103..19eca23 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/design.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/design.md @@ -89,8 +89,8 @@ change — they check title, locale, sitemap, and robots, all of which remain va ## Risks / Trade-offs -| Risk | Mitigation | -| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `react-helmet-async` fix may not cover all edge cases | Test with `renderToString` in unit tests for `DefaultRenderStrategy` | -| Removing `component` from frontmatter breaks validation for projects relying on it | Documented as a breaking change | -| Example site e2e assertions depend on rendered page title | `FlatwaveMDPageComponent` renders `<title>` from frontmatter — assertions remain valid | +| Risk | Mitigation | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `react-helmet-async` fix may not cover all edge cases | Test with `renderToString` in unit tests for `DefaultRenderStrategy` | +| Removing `component` from frontmatter breaks validation for projects relying on it | Documented as a breaking change | +| Example site e2e assertions depend on rendered page title | `FlatwaveMDPageComponent` renders `<title>` from frontmatter — assertions remain valid | diff --git a/openspec/changes/make-plugin-non-intrusive-routing/tasks.md b/openspec/changes/make-plugin-non-intrusive-routing/tasks.md index f3b09b5..5817186 100644 --- a/openspec/changes/make-plugin-non-intrusive-routing/tasks.md +++ b/openspec/changes/make-plugin-non-intrusive-routing/tasks.md @@ -1,52 +1,52 @@ ## 1. Fix FlatwaveMDPageComponent for server-side rendering -- [ ] 1.1 Update `FlatwaveMDPageComponent.tsx`: change `import { Helmet } from 'react-helmet-async'` to +- [x] 1.1 Update `FlatwaveMDPageComponent.tsx`: change `import { Helmet } from 'react-helmet-async'` to `import * as ReactHelmet from 'react-helmet-async'; const { Helmet, HelmetProvider } = ReactHelmet;` -- [ ] 1.2 Confirm `react-markdown` import in `FlatwaveMDComponent.tsx` already uses namespace import +- [x] 1.2 Confirm `react-markdown` import in `FlatwaveMDComponent.tsx` already uses namespace import `import * as ReactMarkdown from 'react-markdown'`; fix if not -- [ ] 1.3 Rebuild plugin: `npm run build:plugin` -- [ ] 1.4 Write a unit test (`DefaultRenderStrategy.test.ts`) that calls `renderToString` with +- [x] 1.3 Rebuild plugin: `npm run build:plugin` +- [x] 1.4 Write a unit test (`DefaultRenderStrategy.test.ts`) that calls `renderToString` with `FlatwaveMDPageComponent` and asserts the output contains `<main>`, the markdown body, and `<title>` ## 2. Update DefaultRenderStrategy to use FlatwaveMDPageComponent as only renderer -- [ ] 2.1 Rewrite `DefaultRenderStrategy.tsx`: render path calls +- [x] 2.1 Rewrite `DefaultRenderStrategy.tsx`: render path calls `renderToString(<HelmetProvider><FlatwaveMDPageComponent frontmatter={...} markdownHtml={...} locale={...} /></HelmetProvider>)` using `import * as ReactHelmet from 'react-helmet-async'; const { HelmetProvider } = ReactHelmet;` -- [ ] 2.2 Remove the consumer-component override path entirely — no `componentsDir`, no `buildComponentsMap` -- [ ] 2.3 Remove the raw-markdown fallback (`return compiledBody`) — it MUST NOT appear anywhere -- [ ] 2.4 Ensure error catch blocks still return `<p data-ssg-error>...</p>` for unexpected exceptions +- [x] 2.2 Remove the consumer-component override path entirely — no `componentsDir`, no `buildComponentsMap` +- [x] 2.3 Remove the raw-markdown fallback (`return compiledBody`) — it MUST NOT appear anywhere +- [x] 2.4 Ensure error catch blocks still return `<p data-ssg-error>...</p>` for unexpected exceptions ## 3. Remove component from requiredFields entirely -- [ ] 3.1 In `normalizeOptions()` (`src/index.ts`), change default `requiredFields` from +- [x] 3.1 In `normalizeOptions()` (`src/index.ts`), change default `requiredFields` from `['title', 'slug', 'id', 'component', 'public']` to `['title', 'slug', 'id', 'public']` -- [ ] 3.2 Remove `component` from validation entirely — it is not a valid frontmatter field +- [x] 3.2 Remove `component` from validation entirely — it is not a valid frontmatter field ## 4. Remove componentsDir and buildComponentsMap from runSsg -- [ ] 4.1 In `runSsg.ts`, remove `buildComponentsMap` function entirely -- [ ] 4.2 Remove `componentsDir` from `SsgOptions` type and plugin options -- [ ] 4.3 Pass empty `Map()` to `RenderContext.components` always +- [x] 4.1 In `runSsg.ts`, remove `buildComponentsMap` function entirely +- [x] 4.2 Remove `componentsDir` from `SsgOptions` type and plugin options +- [x] 4.3 Pass empty `Map()` to `RenderContext.components` always ## 5. Make routes explicit in FlatwaveLanguageRouter and FlatwaveAppRoutes -- [ ] 5.1 Update `FlatwaveLanguageRouter.tsx`: remove internal call to `getRoutes(lang)`; +- [x] 5.1 Update `FlatwaveLanguageRouter.tsx`: remove internal call to `getRoutes(lang)`; require `routes` to be passed via prop; update `FlatwaveLanguageRouterProps` type -- [ ] 5.2 Update `FlatwaveAppRoutes.tsx`: change `routes?: FlatwaveRoute[]` to +- [x] 5.2 Update `FlatwaveAppRoutes.tsx`: change `routes?: FlatwaveRoute[]` to `routes: FlatwaveRoute[]` (required); remove fallback `getRoutes(locale)` call -- [ ] 5.3 Update `FlatwaveAppRoutesProps` and `FlatwaveLanguageRouterProps` in `src/react/types.ts` -- [ ] 5.4 Run `npm run type-check` — fix any resulting TypeScript errors +- [x] 5.3 Update `FlatwaveAppRoutesProps` and `FlatwaveLanguageRouterProps` in `src/react/types.ts` +- [x] 5.4 Run `npm run type-check` — fix any resulting TypeScript errors ## 6. Update example site to composable pattern -- [ ] 6.1 Remove `componentsDir` from `examples/basic-react-site/vite.config.ts` -- [ ] 6.2 Remove `component: 'SimplePage'` / `component: 'ProgramPage'` from all example Markdown +- [x] 6.1 Remove `componentsDir` from `examples/basic-react-site/vite.config.ts` +- [x] 6.2 Remove `component: 'SimplePage'` / `component: 'ProgramPage'` from all example Markdown frontmatter files -- [ ] 6.3 Rewrite `examples/basic-react-site/src/App.tsx` to use `FlatwaveLanguageRouter` + +- [x] 6.3 Rewrite `examples/basic-react-site/src/App.tsx` to use `FlatwaveLanguageRouter` + `FlatwaveMDPageComponent` (with `useFlatwaveRoutes` providing explicit routes) -- [ ] 6.4 Remove `SimplePage.tsx` and `ProgramPage.tsx` — they are no longer used -- [ ] 6.5 Run `npm run build:example` and verify all HTML files are generated correctly with no +- [x] 6.4 Remove `SimplePage.tsx` and `ProgramPage.tsx` — they are no longer used +- [x] 6.5 Run `npm run build:example` and verify all HTML files are generated correctly with no `data-ssg-error` elements ## 7. Validation and CI diff --git a/packages/vite-plugin-flatwave-react/src/cli/validate.ts b/packages/vite-plugin-flatwave-react/src/cli/validate.ts index 5d77452..1505b22 100644 --- a/packages/vite-plugin-flatwave-react/src/cli/validate.ts +++ b/packages/vite-plugin-flatwave-react/src/cli/validate.ts @@ -10,11 +10,6 @@ program .requiredOption('--content-dir <dir>', 'Markdown content directory, e.g. src/content') .requiredOption('--locales <locales>', 'Comma-separated locales, e.g. es,pt') .requiredOption('--default-locale <locale>', 'Default locale, e.g. es') - .option( - '--components-dir <dirs>', - 'Comma-separated component directories', - 'src/components,src/pages' - ) .option('--strict-missing', 'Fail when locale variants are missing', false) .option('--no-validate-components', 'Disable component existence validation') .action( @@ -22,7 +17,6 @@ program contentDir: string; locales: string; defaultLocale: string; - componentsDir: string; strictMissing: boolean; validateComponents: boolean; }) => { @@ -30,16 +24,11 @@ program .split(',') .map((locale) => locale.trim()) .filter(Boolean); - const componentsDir = options.componentsDir - .split(',') - .map((dir) => dir.trim()) - .filter(Boolean); const result = await validateContent({ contentDir: options.contentDir, locales, defaultLocale: options.defaultLocale, - componentsDir, strictMissingLocales: options.strictMissing, validateComponents: options.validateComponents, }); diff --git a/packages/vite-plugin-flatwave-react/src/content/indexer.ts b/packages/vite-plugin-flatwave-react/src/content/indexer.ts index 0762f77..9470b0b 100644 --- a/packages/vite-plugin-flatwave-react/src/content/indexer.ts +++ b/packages/vite-plugin-flatwave-react/src/content/indexer.ts @@ -16,7 +16,6 @@ export async function buildIndex(options: FlatwaveContentOptions): Promise<Flatw slug: file.slug, path: routeForLocaleSlug(file.locale, file.slug), file: file.file, - component: file.frontmatter.component ? String(file.frontmatter.component) : undefined, public: file.frontmatter.public !== false && String(file.frontmatter.public ?? 'true').toLowerCase() !== 'false', diff --git a/packages/vite-plugin-flatwave-react/src/content/routeBuilder.ts b/packages/vite-plugin-flatwave-react/src/content/routeBuilder.ts index 596a171..899a5ad 100644 --- a/packages/vite-plugin-flatwave-react/src/content/routeBuilder.ts +++ b/packages/vite-plugin-flatwave-react/src/content/routeBuilder.ts @@ -37,7 +37,6 @@ export function buildContentIndex(entries: FlatwaveContentEntry[]): { locale: entry.locale, path: entry.route, contentId: entry.id, - component: entry.component, metadata: buildSeoMetadata(entry.frontmatter, entry.route, entry.locale), frontmatter: entry.frontmatter, alternatives, diff --git a/packages/vite-plugin-flatwave-react/src/content/scanner.ts b/packages/vite-plugin-flatwave-react/src/content/scanner.ts index beb88b2..c2c834d 100644 --- a/packages/vite-plugin-flatwave-react/src/content/scanner.ts +++ b/packages/vite-plugin-flatwave-react/src/content/scanner.ts @@ -77,7 +77,6 @@ export function buildContentEntry( slug: parsed.slug, path: route, file: parsed.file, - component: parsed.frontmatter.component ? String(parsed.frontmatter.component) : undefined, public: isPublicEntry(parsed.frontmatter), attributes, frontmatter: parsed.frontmatter, diff --git a/packages/vite-plugin-flatwave-react/src/content/validator.ts b/packages/vite-plugin-flatwave-react/src/content/validator.ts index b2e9d4d..6e2376b 100644 --- a/packages/vite-plugin-flatwave-react/src/content/validator.ts +++ b/packages/vite-plugin-flatwave-react/src/content/validator.ts @@ -1,21 +1,19 @@ -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'; import { buildContentIndex } from './routeBuilder.js'; +export type { ValidationResult } from '../types'; + export async function validateContent(options: FlatwaveContentOptions): Promise<ValidationResult> { const errors: string[] = []; const warnings: string[] = []; - const requiredFields = options.requiredFields ?? ['title', 'slug', 'id', 'component', 'public']; + const requiredFields = options.requiredFields ?? ['title', 'slug', 'id', 'public']; const files = await scanMarkdownFiles(options.contentDir, options.locales); await validateRequiredFields(files, requiredFields, errors); await validateDuplicateIds(files, errors); await validateDuplicateSlugs(files, errors); await validateMenuPositions(files, errors); - await validateComponents(files, options, errors); validateMissingLocales(files, options, warnings); const entries = files.map((file) => { @@ -27,7 +25,6 @@ export async function validateContent(options: FlatwaveContentOptions): Promise< slug: file.slug, path: route, file: file.file, - component: file.frontmatter.component ? String(file.frontmatter.component) : undefined, public: file.frontmatter.public !== false && String(file.frontmatter.public ?? 'true').toLowerCase() !== 'false', @@ -134,55 +131,6 @@ async function validateMenuPositions( } } -async function validateComponents( - files: Awaited<ReturnType<typeof scanMarkdownFiles>>, - options: FlatwaveContentOptions, - errors: string[] -): Promise<void> { - if (options.validateComponents === false) return; - - const available = await discoverComponents(options.componentsDir); - for (const file of files) { - const component = file.frontmatter.component ? String(file.frontmatter.component) : undefined; - if (!component) continue; - if (!available.has(component)) { - errors.push( - `[${file.locale}] ${file.file}: Component '${component}' does not exist in ${formatComponentsDir(options.componentsDir)}.` - ); - } - } -} - -async function discoverComponents(componentsDir?: string | string[]): Promise<Set<string>> { - const dirs = Array.isArray(componentsDir) - ? componentsDir - : componentsDir - ? [componentsDir] - : ['src/components', 'src/pages']; - const components = new Set<string>(); - - for (const dir of dirs) { - const absolute = path.resolve(dir); - let files: Array<{ isFile: () => boolean; name: string }>; - try { - files = (await readdir(absolute, { withFileTypes: true })) as Array<{ - isFile: () => boolean; - name: string; - }>; - } catch { - continue; - } - - for (const file of files) { - if (!file.isFile()) continue; - if (!/\.(tsx?|jsx?)$/.test(file.name)) continue; - components.add(file.name.replace(/\.[^.]+$/, '')); - } - } - - return components; -} - function validateMissingLocales( files: Awaited<ReturnType<typeof scanMarkdownFiles>>, options: FlatwaveContentOptions, @@ -208,8 +156,3 @@ function validateMissingLocales( } } } - -function formatComponentsDir(componentsDir?: string | string[]): string { - if (!componentsDir) return 'src/components or src/pages'; - return Array.isArray(componentsDir) ? componentsDir.join(', ') : componentsDir; -} diff --git a/packages/vite-plugin-flatwave-react/src/index.ts b/packages/vite-plugin-flatwave-react/src/index.ts index ffc1b34..1946da0 100644 --- a/packages/vite-plugin-flatwave-react/src/index.ts +++ b/packages/vite-plugin-flatwave-react/src/index.ts @@ -4,7 +4,6 @@ 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 { runSsg } from './ssg/runSsg.js'; import type { FlatwaveContentIndex, FlatwaveContentOptions } from './types'; const VIRTUAL_ID = '\0virtual:flatwave/content'; @@ -81,6 +80,7 @@ export function flatwaveContent(options: FlatwaveContentOptions): Plugin[] { const html = findIndexHtml(bundle); const assets = extractAssets(html); + const { runSsg } = await import('./ssg/runSsg.js'); const outputFiles = await runSsg(index, normalizedOptions, assets); for (const file of outputFiles) { @@ -102,9 +102,8 @@ function normalizeOptions(options: FlatwaveContentOptions): FlatwaveContentOptio return { ...options, - requiredFields: options.requiredFields ?? ['title', 'slug', 'id', 'component', 'public'], + requiredFields: options.requiredFields ?? ['title', 'slug', 'id', 'public'], validateComponents: options.validateComponents ?? true, - componentsDir: options.componentsDir, emitRouteManifest: options.emitRouteManifest ?? true, emitSitemap: options.emitSitemap ?? true, emitRobotsTxt: options.emitRobotsTxt ?? true, diff --git a/packages/vite-plugin-flatwave-react/src/react/FlatwaveAppRoutes.tsx b/packages/vite-plugin-flatwave-react/src/react/FlatwaveAppRoutes.tsx index d7a1751..aeeba1e 100644 --- a/packages/vite-plugin-flatwave-react/src/react/FlatwaveAppRoutes.tsx +++ b/packages/vite-plugin-flatwave-react/src/react/FlatwaveAppRoutes.tsx @@ -7,18 +7,15 @@ const Routes = ReactRouter.Routes; const Route = ReactRouter.Route; export function FlatwaveAppRoutes({ - routes: providedRoutes, + routes, renderPage, layoutWrapper, }: FlatwaveAppRoutesProps): React.ReactElement { const context = React.useContext(FlatwaveLanguageContext); const locale = context?.locale || ''; - // Use providedRoutes or empty array (caller should provide routes) - const allRoutes = providedRoutes ?? []; - // Group routes by locale - const localeRoutes = allRoutes.filter((r) => r.locale === locale); + const localeRoutes = routes.filter((r) => r.locale === locale); return ( <Routes> diff --git a/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageRouter.tsx b/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageRouter.tsx index 6af9f56..6a5d559 100644 --- a/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageRouter.tsx +++ b/packages/vite-plugin-flatwave-react/src/react/FlatwaveLanguageRouter.tsx @@ -10,6 +10,7 @@ export function FlatwaveLanguageRouter({ supportedLanguages, defaultLanguage, onLanguageChange, + routes, renderPage, layoutWrapper, }: FlatwaveLanguageRouterProps): React.ReactElement { @@ -20,7 +21,7 @@ export function FlatwaveLanguageRouter({ defaultLanguage={defaultLanguage} onLanguageChange={onLanguageChange} > - <FlatwaveAppRoutes renderPage={renderPage} layoutWrapper={layoutWrapper} /> + <FlatwaveAppRoutes routes={routes} renderPage={renderPage} layoutWrapper={layoutWrapper} /> </FlatwaveLanguageDetector> </BrowserRouter> ); diff --git a/packages/vite-plugin-flatwave-react/src/react/FlatwaveMDPageComponent.tsx b/packages/vite-plugin-flatwave-react/src/react/FlatwaveMDPageComponent.tsx index d0679a3..6b830ab 100644 --- a/packages/vite-plugin-flatwave-react/src/react/FlatwaveMDPageComponent.tsx +++ b/packages/vite-plugin-flatwave-react/src/react/FlatwaveMDPageComponent.tsx @@ -3,7 +3,19 @@ import * as ReactHelmet from 'react-helmet-async'; import type { FlatwaveMDPageProps } from './types.js'; import { FlatwaveMDComponent } from './FlatwaveMDComponent.js'; -const Helmet = ReactHelmet.Helmet; +const { Helmet, HelmetProvider } = (ReactHelmet as unknown as { default: typeof ReactHelmet }) + .default; + +export { HelmetProvider }; + +interface FrontmatterSeo { + title?: string; + description?: string; + canonical?: string; + image?: string; + robots?: string; + og?: { title?: string; description?: string }; +} export function FlatwaveMDPageComponent(props: FlatwaveMDPageProps): React.ReactElement | null { const { @@ -39,24 +51,27 @@ export function FlatwaveMDPageComponent(props: FlatwaveMDPageProps): React.React /> ); - // SEO Head tags via react-helmet-async (client-side only) - const fm = frontmatter || {}; - const title = typeof fm.title === 'string' ? fm.title : undefined; - const headTags = title ? ( - <Helmet> - <title>{title} - {typeof fm.description === 'string' && } - {typeof fm.canonical === 'string' && } - {fm.og && typeof fm.og.title === 'string' && ( - - )} - {fm.og && typeof fm.og.description === 'string' && ( - - )} - {typeof fm.image === 'string' && } - {typeof fm.robots === 'string' && } - - ) : null; + // SEO Head tags via react-helmet-async (only for client-side rendering when markdown is used) + // For SSG (markdownHtml provided), head tags are handled by renderHtmlHead in runSsg + const fm = frontmatter as FrontmatterSeo | undefined; + const title = typeof fm?.title === 'string' ? fm.title : undefined; + // Only render Helmet when in client-side mode (markdown provided, no markdownHtml) + const headTags = + markdown !== undefined && title ? ( + + {title} + {typeof fm?.description === 'string' && ( + + )} + {typeof fm?.canonical === 'string' && } + {typeof fm?.og?.title === 'string' && } + {typeof fm?.og?.description === 'string' && ( + + )} + {typeof fm?.image === 'string' && } + {typeof fm?.robots === 'string' && } + + ) : null; const contentWithHead = ( <> diff --git a/packages/vite-plugin-flatwave-react/src/react/types.ts b/packages/vite-plugin-flatwave-react/src/react/types.ts index 3f94a8d..1169301 100644 --- a/packages/vite-plugin-flatwave-react/src/react/types.ts +++ b/packages/vite-plugin-flatwave-react/src/react/types.ts @@ -1,4 +1,4 @@ -import type { FlatwaveFrontmatter, FlatwaveRoute } from '../types.js'; +import type { FlatwaveFrontmatter, SeoMetadata } from '../types.js'; export interface FlatwaveMDComponentProps { frontmatter: TFrontmatter; @@ -27,11 +27,21 @@ export interface FlatwaveLanguageContextValue { defaultLanguage: string; } +export interface FlatwaveVirtualRoute { + locale: string; + path: string; + contentId: string; + metadata: SeoMetadata; + frontmatter: Record; + alternatives: Record; +} + export interface FlatwaveLanguageRouterProps { supportedLanguages: string[]; defaultLanguage: string; onLanguageChange?: (lang: string) => void; - renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode; + routes: FlatwaveVirtualRoute[]; + renderPage: (route: FlatwaveVirtualRoute, lang: string) => React.ReactNode; dynamicRoute?: { path: string; renderPage: (params: { slug: string; lang: string }) => React.ReactNode; @@ -47,8 +57,8 @@ export interface FlatwaveLanguageDetectorProps { } export interface FlatwaveAppRoutesProps { - routes?: FlatwaveRoute[]; - renderPage: (route: FlatwaveRoute, lang: string) => React.ReactNode; + routes: FlatwaveVirtualRoute[]; + renderPage: (route: FlatwaveVirtualRoute, lang: string) => React.ReactNode; layoutWrapper?: React.ComponentType<{ children: React.ReactNode; locale: string }>; } diff --git a/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.test.tsx b/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.test.tsx new file mode 100644 index 0000000..3fcef3c --- /dev/null +++ b/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.test.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import * as ReactHelmet from 'react-helmet-async'; +import type { HelmetServerState } from 'react-helmet-async'; +import { FlatwaveMDPageComponent } from '../react/FlatwaveMDPageComponent.js'; + +const { HelmetProvider } = (ReactHelmet as unknown as { default: typeof ReactHelmet }).default; + +describe('DefaultRenderStrategy with FlatwaveMDPageComponent', () => { + it('renders FlatwaveMDPageComponent to string with main and markdown body', async () => { + const frontmatter = { + title: 'Test Page', + slug: 'test', + id: 'test', + public: true, + description: 'Test description', + canonical: '/test', + }; + + const markdownHtml = '

This is test content.

'; + const locale = 'en'; + + const helmetContext: { helmet?: HelmetServerState } = {}; + const html = renderToString( + + + + ); + + // Without markdown prop, Helmet is not rendered (head tags handled by SSG) + expect(html).toContain('
'); + expect(html).toContain('This is test content.'); + }); + + it('renders without title when frontmatter has no title', async () => { + const frontmatter = { + title: '', + slug: 'test', + id: 'test', + public: true, + }; + + const markdownHtml = '

No title page

'; + const locale = 'en'; + + const helmetContext: { helmet?: HelmetServerState } = {}; + const html = renderToString( + + + + ); + + expect(html).toContain('
'); + expect(html).toContain('No title page'); + }); + + it('renders loading fallback when no markdownHtml or markdown provided', async () => { + const frontmatter = { + title: 'Test Page', + slug: 'test', + id: 'test', + public: true, + }; + + const helmetContext: { helmet?: HelmetServerState } = {}; + const html = renderToString( + + Loading...} + /> + + ); + + expect(html).toContain('Loading...'); + }); +}); diff --git a/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx b/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx index 75f0843..96b42ca 100644 --- a/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx +++ b/packages/vite-plugin-flatwave-react/src/ssg/DefaultRenderStrategy.tsx @@ -1,45 +1,32 @@ import { renderToString } from 'react-dom/server'; +import * as ReactHelmet from 'react-helmet-async'; +import type { HelmetServerState } from 'react-helmet-async'; import type { RenderStrategy, RenderContext } from './types.js'; +import { FlatwaveMDPageComponent } from '../react/FlatwaveMDPageComponent.js'; + +const { HelmetProvider } = (ReactHelmet as unknown as { default: typeof ReactHelmet }).default; export class DefaultRenderStrategy implements RenderStrategy { async render(context: RenderContext): Promise { - const { route, contentEntry, components } = context; - - const componentModule = components.get(route.component || ''); - - // contentEntry.body is already compiled HTML (set by runSsg before calling render) - const compiledBody = contentEntry.body; + const { contentEntry, route } = context; - 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 markdownHtml = contentEntry.body; + const frontmatter = contentEntry.frontmatter; + const locale = route.locale; - 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` + try { + const helmetContext: { helmet?: HelmetServerState } = {}; + const appHtml = renderToString( + + + ); - 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.locale, - route: route.path, - }; - - try { - const appHtml = renderToString(); + // Return only the body HTML - head tags are extracted via renderHtmlHead in runSsg return appHtml; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts b/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts index b251ba1..5da9b9c 100644 --- a/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts +++ b/packages/vite-plugin-flatwave-react/src/ssg/runSsg.ts @@ -38,27 +38,6 @@ 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 { @@ -83,7 +62,7 @@ export async function runSsg( const strategy = ssgOptions.strategy ?? new DefaultRenderStrategy(); const pipeline = new RenderPipeline(ssgOptions.hooks); - const components = await buildComponentsMap(routes); + const components = new Map(); const concurrencyLimit = 4; const routeChunks: FlatwaveRoute[][] = []; diff --git a/packages/vite-plugin-flatwave-react/src/types.ts b/packages/vite-plugin-flatwave-react/src/types.ts index 0c97af2..dd87d6a 100644 --- a/packages/vite-plugin-flatwave-react/src/types.ts +++ b/packages/vite-plugin-flatwave-react/src/types.ts @@ -64,7 +64,6 @@ export interface FlatwaveContentOptions { strictMissingLocales?: boolean; requiredFields?: string[]; validateComponents?: boolean; - componentsDir?: string | string[]; emitRouteManifest?: boolean; emitSitemap?: boolean; emitRobotsTxt?: boolean; @@ -77,7 +76,6 @@ export interface FlatwaveFrontmatter extends Record { title: string; slug: string; id: string; - component: string; public?: boolean | string; description?: string; canonical?: string; @@ -97,7 +95,6 @@ export interface FlatwaveContentEntry { slug: string; path: string; file: string; - component?: string; public: boolean; attributes: FlatwaveFrontmatter; frontmatter: FlatwaveFrontmatter; @@ -110,7 +107,6 @@ export interface FlatwaveRoute { locale: string; path: string; contentId: string; - component?: string; metadata: SeoMetadata; frontmatter: FlatwaveFrontmatter; alternatives: Record; diff --git a/packages/vite-plugin-flatwave-react/src/virtual.d.ts b/packages/vite-plugin-flatwave-react/src/virtual.d.ts index 350d3c2..c8f7b67 100644 --- a/packages/vite-plugin-flatwave-react/src/virtual.d.ts +++ b/packages/vite-plugin-flatwave-react/src/virtual.d.ts @@ -1,26 +1,55 @@ declare module 'virtual:flatwave/content' { + export interface FlatwaveVirtualFrontmatter { + title?: string; + slug?: string; + id?: string; + public?: boolean | string; + description?: string; + canonical?: string; + image?: string; + robots?: string; + keywords?: string[]; + jsonLd?: unknown; + og?: Record; + twitter?: Record; + menu?: string; + menu_position?: number | string; + body?: string; + [key: string]: unknown; + } + export interface FlatwaveVirtualContent { id: string; locale: string; slug: string; path: string; file: string; - component?: string; public: boolean; attributes: Record; - frontmatter: Record; + frontmatter: FlatwaveVirtualFrontmatter; body: string; route: string; alternatives: Record; } + export interface FlatwaveSeoMetadata { + title: string; + description?: string; + canonical?: string; + image?: string; + robots?: string; + keywords?: string[]; + jsonLd?: unknown; + og?: Record; + twitter?: Record; + } + export interface FlatwaveVirtualRoute { locale: string; path: string; contentId: string; - component?: string; - metadata: Record; - frontmatter: Record; + metadata: FlatwaveSeoMetadata; + frontmatter: FlatwaveVirtualFrontmatter; alternatives: Record; } From 0d8095773715fa282017e8540aad4fea1b246234 Mon Sep 17 00:00:00 2001 From: lemys lopez Date: Mon, 22 Jun 2026 10:49:59 -0500 Subject: [PATCH 06/10] fix(nonintrusive): Finish non intrusive plan --- .../basic-react-site/dist/es/about/index.html | 4 +- examples/basic-react-site/dist/es/index.html | 4 +- .../dist/es/program/index.html | 4 +- examples/basic-react-site/dist/index.html | 2 +- .../basic-react-site/dist/pt/about/index.html | 4 +- examples/basic-react-site/dist/pt/index.html | 8 +-- .../dist/pt/program/index.html | 4 +- .../basic-react-site/dist/route-manifest.json | 16 +----- examples/basic-react-site/dist/sitemap.xml | 2 +- .../dist/cli/validate.js | 6 --- .../dist/content/indexer.js | 1 - .../dist/content/routeBuilder.js | 1 - .../dist/content/scanner.js | 1 - .../dist/content/validator.d.ts | 2 +- .../dist/content/validator.js | 50 +------------------ .../vite-plugin-flatwave-react/dist/index.js | 5 +- .../dist/react/virtual.d.ts | 39 +++++++++++++-- .../dist/types.d.ts | 4 -- 18 files changed, 56 insertions(+), 101 deletions(-) diff --git a/examples/basic-react-site/dist/es/about/index.html b/examples/basic-react-site/dist/es/about/index.html index 1423901..38a775e 100644 --- a/examples/basic-react-site/dist/es/about/index.html +++ b/examples/basic-react-site/dist/es/about/index.html @@ -14,12 +14,12 @@ -

Acerca de

+

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.

+

This page was built with Flatwave SSG 1.0.0.

diff --git a/examples/basic-react-site/dist/es/index.html b/examples/basic-react-site/dist/es/index.html index 221079f..9b9cb92 100644 --- a/examples/basic-react-site/dist/es/index.html +++ b/examples/basic-react-site/dist/es/index.html @@ -14,7 +14,7 @@ -

Inicio

+

Inicio

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

  • Rutas localizadas.
  • @@ -22,7 +22,7 @@
  • Sitemap y robots generados.

-

This page was built with Flatwave SSG 1.0.0.

+

This page was built with Flatwave SSG 1.0.0.

diff --git a/examples/basic-react-site/dist/es/program/index.html b/examples/basic-react-site/dist/es/program/index.html index 214aacd..b202906 100644 --- a/examples/basic-react-site/dist/es/program/index.html +++ b/examples/basic-react-site/dist/es/program/index.html @@ -14,12 +14,12 @@ -

Programa

+

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.

+

This page was built with Flatwave SSG 1.0.0.

diff --git a/examples/basic-react-site/dist/index.html b/examples/basic-react-site/dist/index.html index c3ba9aa..9a5bac4 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 55ab478..8ac42ce 100644 --- a/examples/basic-react-site/dist/pt/about/index.html +++ b/examples/basic-react-site/dist/pt/about/index.html @@ -14,12 +14,12 @@ -

Sobre

+

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.

+

This page was built with Flatwave SSG 1.0.0.

diff --git a/examples/basic-react-site/dist/pt/index.html b/examples/basic-react-site/dist/pt/index.html index d4294ff..75a46b3 100644 --- a/examples/basic-react-site/dist/pt/index.html +++ b/examples/basic-react-site/dist/pt/index.html @@ -4,17 +4,17 @@ Início - + - + -

Início

+

Início

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

  • Rotas localizadas.
  • @@ -22,7 +22,7 @@
  • Sitemap e robots gerados.

-

This page was built with Flatwave SSG 1.0.0.

+

This page was built with Flatwave SSG 1.0.0.

diff --git a/examples/basic-react-site/dist/pt/program/index.html b/examples/basic-react-site/dist/pt/program/index.html index 6878bd5..9056986 100644 --- a/examples/basic-react-site/dist/pt/program/index.html +++ b/examples/basic-react-site/dist/pt/program/index.html @@ -14,12 +14,12 @@ -

Programa

+

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.

+

This page was built with Flatwave SSG 1.0.0.

diff --git a/examples/basic-react-site/dist/route-manifest.json b/examples/basic-react-site/dist/route-manifest.json index e9d6b71..22b58f4 100644 --- a/examples/basic-react-site/dist/route-manifest.json +++ b/examples/basic-react-site/dist/route-manifest.json @@ -3,7 +3,6 @@ "locale": "es", "path": "/es/", "contentId": "home", - "component": "SimplePage", "metadata": { "title": "Inicio", "description": "Página de inicio del ejemplo Flatwave.", @@ -18,7 +17,6 @@ "title": "Inicio", "slug": "index", "id": "home", - "component": "SimplePage", "public": true, "description": "Página de inicio del ejemplo Flatwave.", "canonical": "/es/", @@ -39,7 +37,6 @@ "locale": "es", "path": "/es/about", "contentId": "about", - "component": "SimplePage", "metadata": { "title": "Acerca de", "description": "Página acerca del ejemplo Flatwave.", @@ -54,7 +51,6 @@ "title": "Acerca de", "slug": "about", "id": "about", - "component": "SimplePage", "public": true, "description": "Página acerca del ejemplo Flatwave.", "canonical": "/es/about", @@ -75,7 +71,6 @@ "locale": "es", "path": "/es/program", "contentId": "program", - "component": "ProgramPage", "metadata": { "title": "Programa", "description": "Página de programa con frontmatter específico del componente.", @@ -89,7 +84,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", @@ -114,10 +108,9 @@ "locale": "pt", "path": "/pt/", "contentId": "home", - "component": "SimplePage", "metadata": { "title": "Início", - "description": "Página inicial do exemplo Flatwave.", + "description": "Página de início do exemplo Flatwave.", "canonical": "/pt/", "robots": "index, follow", "keywords": [ @@ -129,9 +122,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": [ @@ -150,7 +142,6 @@ "locale": "pt", "path": "/pt/about", "contentId": "about", - "component": "SimplePage", "metadata": { "title": "Sobre", "description": "Página sobre o exemplo Flatwave.", @@ -165,7 +156,6 @@ "title": "Sobre", "slug": "about", "id": "about", - "component": "SimplePage", "public": true, "description": "Página sobre o exemplo Flatwave.", "canonical": "/pt/about", @@ -186,7 +176,6 @@ "locale": "pt", "path": "/pt/program", "contentId": "program", - "component": "ProgramPage", "metadata": { "title": "Programa", "description": "Página de programa com frontmatter específico do componente.", @@ -200,7 +189,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/dist/sitemap.xml b/examples/basic-react-site/dist/sitemap.xml index a52648a..c91fc29 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-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 +http://localhost:4173/es/2026-06-21weekly0.8http://localhost:4173/es/about2026-06-21weekly0.8http://localhost:4173/es/program2026-06-21weekly0.8http://localhost:4173/pt/2026-06-21weekly0.8http://localhost:4173/pt/about2026-06-21weekly0.8http://localhost:4173/pt/program2026-06-21weekly0.8 diff --git a/packages/vite-plugin-flatwave-react/dist/cli/validate.js b/packages/vite-plugin-flatwave-react/dist/cli/validate.js index f01f0f8..ba8331d 100755 --- a/packages/vite-plugin-flatwave-react/dist/cli/validate.js +++ b/packages/vite-plugin-flatwave-react/dist/cli/validate.js @@ -8,7 +8,6 @@ program .requiredOption('--content-dir ', 'Markdown content directory, e.g. src/content') .requiredOption('--locales ', 'Comma-separated locales, e.g. es,pt') .requiredOption('--default-locale ', 'Default locale, e.g. es') - .option('--components-dir ', 'Comma-separated component directories', 'src/components,src/pages') .option('--strict-missing', 'Fail when locale variants are missing', false) .option('--no-validate-components', 'Disable component existence validation') .action(async (options) => { @@ -16,15 +15,10 @@ program .split(',') .map((locale) => locale.trim()) .filter(Boolean); - const componentsDir = options.componentsDir - .split(',') - .map((dir) => dir.trim()) - .filter(Boolean); const result = await validateContent({ contentDir: options.contentDir, locales, defaultLocale: options.defaultLocale, - componentsDir, strictMissingLocales: options.strictMissing, validateComponents: options.validateComponents, }); diff --git a/packages/vite-plugin-flatwave-react/dist/content/indexer.js b/packages/vite-plugin-flatwave-react/dist/content/indexer.js index 7420de9..043f700 100644 --- a/packages/vite-plugin-flatwave-react/dist/content/indexer.js +++ b/packages/vite-plugin-flatwave-react/dist/content/indexer.js @@ -13,7 +13,6 @@ export async function buildIndex(options) { slug: file.slug, path: routeForLocaleSlug(file.locale, file.slug), file: file.file, - component: file.frontmatter.component ? String(file.frontmatter.component) : undefined, public: file.frontmatter.public !== false && String(file.frontmatter.public ?? 'true').toLowerCase() !== 'false', attributes: { ...file.frontmatter }, diff --git a/packages/vite-plugin-flatwave-react/dist/content/routeBuilder.js b/packages/vite-plugin-flatwave-react/dist/content/routeBuilder.js index 0fbb231..3c887ec 100644 --- a/packages/vite-plugin-flatwave-react/dist/content/routeBuilder.js +++ b/packages/vite-plugin-flatwave-react/dist/content/routeBuilder.js @@ -22,7 +22,6 @@ export function buildContentIndex(entries) { locale: entry.locale, path: entry.route, contentId: entry.id, - component: entry.component, metadata: buildSeoMetadata(entry.frontmatter, entry.route, entry.locale), frontmatter: entry.frontmatter, alternatives, diff --git a/packages/vite-plugin-flatwave-react/dist/content/scanner.js b/packages/vite-plugin-flatwave-react/dist/content/scanner.js index 52abf3a..8a06027 100644 --- a/packages/vite-plugin-flatwave-react/dist/content/scanner.js +++ b/packages/vite-plugin-flatwave-react/dist/content/scanner.js @@ -53,7 +53,6 @@ export function buildContentEntry(parsed, alternatives) { slug: parsed.slug, path: route, file: parsed.file, - component: parsed.frontmatter.component ? String(parsed.frontmatter.component) : undefined, public: isPublicEntry(parsed.frontmatter), attributes, frontmatter: parsed.frontmatter, 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 76ed923..977a36b 100644 --- a/packages/vite-plugin-flatwave-react/dist/content/validator.d.ts +++ b/packages/vite-plugin-flatwave-react/dist/content/validator.d.ts @@ -1,3 +1,3 @@ -export type { ValidationResult } from '../types.js'; import type { FlatwaveContentOptions, ValidationResult } from '../types'; +export type { ValidationResult } from '../types'; export declare function validateContent(options: FlatwaveContentOptions): Promise; diff --git a/packages/vite-plugin-flatwave-react/dist/content/validator.js b/packages/vite-plugin-flatwave-react/dist/content/validator.js index 5f92977..34f541c 100644 --- a/packages/vite-plugin-flatwave-react/dist/content/validator.js +++ b/packages/vite-plugin-flatwave-react/dist/content/validator.js @@ -1,17 +1,14 @@ -import { readdir } from 'node:fs/promises'; -import path from 'node:path'; import { routeForLocaleSlug, scanMarkdownFiles } from './scanner.js'; import { buildContentIndex } from './routeBuilder.js'; export async function validateContent(options) { const errors = []; const warnings = []; - const requiredFields = options.requiredFields ?? ['title', 'slug', 'id', 'component', 'public']; + const requiredFields = options.requiredFields ?? ['title', 'slug', 'id', 'public']; const files = await scanMarkdownFiles(options.contentDir, options.locales); await validateRequiredFields(files, requiredFields, errors); await validateDuplicateIds(files, errors); await validateDuplicateSlugs(files, errors); await validateMenuPositions(files, errors); - await validateComponents(files, options, errors); validateMissingLocales(files, options, warnings); const entries = files.map((file) => { const id = String(file.frontmatter.id || file.slug); @@ -22,7 +19,6 @@ export async function validateContent(options) { slug: file.slug, path: route, file: file.file, - component: file.frontmatter.component ? String(file.frontmatter.component) : undefined, public: file.frontmatter.public !== false && String(file.frontmatter.public ?? 'true').toLowerCase() !== 'false', attributes: file.frontmatter, @@ -101,45 +97,6 @@ async function validateMenuPositions(files, errors) { } } } -async function validateComponents(files, options, errors) { - if (options.validateComponents === false) - return; - const available = await discoverComponents(options.componentsDir); - for (const file of files) { - const component = file.frontmatter.component ? String(file.frontmatter.component) : undefined; - if (!component) - continue; - if (!available.has(component)) { - errors.push(`[${file.locale}] ${file.file}: Component '${component}' does not exist in ${formatComponentsDir(options.componentsDir)}.`); - } - } -} -async function discoverComponents(componentsDir) { - const dirs = Array.isArray(componentsDir) - ? componentsDir - : componentsDir - ? [componentsDir] - : ['src/components', 'src/pages']; - const components = new Set(); - for (const dir of dirs) { - const absolute = path.resolve(dir); - let files; - try { - files = (await readdir(absolute, { withFileTypes: true })); - } - catch { - continue; - } - for (const file of files) { - if (!file.isFile()) - continue; - if (!/\.(tsx?|jsx?)$/.test(file.name)) - continue; - components.add(file.name.replace(/\.[^.]+$/, '')); - } - } - return components; -} function validateMissingLocales(files, options, warnings) { const idsByLocale = new Map(); for (const file of files) { @@ -161,8 +118,3 @@ function validateMissingLocales(files, options, warnings) { } } } -function formatComponentsDir(componentsDir) { - if (!componentsDir) - return 'src/components or src/pages'; - return Array.isArray(componentsDir) ? componentsDir.join(', ') : componentsDir; -} diff --git a/packages/vite-plugin-flatwave-react/dist/index.js b/packages/vite-plugin-flatwave-react/dist/index.js index e65c997..247aaae 100644 --- a/packages/vite-plugin-flatwave-react/dist/index.js +++ b/packages/vite-plugin-flatwave-react/dist/index.js @@ -3,7 +3,6 @@ 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 { runSsg } from './ssg/runSsg.js'; const VIRTUAL_ID = '\0virtual:flatwave/content'; const PUBLIC_VIRTUAL_ID = 'virtual:flatwave/content'; export function flatwaveContent(options) { @@ -74,6 +73,7 @@ export function flatwaveContent(options) { async generateBundle(_, bundle) { const html = findIndexHtml(bundle); const assets = extractAssets(html); + const { runSsg } = await import('./ssg/runSsg.js'); const outputFiles = await runSsg(index, normalizedOptions, assets); for (const file of outputFiles) { this.emitFile({ @@ -92,9 +92,8 @@ function normalizeOptions(options) { } return { ...options, - requiredFields: options.requiredFields ?? ['title', 'slug', 'id', 'component', 'public'], + requiredFields: options.requiredFields ?? ['title', 'slug', 'id', 'public'], validateComponents: options.validateComponents ?? true, - componentsDir: options.componentsDir, emitRouteManifest: options.emitRouteManifest ?? true, emitSitemap: options.emitSitemap ?? true, emitRobotsTxt: options.emitRobotsTxt ?? true, diff --git a/packages/vite-plugin-flatwave-react/dist/react/virtual.d.ts b/packages/vite-plugin-flatwave-react/dist/react/virtual.d.ts index 350d3c2..c8f7b67 100644 --- a/packages/vite-plugin-flatwave-react/dist/react/virtual.d.ts +++ b/packages/vite-plugin-flatwave-react/dist/react/virtual.d.ts @@ -1,26 +1,55 @@ declare module 'virtual:flatwave/content' { + export interface FlatwaveVirtualFrontmatter { + title?: string; + slug?: string; + id?: string; + public?: boolean | string; + description?: string; + canonical?: string; + image?: string; + robots?: string; + keywords?: string[]; + jsonLd?: unknown; + og?: Record; + twitter?: Record; + menu?: string; + menu_position?: number | string; + body?: string; + [key: string]: unknown; + } + export interface FlatwaveVirtualContent { id: string; locale: string; slug: string; path: string; file: string; - component?: string; public: boolean; attributes: Record; - frontmatter: Record; + frontmatter: FlatwaveVirtualFrontmatter; body: string; route: string; alternatives: Record; } + export interface FlatwaveSeoMetadata { + title: string; + description?: string; + canonical?: string; + image?: string; + robots?: string; + keywords?: string[]; + jsonLd?: unknown; + og?: Record; + twitter?: Record; + } + export interface FlatwaveVirtualRoute { locale: string; path: string; contentId: string; - component?: string; - metadata: Record; - frontmatter: Record; + metadata: FlatwaveSeoMetadata; + frontmatter: FlatwaveVirtualFrontmatter; alternatives: Record; } diff --git a/packages/vite-plugin-flatwave-react/dist/types.d.ts b/packages/vite-plugin-flatwave-react/dist/types.d.ts index 9ff78d9..bad4463 100644 --- a/packages/vite-plugin-flatwave-react/dist/types.d.ts +++ b/packages/vite-plugin-flatwave-react/dist/types.d.ts @@ -54,7 +54,6 @@ export interface FlatwaveContentOptions { strictMissingLocales?: boolean; requiredFields?: string[]; validateComponents?: boolean; - componentsDir?: string | string[]; emitRouteManifest?: boolean; emitSitemap?: boolean; emitRobotsTxt?: boolean; @@ -66,7 +65,6 @@ export interface FlatwaveFrontmatter extends Record { title: string; slug: string; id: string; - component: string; public?: boolean | string; description?: string; canonical?: string; @@ -85,7 +83,6 @@ export interface FlatwaveContentEntry { slug: string; path: string; file: string; - component?: string; public: boolean; attributes: FlatwaveFrontmatter; frontmatter: FlatwaveFrontmatter; @@ -97,7 +94,6 @@ export interface FlatwaveRoute { locale: string; path: string; contentId: string; - component?: string; metadata: SeoMetadata; frontmatter: FlatwaveFrontmatter; alternatives: Record; From bbb4aec32e6fc10eb5d62affff19fe293e37548f Mon Sep 17 00:00:00 2001 From: lemys lopez Date: Mon, 22 Jun 2026 12:19:54 -0500 Subject: [PATCH 07/10] fix(versioning): Fixed pacakge version --- .github/workflows/release.yml | 19 +++++++++++ CHANGELOG.md | 34 +++++++++++++++++++ docs/DEVELOPMENT.md | 2 +- docs/ci-cd-release-automation.md | 12 +++---- examples/basic-react-site/dist/sitemap.xml | 2 +- examples/basic-react-site/package.json | 2 +- package.json | 4 +-- .../vite-plugin-flatwave-react/package.json | 2 +- 8 files changed, 65 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a262a0c..553c23b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,3 +96,22 @@ jobs: # automatically — no long-lived NPM_TOKEN secret required. # Trusted publisher configured at: # https://www.npmjs.com/package/@kamansoft/vite-plugin-flatwave-react/access + + # ─── 3. Sync workspace version ────────────────────────────────────────────── + # semantic-release only bumps the plugin package.json (pkgRoot). + # This step syncs the version to the root workspace package.json for consistency. + - name: Sync workspace version + if: success() + run: | + PLUGIN_VERSION=$(cat packages/vite-plugin-flatwave-react/package.json | grep '"version"' | head -1 | sed 's/.*"version": "\(.*\)".*/\1/') + echo "Plugin version: $PLUGIN_VERSION" + # Update root package.json + npm pkg set version=$PLUGIN_VERSION --workspaces=false + # Commit and push the version sync (chore commit won't trigger new release) + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json + git commit -m "chore: sync workspace version to $PLUGIN_VERSION" || echo "No changes to commit" + git push origin HEAD:main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ffae954..b26a4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 2.0.1 (2026-06-20) + +### Documentation + +- **documentation:** update documentation badges ([#14](https://github.com/kamansoft/vite-plugin-flatwave-react/pull/14)) +- **docs:** add architecture, development, contributing documentation and refactor readme ([#13](https://github.com/kamansoft/vite-plugin-flatwave-react/pull/13)) + +## 2.0.0 (2026-06-20) + +### ⚠ BREAKING CHANGES + +- **rendering!:** rewrite SSG pipeline with fully-rendered HTML output ([#12](https://github.com/kamansoft/vite-plugin-flatwave-react/pull/12)) + +### Features + +- **rendering:** new SSG pipeline with RenderStrategy interface, DefaultRenderStrategy, RenderPipeline, and hook phases (beforeRender, transformMarkdown, transformHtml, afterRender, onError) +- **rendering:** template system with built-in index.html.ejs and filesystem override convention +- **rendering:** markdown compiler extracted to reusable compileMarkdownToHtml function +- **ci-cd:** document enforce_admins lock and stale-tag cleanup procedure + +## 1.1.0 (2026-06-19) + +### Features + +- **release:** scope package to @kamansoft org and fix CI/CD pipeline +- **ci:** switch to npm OIDC trusted publishing and enforce conventional commit pipeline + +### Bug Fixes + +- **publish:** force npm token auth for semantic-release +- **release:** enable npm trusted publishing in release workflow +- **release-workflow:** fix failure caused by registry resolution during semantic-release prepare +- **npm-publish:** resolve publishing pipeline conflicts and CI issues + ## 1.0.0 (2026-06-17) ### ⚠ BREAKING CHANGES diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7a09dda..3ed1be6 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -646,7 +646,7 @@ cd /path/to/your-project && npm install ```json { "dependencies": { - "@kamansoft/vite-plugin-flatwave-react": "^1.0.0" + "@kamansoft/vite-plugin-flatwave-react": "^2.0.1" } } ``` diff --git a/docs/ci-cd-release-automation.md b/docs/ci-cd-release-automation.md index 1bb04e6..8bfb2ed 100644 --- a/docs/ci-cd-release-automation.md +++ b/docs/ci-cd-release-automation.md @@ -1,8 +1,8 @@ # CI/CD & Release Automation -> **Current state (2026-06-19):** Pipeline is fully operational and fully locked. -> `@kamansoft/vite-plugin-flatwave-react@1.1.0` was the first version published -> automatically by GitHub Actions using npm OIDC trusted publishing — no tokens +> **Current state (2026-06-22):** Pipeline is fully operational and fully locked. +> `@kamansoft/vite-plugin-flatwave-react@2.0.1` is the current published version. +> Releases are automated by GitHub Actions using npm OIDC trusted publishing — no tokens > stored anywhere. `main` cannot receive direct pushes from anyone, including > org admins (`enforce_admins: true`, `allow_force_pushes: false`). @@ -140,7 +140,7 @@ npm publish --access public \ --registry https://registry.npmjs.org \ --userconfig /tmp/.npmrc-publish -# Output: + @kamansoft/vite-plugin-flatwave-react@0.1.0 +# Output: + @kamansoft/vite-plugin-flatwave-react@1.0.0 ``` Package URL: https://www.npmjs.com/package/@kamansoft/vite-plugin-flatwave-react @@ -646,7 +646,7 @@ cd packages/vite-plugin-flatwave-react npm publish --access public \ --registry https://registry.npmjs.org \ --userconfig /tmp/.npmrc-publish -# → + @kamansoft/vite-plugin-flatwave-react@0.1.0 +# → + @kamansoft/vite-plugin-flatwave-react@1.0.0 (first manual publish; semantic-release then published 1.1.0, 2.0.0, 2.0.1) # ── Merge strategy — enforce squash-only with PR title as commit ────────────── gh api repos/kamansoft/vite-plugin-flatwave-react \ @@ -755,7 +755,7 @@ git push origin v1.1.0 # push corrected tag # ── Verify final published version ─────────────────────────────────────────── npm view @kamansoft/vite-plugin-flatwave-react versions --json -# → ["0.1.0", "1.1.0"] +# → ["0.1.0", "1.1.0", "2.0.0", "2.0.1"] # published by: GitHub Actions ``` diff --git a/examples/basic-react-site/dist/sitemap.xml b/examples/basic-react-site/dist/sitemap.xml index c91fc29..76872b1 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-21weekly0.8http://localhost:4173/es/about2026-06-21weekly0.8http://localhost:4173/es/program2026-06-21weekly0.8http://localhost:4173/pt/2026-06-21weekly0.8http://localhost:4173/pt/about2026-06-21weekly0.8http://localhost:4173/pt/program2026-06-21weekly0.8 +http://localhost:4173/es/2026-06-22weekly0.8http://localhost:4173/es/about2026-06-22weekly0.8http://localhost:4173/es/program2026-06-22weekly0.8http://localhost:4173/pt/2026-06-22weekly0.8http://localhost:4173/pt/about2026-06-22weekly0.8http://localhost:4173/pt/program2026-06-22weekly0.8 diff --git a/examples/basic-react-site/package.json b/examples/basic-react-site/package.json index 7bf6754..e571ba4 100644 --- a/examples/basic-react-site/package.json +++ b/examples/basic-react-site/package.json @@ -13,7 +13,7 @@ "@kamansoft/vite-plugin-flatwave-react": "file:../../packages/vite-plugin-flatwave-react", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-markdown": "^9.0.3", + "react-markdown": "^10.0.0", "react-helmet-async": "^2.0.0", "react-router-dom": "^6.0.0" }, diff --git a/package.json b/package.json index bab5157..dbb794c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "vite-plugin-flatwave-react-workspace", - "version": "1.0.0", + "name": "vite-plugin-flatwave-react", + "version": "2.0.1", "private": true, "type": "module", "workspaces": [ diff --git a/packages/vite-plugin-flatwave-react/package.json b/packages/vite-plugin-flatwave-react/package.json index 5282929..57e267c 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": "1.0.0", + "version": "2.0.1", "description": "Vite content plugin for Markdown-driven, i18n-aware static React sites.", "type": "module", "main": "./dist/index.js", From d5e235a1fa869fde2aada10c599b2b7615df35ca Mon Sep 17 00:00:00 2001 From: lemys lopez Date: Mon, 22 Jun 2026 12:41:22 -0500 Subject: [PATCH 08/10] chore: update package-lock.json --- package-lock.json | 46 +++++++++------------------------------------- 1 file changed, 9 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index bf03d20..0e43049 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "vite-plugin-flatwave-react-workspace", - "version": "1.0.0", + "name": "vite-plugin-flatwave-react", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "vite-plugin-flatwave-react-workspace", - "version": "1.0.0", + "name": "vite-plugin-flatwave-react", + "version": "2.0.1", "workspaces": [ "packages/*", "examples/*" @@ -39,7 +39,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-helmet-async": "^2.0.0", - "react-markdown": "^9.0.3", + "react-markdown": "^10.0.0", "react-router-dom": "^6.0.0" }, "devDependencies": { @@ -12458,9 +12458,9 @@ "license": "MIT" }, "node_modules/react-markdown": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", - "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -15744,7 +15744,7 @@ }, "packages/vite-plugin-flatwave-react": { "name": "@kamansoft/vite-plugin-flatwave-react", - "version": "1.0.0", + "version": "2.0.1", "license": "MIT", "dependencies": { "commander": "^13.1.0", @@ -15784,34 +15784,6 @@ "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", From 93575c3bb4d55c4b2ebc602b2d190dd72398dd5c Mon Sep 17 00:00:00 2001 From: lemys lopez Date: Mon, 22 Jun 2026 13:06:14 -0500 Subject: [PATCH 09/10] fix: add proper types to renderPage callback in App.tsx --- examples/basic-react-site/src/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/basic-react-site/src/App.tsx b/examples/basic-react-site/src/App.tsx index cb1cda4..f7768f7 100644 --- a/examples/basic-react-site/src/App.tsx +++ b/examples/basic-react-site/src/App.tsx @@ -5,6 +5,7 @@ import { useFlatwaveContent, } from '@kamansoft/vite-plugin-flatwave-react/react'; import type { FlatwaveFrontmatter } from '@kamansoft/vite-plugin-flatwave-react/types'; +import type { FlatwaveVirtualRoute } from 'virtual:flatwave/content'; export function App() { const routes = useFlatwaveRoutes(); @@ -14,7 +15,7 @@ export function App() { supportedLanguages={['es', 'pt']} defaultLanguage="es" routes={routes} - renderPage={(route, lang) => { + renderPage={(route: FlatwaveVirtualRoute, lang: string) => { const content = useFlatwaveContent(route.contentId, lang); return ( Date: Mon, 22 Jun 2026 15:22:44 -0500 Subject: [PATCH 10/10] publish changes --- examples/basic-react-site/dist/index.html | 2 +- node_modules/.package-lock.json | 43 +++--------- node_modules/react-markdown/index.d.ts | 1 + node_modules/react-markdown/index.d.ts.map | 2 +- node_modules/react-markdown/index.js | 1 + node_modules/react-markdown/lib/index.d.ts | 33 ++++++--- .../react-markdown/lib/index.d.ts.map | 2 +- node_modules/react-markdown/lib/index.js | 67 +++++++++--------- node_modules/react-markdown/package.json | 4 +- node_modules/react-markdown/readme.md | 68 +++++++++++-------- 10 files changed, 112 insertions(+), 111 deletions(-) diff --git a/examples/basic-react-site/dist/index.html b/examples/basic-react-site/dist/index.html index 9a5bac4..224f476 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/node_modules/.package-lock.json b/node_modules/.package-lock.json index c50f86a..dbdd3df 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -1,6 +1,6 @@ { - "name": "vite-plugin-flatwave-react-workspace", - "version": "1.0.0", + "name": "vite-plugin-flatwave-react", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { @@ -13,7 +13,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-helmet-async": "^2.0.0", - "react-markdown": "^9.0.3", + "react-markdown": "^10.0.0", "react-router-dom": "^6.0.0" }, "devDependencies": { @@ -5962,6 +5962,7 @@ "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": { @@ -11693,9 +11694,9 @@ "license": "MIT" }, "node_modules/react-markdown": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", - "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -14627,7 +14628,7 @@ }, "packages/vite-plugin-flatwave-react": { "name": "@kamansoft/vite-plugin-flatwave-react", - "version": "1.0.0", + "version": "2.0.1", "license": "MIT", "dependencies": { "commander": "^13.1.0", @@ -14667,34 +14668,6 @@ "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/react-markdown/index.d.ts b/node_modules/react-markdown/index.d.ts index 8ccbbf0..80fe5f3 100644 --- a/node_modules/react-markdown/index.d.ts +++ b/node_modules/react-markdown/index.d.ts @@ -1,6 +1,7 @@ export type AllowElement = import("./lib/index.js").AllowElement; export type Components = import("./lib/index.js").Components; export type ExtraProps = import("./lib/index.js").ExtraProps; +export type HooksOptions = import("./lib/index.js").HooksOptions; export type Options = import("./lib/index.js").Options; export type UrlTransform = import("./lib/index.js").UrlTransform; export { MarkdownAsync, MarkdownHooks, Markdown as default, defaultUrlTransform } from "./lib/index.js"; diff --git a/node_modules/react-markdown/index.d.ts.map b/node_modules/react-markdown/index.d.ts.map index 774ec64..1041dba 100644 --- a/node_modules/react-markdown/index.d.ts.map +++ b/node_modules/react-markdown/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.js"],"names":[],"mappings":"2BACa,OAAO,gBAAgB,EAAE,YAAY;yBACrC,OAAO,gBAAgB,EAAE,UAAU;yBACnC,OAAO,gBAAgB,EAAE,UAAU;sBACnC,OAAO,gBAAgB,EAAE,OAAO;2BAChC,OAAO,gBAAgB,EAAE,YAAY"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.js"],"names":[],"mappings":"2BACa,OAAO,gBAAgB,EAAE,YAAY;yBACrC,OAAO,gBAAgB,EAAE,UAAU;yBACnC,OAAO,gBAAgB,EAAE,UAAU;2BACnC,OAAO,gBAAgB,EAAE,YAAY;sBACrC,OAAO,gBAAgB,EAAE,OAAO;2BAChC,OAAO,gBAAgB,EAAE,YAAY"} \ No newline at end of file diff --git a/node_modules/react-markdown/index.js b/node_modules/react-markdown/index.js index 629aec0..d0fc80e 100644 --- a/node_modules/react-markdown/index.js +++ b/node_modules/react-markdown/index.js @@ -2,6 +2,7 @@ * @typedef {import('./lib/index.js').AllowElement} AllowElement * @typedef {import('./lib/index.js').Components} Components * @typedef {import('./lib/index.js').ExtraProps} ExtraProps + * @typedef {import('./lib/index.js').HooksOptions} HooksOptions * @typedef {import('./lib/index.js').Options} Options * @typedef {import('./lib/index.js').UrlTransform} UrlTransform */ diff --git a/node_modules/react-markdown/lib/index.d.ts b/node_modules/react-markdown/lib/index.d.ts index dbb6a9c..f7ae6b7 100644 --- a/node_modules/react-markdown/lib/index.d.ts +++ b/node_modules/react-markdown/lib/index.d.ts @@ -33,12 +33,12 @@ export function MarkdownAsync(options: Readonly): Promise * For async support on the server, * see {@linkcode MarkdownAsync}. * - * @param {Readonly} options + * @param {Readonly} options * Props. - * @returns {ReactElement} - * React element. + * @returns {ReactNode} + * React node. */ -export function MarkdownHooks(options: Readonly): ReactElement; +export function MarkdownHooks(options: Readonly): ReactNode; /** * Make a URL safe. * @@ -65,7 +65,7 @@ export type ExtraProps = { /** * Map tag names to components. */ -export type Components = { [Key in Extract]?: ElementType & ExtraProps>; }; +export type Components = { [Key in keyof JSX.IntrinsicElements]?: ComponentType | keyof JSX.IntrinsicElements; }; /** * Deprecation. */ @@ -101,10 +101,6 @@ export type Options = { * Markdown. */ children?: string | null | undefined; - /** - * Wrap in a `div` with this class name. - */ - className?: string | null | undefined; /** * Map tag names to components. */ @@ -141,15 +137,30 @@ export type Options = { */ urlTransform?: UrlTransform | null | undefined; }; +/** + * Configuration specifically for {@linkcode MarkdownHooks}. + */ +export type HooksOptionsOnly = { + /** + * Content to render while the processor processing the markdown (optional). + */ + fallback?: ReactNode | null | undefined; +}; +/** + * Configuration for {@linkcode MarkdownHooks}; + * extends the regular {@linkcode Options} with a `fallback` prop. + */ +export type HooksOptions = Options & HooksOptionsOnly; /** * Transform all URLs. */ export type UrlTransform = (url: string, key: string, node: Readonly) => string | null | undefined; import type { ReactElement } from 'react'; +import type { ReactNode } from 'react'; import type { Element } from 'hast'; import type { Parents } from 'hast'; -import type { ElementType } from 'react'; -import type { ComponentProps } from 'react'; +import type { JSX } from 'react'; +import type { ComponentType } from 'react'; import type { PluggableList } from 'unified'; import type { Options as RemarkRehypeOptions } from 'remark-rehype'; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/node_modules/react-markdown/lib/index.d.ts.map b/node_modules/react-markdown/lib/index.d.ts.map index 541d60f..fbe89d0 100644 --- a/node_modules/react-markdown/lib/index.d.ts.map +++ b/node_modules/react-markdown/lib/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.js"],"names":[],"mappings":"AAsJA;;;;;;;;;;;GAWG;AACH,kCALW,QAAQ,CAAC,OAAO,CAAC,GAEf,YAAY,CAOxB;AAED;;;;;;;;;;;;GAYG;AACH,uCALW,QAAQ,CAAC,OAAO,CAAC,GAEf,OAAO,CAAC,YAAY,CAAC,CAQjC;AAED;;;;;;;;;;;;GAYG;AACH,uCALW,QAAQ,CAAC,OAAO,CAAC,GAEf,YAAY,CAgCxB;AAgLD;;;;;;;;GAQG;AACH,2CALW,MAAM,GAEJ,MAAM,CA0BlB;;;;qCA1aU,QAAQ,CAAC,OAAO,CAAC,SAEjB,MAAM,UAEN,QAAQ,CAAC,OAAO,CAAC,GAAG,SAAS,KAE3B,OAAO,GAAG,IAAI,GAAG,SAAS;;;;;;;;WAOzB,OAAO,GAAG,SAAS;;;;;yBAKpB,GACP,GAAG,IAAI,OAAO,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,YAAY,eAAe,GAAG,CAAC,GAAG,UAAU,CAAC,GACtF;;;;;;;;UAOU,MAAM;;;;QAEN,MAAM;;;;SAEN,MAAM,OAAO;;;;;;;;;;mBAOb,YAAY,GAAG,IAAI,GAAG,SAAS;;;;;sBAG/B,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI,GAAG,SAAS;;;;eAGxC,MAAM,GAAG,IAAI,GAAG,SAAS;;;;gBAEzB,MAAM,GAAG,IAAI,GAAG,SAAS;;;;iBAEzB,UAAU,GAAG,IAAI,GAAG,SAAS;;;;;yBAE7B,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI,GAAG,SAAS;;;;oBAGxC,aAAa,GAAG,IAAI,GAAG,SAAS;;;;oBAEhC,aAAa,GAAG,IAAI,GAAG,SAAS;;;;0BAEhC,QAAQ,CAAC,mBAAmB,CAAC,GAAG,IAAI,GAAG,SAAS;;;;eAEhD,OAAO,GAAG,IAAI,GAAG,SAAS;;;;;;uBAE1B,OAAO,GAAG,IAAI,GAAG,SAAS;;;;mBAI1B,YAAY,GAAG,IAAI,GAAG,SAAS;;;;;iCAOlC,MAAM,OAEN,MAAM,QAEN,QAAQ,CAAC,OAAO,CAAC,KAEf,MAAM,GAAG,IAAI,GAAG,SAAS;kCAvFsB,OAAO;6BAFH,MAAM;6BAAN,MAAM;iCAEV,OAAO;oCAAP,OAAO;mCAGxB,SAAS;oDAFH,eAAe"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.js"],"names":[],"mappings":"AAkKA;;;;;;;;;;;GAWG;AACH,kCALW,QAAQ,CAAC,OAAO,CAAC,GAEf,YAAY,CAOxB;AAED;;;;;;;;;;;;GAYG;AACH,uCALW,QAAQ,CAAC,OAAO,CAAC,GAEf,OAAO,CAAC,YAAY,CAAC,CAQjC;AAED;;;;;;;;;;;;GAYG;AACH,uCALW,QAAQ,CAAC,YAAY,CAAC,GAEpB,SAAS,CAyCrB;AAgKD;;;;;;;;GAQG;AACH,2CALW,MAAM,GAEJ,MAAM,CA0BlB;;;;qCA/aU,QAAQ,CAAC,OAAO,CAAC,SAEjB,MAAM,UAEN,QAAQ,CAAC,OAAO,CAAC,GAAG,SAAS,KAE3B,OAAO,GAAG,IAAI,GAAG,SAAS;;;;;;;;WAOzB,OAAO,GAAG,SAAS;;;;;yBAKpB,GACP,GAAG,IAAI,MAAM,qBAAqB,CAAC,CAAC,EAAE,cAAc,qBAAqB,CAAC,GAAG,CAAC,GAAG,UAAU,CAAC,GAAG,MAAM,qBAAqB,GAC5H;;;;;;;;UAOU,MAAM;;;;QAEN,MAAM;;;;SAEN,MAAM,OAAO;;;;;;;;;;mBAOb,YAAY,GAAG,IAAI,GAAG,SAAS;;;;;sBAG/B,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI,GAAG,SAAS;;;;eAGxC,MAAM,GAAG,IAAI,GAAG,SAAS;;;;iBAEzB,UAAU,GAAG,IAAI,GAAG,SAAS;;;;;yBAE7B,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI,GAAG,SAAS;;;;oBAGxC,aAAa,GAAG,IAAI,GAAG,SAAS;;;;oBAEhC,aAAa,GAAG,IAAI,GAAG,SAAS;;;;0BAEhC,QAAQ,CAAC,mBAAmB,CAAC,GAAG,IAAI,GAAG,SAAS;;;;eAEhD,OAAO,GAAG,IAAI,GAAG,SAAS;;;;;;uBAE1B,OAAO,GAAG,IAAI,GAAG,SAAS;;;;mBAI1B,YAAY,GAAG,IAAI,GAAG,SAAS;;;;;;;;;eAO/B,SAAS,GAAG,IAAI,GAAG,SAAS;;;;;;2BAK7B,OAAO,GAAG,gBAAgB;;;;iCAQ5B,MAAM,OAEN,MAAM,QAEN,QAAQ,CAAC,OAAO,CAAC,KAEf,MAAM,GAAG,IAAI,GAAG,SAAS;kCAlGwB,OAAO;+BAAP,OAAO;6BAFrB,MAAM;6BAAN,MAAM;yBAEQ,OAAO;mCAAP,OAAO;mCAG1B,SAAS;oDAFH,eAAe"} \ No newline at end of file diff --git a/node_modules/react-markdown/lib/index.js b/node_modules/react-markdown/lib/index.js index c88a5a0..b6f5ed0 100644 --- a/node_modules/react-markdown/lib/index.js +++ b/node_modules/react-markdown/lib/index.js @@ -1,7 +1,7 @@ /** - * @import {Element, ElementContent, Nodes, Parents, Root} from 'hast' + * @import {Element, Nodes, Parents, Root} from 'hast' * @import {Root as MdastRoot} from 'mdast' - * @import {ComponentProps, ElementType, ReactElement} from 'react' + * @import {ComponentType, JSX, ReactElement, ReactNode} from 'react' * @import {Options as RemarkRehypeOptions} from 'remark-rehype' * @import {BuildVisitor} from 'unist-util-visit' * @import {PluggableList, Processor} from 'unified' @@ -29,7 +29,7 @@ /** * @typedef {{ - * [Key in Extract]?: ElementType & ExtraProps> + * [Key in keyof JSX.IntrinsicElements]?: ComponentType | keyof JSX.IntrinsicElements * }} Components * Map tag names to components. */ @@ -56,8 +56,6 @@ * cannot combine w/ `disallowedElements`. * @property {string | null | undefined} [children] * Markdown. - * @property {string | null | undefined} [className] - * Wrap in a `div` with this class name. * @property {Components | null | undefined} [components] * Map tag names to components. * @property {ReadonlyArray | null | undefined} [disallowedElements] @@ -79,6 +77,19 @@ * Change URLs (default: `defaultUrlTransform`) */ +/** + * @typedef HooksOptionsOnly + * Configuration specifically for {@linkcode MarkdownHooks}. + * @property {ReactNode | null | undefined} [fallback] + * Content to render while the processor processing the markdown (optional). + */ + +/** + * @typedef {Options & HooksOptionsOnly} HooksOptions + * Configuration for {@linkcode MarkdownHooks}; + * extends the regular {@linkcode Options} with a `fallback` prop. + */ + /** * @callback UrlTransform * Transform all URLs. @@ -96,7 +107,7 @@ import {unreachable} from 'devlop' import {toJsxRuntime} from 'hast-util-to-jsx-runtime' import {urlAttributes} from 'html-url-attributes' import {Fragment, jsx, jsxs} from 'react/jsx-runtime' -import {createElement, useEffect, useState} from 'react' +import {useEffect, useState} from 'react' import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import {unified} from 'unified' @@ -127,6 +138,7 @@ const deprecations = [ id: 'replace-allownode-allowedtypes-and-disallowedtypes', to: 'allowedElements' }, + {from: 'className', id: 'remove-classname'}, { from: 'disallowedTypes', id: 'replace-allownode-allowedtypes-and-disallowedtypes', @@ -194,10 +206,10 @@ export async function MarkdownAsync(options) { * For async support on the server, * see {@linkcode MarkdownAsync}. * - * @param {Readonly} options + * @param {Readonly} options * Props. - * @returns {ReactElement} - * React element. + * @returns {ReactNode} + * React node. */ export function MarkdownHooks(options) { const processor = createProcessor(options) @@ -207,13 +219,24 @@ export function MarkdownHooks(options) { const [tree, setTree] = useState(/** @type {Root | undefined} */ (undefined)) useEffect( - /* c8 ignore next 7 -- hooks are client-only. */ function () { + let cancelled = false const file = createFile(options) + processor.run(processor.parse(file), file, function (error, tree) { - setError(error) - setTree(tree) + if (!cancelled) { + setError(error) + setTree(tree) + } }) + + /** + * @returns {undefined} + * Nothing. + */ + return function () { + cancelled = true + } }, [ options.children, @@ -223,11 +246,9 @@ export function MarkdownHooks(options) { ] ) - /* c8 ignore next -- hooks are client-only. */ if (error) throw error - /* c8 ignore next -- hooks are client-only. */ - return tree ? post(tree, options) : createElement(Fragment) + return tree ? post(tree, options) : options.fallback } /** @@ -322,26 +343,10 @@ function post(tree, options) { ) } - // Wrap in `div` if there’s a class name. - if (options.className) { - tree = { - type: 'element', - tagName: 'div', - properties: {className: options.className}, - // Assume no doctypes. - children: /** @type {Array} */ ( - tree.type === 'root' ? tree.children : [tree] - ) - } - } - visit(tree, transform) return toJsxRuntime(tree, { Fragment, - // @ts-expect-error - // React components are allowed to return numbers, - // but not according to the types in hast-util-to-jsx-runtime components, ignoreInvalidStyle: true, jsx, diff --git a/node_modules/react-markdown/package.json b/node_modules/react-markdown/package.json index 8b5dce3..35fe1fa 100644 --- a/node_modules/react-markdown/package.json +++ b/node_modules/react-markdown/package.json @@ -62,6 +62,7 @@ }, "description": "React component to render markdown", "devDependencies": { + "@testing-library/react": "^16.0.0", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -69,6 +70,7 @@ "concat-stream": "^2.0.0", "esbuild": "^0.25.0", "eslint-plugin-react": "^7.0.0", + "global-jsdom": "^26.0.0", "prettier": "^3.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -141,7 +143,7 @@ "strict": true }, "type": "module", - "version": "9.1.0", + "version": "10.1.0", "xo": { "envs": [ "shared-node-browser" diff --git a/node_modules/react-markdown/readme.md b/node_modules/react-markdown/readme.md index d1ff26c..ecdd661 100644 --- a/node_modules/react-markdown/readme.md +++ b/node_modules/react-markdown/readme.md @@ -38,6 +38,7 @@ React component to render markdown. * [`AllowElement`](#allowelement) * [`Components`](#components) * [`ExtraProps`](#extraprops) + * [`HooksOptions`](#hooksoptions) * [`Options`](#options) * [`UrlTransform`](#urltransform) * [Examples](#examples) @@ -47,7 +48,6 @@ React component to render markdown. * [Use remark and rehype plugins (math)](#use-remark-and-rehype-plugins-math) * [Plugins](#plugins) * [Syntax](#syntax) -* [Types](#types) * [Compatibility](#compatibility) * [Architecture](#architecture) * [Appendix A: HTML in markdown](#appendix-a-html-in-markdown) @@ -101,14 +101,14 @@ npm install react-markdown In Deno with [`esm.sh`][esmsh]: ```js -import Markdown from 'https://esm.sh/react-markdown@9' +import Markdown from 'https://esm.sh/react-markdown@10' ``` In browsers with [`esm.sh`][esmsh]: ```html ``` @@ -175,6 +175,15 @@ and [`defaultUrlTransform`][api-default-url-transform]. The default export is [`Markdown`][api-markdown]. +It also exports the additional [TypeScript][] types +[`AllowElement`][api-allow-element], +[`Components`][api-components], +[`ExtraProps`][api-extra-props], +[`HooksOptions`][api-hooks-options], +[`Options`][api-options], +and +[`UrlTransform`][api-url-transform]. + ### `Markdown` Component to render markdown. @@ -191,7 +200,7 @@ see [`MarkdownAsync`][api-markdown-async] or ###### Returns -React element (`JSX.Element`). +React element (`ReactElement`). ### `MarkdownAsync` @@ -209,7 +218,7 @@ see [`MarkdownHooks`][api-markdown-hooks]. ###### Returns -Promise to a React element (`Promise`). +Promise to a React element (`Promise`). ### `MarkdownHooks` @@ -227,7 +236,7 @@ see [`MarkdownAsync`][api-markdown-async]. ###### Returns -React element (`JSX.Element`). +React node (`ReactNode`). ### `defaultUrlTransform(url)` @@ -266,17 +275,12 @@ Map tag names to components (TypeScript type). ###### Type ```ts -import type {Element} from 'hast' - -type Components = Partial<{ - [TagName in keyof JSX.IntrinsicElements]: - // Class component: - | (new (props: JSX.IntrinsicElements[TagName] & ExtraProps) => JSX.ElementClass) - // Function component: - | ((props: JSX.IntrinsicElements[TagName] & ExtraProps) => JSX.Element | string | null | undefined) - // Tag name: - | keyof JSX.IntrinsicElements -}> +import type {ExtraProps} from 'react-markdown' +import type {ComponentProps, ElementType} from 'react' + +type Components = { + [Key in Extract]?: ElementType & ExtraProps> +} ``` ### `ExtraProps` @@ -288,6 +292,20 @@ Extra fields we pass to components (TypeScript type). * `node` ([`Element` from `hast`][github-hast-element], optional) — original node +### `HooksOptions` + +Configuration for [`MarkdownHooks`][api-markdown-hooks] (TypeScript type); +extends the regular [`Options`][api-options] with a `fallback` prop. + +###### Extends + +[`Options`][api-options]. + +###### Fields + +* `fallback` (`ReactNode`, optional) + — content to render while the processor processing the markdown + ### `Options` Configuration (TypeScript type). @@ -302,8 +320,6 @@ Configuration (TypeScript type). cannot combine w/ `disallowedElements` * `children` (`string`, optional) — markdown -* `className` (`string`, optional) - — wrap in a `div` with this class name * `components` ([`Components`][api-components], optional) — map tag names to components * `disallowedElements` (`Array`, default: `[]`) @@ -585,16 +601,6 @@ We use [`micromark`][github-micromark] under the hood for our parsing. See its documentation for more information on markdown, CommonMark, and extensions. -## Types - -This package is fully typed with [TypeScript][]. -It exports the additional types -[`AllowElement`][api-allow-element], -[`ExtraProps`][api-extra-props], -[`Components`][api-components], -[`Options`][api-options], and -[`UrlTransform`][api-url-transform]. - ## Compatibility Projects maintained by the unified collective are compatible with maintained @@ -602,7 +608,7 @@ versions of Node.js. When we cut a new major release, we drop support for unmaintained versions of Node. -This means we try to keep the current release line, `react-markdown@^9`, +This means we try to keep the current release line, `react-markdown@10`, compatible with Node.js 16. They work in all modern browsers (essentially: everything not IE 11). @@ -823,6 +829,8 @@ abide by its terms. [api-extra-props]: #extraprops +[api-hooks-options]: #hooksoptions + [api-markdown]: #markdown [api-markdown-async]: #markdownasync