@moku-labs/web 1.4.1 → 1.5.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.
@@ -1138,6 +1138,25 @@ type Api$1 = {
1138
1138
  * ```
1139
1139
  */
1140
1140
  render(route: ResolvedRoute, data: unknown): string;
1141
+ /**
1142
+ * Compose the SITE-LEVEL `<head>` Open Graph / Twitter block for a bare-path redirect or
1143
+ * landing page that has no route identity (e.g. the apex-domain `/` redirect a
1144
+ * `localeRedirects` build emits). Returns `""` UNLESS `defaultOgImage` is configured, so
1145
+ * apps that opt out keep a bare redirect. Pulled synchronously by `build`.
1146
+ *
1147
+ * @param input - The landing URL (resolved to an absolute canonical) plus an optional locale.
1148
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
1149
+ * @param input.locale - Optional locale whose `og:locale` is emitted (e.g. the default locale).
1150
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
1151
+ * @example
1152
+ * ```ts
1153
+ * api.siteHead({ url: "/en/", locale: "en" });
1154
+ * ```
1155
+ */
1156
+ siteHead(input: {
1157
+ url: string;
1158
+ locale?: string;
1159
+ }): string;
1141
1160
  };
1142
1161
  declare namespace types_d_exports$6 {
1143
1162
  export { COMPONENT_HOOK_NAMES, ComponentContext, ComponentDef, ComponentHooks, ComponentInstance, ExtractApi, PageData, ResolvedSpaConfig, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState };
package/dist/browser.mjs CHANGED
@@ -2514,6 +2514,42 @@ function composeHead(input) {
2514
2514
  }), ...head.elements ?? []]);
2515
2515
  }
2516
2516
  /**
2517
+ * Compose the SITE-LEVEL Open Graph / Twitter block for a bare-path redirect or landing
2518
+ * page that has no per-route head of its own. Returns `[]` UNLESS a `defaultOgImage` is
2519
+ * configured — so apps that opt out keep a bare redirect (no behavior change). The site
2520
+ * name + description become the card's title/description (`og:type=website`); `url` is the
2521
+ * canonical the page points at. A bare article/tag alias gets this site card as a fallback;
2522
+ * crawlers that honor the page's `rel=canonical` still resolve the per-route card.
2523
+ *
2524
+ * @param input - The site slice, head defaults, landing URL, and optional `og:locale`.
2525
+ * @returns The ordered site-level head element set, or `[]` when no default image is set.
2526
+ * @example composeSiteHead({ site, defaults, url: "https://blog.dev/en/", ogLocale: "en_US" })
2527
+ */
2528
+ function composeSiteHead(input) {
2529
+ const { site, defaults, url, ogLocale } = input;
2530
+ const image = defaults.defaultOgImage;
2531
+ if (image === void 0) return [];
2532
+ const absoluteImage = resolveImage(image, site);
2533
+ const name = site.name();
2534
+ const description = site.description();
2535
+ const elements = [
2536
+ meta("description", description),
2537
+ og("og:type", "website"),
2538
+ og("og:site_name", name),
2539
+ og("og:title", name),
2540
+ og("og:description", description),
2541
+ og("og:url", url),
2542
+ og("og:image", absoluteImage),
2543
+ twitter("twitter:card", defaults.twitterCard),
2544
+ twitter("twitter:title", name),
2545
+ twitter("twitter:description", description),
2546
+ twitter("twitter:image", absoluteImage)
2547
+ ];
2548
+ if (defaults.twitterHandle) elements.push(twitter("twitter:site", defaults.twitterHandle));
2549
+ if (ogLocale) elements.push(og("og:locale", ogLocale));
2550
+ return elements;
2551
+ }
2552
+ /**
2517
2553
  * HTML-escape a value for safe insertion into an attribute or text node. `&` is
2518
2554
  * escaped first so already-escaped entities are not double-escaped.
2519
2555
  *
@@ -2602,28 +2638,53 @@ function readDefaults(state) {
2602
2638
  * ```
2603
2639
  */
2604
2640
  function createApi$1(ctx) {
2605
- return {
2606
- /**
2607
- * Compose the final `<head>` inner HTML for a route (pulled by `build`).
2608
- *
2609
- * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
2610
- * @param data - The page data object passed to the route's loader/render.
2611
- * @returns The serialized inner HTML of `<head>`.
2612
- * @example
2613
- * ```ts
2614
- * api.render(route, { title: "Post" });
2615
- * ```
2616
- */
2617
- render(route, data) {
2618
- return serializeHead(composeHead({
2619
- route,
2620
- data,
2621
- defaults: readDefaults(ctx.state),
2622
- site: ctx.require(sitePlugin),
2623
- i18n: ctx.require(i18nPlugin),
2624
- router: ctx.require(routerPlugin)
2625
- }));
2626
- } };
2641
+ return {
2642
+ /**
2643
+ * Compose the final `<head>` inner HTML for a route (pulled by `build`).
2644
+ *
2645
+ * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
2646
+ * @param data - The page data object passed to the route's loader/render.
2647
+ * @returns The serialized inner HTML of `<head>`.
2648
+ * @example
2649
+ * ```ts
2650
+ * api.render(route, { title: "Post" });
2651
+ * ```
2652
+ */
2653
+ render(route, data) {
2654
+ return serializeHead(composeHead({
2655
+ route,
2656
+ data,
2657
+ defaults: readDefaults(ctx.state),
2658
+ site: ctx.require(sitePlugin),
2659
+ i18n: ctx.require(i18nPlugin),
2660
+ router: ctx.require(routerPlugin)
2661
+ }));
2662
+ },
2663
+ /**
2664
+ * Compose the site-level OG/Twitter block for a bare-path redirect/landing page. Resolves
2665
+ * `site`/`i18n` via `ctx.require`, absolutizes `url` against the site base, and emits an
2666
+ * `og:locale` for `locale` when supplied. Returns `""` when no `defaultOgImage` is configured.
2667
+ *
2668
+ * @param input - The landing URL/path plus an optional locale (for `og:locale`).
2669
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
2670
+ * @param input.locale - Optional locale whose `og:locale` is emitted.
2671
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
2672
+ * @example
2673
+ * ```ts
2674
+ * api.siteHead({ url: "/en/", locale: "en" });
2675
+ * ```
2676
+ */
2677
+ siteHead(input) {
2678
+ const site = ctx.require(sitePlugin);
2679
+ const ogLocale = input.locale === void 0 ? void 0 : ctx.require(i18nPlugin).ogLocale(input.locale);
2680
+ return serializeHead(composeSiteHead({
2681
+ site,
2682
+ defaults: readDefaults(ctx.state),
2683
+ url: site.canonical(input.url),
2684
+ ...ogLocale === void 0 ? {} : { ogLocale }
2685
+ }));
2686
+ }
2687
+ };
2627
2688
  }
2628
2689
  //#endregion
2629
2690
  //#region src/plugins/head/config.ts
package/dist/index.cjs CHANGED
@@ -3072,6 +3072,42 @@ function composeHead(input) {
3072
3072
  }), ...head.elements ?? []]);
3073
3073
  }
3074
3074
  /**
3075
+ * Compose the SITE-LEVEL Open Graph / Twitter block for a bare-path redirect or landing
3076
+ * page that has no per-route head of its own. Returns `[]` UNLESS a `defaultOgImage` is
3077
+ * configured — so apps that opt out keep a bare redirect (no behavior change). The site
3078
+ * name + description become the card's title/description (`og:type=website`); `url` is the
3079
+ * canonical the page points at. A bare article/tag alias gets this site card as a fallback;
3080
+ * crawlers that honor the page's `rel=canonical` still resolve the per-route card.
3081
+ *
3082
+ * @param input - The site slice, head defaults, landing URL, and optional `og:locale`.
3083
+ * @returns The ordered site-level head element set, or `[]` when no default image is set.
3084
+ * @example composeSiteHead({ site, defaults, url: "https://blog.dev/en/", ogLocale: "en_US" })
3085
+ */
3086
+ function composeSiteHead(input) {
3087
+ const { site, defaults, url, ogLocale } = input;
3088
+ const image = defaults.defaultOgImage;
3089
+ if (image === void 0) return [];
3090
+ const absoluteImage = resolveImage(image, site);
3091
+ const name = site.name();
3092
+ const description = site.description();
3093
+ const elements = [
3094
+ meta("description", description),
3095
+ og("og:type", "website"),
3096
+ og("og:site_name", name),
3097
+ og("og:title", name),
3098
+ og("og:description", description),
3099
+ og("og:url", url),
3100
+ og("og:image", absoluteImage),
3101
+ twitter("twitter:card", defaults.twitterCard),
3102
+ twitter("twitter:title", name),
3103
+ twitter("twitter:description", description),
3104
+ twitter("twitter:image", absoluteImage)
3105
+ ];
3106
+ if (defaults.twitterHandle) elements.push(twitter("twitter:site", defaults.twitterHandle));
3107
+ if (ogLocale) elements.push(og("og:locale", ogLocale));
3108
+ return elements;
3109
+ }
3110
+ /**
3075
3111
  * HTML-escape a value for safe insertion into an attribute or text node. `&` is
3076
3112
  * escaped first so already-escaped entities are not double-escaped.
3077
3113
  *
@@ -3160,28 +3196,53 @@ function readDefaults(state) {
3160
3196
  * ```
3161
3197
  */
3162
3198
  function createApi$4(ctx) {
3163
- return {
3164
- /**
3165
- * Compose the final `<head>` inner HTML for a route (pulled by `build`).
3166
- *
3167
- * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
3168
- * @param data - The page data object passed to the route's loader/render.
3169
- * @returns The serialized inner HTML of `<head>`.
3170
- * @example
3171
- * ```ts
3172
- * api.render(route, { title: "Post" });
3173
- * ```
3174
- */
3175
- render(route, data) {
3176
- return serializeHead(composeHead({
3177
- route,
3178
- data,
3179
- defaults: readDefaults(ctx.state),
3180
- site: ctx.require(sitePlugin),
3181
- i18n: ctx.require(i18nPlugin),
3182
- router: ctx.require(routerPlugin)
3183
- }));
3184
- } };
3199
+ return {
3200
+ /**
3201
+ * Compose the final `<head>` inner HTML for a route (pulled by `build`).
3202
+ *
3203
+ * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
3204
+ * @param data - The page data object passed to the route's loader/render.
3205
+ * @returns The serialized inner HTML of `<head>`.
3206
+ * @example
3207
+ * ```ts
3208
+ * api.render(route, { title: "Post" });
3209
+ * ```
3210
+ */
3211
+ render(route, data) {
3212
+ return serializeHead(composeHead({
3213
+ route,
3214
+ data,
3215
+ defaults: readDefaults(ctx.state),
3216
+ site: ctx.require(sitePlugin),
3217
+ i18n: ctx.require(i18nPlugin),
3218
+ router: ctx.require(routerPlugin)
3219
+ }));
3220
+ },
3221
+ /**
3222
+ * Compose the site-level OG/Twitter block for a bare-path redirect/landing page. Resolves
3223
+ * `site`/`i18n` via `ctx.require`, absolutizes `url` against the site base, and emits an
3224
+ * `og:locale` for `locale` when supplied. Returns `""` when no `defaultOgImage` is configured.
3225
+ *
3226
+ * @param input - The landing URL/path plus an optional locale (for `og:locale`).
3227
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
3228
+ * @param input.locale - Optional locale whose `og:locale` is emitted.
3229
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
3230
+ * @example
3231
+ * ```ts
3232
+ * api.siteHead({ url: "/en/", locale: "en" });
3233
+ * ```
3234
+ */
3235
+ siteHead(input) {
3236
+ const site = ctx.require(sitePlugin);
3237
+ const ogLocale = input.locale === void 0 ? void 0 : ctx.require(i18nPlugin).ogLocale(input.locale);
3238
+ return serializeHead(composeSiteHead({
3239
+ site,
3240
+ defaults: readDefaults(ctx.state),
3241
+ url: site.canonical(input.url),
3242
+ ...ogLocale === void 0 ? {} : { ogLocale }
3243
+ }));
3244
+ }
3245
+ };
3185
3246
  }
3186
3247
  //#endregion
3187
3248
  //#region src/plugins/head/config.ts
@@ -3754,19 +3815,26 @@ async function processImages(ctx, options = {}) {
3754
3815
  * bare path that points at the default-locale-prefixed URL. Deliberately does NOT
3755
3816
  * emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3756
3817
  * `config.localeRedirects` (false/unset disables).
3818
+ *
3819
+ * When `head.defaultOgImage` is configured, each redirect page ALSO carries the
3820
+ * site-level Open Graph / Twitter block (`head.siteHead`) so a social crawler that
3821
+ * fetches the apex domain (or any locale-less alias) — and does not follow the
3822
+ * meta-refresh — still gets a branded preview card. No image configured ⇒ bare redirect.
3757
3823
  */
3758
3824
  /**
3759
- * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
3825
+ * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`,
3826
+ * with an optional site-level OG/Twitter block injected at the end of `<head>`.
3760
3827
  *
3761
3828
  * @param target - The default-locale-prefixed URL to redirect to.
3829
+ * @param headExtra - Extra `<head>` inner HTML (the site-level OG block), or `""` for none.
3762
3830
  * @returns The complete redirect HTML document string.
3763
3831
  * @example
3764
3832
  * ```ts
3765
- * redirectHtml("/en/about/");
3833
+ * redirectHtml("/en/about/", '<meta property="og:image" content="…">');
3766
3834
  * ```
3767
3835
  */
3768
- function redirectHtml(target) {
3769
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}"></head><body><a href="${target}">Redirecting…</a></body></html>`;
3836
+ function redirectHtml(target, headExtra = "") {
3837
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}">${headExtra}</head><body><a href="${target}">Redirecting…</a></body></html>`;
3770
3838
  }
3771
3839
  /**
3772
3840
  * Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
@@ -3858,16 +3926,17 @@ async function expandRedirects(definition, entry, defaultLocale, ctx) {
3858
3926
  * @param job.file - The redirect page's output path, relative to `outDir`.
3859
3927
  * @param job.target - The absolute default-locale URL the page redirects to.
3860
3928
  * @param outDir - The build output directory the file is resolved against.
3929
+ * @param headExtra - The site-level OG block to inject into `<head>`, or `""` for none.
3861
3930
  * @returns Resolves once the redirect HTML page is written.
3862
3931
  * @example
3863
3932
  * ```ts
3864
- * await writeRedirectFile({ file: "about/index.html", target: "/en/about/" }, "dist");
3933
+ * await writeRedirectFile({ file: "about/index.html", target: "/en/about/" }, "dist", "");
3865
3934
  * ```
3866
3935
  */
3867
- async function writeRedirectFile(job, outDir) {
3936
+ async function writeRedirectFile(job, outDir, headExtra = "") {
3868
3937
  const filePath = node_path$1.default.join(outDir, job.file);
3869
3938
  await (0, node_fs_promises.mkdir)(node_path$1.default.dirname(filePath), { recursive: true });
3870
- await (0, node_fs_promises.writeFile)(filePath, redirectHtml(job.target), "utf8");
3939
+ await (0, node_fs_promises.writeFile)(filePath, redirectHtml(job.target, headExtra), "utf8");
3871
3940
  }
3872
3941
  /**
3873
3942
  * Emits one bare-path redirect HTML page per locale-prefixed route path, each a
@@ -3890,7 +3959,14 @@ async function generateLocaleRedirects(ctx) {
3890
3959
  const router = ctx.require(routerPlugin);
3891
3960
  const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
3892
3961
  const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale, ctx)))).flat();
3893
- await Promise.all(jobs.map((job) => writeRedirectFile(job, ctx.config.outDir)));
3962
+ const head = ctx.has("head") ? ctx.require(headPlugin) : void 0;
3963
+ await Promise.all(jobs.map((job) => {
3964
+ const headExtra = head ? head.siteHead({
3965
+ url: job.target,
3966
+ locale: defaultLocale
3967
+ }) : "";
3968
+ return writeRedirectFile(job, ctx.config.outDir, headExtra);
3969
+ }));
3894
3970
  ctx.log.debug("build:locale-redirects", { written: jobs.length });
3895
3971
  return { written: jobs.length };
3896
3972
  }
@@ -4098,6 +4174,45 @@ function defaultCard(input) {
4098
4174
  } }, input.title);
4099
4175
  }
4100
4176
  /**
4177
+ * The built-in SITE-LEVEL default card — the site name over its description on a dark
4178
+ * background, centered. Rendered once (when `ogImage.defaultCard` is true) to `og-default.png`
4179
+ * and used as the `head.defaultOgImage` fallback. Reads `siteName` + `description` from the
4180
+ * {@link RichOgInput}; the per-article `render` hook is deliberately NOT applied here.
4181
+ *
4182
+ * @param input - The rich OG input (only `siteName` + `description` are used).
4183
+ * @returns The Preact `VNode` for the site default card.
4184
+ * @example
4185
+ * ```ts
4186
+ * defaultSiteCard({ ...input, siteName: "My Blog", description: "A dev blog" });
4187
+ * ```
4188
+ */
4189
+ function defaultSiteCard(input) {
4190
+ const children = [(0, preact.h)("div", { style: {
4191
+ display: "flex",
4192
+ fontSize: 72,
4193
+ fontWeight: 700,
4194
+ color: "#ffffff"
4195
+ } }, input.siteName)];
4196
+ if (input.description) children.push((0, preact.h)("div", { style: {
4197
+ display: "flex",
4198
+ marginTop: 28,
4199
+ maxWidth: 900,
4200
+ fontSize: 32,
4201
+ color: "#a1a1aa",
4202
+ textAlign: "center"
4203
+ } }, input.description));
4204
+ return (0, preact.h)("div", { style: {
4205
+ display: "flex",
4206
+ flexDirection: "column",
4207
+ width: "100%",
4208
+ height: "100%",
4209
+ alignItems: "center",
4210
+ justifyContent: "center",
4211
+ padding: 80,
4212
+ background: "#0b0b0c"
4213
+ } }, ...children);
4214
+ }
4215
+ /**
4101
4216
  * The default PNG renderer: a Preact `VNode` (custom `render` hook or the built-in
4102
4217
  * card) is rendered to SVG by Satori, then rasterized to PNG by resvg. Both native
4103
4218
  * deps are imported LAZILY (browser-safe goal); the VNode→Satori-input cast happens
@@ -4187,6 +4302,24 @@ function resolveSiteName(ctx) {
4187
4302
  }
4188
4303
  }
4189
4304
  /**
4305
+ * Resolve the site description via `ctx.require(sitePlugin)`, falling back to `""` when the
4306
+ * site API is unavailable (e.g. unit mocks that omit it). Used by the site default card.
4307
+ *
4308
+ * @param ctx - Plugin context (provides `require`).
4309
+ * @returns The site description, or `""` when the site plugin is not wired.
4310
+ * @example
4311
+ * ```ts
4312
+ * resolveSiteDescription(ctx);
4313
+ * ```
4314
+ */
4315
+ function resolveSiteDescription(ctx) {
4316
+ try {
4317
+ return ctx.require(sitePlugin).description();
4318
+ } catch {
4319
+ return "";
4320
+ }
4321
+ }
4322
+ /**
4190
4323
  * Render (or cache-skip) one article's OG image, mutating {@link RenderTally} in
4191
4324
  * place. A matching cached hash bumps `skipped` and returns early; otherwise the
4192
4325
  * PNG is rasterized to `<outDir>/og/<slug>.png`, the cache entry is updated, and
@@ -4257,6 +4390,10 @@ async function generateOgImages(ctx, options = {}) {
4257
4390
  fonts,
4258
4391
  ...renderHook
4259
4392
  });
4393
+ const renderSitePng = options.renderPng ?? makeDefaultRenderer({
4394
+ fonts,
4395
+ render: defaultSiteCard
4396
+ });
4260
4397
  const siteName = resolveSiteName(ctx);
4261
4398
  const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
4262
4399
  const articles = selectArticles(readCachedContent(ctx), defaultLocale);
@@ -4281,15 +4418,31 @@ async function generateOgImages(ctx, options = {}) {
4281
4418
  outDir
4282
4419
  }, tally);
4283
4420
  })));
4421
+ const defaultCard = config.defaultCard === true;
4422
+ if (defaultCard) {
4423
+ const png = await renderSitePng({
4424
+ title: siteName,
4425
+ description: resolveSiteDescription(ctx),
4426
+ date: "",
4427
+ tags: [],
4428
+ locale: defaultLocale,
4429
+ siteName,
4430
+ size
4431
+ });
4432
+ await (0, node_fs_promises.mkdir)(ctx.config.outDir, { recursive: true });
4433
+ await (0, node_fs_promises.writeFile)(node_path.default.join(ctx.config.outDir, "og-default.png"), png);
4434
+ }
4284
4435
  await persistDiskCache(ctx.config.outDir, cache);
4285
4436
  ctx.log.debug("build:og-images", {
4286
4437
  rendered: tally.rendered,
4287
- skipped: tally.skipped
4438
+ skipped: tally.skipped,
4439
+ defaultCard
4288
4440
  });
4289
4441
  return {
4290
4442
  rendered: tally.rendered,
4291
4443
  skipped: tally.skipped,
4292
- peakConcurrency: tally.peakConcurrency
4444
+ peakConcurrency: tally.peakConcurrency,
4445
+ defaultCard
4293
4446
  };
4294
4447
  }
4295
4448
  /**
package/dist/index.d.cts CHANGED
@@ -1138,6 +1138,25 @@ type Api$4 = {
1138
1138
  * ```
1139
1139
  */
1140
1140
  render(route: ResolvedRoute, data: unknown): string;
1141
+ /**
1142
+ * Compose the SITE-LEVEL `<head>` Open Graph / Twitter block for a bare-path redirect or
1143
+ * landing page that has no route identity (e.g. the apex-domain `/` redirect a
1144
+ * `localeRedirects` build emits). Returns `""` UNLESS `defaultOgImage` is configured, so
1145
+ * apps that opt out keep a bare redirect. Pulled synchronously by `build`.
1146
+ *
1147
+ * @param input - The landing URL (resolved to an absolute canonical) plus an optional locale.
1148
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
1149
+ * @param input.locale - Optional locale whose `og:locale` is emitted (e.g. the default locale).
1150
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
1151
+ * @example
1152
+ * ```ts
1153
+ * api.siteHead({ url: "/en/", locale: "en" });
1154
+ * ```
1155
+ */
1156
+ siteHead(input: {
1157
+ url: string;
1158
+ locale?: string;
1159
+ }): string;
1141
1160
  };
1142
1161
  declare namespace types_d_exports$9 {
1143
1162
  export { COMPONENT_HOOK_NAMES, ComponentContext, ComponentDef, ComponentHooks, ComponentInstance, ExtractApi$1 as ExtractApi, PageData, ResolvedSpaConfig, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState };
@@ -1613,6 +1632,13 @@ interface OgImageConfig {
1613
1632
  render?(input: RichOgInput): import("preact").VNode;
1614
1633
  /** Explicit named fonts loaded once per build (overrides the first-file scan). */
1615
1634
  fonts?: OgFont[];
1635
+ /**
1636
+ * When `true`, also render a single SITE-LEVEL default card to `<outDir>/og-default.png`
1637
+ * — a generic site name + description on a dark background, using the same loaded fonts (the
1638
+ * per-article `render` hook is NOT applied). Point `head.defaultOgImage` at `"/og-default.png"`
1639
+ * to use it as the og:image fallback for non-article pages. Default `false`.
1640
+ */
1641
+ defaultCard?: boolean;
1616
1642
  }
1617
1643
  /**
1618
1644
  * Public configuration for the `build` plugin. Flags give opt-in granularity over
package/dist/index.d.mts CHANGED
@@ -1138,6 +1138,25 @@ type Api$4 = {
1138
1138
  * ```
1139
1139
  */
1140
1140
  render(route: ResolvedRoute, data: unknown): string;
1141
+ /**
1142
+ * Compose the SITE-LEVEL `<head>` Open Graph / Twitter block for a bare-path redirect or
1143
+ * landing page that has no route identity (e.g. the apex-domain `/` redirect a
1144
+ * `localeRedirects` build emits). Returns `""` UNLESS `defaultOgImage` is configured, so
1145
+ * apps that opt out keep a bare redirect. Pulled synchronously by `build`.
1146
+ *
1147
+ * @param input - The landing URL (resolved to an absolute canonical) plus an optional locale.
1148
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
1149
+ * @param input.locale - Optional locale whose `og:locale` is emitted (e.g. the default locale).
1150
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
1151
+ * @example
1152
+ * ```ts
1153
+ * api.siteHead({ url: "/en/", locale: "en" });
1154
+ * ```
1155
+ */
1156
+ siteHead(input: {
1157
+ url: string;
1158
+ locale?: string;
1159
+ }): string;
1141
1160
  };
1142
1161
  declare namespace types_d_exports$9 {
1143
1162
  export { COMPONENT_HOOK_NAMES, ComponentContext, ComponentDef, ComponentHooks, ComponentInstance, ExtractApi$1 as ExtractApi, PageData, ResolvedSpaConfig, SpaApi, SpaConfig, SpaContext, SpaDataReader, SpaEmitFunction, SpaEvents, SpaKernel, SpaKernelDeps, SpaRequire, SpaState };
@@ -1613,6 +1632,13 @@ interface OgImageConfig {
1613
1632
  render?(input: RichOgInput): import("preact").VNode;
1614
1633
  /** Explicit named fonts loaded once per build (overrides the first-file scan). */
1615
1634
  fonts?: OgFont[];
1635
+ /**
1636
+ * When `true`, also render a single SITE-LEVEL default card to `<outDir>/og-default.png`
1637
+ * — a generic site name + description on a dark background, using the same loaded fonts (the
1638
+ * per-article `render` hook is NOT applied). Point `head.defaultOgImage` at `"/og-default.png"`
1639
+ * to use it as the og:image fallback for non-article pages. Default `false`.
1640
+ */
1641
+ defaultCard?: boolean;
1616
1642
  }
1617
1643
  /**
1618
1644
  * Public configuration for the `build` plugin. Flags give opt-in granularity over
package/dist/index.mjs CHANGED
@@ -3059,6 +3059,42 @@ function composeHead(input) {
3059
3059
  }), ...head.elements ?? []]);
3060
3060
  }
3061
3061
  /**
3062
+ * Compose the SITE-LEVEL Open Graph / Twitter block for a bare-path redirect or landing
3063
+ * page that has no per-route head of its own. Returns `[]` UNLESS a `defaultOgImage` is
3064
+ * configured — so apps that opt out keep a bare redirect (no behavior change). The site
3065
+ * name + description become the card's title/description (`og:type=website`); `url` is the
3066
+ * canonical the page points at. A bare article/tag alias gets this site card as a fallback;
3067
+ * crawlers that honor the page's `rel=canonical` still resolve the per-route card.
3068
+ *
3069
+ * @param input - The site slice, head defaults, landing URL, and optional `og:locale`.
3070
+ * @returns The ordered site-level head element set, or `[]` when no default image is set.
3071
+ * @example composeSiteHead({ site, defaults, url: "https://blog.dev/en/", ogLocale: "en_US" })
3072
+ */
3073
+ function composeSiteHead(input) {
3074
+ const { site, defaults, url, ogLocale } = input;
3075
+ const image = defaults.defaultOgImage;
3076
+ if (image === void 0) return [];
3077
+ const absoluteImage = resolveImage(image, site);
3078
+ const name = site.name();
3079
+ const description = site.description();
3080
+ const elements = [
3081
+ meta("description", description),
3082
+ og("og:type", "website"),
3083
+ og("og:site_name", name),
3084
+ og("og:title", name),
3085
+ og("og:description", description),
3086
+ og("og:url", url),
3087
+ og("og:image", absoluteImage),
3088
+ twitter("twitter:card", defaults.twitterCard),
3089
+ twitter("twitter:title", name),
3090
+ twitter("twitter:description", description),
3091
+ twitter("twitter:image", absoluteImage)
3092
+ ];
3093
+ if (defaults.twitterHandle) elements.push(twitter("twitter:site", defaults.twitterHandle));
3094
+ if (ogLocale) elements.push(og("og:locale", ogLocale));
3095
+ return elements;
3096
+ }
3097
+ /**
3062
3098
  * HTML-escape a value for safe insertion into an attribute or text node. `&` is
3063
3099
  * escaped first so already-escaped entities are not double-escaped.
3064
3100
  *
@@ -3147,28 +3183,53 @@ function readDefaults(state) {
3147
3183
  * ```
3148
3184
  */
3149
3185
  function createApi$4(ctx) {
3150
- return {
3151
- /**
3152
- * Compose the final `<head>` inner HTML for a route (pulled by `build`).
3153
- *
3154
- * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
3155
- * @param data - The page data object passed to the route's loader/render.
3156
- * @returns The serialized inner HTML of `<head>`.
3157
- * @example
3158
- * ```ts
3159
- * api.render(route, { title: "Post" });
3160
- * ```
3161
- */
3162
- render(route, data) {
3163
- return serializeHead(composeHead({
3164
- route,
3165
- data,
3166
- defaults: readDefaults(ctx.state),
3167
- site: ctx.require(sitePlugin),
3168
- i18n: ctx.require(i18nPlugin),
3169
- router: ctx.require(routerPlugin)
3170
- }));
3171
- } };
3186
+ return {
3187
+ /**
3188
+ * Compose the final `<head>` inner HTML for a route (pulled by `build`).
3189
+ *
3190
+ * @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
3191
+ * @param data - The page data object passed to the route's loader/render.
3192
+ * @returns The serialized inner HTML of `<head>`.
3193
+ * @example
3194
+ * ```ts
3195
+ * api.render(route, { title: "Post" });
3196
+ * ```
3197
+ */
3198
+ render(route, data) {
3199
+ return serializeHead(composeHead({
3200
+ route,
3201
+ data,
3202
+ defaults: readDefaults(ctx.state),
3203
+ site: ctx.require(sitePlugin),
3204
+ i18n: ctx.require(i18nPlugin),
3205
+ router: ctx.require(routerPlugin)
3206
+ }));
3207
+ },
3208
+ /**
3209
+ * Compose the site-level OG/Twitter block for a bare-path redirect/landing page. Resolves
3210
+ * `site`/`i18n` via `ctx.require`, absolutizes `url` against the site base, and emits an
3211
+ * `og:locale` for `locale` when supplied. Returns `""` when no `defaultOgImage` is configured.
3212
+ *
3213
+ * @param input - The landing URL/path plus an optional locale (for `og:locale`).
3214
+ * @param input.url - The page's URL or path (absolutized via `site.canonical`) → `og:url`.
3215
+ * @param input.locale - Optional locale whose `og:locale` is emitted.
3216
+ * @returns The serialized inner HTML of the site-level head block, or `""` when disabled.
3217
+ * @example
3218
+ * ```ts
3219
+ * api.siteHead({ url: "/en/", locale: "en" });
3220
+ * ```
3221
+ */
3222
+ siteHead(input) {
3223
+ const site = ctx.require(sitePlugin);
3224
+ const ogLocale = input.locale === void 0 ? void 0 : ctx.require(i18nPlugin).ogLocale(input.locale);
3225
+ return serializeHead(composeSiteHead({
3226
+ site,
3227
+ defaults: readDefaults(ctx.state),
3228
+ url: site.canonical(input.url),
3229
+ ...ogLocale === void 0 ? {} : { ogLocale }
3230
+ }));
3231
+ }
3232
+ };
3172
3233
  }
3173
3234
  //#endregion
3174
3235
  //#region src/plugins/head/config.ts
@@ -3741,19 +3802,26 @@ async function processImages(ctx, options = {}) {
3741
3802
  * bare path that points at the default-locale-prefixed URL. Deliberately does NOT
3742
3803
  * emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3743
3804
  * `config.localeRedirects` (false/unset disables).
3805
+ *
3806
+ * When `head.defaultOgImage` is configured, each redirect page ALSO carries the
3807
+ * site-level Open Graph / Twitter block (`head.siteHead`) so a social crawler that
3808
+ * fetches the apex domain (or any locale-less alias) — and does not follow the
3809
+ * meta-refresh — still gets a branded preview card. No image configured ⇒ bare redirect.
3744
3810
  */
3745
3811
  /**
3746
- * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
3812
+ * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`,
3813
+ * with an optional site-level OG/Twitter block injected at the end of `<head>`.
3747
3814
  *
3748
3815
  * @param target - The default-locale-prefixed URL to redirect to.
3816
+ * @param headExtra - Extra `<head>` inner HTML (the site-level OG block), or `""` for none.
3749
3817
  * @returns The complete redirect HTML document string.
3750
3818
  * @example
3751
3819
  * ```ts
3752
- * redirectHtml("/en/about/");
3820
+ * redirectHtml("/en/about/", '<meta property="og:image" content="…">');
3753
3821
  * ```
3754
3822
  */
3755
- function redirectHtml(target) {
3756
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}"></head><body><a href="${target}">Redirecting…</a></body></html>`;
3823
+ function redirectHtml(target, headExtra = "") {
3824
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}">${headExtra}</head><body><a href="${target}">Redirecting…</a></body></html>`;
3757
3825
  }
3758
3826
  /**
3759
3827
  * Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
@@ -3845,16 +3913,17 @@ async function expandRedirects(definition, entry, defaultLocale, ctx) {
3845
3913
  * @param job.file - The redirect page's output path, relative to `outDir`.
3846
3914
  * @param job.target - The absolute default-locale URL the page redirects to.
3847
3915
  * @param outDir - The build output directory the file is resolved against.
3916
+ * @param headExtra - The site-level OG block to inject into `<head>`, or `""` for none.
3848
3917
  * @returns Resolves once the redirect HTML page is written.
3849
3918
  * @example
3850
3919
  * ```ts
3851
- * await writeRedirectFile({ file: "about/index.html", target: "/en/about/" }, "dist");
3920
+ * await writeRedirectFile({ file: "about/index.html", target: "/en/about/" }, "dist", "");
3852
3921
  * ```
3853
3922
  */
3854
- async function writeRedirectFile(job, outDir) {
3923
+ async function writeRedirectFile(job, outDir, headExtra = "") {
3855
3924
  const filePath = path.join(outDir, job.file);
3856
3925
  await mkdir(path.dirname(filePath), { recursive: true });
3857
- await writeFile(filePath, redirectHtml(job.target), "utf8");
3926
+ await writeFile(filePath, redirectHtml(job.target, headExtra), "utf8");
3858
3927
  }
3859
3928
  /**
3860
3929
  * Emits one bare-path redirect HTML page per locale-prefixed route path, each a
@@ -3877,7 +3946,14 @@ async function generateLocaleRedirects(ctx) {
3877
3946
  const router = ctx.require(routerPlugin);
3878
3947
  const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
3879
3948
  const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale, ctx)))).flat();
3880
- await Promise.all(jobs.map((job) => writeRedirectFile(job, ctx.config.outDir)));
3949
+ const head = ctx.has("head") ? ctx.require(headPlugin) : void 0;
3950
+ await Promise.all(jobs.map((job) => {
3951
+ const headExtra = head ? head.siteHead({
3952
+ url: job.target,
3953
+ locale: defaultLocale
3954
+ }) : "";
3955
+ return writeRedirectFile(job, ctx.config.outDir, headExtra);
3956
+ }));
3881
3957
  ctx.log.debug("build:locale-redirects", { written: jobs.length });
3882
3958
  return { written: jobs.length };
3883
3959
  }
@@ -4085,6 +4161,45 @@ function defaultCard(input) {
4085
4161
  } }, input.title);
4086
4162
  }
4087
4163
  /**
4164
+ * The built-in SITE-LEVEL default card — the site name over its description on a dark
4165
+ * background, centered. Rendered once (when `ogImage.defaultCard` is true) to `og-default.png`
4166
+ * and used as the `head.defaultOgImage` fallback. Reads `siteName` + `description` from the
4167
+ * {@link RichOgInput}; the per-article `render` hook is deliberately NOT applied here.
4168
+ *
4169
+ * @param input - The rich OG input (only `siteName` + `description` are used).
4170
+ * @returns The Preact `VNode` for the site default card.
4171
+ * @example
4172
+ * ```ts
4173
+ * defaultSiteCard({ ...input, siteName: "My Blog", description: "A dev blog" });
4174
+ * ```
4175
+ */
4176
+ function defaultSiteCard(input) {
4177
+ const children = [h("div", { style: {
4178
+ display: "flex",
4179
+ fontSize: 72,
4180
+ fontWeight: 700,
4181
+ color: "#ffffff"
4182
+ } }, input.siteName)];
4183
+ if (input.description) children.push(h("div", { style: {
4184
+ display: "flex",
4185
+ marginTop: 28,
4186
+ maxWidth: 900,
4187
+ fontSize: 32,
4188
+ color: "#a1a1aa",
4189
+ textAlign: "center"
4190
+ } }, input.description));
4191
+ return h("div", { style: {
4192
+ display: "flex",
4193
+ flexDirection: "column",
4194
+ width: "100%",
4195
+ height: "100%",
4196
+ alignItems: "center",
4197
+ justifyContent: "center",
4198
+ padding: 80,
4199
+ background: "#0b0b0c"
4200
+ } }, ...children);
4201
+ }
4202
+ /**
4088
4203
  * The default PNG renderer: a Preact `VNode` (custom `render` hook or the built-in
4089
4204
  * card) is rendered to SVG by Satori, then rasterized to PNG by resvg. Both native
4090
4205
  * deps are imported LAZILY (browser-safe goal); the VNode→Satori-input cast happens
@@ -4174,6 +4289,24 @@ function resolveSiteName(ctx) {
4174
4289
  }
4175
4290
  }
4176
4291
  /**
4292
+ * Resolve the site description via `ctx.require(sitePlugin)`, falling back to `""` when the
4293
+ * site API is unavailable (e.g. unit mocks that omit it). Used by the site default card.
4294
+ *
4295
+ * @param ctx - Plugin context (provides `require`).
4296
+ * @returns The site description, or `""` when the site plugin is not wired.
4297
+ * @example
4298
+ * ```ts
4299
+ * resolveSiteDescription(ctx);
4300
+ * ```
4301
+ */
4302
+ function resolveSiteDescription(ctx) {
4303
+ try {
4304
+ return ctx.require(sitePlugin).description();
4305
+ } catch {
4306
+ return "";
4307
+ }
4308
+ }
4309
+ /**
4177
4310
  * Render (or cache-skip) one article's OG image, mutating {@link RenderTally} in
4178
4311
  * place. A matching cached hash bumps `skipped` and returns early; otherwise the
4179
4312
  * PNG is rasterized to `<outDir>/og/<slug>.png`, the cache entry is updated, and
@@ -4244,6 +4377,10 @@ async function generateOgImages(ctx, options = {}) {
4244
4377
  fonts,
4245
4378
  ...renderHook
4246
4379
  });
4380
+ const renderSitePng = options.renderPng ?? makeDefaultRenderer({
4381
+ fonts,
4382
+ render: defaultSiteCard
4383
+ });
4247
4384
  const siteName = resolveSiteName(ctx);
4248
4385
  const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
4249
4386
  const articles = selectArticles(readCachedContent(ctx), defaultLocale);
@@ -4268,15 +4405,31 @@ async function generateOgImages(ctx, options = {}) {
4268
4405
  outDir
4269
4406
  }, tally);
4270
4407
  })));
4408
+ const defaultCard = config.defaultCard === true;
4409
+ if (defaultCard) {
4410
+ const png = await renderSitePng({
4411
+ title: siteName,
4412
+ description: resolveSiteDescription(ctx),
4413
+ date: "",
4414
+ tags: [],
4415
+ locale: defaultLocale,
4416
+ siteName,
4417
+ size
4418
+ });
4419
+ await mkdir(ctx.config.outDir, { recursive: true });
4420
+ await writeFile(path.join(ctx.config.outDir, "og-default.png"), png);
4421
+ }
4271
4422
  await persistDiskCache(ctx.config.outDir, cache);
4272
4423
  ctx.log.debug("build:og-images", {
4273
4424
  rendered: tally.rendered,
4274
- skipped: tally.skipped
4425
+ skipped: tally.skipped,
4426
+ defaultCard
4275
4427
  });
4276
4428
  return {
4277
4429
  rendered: tally.rendered,
4278
4430
  skipped: tally.skipped,
4279
- peakConcurrency: tally.peakConcurrency
4431
+ peakConcurrency: tally.peakConcurrency,
4432
+ defaultCard
4280
4433
  };
4281
4434
  }
4282
4435
  /**
package/package.json CHANGED
@@ -113,5 +113,5 @@
113
113
  "test:cli-e2e": "bun test src/plugins/cli/__tests__/e2e/",
114
114
  "test:coverage": "vitest run --project unit --project integration --coverage"
115
115
  },
116
- "version": "1.4.1"
116
+ "version": "1.5.1"
117
117
  }