@moku-labs/web 0.6.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { EmitFn } from "@moku-labs/core";
2
- import { Pluggable, Processor } from "unified";
3
2
  import { ComponentChildren, VNode } from "preact";
3
+ import { Pluggable, Processor } from "unified";
4
4
  import { BundledTheme, ThemeRegistrationAny } from "shiki";
5
5
 
6
6
  //#region src/plugins/log/types.d.ts
@@ -155,8 +155,9 @@ declare namespace types_d_exports$5 {
155
155
  *
156
156
  * Providers are walked in array order during resolution; the first provider to
157
157
  * return a non-`undefined` (and non-empty-string) value for a key wins. `load()`
158
- * is called once per resolution at `onInit` time except for live, per-request
159
- * sources (e.g. {@link cloudflareBindings}) which read fresh on every call.
158
+ * is called exactly once per resolution at `onInit` time, after which both env
159
+ * maps are frozen. A provider like {@link cloudflareBindings} reads `globalThis`
160
+ * at that single `onInit` call (not per request).
160
161
  *
161
162
  * @example
162
163
  * ```ts
@@ -227,7 +228,8 @@ type EnvConfig = {
227
228
  /**
228
229
  * Ordered list of value sources. The first provider yielding a non-`undefined`
229
230
  * (and non-empty-string) value for a key wins. The plugin's own spec default is
230
- * `[]`; the framework layer supplies the working default `[dotenv(), processEnv()]`.
231
+ * `[]`; the consumer supplies the providers per target (`[dotenv(), processEnv()]`
232
+ * on Node) — only the `/browser` entry pre-wires `browserEnv()` out of the box.
231
233
  */
232
234
  providers: EnvProvider[];
233
235
  /**
@@ -336,12 +338,25 @@ declare function browserEnv(options?: {
336
338
  declare const envPlugin: import("@moku-labs/core").CorePluginInstance<"env", EnvConfig, EnvState, EnvApi>;
337
339
  //#endregion
338
340
  //#region src/config.d.ts
341
+ /**
342
+ * Deployment stage. Drives content draft visibility — drafts are suppressed
343
+ * only in `"production"`; `"development"` and `"test"` both surface them.
344
+ */
345
+ type Stage = "production" | "development" | "test";
339
346
  /**
340
347
  * Global framework configuration. Minimal by design — per-plugin config is
341
348
  * resolved via `pluginConfigs`, not merged here.
342
349
  */
343
350
  type Config$8 = {
344
- /** Runtime mode. Drives log sink defaults, content draft filtering, build minify. */mode: "production" | "development";
351
+ /** Deployment stage. Drives content draft visibility (drafts hidden only in `"production"`). */stage: Stage;
352
+ /**
353
+ * Render mode — the single SSG/DATA/SPA switch, read by the router (`ctx.global`)
354
+ * and consumed by `build`/`spa` via `router.mode()`.
355
+ * - `"ssg"` static generation only (no client router emitted).
356
+ * - `"spa"` client-side routing only.
357
+ * - `"hybrid"` static HTML + client navigation overlay (default).
358
+ */
359
+ mode: "ssg" | "spa" | "hybrid";
345
360
  };
346
361
  /**
347
362
  * Framework event contract. Empty base — each plugin declares its own events
@@ -446,7 +461,7 @@ type Api$7 = {
446
461
  * @file i18n plugin — public type definitions (Config + Api).
447
462
  */
448
463
  /**
449
- * i18n plugin configuration. Mirrors the legacy `I18nConfig` shape.
464
+ * i18n plugin configuration.
450
465
  *
451
466
  * `locales` and `defaultLocale` are required and validated in `onInit`. The
452
467
  * optional maps default to empty objects so every lookup method is total —
@@ -543,7 +558,7 @@ type Api$6 = {
543
558
  t(locale: string, key: string): string;
544
559
  };
545
560
  declare namespace types_d_exports$8 {
546
- export { Api$5 as Api, ClientRoute, CompileInput, CompiledRoute, Config$5 as Config, ExtractRouteParams, ExtractSegmentParameter, HeadConfig$1 as HeadConfig, LayoutContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteState, RouterApi, RouterConfig, RouterState, State$5 as State, TypedRoute };
561
+ export { Api$5 as Api, ClientRoute, CompileInput, CompiledRoute, Config$5 as Config, ExtractApi$2 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, State$5 as State, TypedRoute, Urls };
547
562
  }
548
563
  /**
549
564
  * Param contribution of a single path segment. `{name:?}` / `:name?` → optional;
@@ -582,6 +597,84 @@ interface RouteContext<S extends RouteState> {
582
597
  readonly data: S["data"];
583
598
  /** Active locale for this render. */
584
599
  readonly locale: string;
600
+ /**
601
+ * Build a link to a named route by pattern substitution — the framework delivers
602
+ * this on the context (same output as `app.router.toUrl`), so render/head build
603
+ * links with no `app`/`createUrls` reference. Works identically at build and on
604
+ * client navigation.
605
+ */
606
+ readonly url: (name: string, params?: Record<string, string>) => string;
607
+ }
608
+ /**
609
+ * Structural extraction of a plugin instance's public API from its `_phantom`
610
+ * carrier (mirrors the kernel's `ExtractApi` / spec/09 §3). Lets the loader/generator
611
+ * `require` resolve a plugin instance to its typed public API.
612
+ *
613
+ * @example
614
+ * type ContentApi = ExtractApi<typeof contentPlugin>;
615
+ */
616
+ type ExtractApi$2<PluginCandidate> = PluginCandidate extends {
617
+ readonly _phantom: {
618
+ readonly api: infer PluginApi;
619
+ };
620
+ } ? PluginApi : never;
621
+ /**
622
+ * Generic, instance-only `require` handed to a route's `.load()` / `.generate()` —
623
+ * the SAME shape as the kernel's `RequireFunction` (spec/08 §7) and the build's
624
+ * `PhaseRequire`, so the build forwards its own `ctx.require` straight through.
625
+ * Resolves a plugin INSTANCE to its public API; the consumer supplies the instance
626
+ * (e.g. `ctx.require(contentPlugin)`), so the router never names a sibling plugin.
627
+ *
628
+ * @example
629
+ * const content = ctx.require(contentPlugin); // ContentApi
630
+ */
631
+ type RouteRequire = <PluginCandidate extends {
632
+ readonly name: string;
633
+ readonly spec: unknown;
634
+ readonly _phantom: {
635
+ readonly config: unknown;
636
+ readonly state: unknown;
637
+ readonly api: unknown;
638
+ readonly events: Record<string, unknown>;
639
+ };
640
+ }>(plugin: PluginCandidate) => ExtractApi$2<PluginCandidate>;
641
+ /**
642
+ * Build-time context handed to a route's `.load()`. Carries the resolved path
643
+ * `params` and active `locale`, plus the spec's `require`/`has` so a loader pulls
644
+ * sibling plugin APIs the canonical way — `ctx.require(contentPlugin)` — with no
645
+ * module global and no router→content coupling. Loaders run ONLY at build time
646
+ * (never on the client), inside the build plugin's context, so `require`/`has` are
647
+ * always live here.
648
+ *
649
+ * @example
650
+ * route("/{slug}/").load((ctx) => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale));
651
+ */
652
+ interface LoadContext<S extends RouteState> {
653
+ /** Resolved path params for this page instance. */
654
+ readonly params: S["params"];
655
+ /** Active locale this page instance is built for. */
656
+ readonly locale: string;
657
+ /** Resolve a sibling plugin instance to its public API (spec/08 §7). */
658
+ readonly require: RouteRequire;
659
+ /** Whether a plugin is registered (by name) — branch on OPTIONAL plugins. */
660
+ readonly has: (name: string) => boolean;
661
+ }
662
+ /**
663
+ * Build-time context handed to a route's `.generate()` — the static-param producer.
664
+ * Carries the active `locale` plus `require`/`has` (no `params` yet — `.generate()`
665
+ * PRODUCES the param sets). Same build-only guarantee as {@link LoadContext}.
666
+ *
667
+ * @example
668
+ * route("/{slug}/").generate(async (ctx) =>
669
+ * [...(await ctx.require(contentPlugin).loadAll()).get(ctx.locale) ?? []].map((a) => ({ slug: a.computed.slug })));
670
+ */
671
+ interface GenerateContext {
672
+ /** Active locale to enumerate param sets for. */
673
+ readonly locale: string;
674
+ /** Resolve a sibling plugin instance to its public API (spec/08 §7). */
675
+ readonly require: RouteRequire;
676
+ /** Whether a plugin is registered (by name). */
677
+ readonly has: (name: string) => boolean;
585
678
  }
586
679
  /**
587
680
  * Context handed to a route's `.layout()` wrapper: the render-time
@@ -617,7 +710,7 @@ interface RouteBuilder<S extends RouteState> extends RouteDefinition {
617
710
  * Attach a data loader; widens the data generic (and ONLY the data generic) so
618
711
  * `.render()`/`.head()` see its return. Path params are preserved unchanged.
619
712
  */
620
- load<D>(loader: (params: S["params"], locale: string) => D | Promise<D>): RouteBuilder<{
713
+ load<D>(loader: (ctx: LoadContext<S>) => D | Promise<D>): RouteBuilder<{
621
714
  readonly params: S["params"];
622
715
  readonly data: Awaited<D>;
623
716
  }>;
@@ -631,19 +724,10 @@ interface RouteBuilder<S extends RouteState> extends RouteDefinition {
631
724
  layout(component: (ctx: LayoutContext<S>, children: ComponentChildren) => VNode): RouteBuilder<S>;
632
725
  /** Attach the page render handler. */
633
726
  render(handler: (ctx: RouteContext<S>) => VNode): RouteBuilder<S>;
634
- /**
635
- * Attach the client-side validation gate: parse the raw `unknown` fetched from
636
- * the persisted data file back into this route's data type `S["data"]`. Runs at
637
- * the trust boundary before `render` on the client (and MUST return `S["data"]`,
638
- * so a mismatched schema is a compile error). Throw inside it to reject malformed
639
- * data — `spa` then falls back to HTML-over-fetch. Use a hand guard or any
640
- * Standard-Schema validator (zod/valibot/arktype).
641
- */
642
- parse(handler: (raw: unknown) => S["data"]): RouteBuilder<S>;
643
727
  /** Attach the head/SEO handler. */
644
728
  head(handler: (ctx: RouteContext<S>) => HeadConfig$1): RouteBuilder<S>;
645
- /** Attach a static-generation param producer. */
646
- generate(handler: (locale: string) => S["params"][] | Promise<S["params"][]>): RouteBuilder<S>;
729
+ /** Attach a static-generation param producer (receives a {@link GenerateContext}). */
730
+ generate(handler: (ctx: GenerateContext) => S["params"][] | Promise<S["params"][]>): RouteBuilder<S>;
647
731
  /**
648
732
  * Attach an arbitrary metadata bag. The bag MUST be JSON-serializable: it is
649
733
  * projected verbatim into `clientManifest()` and shipped to the browser.
@@ -656,18 +740,16 @@ interface RouteBuilder<S extends RouteState> extends RouteDefinition {
656
740
  }
657
741
  /** Build-only handler bag captured by a `RouteBuilder` (consumed by `build` via `manifest()`). */
658
742
  interface RouteHandlers {
659
- /** Data loader. */
660
- readonly load?: (params: Record<string, string>, locale: string) => unknown;
743
+ /** Data loader (receives a {@link LoadContext}: params + locale + require/has). */
744
+ readonly load?: (ctx: LoadContext<RouteState>) => unknown;
661
745
  /** Layout wrapper (ctx-aware): frames the page in persistent chrome. SSG-only. */
662
746
  readonly layout?: (ctx: LayoutContext<RouteState>, children: ComponentChildren) => VNode;
663
747
  /** Page renderer. */
664
748
  readonly render?: (ctx: RouteContext<RouteState>) => VNode;
665
- /** Client-side validation gate: `unknown` (fetched JSON) → the route's data type, or throw. */
666
- readonly parse?: (raw: unknown) => unknown;
667
749
  /** Head/SEO producer. */
668
750
  readonly head?: (ctx: RouteContext<RouteState>) => HeadConfig$1;
669
- /** Static-generation param producer. */
670
- readonly generate?: (locale: string) => unknown[] | Promise<unknown[]>;
751
+ /** Static-generation param producer (receives a {@link GenerateContext}). */
752
+ readonly generate?: (ctx: GenerateContext) => unknown[] | Promise<unknown[]>;
671
753
  /** JSON serializer. */
672
754
  readonly toJson?: (ctx: RouteContext<RouteState>) => unknown;
673
755
  /** Output file-path producer. */
@@ -691,28 +773,45 @@ interface RouteDefinition {
691
773
  * base (erased) `RouteDefinition`; this is the documented generic-erasure boundary.
692
774
  */
693
775
  type RouteMap = Record<string, RouteDefinition>;
776
+ /**
777
+ * A pure, app-free URL builder over a route map (the return type of `createUrls`).
778
+ * `toUrl` builds a route's path by name + params via pattern substitution — it needs
779
+ * NO running app, router instance, base URL, or i18n: just the route map the consumer
780
+ * already holds at module scope. Works identically at build, on client navigation,
781
+ * and inside hydrated islands. Reuses the SAME `buildUrl` as the runtime `RouterApi`,
782
+ * so the helper and the API can never diverge.
783
+ *
784
+ * @example
785
+ * const url = createUrls(routes);
786
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
787
+ */
788
+ interface Urls<T extends RouteMap> {
789
+ /**
790
+ * Build a route's URL path from its name and params. The name is typed to the
791
+ * route map's keys — only declared routes are accepted.
792
+ *
793
+ * @param name - Route name key from the map (e.g. `"home"`, `"article"`).
794
+ * @param params - Path params to substitute into the pattern. Defaults to `{}`.
795
+ * @returns The resolved relative URL path.
796
+ * @throws {Error} If `name` is not present in the route map.
797
+ * @example
798
+ * url.toUrl("home", { lang: "en" }); // "/en/"
799
+ */
800
+ toUrl<K extends keyof T & string>(name: K, params?: Record<string, string>): string;
801
+ }
694
802
  /**
695
803
  * Configuration for the router plugin.
696
804
  *
697
805
  * @remarks
698
- * `routes` is an OPAQUE carrier at the config boundary the framework `Config`
699
- * generic erases the per-route element types (spec/05 §8, spec/09 §4). Downstream
700
- * plugins read the typed route set via `ctx.require(routerPlugin).manifest()`.
806
+ * `routes` is the declarative route map registered the normal config way via
807
+ * `createApp({ pluginConfigs: { router: { routes } } })` and compiled into the matcher
808
+ * table in the router's `onInit`. An `import * as routes` namespace is a valid value. It is
809
+ * the SOLE registration path: omitting it leaves the matcher table empty, so every read
810
+ * (`match`/`toUrl`/`entries`/…) throws. The render `mode` is NOT here — it is a GLOBAL
811
+ * framework option (`createApp({ config: { mode } })`), read by the router via `ctx.global`.
701
812
  */
702
813
  type RouterConfig = {
703
- /**
704
- * Named route definitions. Element type erases to the base `RouteDefinition`
705
- * at this config boundary; per-route call-site types are preserved only through
706
- * `defineRoutes()` + `route()` at the consumer and re-exposed via `manifest()`.
707
- */
708
- routes: RouteMap;
709
- /**
710
- * Render mode for URL/file resolution. Defaults to `"hybrid"`.
711
- * - `"ssg"` static generation only (no client router emitted).
712
- * - `"spa"` client-side routing only.
713
- * - `"hybrid"` static HTML + client navigation overlay.
714
- */
715
- mode?: "ssg" | "spa" | "hybrid";
814
+ /** Declarative route map (route name → `route(...)`); compiled at init. An `import * as` namespace works. */readonly routes?: RouteMap;
716
815
  };
717
816
  /** A resolved route exposing URL utilities with typed params (port of legacy TypedRoute). */
718
817
  interface TypedRoute<TParams = Record<string, string>> {
@@ -761,15 +860,14 @@ interface MatcherTable {
761
860
  readonly byName: ReadonlyMap<string, CompiledRoute>;
762
861
  }
763
862
  /**
764
- * Router plugin state. `createState` runs with minimal context and returns a
765
- * mutable holder whose `table` is `null` until `onInit` (which has full context)
766
- * compiles and assigns it. Keeps all mutable state in `ctx.state` (no singletons).
863
+ * Router plugin state — a mutable holder whose `table` is `null` until the router's
864
+ * `onInit` compiles `config.routes`. The render `mode` is NOT stored here; it is read
865
+ * from the global framework config via the API context. Keeps all mutable state in
866
+ * `ctx.state` (no singletons).
767
867
  */
768
868
  interface RouterState {
769
- /** Compiled matcher table; `null` until `onInit` assigns it. */
869
+ /** Compiled matcher table; `null` until `onInit` compiles `config.routes`. */
770
870
  table: MatcherTable | null;
771
- /** Resolved render mode (single source of truth; set in `onInit`). Defaults `"hybrid"`. */
772
- mode: "ssg" | "spa" | "hybrid";
773
871
  }
774
872
  /** Plain-data input to `compileRoutes` — resolved DATA only, never the plugin ctx. */
775
873
  interface CompileInput {
@@ -802,7 +900,7 @@ interface ClientRoute {
802
900
  /** Route metadata bag from `.meta()`. MUST be JSON-serializable. */
803
901
  readonly meta: Record<string, unknown>;
804
902
  }
805
- /** Public API exposed via `ctx.require(routerPlugin)`. */
903
+ /** Public API exposed via `ctx.require(routerPlugin)` and `app.router`. */
806
904
  type RouterApi = {
807
905
  /**
808
906
  * Match a pathname against the compiled route table (specificity-sorted).
@@ -841,7 +939,7 @@ type RouterApi = {
841
939
  *
842
940
  * @returns Read-only array of the typed route definitions, in declaration order.
843
941
  * @example
844
- * for (const def of ctx.require(routerPlugin).manifest()) { def._handlers.load?.({}, "en"); }
942
+ * for (const def of ctx.require(routerPlugin).manifest()) def._handlers.render?.(routeContext);
845
943
  */
846
944
  manifest(): readonly RouteDefinition[];
847
945
  /**
@@ -1220,7 +1318,7 @@ interface SpaKernelDeps {
1220
1318
  /**
1221
1319
  * The OPTIONAL `data` reader. Present only when the `data` plugin is composed.
1222
1320
  * When present (and `router.mode() !== "ssg"`), navigation first tries the client
1223
- * DATA path (match → `dataAt(path)` → `route.parse` → `route.render`); otherwise
1321
+ * DATA path (match → `dataAt(path)` → `route.render`); otherwise
1224
1322
  * it always uses HTML-over-fetch.
1225
1323
  */
1226
1324
  dataAt?: SpaDataReader;
@@ -1323,7 +1421,7 @@ type SpaApi = {
1323
1421
  current(): string;
1324
1422
  };
1325
1423
  declare namespace types_d_exports {
1326
- export { Api$3 as Api, BuildCacheEntry, BuildEvents, BuildResult, Config$3 as Config, ExtractApi, OgFont, OgImageConfig, OgPngRenderer, PhaseContext, PhaseEmit, PhaseLog, PhaseName, PhaseRequire, RichOgInput, State$3 as State };
1424
+ export { Api$3 as Api, BuildCacheEntry, BuildEvents, BuildResult, Config$3 as Config, ExtractApi, OgFont, OgImageConfig, PhaseContext, PhaseEmit, PhaseLog, PhaseName, PhaseRequire, RichOgInput, State$3 as State };
1327
1425
  }
1328
1426
  /**
1329
1427
  * Structural extraction of a plugin instance's public API from its `_phantom`
@@ -1401,26 +1499,15 @@ type PhaseRequire = <PluginCandidate extends {
1401
1499
  */
1402
1500
  type PhaseContext = {
1403
1501
  /** Mutable per-run build state (caches + runId). */state: State$3; /** Resolved, frozen build config. */
1404
- readonly config: Readonly<Config$3>; /** Global framework config (mode, etc.). */
1502
+ readonly config: Readonly<Config$3>; /** Global framework config (deployment stage; render mode is read via `router.mode()`). */
1405
1503
  readonly global: Readonly<{
1406
- mode: "production" | "development";
1504
+ stage: Stage;
1407
1505
  }>; /** Resolve a depended-upon plugin instance to its public API. */
1408
1506
  require: PhaseRequire; /** Whether a plugin is registered (by name) — used to detect the OPTIONAL `data` plugin. */
1409
1507
  has: (name: string) => boolean; /** Emit a build event (notification-only). */
1410
1508
  emit: PhaseEmit; /** Structured logger (core `log` API). */
1411
1509
  readonly log: PhaseLog;
1412
1510
  };
1413
- /**
1414
- * Injectable PNG renderer for the og-images phase. Defaults to the real
1415
- * Satori → resvg pipeline; unit tests inject a fake to assert hash-cache skip
1416
- * and the `p-limit` bound without rasterizing real images.
1417
- *
1418
- * @example
1419
- * ```ts
1420
- * const render: OgPngRenderer = async () => new Uint8Array();
1421
- * ```
1422
- */
1423
- type OgPngRenderer = (input: RichOgInput) => Promise<Uint8Array>;
1424
1511
  /**
1425
1512
  * Rich input handed to a custom OG `render` hook for a single article card. Carries
1426
1513
  * the full article + site metadata so a consumer can compose any layout. Returned by
@@ -1521,11 +1608,11 @@ type Config$3 = {
1521
1608
  publicDir?: string;
1522
1609
  /**
1523
1610
  * Emit `outDir/404.html`. `true` for the built-in default page, or
1524
- * `{ route }` to supply the page's literal HTML body content (NOT a route
1525
- * path/slug — the string is written into the 404 page verbatim). Default `false`.
1611
+ * `{ body }` to supply the page's literal HTML body content (written into the
1612
+ * 404 page verbatim). Default `false`.
1526
1613
  */
1527
1614
  notFound?: boolean | {
1528
- route?: string;
1615
+ body?: string;
1529
1616
  }; /** Emit per-path i18n bare-path redirect HTML pages. Default `false`. */
1530
1617
  localeRedirects?: boolean; /** Authoritative client bundle entry path (overrides the conventional scan). */
1531
1618
  clientEntry?: string; /** HTML shell template with `<!--moku:head-->`/`<!--moku:body-->`/`<!--moku:assets-->` placeholders. */
@@ -1773,11 +1860,6 @@ type DeployRunOptions = {
1773
1860
  * /^[a-zA-Z0-9/_.-]+$/ — otherwise rejected with ERR_DEPLOY_INVALID_BRANCH.
1774
1861
  */
1775
1862
  branch?: string;
1776
- /**
1777
- * Whether to run the build before deploying. When false, deploys the existing outDir.
1778
- * Defaults to `true`.
1779
- */
1780
- build?: boolean;
1781
1863
  };
1782
1864
  /**
1783
1865
  * Options for DeployApi.init.
@@ -1804,14 +1886,14 @@ type Api$2 = {
1804
1886
  * the branch argument, spawns wrangler (no shell), scrubs all subprocess output
1805
1887
  * before logging, records lastDeployment, and emits deploy:complete.
1806
1888
  *
1807
- * @param options - Optional branch override and build toggle.
1889
+ * @param options - Optional branch override.
1808
1890
  * @returns The deploy result (url, deploymentId, branch, durationMs).
1809
1891
  * @throws {Error} With a `code` from the deploy error taxonomy on any failure.
1810
1892
  * @example
1811
1893
  * const result = await app.deploy.run();
1812
1894
  * console.log(result.url); // https://my-site.pages.dev
1813
1895
  * @example
1814
- * await app.deploy.run({ branch: "preview/landing", build: false });
1896
+ * await app.deploy.run({ branch: "preview/landing" });
1815
1897
  */
1816
1898
  run(options?: DeployRunOptions): Promise<DeployResult>;
1817
1899
  /**
@@ -1871,14 +1953,15 @@ type ServerInfo = {
1871
1953
  watching?: string[];
1872
1954
  };
1873
1955
  /**
1874
- * Information rendered after a single `serve()` rebuild: which file changed plus the
1875
- * fresh build summary used to print the "rebuilt N pages" line.
1956
+ * Information rendered after a single `serve()` rebuild: the watched directory whose
1957
+ * subtree changed plus the fresh build summary used to print the "rebuilt N pages"
1958
+ * line.
1876
1959
  *
1877
1960
  * @example
1878
- * const info: ReloadInfo = { file: "content/post.md", pageCount: 12, durationMs: 84 };
1961
+ * const info: ReloadInfo = { file: "content", pageCount: 12, durationMs: 84 };
1879
1962
  */
1880
1963
  type ReloadInfo = {
1881
- /** The changed path that triggered the rebuild. */file: string; /** Number of route pages rendered by the rebuild. */
1964
+ /** The watched directory whose subtree changed (the rebuild is per-watchDir, not per-file). */file: string; /** Number of route pages rendered by the rebuild. */
1882
1965
  pageCount: number; /** Wall-clock duration of the rebuild in milliseconds. */
1883
1966
  durationMs: number;
1884
1967
  };
@@ -1930,12 +2013,13 @@ type CliRenderer = {
1930
2013
  */
1931
2014
  serverReady(info: ServerInfo): void;
1932
2015
  /**
1933
- * Render the post-rebuild line ("~ file" + "✓ rebuilt N pages · Xms · reloaded").
2016
+ * Render the post-rebuild line ("~ dir" + "✓ rebuilt N pages · Xms · reloaded"),
2017
+ * where the label is the watched directory whose subtree changed (see {@link ReloadInfo.file}).
1934
2018
  *
1935
- * @param info - The changed file plus the rebuild's page count and duration.
2019
+ * @param info - The changed watched directory plus the rebuild's page count and duration.
1936
2020
  * @returns Nothing.
1937
2021
  * @example
1938
- * render.reload({ file: "content/a.md", pageCount: 12, durationMs: 84 });
2022
+ * render.reload({ file: "content", pageCount: 12, durationMs: 84 });
1939
2023
  */
1940
2024
  reload(info: ReloadInfo): void;
1941
2025
  /**
@@ -2257,50 +2341,8 @@ type Api$1 = {
2257
2341
  */
2258
2342
  declare const cliPlugin: import("@moku-labs/core").PluginInstance<"cli", Config$1, State$1, Api$1, {}> & Record<never, never>;
2259
2343
  declare namespace types_d_exports$2 {
2260
- export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, Frontmatter, State };
2344
+ export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, FileSystemContentOptions, Frontmatter, State };
2261
2345
  }
2262
- /**
2263
- * Configuration for the content plugin.
2264
- *
2265
- * @example
2266
- * ```ts
2267
- * { contentDir: "./src/content", trustedContent: false, shikiTheme: "github-dark" }
2268
- * ```
2269
- */
2270
- type Config = {
2271
- /** Absolute or project-relative path to the content directory. Validated in onInit. */contentDir: string;
2272
- /**
2273
- * SECURITY GATE. When false (the default), rehype-sanitize runs as the final
2274
- * pipeline step. Set true ONLY for fully author-controlled Markdown — true
2275
- * disables sanitize and trusts all raw HTML.
2276
- */
2277
- trustedContent: boolean; /** Additional remark plugins, concatenated AFTER framework defaults. Defaults to []. */
2278
- extraRemarkPlugins?: readonly Pluggable[]; /** Additional rehype plugins, concatenated after custom transforms, before Shiki + sanitize. Defaults to []. */
2279
- extraRehypePlugins?: readonly Pluggable[];
2280
- /**
2281
- * Shiki theme for syntax highlighting: a bundled theme NAME — typed as Shiki's
2282
- * `BundledTheme` union so editors autocomplete the ~60 built-ins (default
2283
- * "github-dark") — or a custom `ThemeRegistration` object. Passed straight through
2284
- * to `@shikijs/rehype`'s `theme`. (Like Shiki's own theme type, an arbitrary string
2285
- * still compiles via the object arm, so this is autocomplete, not typo-rejection.)
2286
- */
2287
- shikiTheme?: BundledTheme | ThemeRegistrationAny; /** Author applied to articles whose frontmatter omits author. Defaults to undefined. */
2288
- defaultAuthor?: string;
2289
- };
2290
- /**
2291
- * Internal mutable state for the content plugin.
2292
- *
2293
- * @example
2294
- * ```ts
2295
- * { processor: null, articles: new Map(), slugs: null, dirtyPaths: new Set() }
2296
- * ```
2297
- */
2298
- type State = {
2299
- /** Lazily-created unified processor singleton. null until first render()/loadAll(). */processor: Processor | null; /** Article cache keyed locale -> (slug -> Article). Starts empty. */
2300
- articles: Map<string, Map<string, Article>>; /** Discovered, sorted slug list cached after first disk scan. null until first discovery. */
2301
- slugs: string[] | null; /** Paths marked stale by invalidate(); next loadAll() re-reads only these. Starts empty. */
2302
- dirtyPaths: Set<string>;
2303
- };
2304
2346
  /**
2305
2347
  * YAML frontmatter parsed from each article file.
2306
2348
  *
@@ -2315,7 +2357,7 @@ type Frontmatter = {
2315
2357
  description: string; /** Topic tags. Required (may be empty array). */
2316
2358
  tags: string[]; /** Source language code of this file. Required. */
2317
2359
  language: string; /** Draft flag. Excluded from output in production mode. Defaults to false. */
2318
- draft?: boolean; /** Author name. Falls back to config.defaultAuthor when omitted. */
2360
+ draft?: boolean; /** Author name. Falls back to the provider's defaultAuthor when omitted. */
2319
2361
  author?: string;
2320
2362
  };
2321
2363
  /**
@@ -2367,6 +2409,119 @@ type ArticleCard = {
2367
2409
  readingTime: number; /** Canonical URL for this article in this locale. */
2368
2410
  url: string;
2369
2411
  };
2412
+ /**
2413
+ * A pluggable content SOURCE. The shell calls these to read articles; whether content
2414
+ * is read from the filesystem (Node) or some other source is chosen by which provider
2415
+ * you compose — exactly like `env` providers (`dotenv`/`processEnv` vs `browserEnv`).
2416
+ * The shell adds locale fallback, draft filtering, sorting, caching, and events on top.
2417
+ *
2418
+ * @example
2419
+ * ```ts
2420
+ * const provider = fileSystemContent({ contentDir: "./content" });
2421
+ * ```
2422
+ */
2423
+ interface ContentProvider {
2424
+ /** Human-readable provider name, used in diagnostics. */
2425
+ readonly name: string;
2426
+ /** Source directory surfaced via `api.contentDir()` (filesystem providers; "" otherwise). */
2427
+ readonly contentDir: string;
2428
+ /**
2429
+ * Discover the article slugs this provider can supply.
2430
+ *
2431
+ * @returns The provider's slug list.
2432
+ */
2433
+ slugs(): Promise<readonly string[]>;
2434
+ /**
2435
+ * Read + render ONE article for a file-locale; `null` if this provider has no such file.
2436
+ *
2437
+ * @param slug - Article directory name.
2438
+ * @param fileLocale - Locale whose source file is read.
2439
+ * @param outLocale - Locale the resulting Article represents (the requested locale).
2440
+ * @param isFallback - Whether this resolution used the default-locale fallback.
2441
+ * @returns The constructed Article, or `null` when absent.
2442
+ */
2443
+ readArticle(slug: string, fileLocale: string, outLocale: string, isFallback: boolean): Promise<Article | null>;
2444
+ /**
2445
+ * Render a standalone Markdown string to HTML through the provider's pipeline.
2446
+ *
2447
+ * @param markdown - Raw Markdown source.
2448
+ * @returns The rendered HTML.
2449
+ */
2450
+ render(markdown: string): Promise<string>;
2451
+ /**
2452
+ * Optional dev hook: drop cached discovery so stale paths are re-read next time.
2453
+ *
2454
+ * @param paths - Stale file paths.
2455
+ */
2456
+ invalidate?(paths: readonly string[]): void;
2457
+ }
2458
+ /**
2459
+ * Options for the node filesystem provider {@link ContentProvider} `fileSystemContent`.
2460
+ * These are the markdown-pipeline + source concerns that used to live on the content
2461
+ * plugin config; they now belong to the provider you compose.
2462
+ *
2463
+ * @example
2464
+ * ```ts
2465
+ * fileSystemContent({ contentDir: "./content", shikiTheme: "github-dark", defaultAuthor: "Ada" });
2466
+ * ```
2467
+ */
2468
+ type FileSystemContentOptions = {
2469
+ /** Absolute or project-relative path to the content directory. */contentDir: string;
2470
+ /**
2471
+ * SECURITY GATE. When false (the default), rehype-sanitize runs as the final
2472
+ * pipeline step. Set true ONLY for fully author-controlled Markdown.
2473
+ */
2474
+ trustedContent?: boolean; /** Additional remark plugins, concatenated AFTER framework defaults. Defaults to []. */
2475
+ extraRemarkPlugins?: readonly Pluggable[]; /** Additional rehype plugins, concatenated after custom transforms, before Shiki + sanitize. Defaults to []. */
2476
+ extraRehypePlugins?: readonly Pluggable[];
2477
+ /**
2478
+ * Shiki theme for syntax highlighting: a bundled theme NAME (default "github-dark")
2479
+ * or a custom `ThemeRegistration` object. Passed straight through to `@shikijs/rehype`.
2480
+ */
2481
+ shikiTheme?: BundledTheme | ThemeRegistrationAny; /** Author applied to articles whose frontmatter omits author. Defaults to undefined. */
2482
+ defaultAuthor?: string;
2483
+ };
2484
+ /**
2485
+ * Internal mutable state of the filesystem provider: the lazy unified processor and
2486
+ * the discovery caches. Owned by the provider closure, never by the plugin shell.
2487
+ *
2488
+ * @example
2489
+ * ```ts
2490
+ * { processor: null, slugs: null, dirtyPaths: new Set() }
2491
+ * ```
2492
+ */
2493
+ type ContentProviderState = {
2494
+ /** Lazily-created unified processor singleton. null until first render()/readArticle(). */processor: Processor | null; /** Discovered, sorted slug list cached after first disk scan. null until first discovery. */
2495
+ slugs: string[] | null; /** Paths marked stale by invalidate(); next discovery re-reads only these. Starts empty. */
2496
+ dirtyPaths: Set<string>;
2497
+ };
2498
+ /**
2499
+ * Configuration for the content plugin (shell).
2500
+ *
2501
+ * @example
2502
+ * ```ts
2503
+ * { providers: [fileSystemContent({ contentDir: "./content" })] }
2504
+ * ```
2505
+ */
2506
+ type Config = {
2507
+ /**
2508
+ * Ordered content sources. Compose at least one (e.g. `fileSystemContent(...)` on
2509
+ * Node). The first provider that supplies an article for a slug+locale wins;
2510
+ * `slugs()` are unioned. The plugin's own spec default is `[]` (a build must supply one).
2511
+ */
2512
+ providers: ContentProvider[];
2513
+ };
2514
+ /**
2515
+ * Internal mutable state for the content plugin shell: the locale-keyed article cache.
2516
+ *
2517
+ * @example
2518
+ * ```ts
2519
+ * { articles: new Map() }
2520
+ * ```
2521
+ */
2522
+ type State = {
2523
+ /** Article cache keyed locale -> (slug -> Article). Starts empty. */articles: Map<string, Map<string, Article>>;
2524
+ };
2370
2525
  /**
2371
2526
  * Notification-only events emitted by the content plugin.
2372
2527
  *
@@ -2386,24 +2541,24 @@ type ContentEvents = {
2386
2541
  };
2387
2542
  /**
2388
2543
  * Kernel-free domain context handed to createContentApi by the wiring harness.
2389
- * Carries ctx.state (mutable escape hatch), config, global, emit, and the
2390
- * i18n-derived locale/url helpers — so api.ts stays free of createPlugin/ctx.
2544
+ * Carries the shell state (article cache), global flag, emit, the i18n-derived
2545
+ * locale helpers, and the resolved content {@link ContentProvider} — so api.ts stays
2546
+ * free of createPlugin/ctx AND of any node/pipeline import.
2391
2547
  *
2392
2548
  * @example
2393
2549
  * ```ts
2394
- * const apiContext: ContentApiContext = { state, config, global, emit, locales, defaultLocale, articleToUrl };
2550
+ * const apiContext: ContentApiContext = { state, global, emit, locales, defaultLocale, provider };
2395
2551
  * ```
2396
2552
  */
2397
2553
  type ContentApiContext = {
2398
- /** Mutable plugin state (article cache + lazy processor). */state: State; /** Resolved plugin configuration. */
2399
- config: Config; /** Global framework configuration (mode, etc.). */
2554
+ /** Mutable shell state (article cache). */state: State; /** Global framework configuration (deployment stage). */
2400
2555
  global: {
2401
- mode: "production" | "development";
2556
+ stage: Stage;
2402
2557
  }; /** Emit a registered content event. */
2403
2558
  emit: <K extends keyof ContentEvents>(event: K, payload: ContentEvents[K]) => void; /** Active locale codes from i18n. */
2404
2559
  locales: () => readonly string[]; /** Default locale code from i18n (fallback source). */
2405
- defaultLocale: () => string; /** Build a canonical article URL for a locale + slug. */
2406
- articleToUrl: (locale: string, slug: string) => string;
2560
+ defaultLocale: () => string; /** The resolved content source (merged from `config.providers`). */
2561
+ provider: ContentProvider;
2407
2562
  };
2408
2563
  /**
2409
2564
  * Public API for the content plugin.
@@ -2445,29 +2600,29 @@ type Api = {
2445
2600
  */
2446
2601
  articleToCard(article: Article): ArticleCard;
2447
2602
  /**
2448
- * The configured content source directory (e.g. `"./content"`). Lets the build copy each
2449
- * article's co-located assets (`<contentDir>/<slug>/images/`) into the output so the absolute
2450
- * image URLs the renderer emits resolve.
2603
+ * The configured content source directory (e.g. `"./content"`), from the first
2604
+ * provider. Lets the build copy each article's co-located assets
2605
+ * (`<contentDir>/<slug>/images/`) into the output.
2451
2606
  */
2452
2607
  contentDir(): string;
2453
2608
  };
2454
2609
  //#endregion
2455
2610
  //#region src/plugins/content/index.d.ts
2456
2611
  /**
2457
- * Content plugin — Markdown pipeline: discovers files, parses frontmatter, renders
2458
- * to sanitized HTML (rehype-sanitize unless `trustedContent`), and exposes a
2459
- * locale-keyed Article model. Depends on i18n; emits `content:ready` and
2460
- * `content:invalidated`.
2612
+ * Content plugin (shell) provider-driven locale-keyed Article model. Orchestration
2613
+ * (locale fallback, draft filtering, sort, caching, events) lives here; source I/O +
2614
+ * the Markdown pipeline live in a {@link ContentProvider} you compose (like `env`
2615
+ * providers). The shell imports zero node code, so `contentPlugin` is browser-safe.
2616
+ * Depends on i18n; emits `content:ready` and `content:invalidated`.
2461
2617
  *
2462
- * @example Point at a content directory and pick a Shiki theme
2618
+ * @example Compose the node filesystem provider with a content dir + Shiki theme
2463
2619
  * ```ts
2620
+ * import { contentPlugin, fileSystemContent } from "@moku-labs/web";
2464
2621
  * const app = createApp({
2622
+ * plugins: [contentPlugin],
2465
2623
  * pluginConfigs: {
2466
2624
  * content: {
2467
- * contentDir: "./content",
2468
- * shikiTheme: "github-dark",
2469
- * defaultAuthor: "Ada Lovelace"
2470
- * // trustedContent: true // ONLY for fully author-controlled Markdown — disables sanitize
2625
+ * providers: [fileSystemContent({ contentDir: "./content", shikiTheme: "github-dark", defaultAuthor: "Ada" })]
2471
2626
  * }
2472
2627
  * }
2473
2628
  * });
@@ -2491,13 +2646,13 @@ declare namespace types_d_exports$3 {
2491
2646
  * The `data` plugin is the **agnostic data provider** for the SSG→DATA→SPA pattern.
2492
2647
  * It owns ONE thing: the contract `page path → persisted JSON file`. It knows
2493
2648
  * NOTHING about what the data *is* — no domain types appear here. A route decides
2494
- * its own data shape (`load`'s return) and its own validation (`route.parse`).
2649
+ * its own data shape (`load`'s return).
2495
2650
  *
2496
2651
  * - **Node (build):** `write(entries)` persists one JSON file per page, keyed by
2497
2652
  * the page's URL via {@link DataProvider.fileFor}. `build` supplies the entries
2498
2653
  * (it already expanded the routes), so there is no duplicate expansion here.
2499
- * - **Browser (runtime):** `at(path)` fetches + caches that file as `unknown`; the
2500
- * route's `parse` validates it into the route's data type before `render`.
2654
+ * - **Browser (runtime):** `at(path)` fetches + caches that file as `unknown`, which
2655
+ * the route uses directly as `ctx.data` in `render`.
2501
2656
  *
2502
2657
  * The Node-only file-writing code (`node:fs`) is isolated behind a lazy `import()`
2503
2658
  * inside `write()`, so composing `data` in a browser app keeps the bundle free of
@@ -2519,8 +2674,8 @@ type DataConfig = {
2519
2674
  outputDir: string;
2520
2675
  /**
2521
2676
  * READ side (browser): site-root-relative URL the client fetches the per-page
2522
- * JSON from. A different domain from {@link DataConfig.outputDir} (a filesystem
2523
- * path); keep consistent (`"/" + trim(outputDir) + "/"`). Default `"/_data/"`.
2677
+ * JSON from. The URL-space mirror of {@link DataConfig.outputDir} (a filesystem
2678
+ * path); keep them consistent (`"/" + trim(outputDir) + "/"`). Default `"/_data/"`.
2524
2679
  */
2525
2680
  baseUrl: string;
2526
2681
  };
@@ -2562,15 +2717,15 @@ interface DataState {
2562
2717
  * // Node build (build supplies the entries it already expanded):
2563
2718
  * await app.data.write([{ path: "/en/hello/", data: article }]);
2564
2719
  *
2565
- * // Browser (inside spa nav): fetch the page's data, then route.parse validates it:
2566
- * const raw = await app.data.at("/en/hello/"); // unknown | null
2720
+ * // Browser (inside spa nav): fetch the page's data, used directly as ctx.data:
2721
+ * const raw = await app.data.at("/en/hello/"); // unknown | null (null on failure)
2567
2722
  * ```
2568
2723
  */
2569
2724
  type DataProvider = {
2570
2725
  /**
2571
2726
  * READ (browser) — fetch (and cache) the persisted data for a page path from
2572
- * `config.baseUrl`. Returns the raw parsed JSON as `unknown` (the caller's
2573
- * `route.parse` validates it), or `null` if the fetch/parse fails.
2727
+ * `config.baseUrl`. Returns the raw parsed JSON as `unknown`, used directly as
2728
+ * the route's `ctx.data`; returns `null` if the fetch or JSON parse fails.
2574
2729
  *
2575
2730
  * @param path - The page URL path (e.g. `/en/hello/`).
2576
2731
  * @returns The page's raw data, or `null` on failure.
@@ -2616,13 +2771,14 @@ type DataProvider = {
2616
2771
  * @example
2617
2772
  * ```ts
2618
2773
  * // Node build: `build` calls app.data.write(...) during its pages phase when
2619
- * // router.mode !== "ssg". Just compose the plugin:
2774
+ * // router.mode() !== "ssg". Compose the plugin + set the global render mode:
2775
+ * import * as routes from "./routes";
2620
2776
  * const app = createApp({
2621
2777
  * plugins: [dataPlugin, contentPlugin, buildPlugin],
2622
- * pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
2778
+ * config: { mode: "hybrid" },
2779
+ * pluginConfigs: { content: { providers: [fileSystemContent({ contentDir: "./content" })] }, router: { routes } }
2623
2780
  * });
2624
- * await app.start();
2625
- * await app.build.run(); // writes HTML + per-page data sidecars
2781
+ * await app.build.run(); // writes HTML + per-page data sidecars (routes compiled at init)
2626
2782
  *
2627
2783
  * // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
2628
2784
  * ```
@@ -2736,6 +2892,8 @@ declare function feedLink(title: string, url: string, type?: string): HeadElemen
2736
2892
  * modified times, author, section, tags, plus a JSON-LD `Article` block and canonical.
2737
2893
  *
2738
2894
  * @param articleMeta - Article metadata (title, description, author, dates, tags, image…).
2895
+ * `image`, when present, is pushed to `og:image` verbatim and must therefore be
2896
+ * an absolute URL (this helper does not resolve relative paths against the site).
2739
2897
  * @param canonicalUrl - The article's canonical absolute URL.
2740
2898
  * @returns An ordered array of serializable head elements.
2741
2899
  * @example buildArticleHead({ title: "Hi", author: "A", published: "2026-01-01" }, "https://x/p")
@@ -2818,7 +2976,7 @@ declare const logPlugin: import("@moku-labs/core").CorePluginInstance<"log", Log
2818
2976
  * @example
2819
2977
  * ```ts
2820
2978
  * route("/{lang:?}/{slug}/")
2821
- * .load(({ slug }) => loadArticle(slug))
2979
+ * .load((ctx) => loadArticle(ctx.params.slug))
2822
2980
  * .render((ctx) => <Article a={ctx.data} />)
2823
2981
  * .head((ctx) => ({ title: ctx.data.title }));
2824
2982
  * ```
@@ -2836,31 +2994,46 @@ declare function route<P extends string>(pattern: P): RouteBuilder<RouteState<P>
2836
2994
  * ```
2837
2995
  */
2838
2996
  declare function defineRoutes<T extends RouteMap>(routes: T): T;
2997
+ /**
2998
+ * Build a pure, app-free URL builder from a route map. `toUrl(name, params)` resolves
2999
+ * a route's path by pattern substitution using the SAME `buildUrl` as the runtime
3000
+ * `RouterApi.toUrl`, so the helper and the API can never diverge. It needs no running
3001
+ * app, router instance, base URL, or i18n — just the route map the consumer already
3002
+ * holds at module scope. So components, layouts, and hydrated islands import it
3003
+ * directly: no `app.router` reference, no manual "bind", no module global, no
3004
+ * "not bound" guard, and no createApp ↔ routes cycle.
3005
+ *
3006
+ * @param routes - The route map (typically the value returned by {@link defineRoutes}).
3007
+ * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
3008
+ * @example
3009
+ * ```ts
3010
+ * const url = createUrls(routes);
3011
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
3012
+ * ```
3013
+ */
3014
+ declare function createUrls<T extends RouteMap>(routes: T): Urls<T>;
2839
3015
  //#endregion
2840
3016
  //#region src/plugins/router/index.d.ts
2841
3017
  /**
2842
3018
  * Router plugin — typed, named route definitions with locale-aware URL generation
2843
- * and matching. Author routes with {@link route} + {@link defineRoutes}. Depends
2844
- * on site (base URL) and i18n (locales).
3019
+ * and matching. Author routes with {@link route}, then register them the normal config
3020
+ * way via `pluginConfigs.router.routes` (compiled at init). Depends on site (base URL)
3021
+ * and i18n (locales).
2845
3022
  *
2846
- * @example Define routes and choose a render mode
3023
+ * @example Register routes via config, then start/build
2847
3024
  * ```ts
3025
+ * import * as routes from "./routes";
2848
3026
  * const app = createApp({
2849
- * pluginConfigs: {
2850
- * router: {
2851
- * routes: defineRoutes({
2852
- * home: route("/"),
2853
- * article: route("/blog/{slug}/")
2854
- * }),
2855
- * mode: "hybrid" // "ssg" | "spa" | "hybrid" (default)
2856
- * }
2857
- * }
3027
+ * config: { mode: "hybrid" }, // render mode is GLOBAL config
3028
+ * pluginConfigs: { router: { routes } } // declarative route map (a namespace works)
2858
3029
  * });
3030
+ * await app.build.run(); // or: await app.start(); — routes compiled at init
2859
3031
  * ```
2860
3032
  */
2861
3033
  declare const routerPlugin: import("@moku-labs/core").PluginInstance<"router", RouterConfig, RouterState, RouterApi, {}> & {
2862
3034
  route: typeof route;
2863
3035
  defineRoutes: typeof defineRoutes;
3036
+ createUrls: typeof createUrls;
2864
3037
  };
2865
3038
  //#endregion
2866
3039
  //#region src/plugins/site/index.d.ts
@@ -2984,6 +3157,20 @@ declare function processEnv(): EnvProvider;
2984
3157
  */
2985
3158
  declare function cloudflareBindings(): EnvProvider;
2986
3159
  //#endregion
3160
+ //#region src/plugins/content/providers.d.ts
3161
+ /**
3162
+ * The node filesystem content provider: reads + renders Markdown from `contentDir`
3163
+ * through the full pipeline. Caches discovery + the unified processor internally.
3164
+ *
3165
+ * @param options - Filesystem + pipeline options (`contentDir`, `shikiTheme`, `trustedContent`, …).
3166
+ * @returns A {@link ContentProvider} backed by the local filesystem.
3167
+ * @example
3168
+ * ```ts
3169
+ * createApp({ pluginConfigs: { content: { providers: [fileSystemContent({ contentDir: "./content" })] } } });
3170
+ * ```
3171
+ */
3172
+ declare function fileSystemContent(options: FileSystemContentOptions): ContentProvider;
3173
+ //#endregion
2987
3174
  //#region src/index.d.ts
2988
3175
  /**
2989
3176
  * Create and initialize a `@moku-labs/web` application — the Layer-3 entry point.
@@ -2996,27 +3183,28 @@ declare function cloudflareBindings(): EnvProvider;
2996
3183
  *
2997
3184
  * @param options - Optional configuration:
2998
3185
  * - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
2999
- * - `config` — global framework config (e.g. `{ mode: "development" }`).
3186
+ * - `config` — global framework config (e.g. `{ mode: "spa" }`).
3000
3187
  * - `plugins` — extra plugins (Node-only built-ins or your own) merged into the app and its type.
3001
3188
  * - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
3002
3189
  * @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
3003
3190
  * @example
3004
3191
  * ```ts
3005
- * // Node SSG build — add the node-only plugins:
3192
+ * // Node SSG build — add the node-only plugins, register routes via config, then build:
3193
+ * import * as routes from "./routes";
3006
3194
  * const app = createApp({
3007
3195
  * plugins: [contentPlugin, buildPlugin, deployPlugin],
3008
3196
  * pluginConfigs: {
3009
3197
  * site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes" },
3010
- * router: { routes: defineRoutes({ home: route("/"), post: route("/blog/{slug}/") }) }
3198
+ * router: { routes }
3011
3199
  * }
3012
3200
  * });
3013
- * await app.start();
3014
- * await app.build.run();
3201
+ * await app.build.run(); // routes compiled at init
3015
3202
  * ```
3016
3203
  */
3017
3204
  declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs/core").AnyPluginInstance[] = readonly []>(options?: import("@moku-labs/core").CreateAppOptions<Config$8, Events, (import("@moku-labs/core").PluginInstance<"site", Config$7, Record<string, never>, Api$7, {}> & Record<never, never>) | (import("@moku-labs/core").PluginInstance<"i18n", Config$6, Record<string, never>, Api$6, {}> & Record<never, never>) | (import("@moku-labs/core").PluginInstance<"router", RouterConfig, RouterState, RouterApi, {}> & {
3018
3205
  route: typeof route;
3019
3206
  defineRoutes: typeof defineRoutes;
3207
+ createUrls: typeof createUrls;
3020
3208
  }) | (import("@moku-labs/core").PluginInstance<"head", Config$4, State$4, Api$4, {}> & {
3021
3209
  meta: typeof meta;
3022
3210
  og: typeof og;
@@ -3045,6 +3233,7 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3045
3233
  }> & Record<never, never>) | ExtraPlugins[number], [...ExtraPlugins], import("@moku-labs/core").CoreApisFromTuple<[import("@moku-labs/core").CorePluginInstance<"log", LogConfig, LogState, LogApi>, import("@moku-labs/core").CorePluginInstance<"env", EnvConfig, EnvState, EnvApi>]>> | undefined) => import("@moku-labs/core").App<Config$8, Events, (import("@moku-labs/core").PluginInstance<"site", Config$7, Record<string, never>, Api$7, {}> & Record<never, never>) | (import("@moku-labs/core").PluginInstance<"i18n", Config$6, Record<string, never>, Api$6, {}> & Record<never, never>) | (import("@moku-labs/core").PluginInstance<"router", RouterConfig, RouterState, RouterApi, {}> & {
3046
3234
  route: typeof route;
3047
3235
  defineRoutes: typeof defineRoutes;
3236
+ createUrls: typeof createUrls;
3048
3237
  }) | (import("@moku-labs/core").PluginInstance<"head", Config$4, State$4, Api$4, {}> & {
3049
3238
  meta: typeof meta;
3050
3239
  og: typeof og;
@@ -3088,4 +3277,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3088
3277
  */
3089
3278
  declare const createPlugin: import("@moku-labs/core").BoundCreatePluginFunction<Config$8, Events, import("@moku-labs/core").CoreApisFromTuple<[import("@moku-labs/core").CorePluginInstance<"log", LogConfig, LogState, LogApi>, import("@moku-labs/core").CorePluginInstance<"env", EnvConfig, EnvState, EnvApi>]>>;
3090
3279
  //#endregion
3091
- export { types_d_exports as Build, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, types_d_exports$5 as Env, types_d_exports$6 as Head, types_d_exports$7 as Log, types_d_exports$8 as Router, types_d_exports$9 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createComponent, createPlugin, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
3280
+ export { types_d_exports as Build, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, types_d_exports$5 as Env, types_d_exports$6 as Head, types_d_exports$7 as Log, types_d_exports$8 as Router, types_d_exports$9 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };