@contentrain/emitter-astro 0.14.5 → 0.15.0

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,4 +1,4 @@
1
- import { ComponentDef, EntrySourceRef, ModelDefinition, ProjectIR, RouteModel, RuntimeBinding, RuntimeBinding as RuntimeBinding$1 } from "@contentrain/types";
1
+ import { ComponentDef, EntrySourceRef, ModelDefinition, ProjectIR, RawRedirect, RawRedirect as RawRedirect$1, RawSeoEntry, RouteModel, RuntimeBinding, RuntimeBinding as RuntimeBinding$1, SeoProvider } from "@contentrain/types";
2
2
 
3
3
  //#region src/types.d.ts
4
4
  /**
@@ -33,6 +33,12 @@ interface EmitPost {
33
33
  /** The site's date-format catalog applied to this post's date (index-aligned with `date{n}` marks). */
34
34
  dates?: string[];
35
35
  author?: string;
36
+ /**
37
+ * The author's page — their archive on the site, absolute or site-root-
38
+ * relative. The Article's author gets it as `url`, the address the
39
+ * archive's ProfilePage names its Person at, so the two are one person.
40
+ */
41
+ author_url?: string;
36
42
  author_first?: string;
37
43
  author_last?: string;
38
44
  /**
@@ -61,6 +67,27 @@ interface EmitPost {
61
67
  image_meta?: ImageMeta;
62
68
  /** Canonical override — for a page that should point somewhere else. Default: this page's own address. */
63
69
  canonical?: string;
70
+ /**
71
+ * The document title (`<title>`, and the default og/twitter title) when the
72
+ * source's SEO plugin composed one apart from the entry's — Yoast's
73
+ * "Post – Site". `title` stays the entry's own name: its marks, the Article
74
+ * headline, the last breadcrumb. Default: `title`.
75
+ */
76
+ seo_title?: string;
77
+ /** Open Graph text and image the source set for this page, over the derived ones. */
78
+ open_graph?: SocialOverride;
79
+ /** Twitter card values the source set for this page. Unset values follow Open Graph. */
80
+ twitter?: TwitterOverride;
81
+ /**
82
+ * The page's JSON-LD as the source's SEO plugin rendered it — a `@graph`
83
+ * object, an array of nodes, or one node. Printed instead of the graph the
84
+ * emitter would build, so FAQ, HowTo, Product and whatever else the plugin
85
+ * described survive. The migrated site keeps the source addresses, so the
86
+ * graph's `@id`s and URLs stay true; media URLs in it are printed as given
87
+ * and are the producer's to rewrite. A WebSite `SearchAction` is dropped (a
88
+ * static site has no `?s=` search), as is a copy of the head's WebSite node.
89
+ */
90
+ schema?: unknown;
64
91
  /** ISO 8601, for Article structured data. `dates` holds display strings, which schema.org cannot read. */
65
92
  published_at?: string;
66
93
  modified_at?: string;
@@ -85,6 +112,14 @@ interface EmitPost {
85
112
  * as a placeholder.
86
113
  */
87
114
  entry?: EntrySourceRef;
115
+ /**
116
+ * The source page was `noindex` (e.g. Yoast `robots.index`). The page gets
117
+ * `<meta name="robots" content="noindex">` and is left out of the sitemap —
118
+ * a migration must not make indexable what the site kept out of search.
119
+ */
120
+ noindex?: boolean;
121
+ /** The source page was `nofollow`: `<meta name="robots" content="nofollow">`. */
122
+ nofollow?: boolean;
88
123
  }
89
124
  /**
90
125
  * One block of a list: a template, optional wrapper, and how many items it takes.
@@ -138,6 +173,44 @@ interface QueryPage {
138
173
  canonical?: string;
139
174
  /** The trail to this list page, itself excluded — see `EmitPost.breadcrumbs`. */
140
175
  breadcrumbs?: Breadcrumb[];
176
+ /** See `EmitPost.noindex`. */
177
+ noindex?: boolean;
178
+ /** See `EmitPost.nofollow`. */
179
+ nofollow?: boolean;
180
+ /** See `EmitPost.open_graph`. */
181
+ open_graph?: SocialOverride;
182
+ /** See `EmitPost.twitter`. */
183
+ twitter?: TwitterOverride;
184
+ /** See `EmitPost.schema`. */
185
+ schema?: unknown;
186
+ /**
187
+ * On an author's archive: who it is about. The page becomes a ProfilePage
188
+ * whose main entity is this Person, at the page's own address.
189
+ */
190
+ profile?: AuthorProfile;
191
+ }
192
+ /** An author as their archive page presents them. */
193
+ interface AuthorProfile {
194
+ name: string;
195
+ /** The author's bio. */
196
+ description?: string;
197
+ /** Avatar or photo, absolute or site-root-relative. */
198
+ image?: string;
199
+ /** Profiles elsewhere (social accounts, a personal site) — schema.org `sameAs`; http(s) only. */
200
+ same_as?: string[];
201
+ }
202
+ /**
203
+ * Share-card values a page's source set by hand. Each one left out falls back
204
+ * to the value derived from the page. `image` is absolute or site-root-relative.
205
+ */
206
+ interface SocialOverride {
207
+ title?: string;
208
+ description?: string;
209
+ image?: string;
210
+ }
211
+ interface TwitterOverride extends SocialOverride {
212
+ /** `summary` or `summary_large_image`; anything else is ignored. */
213
+ card?: string;
141
214
  }
142
215
  /** One step of a breadcrumb trail. */
143
216
  interface Breadcrumb {
@@ -175,6 +248,24 @@ interface EmitCssFile {
175
248
  path: string;
176
249
  content: string;
177
250
  }
251
+ /** A host the emitted site may optimize images from (Astro \`image.remotePatterns\`). */
252
+ interface RemoteImagePattern {
253
+ protocol?: 'https' | 'http';
254
+ /** Exact host, or \`*.example.com\` for any subdomain. */
255
+ hostname: string;
256
+ /** Path prefix, e.g. \`/media/\`. Optional. */
257
+ pathname?: string;
258
+ }
259
+ interface ImageOptions {
260
+ /** Default true. \`false\` emits no image pass at all. */
261
+ enabled?: boolean;
262
+ /** Hosts whose images are optimized. The runtime host (Studio's CDN) is always added. */
263
+ remotePatterns?: RemoteImagePattern[];
264
+ /** srcset widths in px. Default 480, 768, 1024, 1600; widths above the width the HTML declares are dropped. */
265
+ widths?: number[];
266
+ /** \`sizes\` for an image that has none. Default \`(max-width: 768px) 100vw, 768px\`. */
267
+ sizes?: string;
268
+ }
178
269
  interface EmitOptions {
179
270
  /** Generated project's package name. Default: "migrated-site". */
180
271
  projectName?: string;
@@ -202,6 +293,37 @@ interface EmitOptions {
202
293
  * sitemap silently for exactly that producer.
203
294
  */
204
295
  sitemap?: boolean;
296
+ /**
297
+ * Build-time image optimization for content bodies (\`astro:assets\`):
298
+ * srcset, WebP, width/height and lazy loading for images on allowed hosts —
299
+ * migrated media on the runtime host is always allowed — and lazy loading
300
+ * with async decoding for every other image. Default on.
301
+ */
302
+ images?: ImageOptions;
303
+ /**
304
+ * Build the site's RSS feed at `/feed.xml` — the newest posts, as WordPress
305
+ * serves them at `/feed/` — and point the theme's head link at it. Default
306
+ * true. Needs `site.url` (feed links are absolute) and a route that builds
307
+ * the `posts` collection.
308
+ */
309
+ feed?: boolean;
310
+ /**
311
+ * Build `/llms.txt` (llmstxt.org): the site's name, tagline and newest pages
312
+ * of each collection, for language models. Default true. Needs `site.url`.
313
+ */
314
+ llms?: boolean;
315
+ /** The site's tagline (WordPress `blogdescription`): the feed's description and the llms.txt summary. */
316
+ siteDescription?: string;
317
+ /**
318
+ * Whether the source site's addresses end in a slash (WordPress
319
+ * `RawRouting.trailing_slash`): `/hello/` or `/hello`. Default true. With
320
+ * false the build writes files (`/hello.html`, which Netlify and Cloudflare
321
+ * Pages serve at `/hello`, and Vercel with the `cleanUrls` this adds to
322
+ * `vercel.json`), and canonical, og:url, hreflang, the feed and llms.txt
323
+ * name every page without the slash — the migrated site keeps the form the
324
+ * source was indexed under.
325
+ */
326
+ trailingSlash?: boolean;
205
327
  /**
206
328
  * The site's interface-text dictionary (see \`UI_STRINGS_MODEL\`). Comments
207
329
  * and forms read it at build time from \`dir\` (default
@@ -215,6 +337,14 @@ interface EmitOptions {
215
337
  dir?: string;
216
338
  locales?: string[];
217
339
  };
340
+ /**
341
+ * The host the site deploys to, for `EmitInput.redirects`: its own redirect
342
+ * file is written so the rules answer with a real HTTP status (a static
343
+ * Astro build serves them as meta-refresh pages, kept as the fallback).
344
+ * Unset: Netlify `public/_redirects` and `vercel.json` are both written; a
345
+ * Cloudflare Pages site must name its host (no forced rules there).
346
+ */
347
+ redirectHost?: 'netlify' | 'cloudflare' | 'vercel';
218
348
  }
219
349
  interface EmitInput {
220
350
  ir: ProjectIR;
@@ -227,6 +357,13 @@ interface EmitInput {
227
357
  * complete without any runtime offer being accepted.
228
358
  */
229
359
  runtime?: RuntimeBinding;
360
+ /**
361
+ * The live site's redirect rules (`RawIR.redirects`). Plain one-to-one rules
362
+ * are written to `astro.config` `redirects`; patterns, regular expressions,
363
+ * other statuses and rules on an address the site builds a page at come
364
+ * back in `EmitResult.redirects.manual` with the reason, to set up at the host.
365
+ */
366
+ redirects?: RawRedirect[];
230
367
  }
231
368
  /**
232
369
  * Pure result: every generated file as path → content. Writing to disk is a
@@ -236,6 +373,16 @@ interface EmitInput {
236
373
  interface EmitResult {
237
374
  files: Record<string, string>;
238
375
  warnings: string[];
376
+ /** Present when `EmitInput.redirects` was given: what was written and what was not, with why. */
377
+ redirects?: {
378
+ written: RawRedirect[];
379
+ manual: Array<{
380
+ redirect: RawRedirect;
381
+ reason: string;
382
+ }>; /** The host redirect files written, e.g. `public/_redirects (netlify)`. Empty: meta-refresh only. */
383
+ host_files: string[]; /** Rules (by `from`) left out of a Cloudflare / Vercel file at its rule limit: meta-refresh only there. */
384
+ host_over_limit: string[];
385
+ };
239
386
  }
240
387
  //#endregion
241
388
  //#region src/css.d.ts
@@ -245,6 +392,39 @@ interface WrapCssResult {
245
392
  }
246
393
  declare function wrapLegacyCss(path: string, css: string): WrapCssResult;
247
394
  //#endregion
395
+ //#region src/feed.d.ts
396
+ /** Where the feed is built. */
397
+ declare const FEED_PATH = "/feed.xml";
398
+ /** Items in the feed — WordPress's default `posts_per_rss`. */
399
+ declare const FEED_ITEMS = 10;
400
+ /** Links per llms.txt section. A long archive is summarised by its newest pages. */
401
+ declare const LLMS_LINKS = 100;
402
+ /** A collection some route builds one page per entry of, and the pattern it builds them at. */
403
+ interface LinkSource {
404
+ collection: string;
405
+ pattern: string;
406
+ }
407
+ /**
408
+ * The collections the site builds entry pages for in its default language,
409
+ * each with the first route that does: posts first, pages next, the rest by
410
+ * name. A route in another language is left out — the feed and llms.txt
411
+ * describe the site as it is in its default one.
412
+ */
413
+ declare function linkSources(routes: RouteModel[], siteLocale: string): LinkSource[];
414
+ interface FeedLinkResult {
415
+ html: string;
416
+ /** Links now pointing at FEED_PATH. */
417
+ rewritten: number;
418
+ /**
419
+ * The site's other feed links, removed: a category's, a tag's or an
420
+ * author's is the template page's own (each archive page links its feed
421
+ * itself), and a comments or Atom feed is not built.
422
+ */
423
+ other: number;
424
+ }
425
+ /** Point the head's link to the site's main feed at the one the build writes, and remove the template page's others. */
426
+ declare function rewriteFeedLinks(html: string, siteUrl: string): FeedLinkResult;
427
+ //#endregion
248
428
  //#region src/util.d.ts
249
429
  /** "f-archive" → "FArchive", "posts" → "Posts" — Astro component file names. */
250
430
  declare function pascalCase(id: string): string;
@@ -283,12 +463,27 @@ declare function isRuntimeImplemented(c: ComponentDef, runtime: RuntimeBinding$1
283
463
  * no id, no link.
284
464
  */
285
465
  declare function websiteIdOf(html: string): string | undefined;
466
+ /**
467
+ * `@id` of the node that publishes the site, as the head's structured data
468
+ * says: the WebSite's own `publisher` reference (Yoast and Rank Math write
469
+ * one — an Organization, or the Person a personal site is published by),
470
+ * else the head's first Organization. The Article names it by `@id`, so the
471
+ * logo and profile the site declared count for every post.
472
+ */
473
+ declare function publisherIdOf(html: string): string | undefined;
286
474
  interface StripResult {
287
475
  html: string;
288
476
  /** What was taken out, for the emit warning — never a silent removal. */
289
477
  removed: string[];
290
478
  /** Site-wide structured-data types kept out of a block that was otherwise page-scoped. */
291
479
  kept: string[];
480
+ /**
481
+ * The robots directives of the template's `robots` / `googlebot` tags that
482
+ * are site settings rather than this page's indexing — Yoast's
483
+ * `max-image-preview:large, max-snippet:-1, max-video-preview:-1`. The Seo
484
+ * component prints them on every page, after the page's own noindex/nofollow.
485
+ */
486
+ robots: string[];
292
487
  }
293
488
  /** Remove the source head's per-page SEO tags so the emitter's own are the only ones. */
294
489
  declare function stripSeoTags(html: string): StripResult;
@@ -308,7 +503,44 @@ declare function bodySeoLeaks(html: string): string[];
308
503
  * `Astro.site` rather than from data: it is then, by construction, the address
309
504
  * Astro actually generated, and no producer has to recompute a permalink.
310
505
  */
311
- declare const SEO_COMPONENT = "---\n/**\n * Per-page SEO \u2014 emitted by @contentrain/emitter-astro.\n *\n * Title, description, canonical, Open Graph, Twitter card and (on entry pages)\n * Article structured data, derived from the entry. The source theme's copies of\n * these tags are removed from the head chrome at emit time, so each appears once.\n *\n * Canonical and absolute URLs need `site` in astro.config.mjs; without it the\n * canonical link and og:url are omitted rather than pointing at a build host.\n */\nimport { absoluteUrl, imageMetaTags, jsonLd, pageStructuredData, seoDescription, type Breadcrumb, type ImageMeta } from '../lib/fill'\n\ninterface Props {\n title?: string\n /** Meta description; falls back to the entry's excerpt, tags stripped. */\n description?: string\n /** Overrides the generated address \u2014 for a page that canonicalises elsewhere. */\n canonical?: string\n /** Social image: absolute, or site-root-relative. */\n image?: string\n /** Size and type of `image`, as the producer measured it. */\n imageMeta?: ImageMeta\n /** This page's translations, itself included, plus x-default. */\n alternates?: Array<{ lang: string; path: string }>\n /** The trail to this page, itself excluded \u2014 the page is appended with its own address. */\n breadcrumbs?: Breadcrumb[]\n /** `CollectionPage` on a list; `WebPage` otherwise. */\n pageType?: 'WebPage' | 'CollectionPage'\n /** `@id` of the site's WebSite node kept in the head chrome, when there is one. */\n websiteId?: string\n /** `article` on entry pages, `website` on lists and static pages. */\n type?: 'article' | 'website'\n /** ISO 8601 \u2014 structured data only; the displayed date stays a mark. */\n publishedAt?: string\n modifiedAt?: string\n author?: string\n siteName?: string\n locale?: string\n}\n\nconst {\n title = '',\n description,\n canonical,\n image,\n imageMeta,\n alternates = [],\n breadcrumbs,\n pageType,\n websiteId,\n type = 'website',\n publishedAt,\n modifiedAt,\n author,\n siteName,\n locale,\n} = Astro.props\n\nconst site = Astro.site\nconst url = canonical ? absoluteUrl(canonical, site) : absoluteUrl(Astro.url.pathname, site)\nconst imageUrl = absoluteUrl(image, site)\nconst imageTags = imageUrl ? imageMetaTags(imageMeta) : {}\n// hreflang needs absolute URLs; without `site` there are none to give.\nconst hreflang = alternates.flatMap((a) => {\n const href = absoluteUrl(a.path, site)\n return href ? [{ lang: a.lang, href }] : []\n})\nconst localeAlternates = [...new Set(hreflang.map((a) => a.lang))].filter((l) => l !== 'x-default' && l !== locale)\nconst desc = seoDescription(description)\nconst article = type === 'article'\nconst structured = pageStructuredData({\n url,\n site,\n title,\n description: desc,\n image: imageUrl,\n locale,\n article,\n pageType,\n publishedAt,\n modifiedAt,\n author,\n siteName,\n websiteId,\n breadcrumbs,\n})\n---\n<title>{title}</title>\n{desc && <meta name=\"description\" content={desc} />}\n{url && <link rel=\"canonical\" href={url} />}\n{hreflang.map((a) => <link rel=\"alternate\" hreflang={a.lang} href={a.href} />)}\n<meta property=\"og:type\" content={type} />\n{title && <meta property=\"og:title\" content={title} />}\n{desc && <meta property=\"og:description\" content={desc} />}\n{url && <meta property=\"og:url\" content={url} />}\n{imageUrl && <meta property=\"og:image\" content={imageUrl} />}\n{imageTags.width && <meta property=\"og:image:width\" content={imageTags.width} />}\n{imageTags.height && <meta property=\"og:image:height\" content={imageTags.height} />}\n{imageTags.type && <meta property=\"og:image:type\" content={imageTags.type} />}\n{siteName && <meta property=\"og:site_name\" content={siteName} />}\n{locale && <meta property=\"og:locale\" content={locale} />}\n{localeAlternates.map((l) => <meta property=\"og:locale:alternate\" content={l} />)}\n{article && publishedAt && <meta property=\"article:published_time\" content={publishedAt} />}\n{article && modifiedAt && <meta property=\"article:modified_time\" content={modifiedAt} />}\n{article && author && <meta property=\"article:author\" content={author} />}\n<meta name=\"twitter:card\" content={imageUrl ? 'summary_large_image' : 'summary'} />\n{title && <meta name=\"twitter:title\" content={title} />}\n{desc && <meta name=\"twitter:description\" content={desc} />}\n{imageUrl && <meta name=\"twitter:image\" content={imageUrl} />}\n{structured && <script is:inline type=\"application/ld+json\" set:html={jsonLd(structured)} />}\n";
506
+ declare const SEO_COMPONENT = "---\n/**\n * Per-page SEO \u2014 emitted by @contentrain/emitter-astro.\n *\n * Title, description, canonical, Open Graph, Twitter card and (on entry pages)\n * Article structured data, derived from the entry. The source theme's copies of\n * these tags are removed from the head chrome at emit time, so each appears once.\n *\n * Canonical and absolute URLs need `site` in astro.config.mjs; without it the\n * canonical link and og:url are omitted rather than pointing at a build host.\n */\nimport { absoluteUrl, imageMetaTags, jsonLd, ogLocale, pagePath, pageStructuredData, seoDescription, sourceStructuredData, type AuthorProfile, type Breadcrumb, type ImageMeta, type SocialOverride, type TwitterOverride } from '../lib/fill'\n\ninterface Props {\n /** The document title \u2014 an SEO plugin's composed one when the source had it. */\n title?: string\n /** The entry's own title, when it differs from `title`: Article headline, last breadcrumb. */\n headline?: string\n /** Meta description; falls back to the entry's excerpt, tags stripped. */\n description?: string\n /** Overrides the generated address \u2014 for a page that canonicalises elsewhere. */\n canonical?: string\n /** Social image: absolute, or site-root-relative. */\n image?: string\n /** Size and type of `image`, as the producer measured it. */\n imageMeta?: ImageMeta\n /** This page's translations, itself included, plus x-default. */\n alternates?: Array<{ lang: string; path: string }>\n /** The trail to this page, itself excluded \u2014 the page is appended with its own address. */\n breadcrumbs?: Breadcrumb[]\n /** `CollectionPage` on a list; `WebPage` otherwise. */\n pageType?: 'WebPage' | 'CollectionPage'\n /** `@id` of the site's WebSite node kept in the head chrome, when there is one. */\n websiteId?: string\n /** `@id` of the node the kept head says publishes the site \u2014 the Article's publisher. */\n publisherId?: string\n /** false: the site's addresses end without a slash (`/hello`), as the source's did. */\n trailingSlash?: boolean\n /** `@id` of every structured-data node the head chrome keeps \u2014 not repeated from `schema`. */\n headLdIds?: string[]\n /** `article` on entry pages, `website` on lists and static pages. */\n type?: 'article' | 'website'\n /** ISO 8601 \u2014 structured data only; the displayed date stays a mark. */\n publishedAt?: string\n modifiedAt?: string\n author?: string\n /** The author's page, for the Article author's `url`. */\n authorUrl?: string\n /** On an author's archive: who the page is about \u2014 ProfilePage + Person. */\n profile?: AuthorProfile\n /** This page's own feed (an archive's), site-root-relative: `<link rel=\"alternate\" type=\"application/rss+xml\">`. */\n feed?: { path: string; title?: string }\n siteName?: string\n locale?: string\n /** The page is kept out of search: `<meta name=\"robots\" content=\"noindex\">`. */\n noindex?: boolean\n nofollow?: boolean\n /** Site-wide robots directives from the template head (`max-image-preview:large`, \u2026), printed on every page. */\n robotsDefault?: string[]\n /** Share-card values the source set by hand, over the derived ones. */\n openGraph?: SocialOverride\n /** Unset values follow Open Graph. */\n twitter?: TwitterOverride\n /** The source SEO plugin's JSON-LD for this page \u2014 printed instead of the generated graph. */\n schema?: unknown\n}\n\nconst {\n title = '',\n headline,\n description,\n canonical,\n image,\n imageMeta,\n alternates = [],\n breadcrumbs,\n pageType,\n websiteId,\n publisherId,\n trailingSlash = true,\n headLdIds = [],\n type = 'website',\n publishedAt,\n modifiedAt,\n author,\n authorUrl,\n profile,\n feed,\n siteName,\n locale,\n noindex = false,\n nofollow = false,\n robotsDefault = [],\n openGraph,\n twitter,\n schema,\n} = Astro.props\n\nconst site = Astro.site\nconst url = canonical ? absoluteUrl(canonical, site) : absoluteUrl(pagePath(Astro.url.pathname, trailingSlash), site)\nconst imageUrl = absoluteUrl(image, site)\nconst imageTags = imageUrl ? imageMetaTags(imageMeta) : {}\n// hreflang needs absolute URLs; without `site` there are none to give.\nconst hreflang = alternates.flatMap((a) => {\n const href = absoluteUrl(pagePath(a.path, trailingSlash), site)\n return href ? [{ lang: a.lang, href }] : []\n})\n// og:locale takes ll_RR; a language without a region has no such form and is left out.\nconst ogLang = ogLocale(locale)\nconst localeAlternates = [...new Set(hreflang.map((a) => ogLocale(a.lang)))].filter((l): l is string => Boolean(l) && l !== ogLang)\nconst desc = seoDescription(description)\nconst robots = [noindex && 'noindex', nofollow && 'nofollow', ...robotsDefault].filter(Boolean).join(', ')\n// Share cards: what the source set by hand, else what the page says.\nconst ogTitle = openGraph?.title || title\nconst ogDesc = seoDescription(openGraph?.description) ?? desc\nconst ogImage = absoluteUrl(openGraph?.image, site) ?? imageUrl\n// The measurements describe `image`; an override is another file.\nconst ogImageTags = ogImage === imageUrl ? imageTags : {}\nconst twTitle = twitter?.title || ogTitle\nconst twDesc = seoDescription(twitter?.description) ?? ogDesc\nconst twImage = absoluteUrl(twitter?.image, site) ?? ogImage\nconst twCard = twitter?.card === 'summary' || twitter?.card === 'summary_large_image' ? twitter.card : twImage ? 'summary_large_image' : 'summary'\nconst article = type === 'article'\nconst structured = sourceStructuredData(schema, headLdIds) ?? pageStructuredData({\n url,\n site,\n title,\n headline,\n description: desc,\n image: imageUrl,\n imageMeta: imageUrl ? imageMeta : undefined,\n locale,\n article,\n pageType,\n publishedAt,\n modifiedAt,\n author,\n authorUrl,\n profile,\n trailingSlash,\n siteName,\n websiteId,\n publisherId,\n breadcrumbs,\n})\n---\n<title>{title}</title>\n{robots && <meta name=\"robots\" content={robots} />}\n{desc && <meta name=\"description\" content={desc} />}\n{url && <link rel=\"canonical\" href={url} />}\n{hreflang.map((a) => <link rel=\"alternate\" hreflang={a.lang} href={a.href} />)}\n{feed && <link rel=\"alternate\" type=\"application/rss+xml\" title={feed.title} href={feed.path} />}\n<meta property=\"og:type\" content={type} />\n{ogTitle && <meta property=\"og:title\" content={ogTitle} />}\n{ogDesc && <meta property=\"og:description\" content={ogDesc} />}\n{url && <meta property=\"og:url\" content={url} />}\n{ogImage && <meta property=\"og:image\" content={ogImage} />}\n{ogImageTags.width && <meta property=\"og:image:width\" content={ogImageTags.width} />}\n{ogImageTags.height && <meta property=\"og:image:height\" content={ogImageTags.height} />}\n{ogImageTags.type && <meta property=\"og:image:type\" content={ogImageTags.type} />}\n{siteName && <meta property=\"og:site_name\" content={siteName} />}\n{ogLang && <meta property=\"og:locale\" content={ogLang} />}\n{localeAlternates.map((l) => <meta property=\"og:locale:alternate\" content={l} />)}\n{article && publishedAt && <meta property=\"article:published_time\" content={publishedAt} />}\n{article && modifiedAt && <meta property=\"article:modified_time\" content={modifiedAt} />}\n{article && author && <meta property=\"article:author\" content={author} />}\n<meta name=\"twitter:card\" content={twCard} />\n{twTitle && <meta name=\"twitter:title\" content={twTitle} />}\n{twDesc && <meta name=\"twitter:description\" content={twDesc} />}\n{twImage && <meta name=\"twitter:image\" content={twImage} />}\n{structured && <script is:inline type=\"application/ld+json\" set:html={jsonLd(structured)} />}\n";
507
+ //#endregion
508
+ //#region src/seo-entry.d.ts
509
+ /** The SEO fields of one page, ready to spread into an `EmitPost` or `QueryPage`. */
510
+ interface SeoFields {
511
+ /** For an `EmitPost`; a `QueryPage`'s `title` is already its document title. */
512
+ seo_title?: string;
513
+ description?: string;
514
+ canonical?: string;
515
+ /** Kept out of search. */
516
+ noindex?: boolean;
517
+ nofollow?: boolean;
518
+ open_graph?: SocialOverride;
519
+ twitter?: TwitterOverride;
520
+ schema?: unknown;
521
+ }
522
+ interface SeoFromRawOptions {
523
+ /** The plugin the live site's head came from (`RawSeo.serving`). Its block is read first. */
524
+ serving?: SeoProvider | 'wordpress-core';
525
+ /**
526
+ * The page's own absolute address. A canonical equal to it is left out — the
527
+ * Seo component already points a page at itself — so only a canonical that
528
+ * sends the page somewhere else becomes an override.
529
+ */
530
+ url?: string;
531
+ }
532
+ /**
533
+ * One page's SEO fields from the blocks each plugin holds for it. The serving
534
+ * plugin's block wins; without one the first plugin (Yoast, Rank Math, AIOSEO)
535
+ * that has data does. A block not marked `resolved` holds stored values and
536
+ * templates, so its template strings — in that plugin's syntax — are dropped
537
+ * and its literal values kept.
538
+ *
539
+ * Robots come from `robots_served` — what the page actually carried after
540
+ * WordPress and the plugin reconciled their settings — and from the plugin's
541
+ * own `robots` only when that is missing.
542
+ */
543
+ declare function seoFromRawEntry(blocks: Partial<Record<SeoProvider, RawSeoEntry>> | undefined, options?: SeoFromRawOptions): SeoFields;
312
544
  //#endregion
313
545
  //#region src/alternates.d.ts
314
546
  interface Alternate {
@@ -338,7 +570,7 @@ interface AlternatesResult {
338
570
  declare function withAlternates(routes: RouteModel[], content: EmitContent, siteLocale: string): AlternatesResult;
339
571
  //#endregion
340
572
  //#region src/embed.d.ts
341
- declare const EMBED_TS = "// Emitted by @contentrain/emitter-astro \u2014 the runtime-component client.\n//\n// Comments and forms are the two regions of a migrated site that need a live\n// service. This module is the browser side of the provider's PUBLIC API\n// (Studio: /api/forms/v1 and /api/comments/v1) \u2014 the same contract that\n// @contentrain/query/cdn implements, inlined so the generated site depends on\n// nothing but Astro. No credential travels with a request: the endpoints are\n// unauthenticated by design, and a page cannot keep a secret.\n//\n// Rendering helpers are pure (strings in, HTML out, escaped by default) so the\n// mount functions at the bottom are the only code that touches the DOM.\n\nexport interface Runtime {\n base_url: string\n project_id: string\n}\n\nexport interface EntryRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\nexport class EmbedError extends Error {\n status: number\n constructor(status: number, message: string) {\n super(message)\n this.name = 'EmbedError'\n this.status = status\n }\n}\n\n/**\n * Visitor-facing text. The component passes the page's text from the site's\n * ui-strings dictionary (`data-strings`); these are the defaults it falls\n * back to, key for key.\n */\nexport const strings = {\n loading: 'Loading\u2026',\n send: 'Send',\n sending: 'Sending\u2026',\n commentsTitle: 'Comments',\n noComments: 'No comments yet.',\n closed: 'Comments are closed.',\n postComment: 'Post comment',\n reply: 'Reply',\n replyingTo: 'Replying to',\n cancel: 'Cancel',\n loadMore: 'Load more comments',\n moderator: 'Moderator',\n pending: 'Thank you \u2014 your comment is awaiting moderation.',\n posted: 'Your comment has been posted.',\n name: 'Name',\n email: 'Email',\n emailNote: '(never shown)',\n website: 'Website',\n comment: 'Comment',\n failed: 'Something went wrong. Please try again.',\n honeypot: 'Leave this field empty',\n}\n\nexport const esc = (value: unknown): string =>\n String(value ?? '')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n\n// \u2500\u2500\u2500 Transport \u2500\u2500\u2500\n\n/** `base` without a trailing slash, segments URL-encoded, empty query values dropped. */\nexport function publicUrl(base: string, segments: string[], query?: Record<string, string | number | undefined>): string {\n const path = segments.map((s) => encodeURIComponent(s)).join('/')\n const params = new URLSearchParams()\n for (const [key, value] of Object.entries(query ?? {})) {\n if (value !== undefined && value !== '') params.set(key, String(value))\n }\n const qs = params.toString()\n return base.replace(/\\/+$/, '') + '/' + path + (qs ? '?' + qs : '')\n}\n\nasync function failure(res: Response): Promise<EmbedError> {\n const text = await res.text().catch(() => '')\n let message = text || 'Request failed'\n try {\n const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown }\n const m = parsed.message ?? parsed.statusMessage\n if (typeof m === 'string' && m) message = m\n } catch {\n // not JSON \u2014 the raw text is the message\n }\n return new EmbedError(res.status, message)\n}\n\nexport async function getJson<T>(url: string): Promise<T> {\n const res = await fetch(url)\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport async function postJson<T>(url: string, body: unknown): Promise<T> {\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport const formsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/forms/v1'\nexport const commentsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/comments/v1'\n\n/** The hidden input Cloudflare Turnstile adds to the enclosing form. */\nexport const CAPTCHA_FIELD = 'cf-turnstile-response'\n\n// \u2500\u2500\u2500 Forms \u2500\u2500\u2500\n\nexport interface FieldDef {\n type: string\n required?: boolean\n label?: string\n options?: string[]\n min?: number\n max?: number\n pattern?: string\n default?: unknown\n}\n\nexport interface FormConfig {\n modelId: string\n locale: string\n /** Exposed fields only, keyed by field id. */\n fields: Record<string, FieldDef>\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n successMessage?: string\n honeypotField: string | null\n}\n\nexport interface FieldError {\n field: string\n message: string\n}\n\nexport interface FormSubmitResult {\n success: boolean\n message?: string\n errors?: FieldError[]\n}\n\n/** The documented request body: values under `data`, control fields beside it. */\nexport interface FormPayload {\n data: Record<string, unknown>\n captchaToken?: string\n _hp?: string\n}\n\nexport function fetchFormConfig(rt: Runtime, model: string): Promise<FormConfig> {\n return getJson<FormConfig>(publicUrl(formsRoot(rt), [rt.project_id, model, 'config']))\n}\n\nexport function submitForm(rt: Runtime, model: string, payload: FormPayload): Promise<FormSubmitResult> {\n return postJson<FormSubmitResult>(publicUrl(formsRoot(rt), [rt.project_id, model, 'submit']), payload)\n}\n\nfunction coerce(def: FieldDef, value: string): unknown {\n switch (def.type) {\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return value === '' ? '' : Number(value)\n case 'boolean':\n return value === 'on' || value === 'true'\n default:\n return value\n }\n}\n\n/**\n * Form entries (`new FormData(form)`) \u2192 request body. Only exposed fields\n * reach `data`; the captcha token and the honeypot value travel beside it, so\n * a control field can never collide with a model field. Files are skipped \u2014\n * uploads are not part of the public contract.\n */\nexport function formPayload(\n entries: Iterable<[string, unknown]>,\n config: { fields: Record<string, FieldDef>; honeypotField: string | null },\n): FormPayload {\n const payload: FormPayload = { data: {} }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (config.honeypotField && name === config.honeypotField) {\n payload._hp = raw\n continue\n }\n const def = config.fields[name]\n if (!def) continue\n payload.data[name] = coerce(def, raw)\n }\n return payload\n}\n\nexport function labelFor(id: string, def: FieldDef): string {\n if (def.label) return def.label\n const words = id.replace(/[_-]+/g, ' ').trim()\n return words.charAt(0).toUpperCase() + words.slice(1)\n}\n\n/** One input for one exposed field, chosen by field type. */\nexport function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string {\n const common =\n ' id=\"' + esc(prefix + '-' + id) + '\" name=\"' + esc(id) + '\"' +\n (def.required ? ' required' : '') +\n (def.pattern ? ' pattern=\"' + esc(def.pattern) + '\"' : '')\n const range =\n (def.min !== undefined ? ' min=\"' + esc(def.min) + '\"' : '') +\n (def.max !== undefined ? ' max=\"' + esc(def.max) + '\"' : '')\n const length = def.max !== undefined ? ' maxlength=\"' + esc(def.max) + '\"' : ''\n switch (def.type) {\n case 'text':\n case 'markdown':\n case 'richtext':\n case 'code':\n return '<textarea' + common + length + ' rows=\"5\"></textarea>'\n case 'select': {\n const blank = def.required ? '' : '<option value=\"\"></option>'\n const options = (def.options ?? []).map((o) => '<option value=\"' + esc(o) + '\">' + esc(o) + '</option>').join('')\n return '<select' + common + '>' + blank + options + '</select>'\n }\n case 'boolean':\n return '<input type=\"checkbox\"' + common + ' />'\n case 'number':\n case 'integer':\n case 'rating':\n return '<input type=\"number\"' + common + range + (def.type === 'integer' ? ' step=\"1\"' : '') + ' />'\n case 'decimal':\n case 'percent':\n return '<input type=\"number\" step=\"any\"' + common + range + ' />'\n case 'email':\n return '<input type=\"email\"' + common + length + ' />'\n case 'url':\n return '<input type=\"url\"' + common + length + ' />'\n case 'phone':\n return '<input type=\"tel\"' + common + length + ' />'\n case 'date':\n return '<input type=\"date\"' + common + ' />'\n case 'datetime':\n return '<input type=\"datetime-local\"' + common + ' />'\n case 'color':\n return '<input type=\"color\"' + common + ' />'\n default:\n return '<input type=\"text\"' + common + length + ' />'\n }\n}\n\nexport function honeypotHtml(field: string | null): string {\n if (!field) return ''\n return (\n '<p class=\"cr-hp\" aria-hidden=\"true\" style=\"position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden\">' +\n '<label>' + esc(strings.honeypot) + ' <input type=\"text\" name=\"' + esc(field) + '\" tabindex=\"-1\" autocomplete=\"off\" /></label></p>'\n )\n}\n\nexport function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null): string {\n if (captcha !== 'turnstile' || !siteKey) return ''\n return '<div class=\"cf-turnstile\" data-sitekey=\"' + esc(siteKey) + '\"></div>'\n}\n\nexport function errorsHtml(errors: FieldError[]): string {\n return (\n '<ul class=\"cr-errors\" role=\"alert\">' +\n errors.map((e) => '<li data-field=\"' + esc(e.field) + '\">' + esc(e.field) + ': ' + esc(e.message) + '</li>').join('') +\n '</ul>'\n )\n}\n\n/** The whole form for a public form config: one control per exposed field, honeypot, captcha, submit. */\nexport function formHtml(config: FormConfig, prefix = 'cr-field'): string {\n const fields = Object.entries(config.fields)\n .map(([id, def]) => {\n const control = fieldControl(id, def, prefix)\n const label = '<label for=\"' + esc(prefix + '-' + id) + '\">' + esc(labelFor(id, def)) + (def.required ? ' <span aria-hidden=\"true\">*</span>' : '') + '</label>'\n return '<p class=\"cr-field cr-field--' + esc(def.type) + '\">' + (def.type === 'boolean' ? control + ' ' + label : label + control) + '</p>'\n })\n .join('')\n return (\n '<form class=\"cr-form\" method=\"post\" data-model=\"' + esc(config.modelId) + '\">' +\n fields +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.send) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// \u2500\u2500\u2500 Comments \u2500\u2500\u2500\n\nexport interface CommentAuthor {\n name: string\n url: string | null\n isModerator: boolean\n}\n\nexport interface PublicComment {\n id: string\n parentId: string | null\n depth: number\n author: CommentAuthor\n /** Plain text. */\n body: string\n type: 'comment' | 'pingback' | 'trackback'\n createdAt: string\n replies: PublicComment[]\n}\n\nexport interface ThreadConfig {\n closed: boolean\n requireApproval: boolean\n requireEmail: boolean\n maxDepth: number\n maxBodyLength: number\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n honeypotField: string | null\n}\n\nexport interface CommentThread {\n entry: { modelId: string; entryId: string; locale: string }\n config: ThreadConfig\n comments: PublicComment[]\n total: number\n page: number\n limit: number\n}\n\nexport interface CommentSubmitBody {\n author: { name: string; email?: string; url?: string }\n body: string\n /** null for a root comment \u2014 sent explicitly, as the provider's own fixture does. */\n parentId: string | null\n captchaToken?: string\n _hp?: string\n}\n\nexport interface CommentSubmitResult {\n success: boolean\n status?: 'pending' | 'approved'\n comment?: PublicComment\n errors?: FieldError[]\n}\n\nexport interface ThreadQuery {\n page?: number\n limit?: number\n sort?: 'oldest' | 'newest'\n}\n\nexport function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise<CommentThread> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], {\n locale: entry.locale,\n page: query?.page,\n limit: query?.limit,\n sort: query?.sort,\n })\n return getJson<CommentThread>(url)\n}\n\nexport function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise<CommentSubmitResult> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale })\n return postJson<CommentSubmitResult>(url, body)\n}\n\n/** Comment-form entries \u2192 the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */\nexport function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody {\n const values: Record<string, string> = {}\n const payload: CommentSubmitBody = { author: { name: '' }, body: '', parentId: null }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (honeypotField && name === honeypotField) {\n payload._hp = raw\n continue\n }\n values[name] = raw\n }\n payload.author.name = (values.author_name ?? '').trim()\n if (values.author_email?.trim()) payload.author.email = values.author_email.trim()\n if (values.author_url?.trim()) payload.author.url = values.author_url.trim()\n payload.body = (values.body ?? '').trim()\n payload.parentId = values.parent_id?.trim() || null\n return payload\n}\n\n/** Plain text \u2192 paragraphs; blank lines separate paragraphs, single newlines break lines. */\nexport function bodyHtml(text: string): string {\n return text\n .split(/\\n{2,}/)\n .map((p) => p.trim())\n .filter(Boolean)\n .map((p) => '<p>' + esc(p).replace(/\\n/g, '<br />') + '</p>')\n .join('')\n}\n\nexport function hasMore(thread: CommentThread): boolean {\n return thread.page * thread.limit < thread.total\n}\n\n/** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */\nexport function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string {\n const author = c.author.url\n ? '<a href=\"' + esc(c.author.url) + '\" rel=\"nofollow ugc noopener\" target=\"_blank\">' + esc(c.author.name) + '</a>'\n : esc(c.author.name)\n const badge = c.author.isModerator ? ' <span class=\"cr-moderator\">' + esc(strings.moderator) + '</span>' : ''\n const date = '<time datetime=\"' + esc(c.createdAt) + '\">' + esc(c.createdAt.slice(0, 10)) + '</time>'\n const canReply = !config.closed && c.depth < config.maxDepth\n const reply = canReply\n ? '<button type=\"button\" class=\"cr-reply\" data-parent=\"' + esc(c.id) + '\" data-author=\"' + esc(c.author.name) + '\">' + esc(strings.reply) + '</button>'\n : ''\n const replies = c.replies.length\n ? '<ol class=\"cr-replies\">' + c.replies.map((r) => commentHtml(r, config)).join('') + '</ol>'\n : ''\n return (\n '<li class=\"cr-comment cr-comment--' + esc(c.type) + '\" id=\"cr-comment-' + esc(c.id) + '\" data-depth=\"' + esc(c.depth) + '\">' +\n '<div class=\"cr-comment-meta\"><span class=\"cr-comment-author\">' + author + '</span>' + badge + ' ' + date + '</div>' +\n '<div class=\"cr-comment-body\">' + bodyHtml(c.body) + '</div>' +\n (reply ? '<div class=\"cr-comment-actions\">' + reply + '</div>' : '') +\n replies +\n '</li>'\n )\n}\n\nexport function threadHtml(thread: CommentThread): string {\n if (!thread.comments.length) return '<p class=\"cr-empty\">' + esc(strings.noComments) + '</p>'\n return '<ol class=\"cr-comment-list\">' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '</ol>'\n}\n\n/** The comment form; a reply carries its parent in a hidden input. */\nexport function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string {\n const emailLabel = esc(strings.email) + (config.requireEmail ? ' <span aria-hidden=\"true\">*</span>' : '') + ' <small>' + esc(strings.emailNote) + '</small>'\n return (\n '<form class=\"cr-comment-form\" method=\"post\">' +\n '<input type=\"hidden\" name=\"parent_id\" value=\"\" />' +\n '<p class=\"cr-replying\" hidden><span class=\"cr-replying-to\"></span> <button type=\"button\" class=\"cr-cancel-reply\">' + esc(strings.cancel) + '</button></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-body\">' + esc(strings.comment) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<textarea id=\"' + prefix + '-body\" name=\"body\" required maxlength=\"' + esc(config.maxBodyLength) + '\" rows=\"5\"></textarea></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-name\">' + esc(strings.name) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<input type=\"text\" id=\"' + prefix + '-name\" name=\"author_name\" required maxlength=\"120\" autocomplete=\"name\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-email\">' + emailLabel + '</label>' +\n '<input type=\"email\" id=\"' + prefix + '-email\" name=\"author_email\"' + (config.requireEmail ? ' required' : '') + ' maxlength=\"254\" autocomplete=\"email\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-url\">' + esc(strings.website) + '</label>' +\n '<input type=\"url\" id=\"' + prefix + '-url\" name=\"author_url\" maxlength=\"2048\" autocomplete=\"url\" /></p>' +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.postComment) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// \u2500\u2500\u2500 Turnstile (explicit render, so widgets added after load still render) \u2500\u2500\u2500\n\ninterface TurnstileApi {\n render: (el: Element, options: { sitekey: string }) => string\n reset: (id?: string) => void\n}\n\nconst TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady'\nconst widgets = new WeakMap<Element, string>()\nconst waiting: Element[] = []\n\nfunction turnstileApi(): TurnstileApi | undefined {\n return (globalThis as { turnstile?: TurnstileApi }).turnstile\n}\n\nfunction renderWidget(api: TurnstileApi, el: Element): void {\n if (widgets.has(el)) return\n widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' }))\n}\n\n/** Render every `.cf-turnstile` under `root`, loading the Turnstile script once on first use. */\nexport function renderCaptcha(root: ParentNode): void {\n const els = Array.from(root.querySelectorAll('.cf-turnstile'))\n if (!els.length) return\n const api = turnstileApi()\n if (api) {\n for (const el of els) renderWidget(api, el)\n return\n }\n waiting.push(...els)\n if (document.querySelector('script[data-cr-turnstile]')) return\n ;(globalThis as Record<string, unknown>).__crTurnstileReady = () => {\n const ready = turnstileApi()\n if (!ready) return\n for (const el of waiting.splice(0)) renderWidget(ready, el)\n }\n const script = document.createElement('script')\n script.src = TURNSTILE_SRC\n script.async = true\n script.defer = true\n script.setAttribute('data-cr-turnstile', '1')\n document.head.appendChild(script)\n}\n\nexport function resetCaptcha(root: ParentNode): void {\n const api = turnstileApi()\n if (!api) return\n for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el))\n}\n\n// \u2500\u2500\u2500 Mounting (the only DOM code) \u2500\u2500\u2500\n\n/** ui-strings dictionary key \u2192 the `strings` entry it sets. */\nexport const STRING_KEYS: ReadonlyArray<readonly [string, keyof typeof strings]> = [\n ['common.loading', 'loading'],\n ['common.failed', 'failed'],\n ['form.send', 'send'],\n ['form.sending', 'sending'],\n ['form.honeypot', 'honeypot'],\n ['comments.title', 'commentsTitle'],\n ['comments.empty', 'noComments'],\n ['comments.closed', 'closed'],\n ['comments.post', 'postComment'],\n ['comments.reply', 'reply'],\n ['comments.replying_to', 'replyingTo'],\n ['comments.cancel', 'cancel'],\n ['comments.load_more', 'loadMore'],\n ['comments.moderator', 'moderator'],\n ['comments.pending', 'pending'],\n ['comments.posted', 'posted'],\n ['comments.field.name', 'name'],\n ['comments.field.email', 'email'],\n ['comments.field.email_note', 'emailNote'],\n ['comments.field.website', 'website'],\n ['comments.field.comment', 'comment'],\n]\n\n/**\n * Take the page's interface text from the host element. A value that is not a\n * non-empty string leaves the default; text that cannot be parsed changes\n * nothing, because a mount that fails over a label is worse than an English one.\n */\nexport function applyStrings(host: HTMLElement): void {\n const raw = host.dataset.strings\n if (!raw) return\n let text: unknown\n try {\n text = JSON.parse(raw)\n } catch {\n return\n }\n if (!text || typeof text !== 'object') return\n for (const [key, name] of STRING_KEYS) {\n const value = (text as Record<string, unknown>)[key]\n if (typeof value === 'string' && value !== '') strings[name] = value\n }\n}\n\nexport function runtimeOf(host: HTMLElement): Runtime {\n return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }\n}\n\nfunction entryOf(host: HTMLElement): EntryRef {\n return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', locale: host.dataset.locale || undefined }\n}\n\nfunction setStatus(form: HTMLFormElement, html: string): void {\n const status = form.querySelector('.cr-status')\n if (status) status.innerHTML = html\n}\n\nfunction busy(form: HTMLFormElement, on: boolean): void {\n const button = form.querySelector<HTMLButtonElement>('button[type=\"submit\"]')\n if (!button) return\n button.disabled = on\n if (on) {\n button.dataset.label = button.textContent ?? ''\n button.textContent = strings.sending\n } else {\n button.textContent = button.dataset.label ?? button.textContent\n }\n}\n\nfunction entriesOf(form: HTMLFormElement): Array<[string, unknown]> {\n const out: Array<[string, unknown]> = []\n new FormData(form).forEach((value, key) => out.push([key, value]))\n return out\n}\n\n/** `<cr-form data-base-url data-project data-model>` \u2192 fetch the config, render, submit. */\nexport async function mountForm(host: HTMLElement): Promise<void> {\n applyStrings(host)\n const rt = runtimeOf(host)\n const model = host.dataset.model ?? ''\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let config: FormConfig\n try {\n config = await fetchFormConfig(rt, model)\n } catch (error) {\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n host.innerHTML = formHtml(config)\n renderCaptcha(host)\n const form = host.querySelector('form')\n if (!form) return\n form.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitForm(rt, model, formPayload(entriesOf(form), config))\n if (result.success) {\n host.innerHTML = '<p class=\"cr-success\" role=\"status\">' + esc(result.message ?? config.successMessage ?? '') + '</p>'\n return\n }\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n } catch (error) {\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n\n/** `<cr-comments data-base-url data-project data-model data-entry data-locale>` \u2192 thread + form. */\nexport async function mountComments(host: HTMLElement): Promise<void> {\n applyStrings(host)\n const rt = runtimeOf(host)\n const entry = entryOf(host)\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let thread: CommentThread\n try {\n thread = await fetchThread(rt, entry)\n } catch (error) {\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n\n host.innerHTML =\n '<section class=\"cr-comments-section\">' +\n '<h2 class=\"cr-comments-title\">' + esc(strings.commentsTitle) + ' <span class=\"cr-count\">(' + esc(thread.total) + ')</span></h2>' +\n '<div class=\"cr-thread\">' + threadHtml(thread) + '</div>' +\n (hasMore(thread) ? '<p class=\"cr-more\"><button type=\"button\" class=\"cr-load-more\">' + esc(strings.loadMore) + '</button></p>' : '') +\n (thread.config.closed ? '<p class=\"cr-closed\">' + esc(strings.closed) + '</p>' : commentFormHtml(thread.config)) +\n '</section>'\n renderCaptcha(host)\n\n const threadEl = host.querySelector('.cr-thread')\n const form = host.querySelector<HTMLFormElement>('form.cr-comment-form')\n let page = thread.page\n\n host.addEventListener('click', async (event) => {\n const target = event.target as HTMLElement | null\n if (!target) return\n const replyButton = target.closest<HTMLElement>('.cr-reply')\n if (replyButton && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = replyButton.dataset.parent ?? ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n const to = form.querySelector('.cr-replying-to')\n if (to) to.textContent = strings.replyingTo + ' ' + (replyButton.dataset.author ?? '')\n if (replying) replying.hidden = false\n replyButton.closest('.cr-comment')?.appendChild(form)\n form.querySelector<HTMLTextAreaElement>('textarea[name=\"body\"]')?.focus()\n return\n }\n if (target.closest('.cr-cancel-reply') && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n host.querySelector('.cr-comments-section')?.appendChild(form)\n return\n }\n const more = target.closest<HTMLButtonElement>('.cr-load-more')\n if (more && threadEl) {\n more.disabled = true\n try {\n const next = await fetchThread(rt, entry, { page: page + 1 })\n page = next.page\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join(''))\n if (!hasMore(next)) more.closest('.cr-more')?.remove()\n } finally {\n more.disabled = false\n }\n }\n })\n\n form?.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitComment(rt, entry, commentPayload(entriesOf(form), thread.config.honeypotField))\n if (!result.success) {\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n return\n }\n const parentId = result.comment?.parentId ?? null\n if (result.status === 'approved' && result.comment && threadEl) {\n const html = commentHtml(result.comment, thread.config)\n const parent = parentId ? threadEl.querySelector('#cr-comment-' + parentId) : null\n if (parent) {\n let replies = parent.querySelector(':scope > .cr-replies')\n if (!replies) {\n parent.insertAdjacentHTML('beforeend', '<ol class=\"cr-replies\"></ol>')\n replies = parent.querySelector(':scope > .cr-replies')\n }\n replies?.insertAdjacentHTML('beforeend', html)\n } else {\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', html)\n else threadEl.innerHTML = '<ol class=\"cr-comment-list\">' + html + '</ol>'\n }\n }\n form.reset()\n host.querySelector('.cr-comments-section')?.appendChild(form)\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n resetCaptcha(form)\n setStatus(form, '<p class=\"cr-success\" role=\"status\">' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '</p>')\n } catch (error) {\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n";
573
+ declare const EMBED_TS = "// Emitted by @contentrain/emitter-astro \u2014 the runtime-component client.\n//\n// Comments and forms are the two regions of a migrated site that need a live\n// service. This module is the browser side of the provider's PUBLIC API\n// (Studio: /api/forms/v1 and /api/comments/v1) \u2014 the same contract that\n// @contentrain/query/cdn implements, inlined so the generated site depends on\n// nothing but Astro. No credential travels with a request: the endpoints are\n// unauthenticated by design, and a page cannot keep a secret.\n//\n// Rendering helpers are pure (strings in, HTML out, escaped by default) so the\n// mount functions at the bottom are the only code that touches the DOM.\n\nexport interface Runtime {\n base_url: string\n project_id: string\n}\n\nexport interface EntryRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\nexport class EmbedError extends Error {\n status: number\n /** The API's machine code (`data.code`), when the body carries one \u2014 e.g. `payment_required`. */\n code?: string\n constructor(status: number, message: string, code?: string) {\n super(message)\n this.name = 'EmbedError'\n this.status = status\n if (code) this.code = code\n }\n}\n\n/**\n * The workspace's subscription is inactive (Studio answers 402\n * `payment_required` on the public forms and comments endpoints). Not the\n * visitor's problem and not transient: no retry helps until the owner updates\n * billing.\n */\nexport function isPaymentRequired(error: unknown): boolean {\n return error instanceof EmbedError && (error.status === 402 || error.code === 'payment_required')\n}\n\n/**\n * Take a widget off the page without a word to the visitor \u2014 the message is\n * written for the workspace owner, who finds it in the console. The rest of\n * the page is untouched.\n */\nexport function hideUnavailable(host: HTMLElement, what: 'form' | 'comments'): void {\n host.innerHTML = ''\n host.hidden = true\n // A site stylesheet that sets display on the element would override hidden.\n host.style.display = 'none'\n console.debug('[contentrain] ' + what + ' hidden: the workspace subscription is inactive (402 payment_required). The workspace owner needs to update billing in Contentrain Studio.')\n}\n\n/**\n * Visitor-facing text. The component passes the page's text from the site's\n * ui-strings dictionary (`data-strings`); these are the defaults it falls\n * back to, key for key.\n */\nexport const strings = {\n loading: 'Loading\u2026',\n send: 'Send',\n sending: 'Sending\u2026',\n commentsTitle: 'Comments',\n noComments: 'No comments yet.',\n closed: 'Comments are closed.',\n postComment: 'Post comment',\n reply: 'Reply',\n replyingTo: 'Replying to',\n cancel: 'Cancel',\n loadMore: 'Load more comments',\n moderator: 'Moderator',\n pending: 'Thank you \u2014 your comment is awaiting moderation.',\n posted: 'Your comment has been posted.',\n name: 'Name',\n email: 'Email',\n emailNote: '(never shown)',\n website: 'Website',\n comment: 'Comment',\n failed: 'Something went wrong. Please try again.',\n honeypot: 'Leave this field empty',\n}\n\nexport const esc = (value: unknown): string =>\n String(value ?? '')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n\n// \u2500\u2500\u2500 Transport \u2500\u2500\u2500\n\n/** `base` without a trailing slash, segments URL-encoded, empty query values dropped. */\nexport function publicUrl(base: string, segments: string[], query?: Record<string, string | number | undefined>): string {\n const path = segments.map((s) => encodeURIComponent(s)).join('/')\n const params = new URLSearchParams()\n for (const [key, value] of Object.entries(query ?? {})) {\n if (value !== undefined && value !== '') params.set(key, String(value))\n }\n const qs = params.toString()\n return base.replace(/\\/+$/, '') + '/' + path + (qs ? '?' + qs : '')\n}\n\nasync function failure(res: Response): Promise<EmbedError> {\n const text = await res.text().catch(() => '')\n let message = text || 'Request failed'\n let code: string | undefined\n try {\n const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown; data?: { code?: unknown } }\n const m = parsed.message ?? parsed.statusMessage\n if (typeof m === 'string' && m) message = m\n if (typeof parsed.data?.code === 'string') code = parsed.data.code\n } catch {\n // not JSON \u2014 the raw text is the message\n }\n return new EmbedError(res.status, message, code)\n}\n\nexport async function getJson<T>(url: string): Promise<T> {\n const res = await fetch(url)\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport async function postJson<T>(url: string, body: unknown): Promise<T> {\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport const formsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/forms/v1'\nexport const commentsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/comments/v1'\n\n/** The hidden input Cloudflare Turnstile adds to the enclosing form. */\nexport const CAPTCHA_FIELD = 'cf-turnstile-response'\n\n// \u2500\u2500\u2500 Forms \u2500\u2500\u2500\n\nexport interface FieldDef {\n type: string\n required?: boolean\n label?: string\n options?: string[]\n min?: number\n max?: number\n pattern?: string\n default?: unknown\n}\n\nexport interface FormConfig {\n modelId: string\n locale: string\n /** Exposed fields only, keyed by field id. */\n fields: Record<string, FieldDef>\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n successMessage?: string\n honeypotField: string | null\n}\n\nexport interface FieldError {\n field: string\n message: string\n}\n\nexport interface FormSubmitResult {\n success: boolean\n message?: string\n errors?: FieldError[]\n}\n\n/** The documented request body: values under `data`, control fields beside it. */\nexport interface FormPayload {\n data: Record<string, unknown>\n captchaToken?: string\n _hp?: string\n}\n\nexport function fetchFormConfig(rt: Runtime, model: string): Promise<FormConfig> {\n return getJson<FormConfig>(publicUrl(formsRoot(rt), [rt.project_id, model, 'config']))\n}\n\nexport function submitForm(rt: Runtime, model: string, payload: FormPayload): Promise<FormSubmitResult> {\n return postJson<FormSubmitResult>(publicUrl(formsRoot(rt), [rt.project_id, model, 'submit']), payload)\n}\n\nfunction coerce(def: FieldDef, value: string): unknown {\n switch (def.type) {\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return value === '' ? '' : Number(value)\n case 'boolean':\n return value === 'on' || value === 'true'\n default:\n return value\n }\n}\n\n/**\n * Form entries (`new FormData(form)`) \u2192 request body. Only exposed fields\n * reach `data`; the captcha token and the honeypot value travel beside it, so\n * a control field can never collide with a model field. Files are skipped \u2014\n * uploads are not part of the public contract.\n */\nexport function formPayload(\n entries: Iterable<[string, unknown]>,\n config: { fields: Record<string, FieldDef>; honeypotField: string | null },\n): FormPayload {\n const payload: FormPayload = { data: {} }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (config.honeypotField && name === config.honeypotField) {\n payload._hp = raw\n continue\n }\n const def = config.fields[name]\n if (!def) continue\n payload.data[name] = coerce(def, raw)\n }\n return payload\n}\n\nexport function labelFor(id: string, def: FieldDef): string {\n if (def.label) return def.label\n const words = id.replace(/[_-]+/g, ' ').trim()\n return words.charAt(0).toUpperCase() + words.slice(1)\n}\n\n/** One input for one exposed field, chosen by field type. */\nexport function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string {\n const common =\n ' id=\"' + esc(prefix + '-' + id) + '\" name=\"' + esc(id) + '\"' +\n (def.required ? ' required' : '') +\n (def.pattern ? ' pattern=\"' + esc(def.pattern) + '\"' : '')\n const range =\n (def.min !== undefined ? ' min=\"' + esc(def.min) + '\"' : '') +\n (def.max !== undefined ? ' max=\"' + esc(def.max) + '\"' : '')\n const length = def.max !== undefined ? ' maxlength=\"' + esc(def.max) + '\"' : ''\n switch (def.type) {\n case 'text':\n case 'markdown':\n case 'richtext':\n case 'code':\n return '<textarea' + common + length + ' rows=\"5\"></textarea>'\n case 'select': {\n const blank = def.required ? '' : '<option value=\"\"></option>'\n const options = (def.options ?? []).map((o) => '<option value=\"' + esc(o) + '\">' + esc(o) + '</option>').join('')\n return '<select' + common + '>' + blank + options + '</select>'\n }\n case 'boolean':\n return '<input type=\"checkbox\"' + common + ' />'\n case 'number':\n case 'integer':\n case 'rating':\n return '<input type=\"number\"' + common + range + (def.type === 'integer' ? ' step=\"1\"' : '') + ' />'\n case 'decimal':\n case 'percent':\n return '<input type=\"number\" step=\"any\"' + common + range + ' />'\n case 'email':\n return '<input type=\"email\"' + common + length + ' />'\n case 'url':\n return '<input type=\"url\"' + common + length + ' />'\n case 'phone':\n return '<input type=\"tel\"' + common + length + ' />'\n case 'date':\n return '<input type=\"date\"' + common + ' />'\n case 'datetime':\n return '<input type=\"datetime-local\"' + common + ' />'\n case 'color':\n return '<input type=\"color\"' + common + ' />'\n default:\n return '<input type=\"text\"' + common + length + ' />'\n }\n}\n\nexport function honeypotHtml(field: string | null): string {\n if (!field) return ''\n return (\n '<p class=\"cr-hp\" aria-hidden=\"true\" style=\"position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden\">' +\n '<label>' + esc(strings.honeypot) + ' <input type=\"text\" name=\"' + esc(field) + '\" tabindex=\"-1\" autocomplete=\"off\" /></label></p>'\n )\n}\n\nexport function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null): string {\n if (captcha !== 'turnstile' || !siteKey) return ''\n return '<div class=\"cf-turnstile\" data-sitekey=\"' + esc(siteKey) + '\"></div>'\n}\n\nexport function errorsHtml(errors: FieldError[]): string {\n return (\n '<ul class=\"cr-errors\" role=\"alert\">' +\n errors.map((e) => '<li data-field=\"' + esc(e.field) + '\">' + esc(e.field) + ': ' + esc(e.message) + '</li>').join('') +\n '</ul>'\n )\n}\n\n/** The whole form for a public form config: one control per exposed field, honeypot, captcha, submit. */\nexport function formHtml(config: FormConfig, prefix = 'cr-field'): string {\n const fields = Object.entries(config.fields)\n .map(([id, def]) => {\n const control = fieldControl(id, def, prefix)\n const label = '<label for=\"' + esc(prefix + '-' + id) + '\">' + esc(labelFor(id, def)) + (def.required ? ' <span aria-hidden=\"true\">*</span>' : '') + '</label>'\n return '<p class=\"cr-field cr-field--' + esc(def.type) + '\">' + (def.type === 'boolean' ? control + ' ' + label : label + control) + '</p>'\n })\n .join('')\n return (\n '<form class=\"cr-form\" method=\"post\" data-model=\"' + esc(config.modelId) + '\">' +\n fields +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.send) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// \u2500\u2500\u2500 Comments \u2500\u2500\u2500\n\nexport interface CommentAuthor {\n name: string\n url: string | null\n isModerator: boolean\n}\n\nexport interface PublicComment {\n id: string\n parentId: string | null\n depth: number\n author: CommentAuthor\n /** Plain text. */\n body: string\n type: 'comment' | 'pingback' | 'trackback'\n createdAt: string\n replies: PublicComment[]\n}\n\nexport interface ThreadConfig {\n closed: boolean\n requireApproval: boolean\n requireEmail: boolean\n maxDepth: number\n maxBodyLength: number\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n honeypotField: string | null\n}\n\nexport interface CommentThread {\n entry: { modelId: string; entryId: string; locale: string }\n config: ThreadConfig\n comments: PublicComment[]\n total: number\n page: number\n limit: number\n}\n\nexport interface CommentSubmitBody {\n author: { name: string; email?: string; url?: string }\n body: string\n /** null for a root comment \u2014 sent explicitly, as the provider's own fixture does. */\n parentId: string | null\n captchaToken?: string\n _hp?: string\n}\n\nexport interface CommentSubmitResult {\n success: boolean\n status?: 'pending' | 'approved'\n comment?: PublicComment\n errors?: FieldError[]\n}\n\nexport interface ThreadQuery {\n page?: number\n limit?: number\n sort?: 'oldest' | 'newest'\n}\n\nexport function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise<CommentThread> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], {\n locale: entry.locale,\n page: query?.page,\n limit: query?.limit,\n sort: query?.sort,\n })\n return getJson<CommentThread>(url)\n}\n\nexport function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise<CommentSubmitResult> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale })\n return postJson<CommentSubmitResult>(url, body)\n}\n\n/** Comment-form entries \u2192 the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */\nexport function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody {\n const values: Record<string, string> = {}\n const payload: CommentSubmitBody = { author: { name: '' }, body: '', parentId: null }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (honeypotField && name === honeypotField) {\n payload._hp = raw\n continue\n }\n values[name] = raw\n }\n payload.author.name = (values.author_name ?? '').trim()\n if (values.author_email?.trim()) payload.author.email = values.author_email.trim()\n if (values.author_url?.trim()) payload.author.url = values.author_url.trim()\n payload.body = (values.body ?? '').trim()\n payload.parentId = values.parent_id?.trim() || null\n return payload\n}\n\n/** Plain text \u2192 paragraphs; blank lines separate paragraphs, single newlines break lines. */\nexport function bodyHtml(text: string): string {\n return text\n .split(/\\n{2,}/)\n .map((p) => p.trim())\n .filter(Boolean)\n .map((p) => '<p>' + esc(p).replace(/\\n/g, '<br />') + '</p>')\n .join('')\n}\n\nexport function hasMore(thread: CommentThread): boolean {\n return thread.page * thread.limit < thread.total\n}\n\n/** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */\nexport function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string {\n const author = c.author.url\n ? '<a href=\"' + esc(c.author.url) + '\" rel=\"nofollow ugc noopener\" target=\"_blank\">' + esc(c.author.name) + '</a>'\n : esc(c.author.name)\n const badge = c.author.isModerator ? ' <span class=\"cr-moderator\">' + esc(strings.moderator) + '</span>' : ''\n const date = '<time datetime=\"' + esc(c.createdAt) + '\">' + esc(c.createdAt.slice(0, 10)) + '</time>'\n const canReply = !config.closed && c.depth < config.maxDepth\n const reply = canReply\n ? '<button type=\"button\" class=\"cr-reply\" data-parent=\"' + esc(c.id) + '\" data-author=\"' + esc(c.author.name) + '\">' + esc(strings.reply) + '</button>'\n : ''\n const replies = c.replies.length\n ? '<ol class=\"cr-replies\">' + c.replies.map((r) => commentHtml(r, config)).join('') + '</ol>'\n : ''\n return (\n '<li class=\"cr-comment cr-comment--' + esc(c.type) + '\" id=\"cr-comment-' + esc(c.id) + '\" data-depth=\"' + esc(c.depth) + '\">' +\n '<div class=\"cr-comment-meta\"><span class=\"cr-comment-author\">' + author + '</span>' + badge + ' ' + date + '</div>' +\n '<div class=\"cr-comment-body\">' + bodyHtml(c.body) + '</div>' +\n (reply ? '<div class=\"cr-comment-actions\">' + reply + '</div>' : '') +\n replies +\n '</li>'\n )\n}\n\nexport function threadHtml(thread: CommentThread): string {\n if (!thread.comments.length) return '<p class=\"cr-empty\">' + esc(strings.noComments) + '</p>'\n return '<ol class=\"cr-comment-list\">' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '</ol>'\n}\n\n/** The comment form; a reply carries its parent in a hidden input. */\nexport function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string {\n const emailLabel = esc(strings.email) + (config.requireEmail ? ' <span aria-hidden=\"true\">*</span>' : '') + ' <small>' + esc(strings.emailNote) + '</small>'\n return (\n '<form class=\"cr-comment-form\" method=\"post\">' +\n '<input type=\"hidden\" name=\"parent_id\" value=\"\" />' +\n '<p class=\"cr-replying\" hidden><span class=\"cr-replying-to\"></span> <button type=\"button\" class=\"cr-cancel-reply\">' + esc(strings.cancel) + '</button></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-body\">' + esc(strings.comment) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<textarea id=\"' + prefix + '-body\" name=\"body\" required maxlength=\"' + esc(config.maxBodyLength) + '\" rows=\"5\"></textarea></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-name\">' + esc(strings.name) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<input type=\"text\" id=\"' + prefix + '-name\" name=\"author_name\" required maxlength=\"120\" autocomplete=\"name\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-email\">' + emailLabel + '</label>' +\n '<input type=\"email\" id=\"' + prefix + '-email\" name=\"author_email\"' + (config.requireEmail ? ' required' : '') + ' maxlength=\"254\" autocomplete=\"email\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-url\">' + esc(strings.website) + '</label>' +\n '<input type=\"url\" id=\"' + prefix + '-url\" name=\"author_url\" maxlength=\"2048\" autocomplete=\"url\" /></p>' +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.postComment) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// \u2500\u2500\u2500 Turnstile (explicit render, so widgets added after load still render) \u2500\u2500\u2500\n\ninterface TurnstileApi {\n render: (el: Element, options: { sitekey: string }) => string\n reset: (id?: string) => void\n}\n\nconst TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady'\nconst widgets = new WeakMap<Element, string>()\nconst waiting: Element[] = []\n\nfunction turnstileApi(): TurnstileApi | undefined {\n return (globalThis as { turnstile?: TurnstileApi }).turnstile\n}\n\nfunction renderWidget(api: TurnstileApi, el: Element): void {\n if (widgets.has(el)) return\n widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' }))\n}\n\n/** Render every `.cf-turnstile` under `root`, loading the Turnstile script once on first use. */\nexport function renderCaptcha(root: ParentNode): void {\n const els = Array.from(root.querySelectorAll('.cf-turnstile'))\n if (!els.length) return\n const api = turnstileApi()\n if (api) {\n for (const el of els) renderWidget(api, el)\n return\n }\n waiting.push(...els)\n if (document.querySelector('script[data-cr-turnstile]')) return\n ;(globalThis as Record<string, unknown>).__crTurnstileReady = () => {\n const ready = turnstileApi()\n if (!ready) return\n for (const el of waiting.splice(0)) renderWidget(ready, el)\n }\n const script = document.createElement('script')\n script.src = TURNSTILE_SRC\n script.async = true\n script.defer = true\n script.setAttribute('data-cr-turnstile', '1')\n document.head.appendChild(script)\n}\n\nexport function resetCaptcha(root: ParentNode): void {\n const api = turnstileApi()\n if (!api) return\n for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el))\n}\n\n// \u2500\u2500\u2500 Mounting (the only DOM code) \u2500\u2500\u2500\n\n/** ui-strings dictionary key \u2192 the `strings` entry it sets. */\nexport const STRING_KEYS: ReadonlyArray<readonly [string, keyof typeof strings]> = [\n ['common.loading', 'loading'],\n ['common.failed', 'failed'],\n ['form.send', 'send'],\n ['form.sending', 'sending'],\n ['form.honeypot', 'honeypot'],\n ['comments.title', 'commentsTitle'],\n ['comments.empty', 'noComments'],\n ['comments.closed', 'closed'],\n ['comments.post', 'postComment'],\n ['comments.reply', 'reply'],\n ['comments.replying_to', 'replyingTo'],\n ['comments.cancel', 'cancel'],\n ['comments.load_more', 'loadMore'],\n ['comments.moderator', 'moderator'],\n ['comments.pending', 'pending'],\n ['comments.posted', 'posted'],\n ['comments.field.name', 'name'],\n ['comments.field.email', 'email'],\n ['comments.field.email_note', 'emailNote'],\n ['comments.field.website', 'website'],\n ['comments.field.comment', 'comment'],\n]\n\n/**\n * Take the page's interface text from the host element. A value that is not a\n * non-empty string leaves the default; text that cannot be parsed changes\n * nothing, because a mount that fails over a label is worse than an English one.\n */\nexport function applyStrings(host: HTMLElement): void {\n const raw = host.dataset.strings\n if (!raw) return\n let text: unknown\n try {\n text = JSON.parse(raw)\n } catch {\n return\n }\n if (!text || typeof text !== 'object') return\n for (const [key, name] of STRING_KEYS) {\n const value = (text as Record<string, unknown>)[key]\n if (typeof value === 'string' && value !== '') strings[name] = value\n }\n}\n\nexport function runtimeOf(host: HTMLElement): Runtime {\n return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }\n}\n\nfunction entryOf(host: HTMLElement): EntryRef {\n return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', locale: host.dataset.locale || undefined }\n}\n\nfunction setStatus(form: HTMLFormElement, html: string): void {\n const status = form.querySelector('.cr-status')\n if (status) status.innerHTML = html\n}\n\nfunction busy(form: HTMLFormElement, on: boolean): void {\n const button = form.querySelector<HTMLButtonElement>('button[type=\"submit\"]')\n if (!button) return\n button.disabled = on\n if (on) {\n button.dataset.label = button.textContent ?? ''\n button.textContent = strings.sending\n } else {\n button.textContent = button.dataset.label ?? button.textContent\n }\n}\n\nfunction entriesOf(form: HTMLFormElement): Array<[string, unknown]> {\n const out: Array<[string, unknown]> = []\n new FormData(form).forEach((value, key) => out.push([key, value]))\n return out\n}\n\n/** `<cr-form data-base-url data-project data-model>` \u2192 fetch the config, render, submit. */\nexport async function mountForm(host: HTMLElement): Promise<void> {\n applyStrings(host)\n const rt = runtimeOf(host)\n const model = host.dataset.model ?? ''\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let config: FormConfig\n try {\n config = await fetchFormConfig(rt, model)\n } catch (error) {\n if (isPaymentRequired(error)) return hideUnavailable(host, 'form')\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n host.innerHTML = formHtml(config)\n renderCaptcha(host)\n const form = host.querySelector('form')\n if (!form) return\n form.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitForm(rt, model, formPayload(entriesOf(form), config))\n if (result.success) {\n host.innerHTML = '<p class=\"cr-success\" role=\"status\">' + esc(result.message ?? config.successMessage ?? '') + '</p>'\n return\n }\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n } catch (error) {\n if (isPaymentRequired(error)) return hideUnavailable(host, 'form')\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n\n/** `<cr-comments data-base-url data-project data-model data-entry data-locale>` \u2192 thread + form. */\nexport async function mountComments(host: HTMLElement): Promise<void> {\n applyStrings(host)\n const rt = runtimeOf(host)\n const entry = entryOf(host)\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let thread: CommentThread\n try {\n thread = await fetchThread(rt, entry)\n } catch (error) {\n if (isPaymentRequired(error)) return hideUnavailable(host, 'comments')\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n\n host.innerHTML =\n '<section class=\"cr-comments-section\">' +\n '<h2 class=\"cr-comments-title\">' + esc(strings.commentsTitle) + ' <span class=\"cr-count\">(' + esc(thread.total) + ')</span></h2>' +\n '<div class=\"cr-thread\">' + threadHtml(thread) + '</div>' +\n (hasMore(thread) ? '<p class=\"cr-more\"><button type=\"button\" class=\"cr-load-more\">' + esc(strings.loadMore) + '</button></p>' : '') +\n (thread.config.closed ? '<p class=\"cr-closed\">' + esc(strings.closed) + '</p>' : commentFormHtml(thread.config)) +\n '</section>'\n renderCaptcha(host)\n\n const threadEl = host.querySelector('.cr-thread')\n const form = host.querySelector<HTMLFormElement>('form.cr-comment-form')\n let page = thread.page\n\n host.addEventListener('click', async (event) => {\n const target = event.target as HTMLElement | null\n if (!target) return\n const replyButton = target.closest<HTMLElement>('.cr-reply')\n if (replyButton && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = replyButton.dataset.parent ?? ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n const to = form.querySelector('.cr-replying-to')\n if (to) to.textContent = strings.replyingTo + ' ' + (replyButton.dataset.author ?? '')\n if (replying) replying.hidden = false\n replyButton.closest('.cr-comment')?.appendChild(form)\n form.querySelector<HTMLTextAreaElement>('textarea[name=\"body\"]')?.focus()\n return\n }\n if (target.closest('.cr-cancel-reply') && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n host.querySelector('.cr-comments-section')?.appendChild(form)\n return\n }\n const more = target.closest<HTMLButtonElement>('.cr-load-more')\n if (more && threadEl) {\n more.disabled = true\n try {\n const next = await fetchThread(rt, entry, { page: page + 1 })\n page = next.page\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join(''))\n if (!hasMore(next)) more.closest('.cr-more')?.remove()\n } catch (error) {\n if (isPaymentRequired(error)) return hideUnavailable(host, 'comments')\n // Anything else: the button comes back for another try.\n } finally {\n more.disabled = false\n }\n }\n })\n\n form?.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitComment(rt, entry, commentPayload(entriesOf(form), thread.config.honeypotField))\n if (!result.success) {\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n return\n }\n const parentId = result.comment?.parentId ?? null\n if (result.status === 'approved' && result.comment && threadEl) {\n const html = commentHtml(result.comment, thread.config)\n const parent = parentId ? threadEl.querySelector('#cr-comment-' + parentId) : null\n if (parent) {\n let replies = parent.querySelector(':scope > .cr-replies')\n if (!replies) {\n parent.insertAdjacentHTML('beforeend', '<ol class=\"cr-replies\"></ol>')\n replies = parent.querySelector(':scope > .cr-replies')\n }\n replies?.insertAdjacentHTML('beforeend', html)\n } else {\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', html)\n else threadEl.innerHTML = '<ol class=\"cr-comment-list\">' + html + '</ol>'\n }\n }\n form.reset()\n host.querySelector('.cr-comments-section')?.appendChild(form)\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n resetCaptcha(form)\n setStatus(form, '<p class=\"cr-success\" role=\"status\">' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '</p>')\n } catch (error) {\n if (isPaymentRequired(error)) return hideUnavailable(host, 'comments')\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n";
342
574
  //#endregion
343
575
  //#region src/ui-strings.d.ts
344
576
  /** Where the dictionary lives in an emitted project, relative to its root. */
@@ -387,10 +619,81 @@ declare function checkBalance(html: string): BalanceReport;
387
619
  /** Human-readable balance complaint, or null when the fragment is sound. */
388
620
  declare function balanceWarning(html: string): string | null;
389
621
  //#endregion
622
+ //#region src/noindex.d.ts
623
+ /** Site-root paths (directory format, `/a/b/`) of every noindex page, sorted. */
624
+ declare function noindexPaths(routes: RouteModel[], content: EmitContent): string[];
625
+ //#endregion
626
+ //#region src/redirects.d.ts
627
+ /** The statuses written to `astro.config`; any other is a manual rule. */
628
+ declare const REDIRECT_STATUSES: readonly [301, 302, 307, 308];
629
+ type RedirectStatus = typeof REDIRECT_STATUSES[number];
630
+ /** A rule the emitter did not write, with why. */
631
+ interface ManualRedirect {
632
+ redirect: RawRedirect$1;
633
+ reason: string;
634
+ }
635
+ interface RedirectPlan {
636
+ /** `from` → rule, as `astro.config` `redirects` takes it. Sorted by `from`. */
637
+ config: Record<string, {
638
+ status: RedirectStatus;
639
+ destination: string;
640
+ }>;
641
+ /** The input rules written, in input order. */
642
+ written: RawRedirect$1[];
643
+ /** The rules to set up by hand at the host, in input order. */
644
+ manual: ManualRedirect[];
645
+ }
646
+ /**
647
+ * Every address the emitted site builds a page at, as `addressKey`s, with the
648
+ * route that owns it. Single and collection routes build one page per entry,
649
+ * query routes one per result set, and a route without parameters one page.
650
+ */
651
+ declare function builtAddresses(routes: RouteModel[], content: EmitContent): Map<string, string>;
652
+ /**
653
+ * Split the rules into what `astro.config` serves and what the host has to.
654
+ * `built` is `builtAddresses` of the emitted routes.
655
+ */
656
+ declare function planRedirects(redirects: RawRedirect$1[], built: Map<string, string>): RedirectPlan;
657
+ /** The `redirects` line for astro.config.mjs, or null when there is nothing to write. */
658
+ declare function astroRedirectsConfig(config: RedirectPlan['config']): string | null;
659
+ /** Hosts whose own redirect file the emitter can write. */
660
+ type RedirectHost = 'netlify' | 'cloudflare' | 'vercel';
661
+ interface HostRedirectFiles {
662
+ /** Path → content, for the emitted project. */
663
+ files: Record<string, string>;
664
+ /** Which host each written file is for, e.g. `public/_redirects (netlify)`. */
665
+ written: string[];
666
+ /** Config keys a host file cannot express, left to the meta-refresh fallback. */
667
+ skipped: string[];
668
+ /**
669
+ * Config keys left out of a Cloudflare or Vercel file because it reached
670
+ * that host's rule limit — served by the meta-refresh fallback only there.
671
+ */
672
+ over_limit: string[];
673
+ }
674
+ /**
675
+ * Real HTTP redirects for the host the site deploys to. A static Astro build
676
+ * serves `redirects` as meta-refresh pages; the status code only exists if the
677
+ * host answers the request itself, from its own redirect file. The
678
+ * meta-refresh pages stay as the fallback for a host without one.
679
+ *
680
+ * - `netlify` — `public/_redirects`, forced (`301!`): the build writes a page
681
+ * at every redirected path, and Netlify serves an existing file before an
682
+ * unforced rule.
683
+ * - `cloudflare` — `public/_redirects`, unforced: Cloudflare Pages has no `!`
684
+ * and applies its rules before static assets.
685
+ * - `vercel` — `vercel.json` `redirects`, which Vercel applies before the
686
+ * filesystem.
687
+ *
688
+ * Without a host both the Netlify file and `vercel.json` are written; a site
689
+ * on Cloudflare Pages should name its host.
690
+ */
691
+ declare function hostRedirectFiles(config: RedirectPlan['config'], host?: RedirectHost): HostRedirectFiles;
692
+ //#endregion
390
693
  //#region src/index.d.ts
391
694
  declare function emitAstroProject(input: EmitInput): EmitResult;
392
695
  /** Write an EmitResult to disk. Separate from emit so the core stays pure. */
393
696
  declare function writeEmit(result: EmitResult, dir: string): Promise<void>;
394
697
  //#endregion
395
- export { type Alternate, type AlternatesResult, type BalanceReport, type Breadcrumb, EMBED_TS, type EmitContent, type EmitCssFile, type EmitInput, type EmitOptions, type EmitPost, type EmitResult, type EmitTermRef, type EntrySourceRef, type ImageMeta, type MountRef, type QueryPage, RUNTIME_IMPLEMENTED, type RuntimeBinding, SEO_COMPONENT, type StripResult, UI_STRINGS_DIR, UI_STRINGS_MODEL, UI_STRING_DEFAULTS, type UiStringKey, balanceWarning, bodySeoLeaks, checkBalance, componentMarkers, emitAstroProject, entryPath, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, stripSeoTags, websiteIdOf, withAlternates, wrapLegacyCss, writeEmit };
698
+ export { type Alternate, type AlternatesResult, type AuthorProfile, type BalanceReport, type Breadcrumb, EMBED_TS, type EmitContent, type EmitCssFile, type EmitInput, type EmitOptions, type EmitPost, type EmitResult, type EmitTermRef, type EntrySourceRef, FEED_ITEMS, FEED_PATH, type FeedLinkResult, type HostRedirectFiles, type ImageMeta, LLMS_LINKS, type LinkSource, type ManualRedirect, type MountRef, type QueryPage, REDIRECT_STATUSES, RUNTIME_IMPLEMENTED, type RawRedirect, type RedirectHost, type RedirectPlan, type RedirectStatus, type RuntimeBinding, SEO_COMPONENT, type SeoFields, type SeoFromRawOptions, type SocialOverride, type StripResult, type TwitterOverride, UI_STRINGS_DIR, UI_STRINGS_MODEL, UI_STRING_DEFAULTS, type UiStringKey, astroRedirectsConfig, balanceWarning, bodySeoLeaks, builtAddresses, checkBalance, componentMarkers, emitAstroProject, entryPath, hostRedirectFiles, isRuntimeImplemented, linkSources, noindexPaths, pascalCase, patternToPagePath, planRedirects, publisherIdOf, rewriteFeedLinks, seoFromRawEntry, stableJson, stripSeoTags, websiteIdOf, withAlternates, wrapLegacyCss, writeEmit };
396
699
  //# sourceMappingURL=index.d.mts.map