@contentrain/emitter-astro 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,7 @@ await writeEmit(result, './out/site')
29
29
  | Pages (`src/pages/…`) | From `RouteModel` patterns: `single` routes render post bodies; list routes render items through the extracted `item_template` (plain fallback list + warning when absent). Pagination is a route param, never a separate family. |
30
30
  | Template markers | `@@mark@@` (escaped) · `@@mark_html@@` (raw, for themes printing a post's own markup) · `<!--@@repeat:list|sep@@-->…<!--@@/repeat@@-->` (per item, with `item` / `item_index` / `item_<key>`) · `<!--@@if:name@@-->…<!--@@/if@@-->` (and `if:!name`). Rendered repeats → conditionals → marks. |
31
31
  | Header & footer | `ChromeChunk.position: 'header' \| 'footer'` lifts the masthead and the colophon out of the body blob into `src/components/*.astro`, rendered as siblings of the body fragment. Families that carry the same region share ONE component — that is where a jQuery-free menu replaces the theme's, in one place. Identity is by content: two different headers claiming the same name get separate files and a warning, never a silent swap. |
32
- | Balance check | A lifted region must close what it opens; a split fragment the browser silently repairs costs a page its layout (measured: 36 against 100). The emitter checks header, footer and body chrome and names the dangling tags in a warning instead of trusting the producer. |
32
+ | Balance check | A lifted region must close what it opens; a split fragment the browser silently repairs costs a page its layout (measured 2026-09-03 on emitter 0.6.1: one page split mid-element scored 36 on layout, against 100 for the same page kept whole). The emitter checks header, footer and body chrome and names the dangling tags in a warning instead of trusting the producer. |
33
33
  | Nested addresses | A trailing `*` makes a rest parameter — `/category/:term*` → `[...term].astro`, so `/category/about-cc/events/` keeps its hierarchy instead of collapsing to its last segment. Same for nested page paths. |
34
34
  | List sections | `QueryPage.sections` renders a list as a sequence of blocks — a big card in its own wrapper, then a grid — instead of forcing one template on every item (or baking the hero into the chrome, which puts the wrong post on page 2). `item_template` stays the single-section shorthand. |
35
35
  | Titles & language | `QueryPage.title` / `RouteModel.title` give archive and static pages a real `<title>`; `RouteModel.locale` (and `EmitPost.locale`) put each route's own language on `<html lang>`, so a multilingual site's second language is a route with its own family and query — not a workaround. |
@@ -41,7 +41,8 @@ await writeEmit(result, './out/site')
41
41
  | Evolution layer (`src/styles/modern.css`) | Tailwind 4, CSS-first: extracted design tokens land in `@theme`. Migrated layouts never load it; new pages build on it. |
42
42
  | SEO (`src/components/Seo.astro`) | Per-page title, description, canonical, Open Graph, Twitter card and Article structured data from the entry; the template page's copies of those tags are taken out of the head chrome and named in a warning. See **SEO** below. |
43
43
  | Component mount points | `<!--@@component:ID@@-->` in the body chrome or an entry’s content body (`componentSlot(id)` from `@contentrain/types`) is where a `ComponentDef` renders: the layout imports the component and mounts it at the marker, with the placement's variant (`LayoutFamily.components`). A marker nobody defined is dropped with a warning; a placement without a marker is warned; header/footer regions are not mount points. Emitting a component file alone is not integration — the marker is what puts it on the page. |
44
- | Runtime components (`comments`, `form`) | With `input.runtime` (`RuntimeBinding`: the provider's origin + project id) these get real implementations: a custom element (`<cr-comments>`, `<cr-form>`) carrying the binding, and a zero-dependency client (`src/lib/embed.ts`) that fetches, renders and submits against the provider's public API — the same contract as `@contentrain/query/cdn`'s `CommentsClient`/`FormsClient`, inlined so the site depends on nothing but Astro. Comments key on the page's entry address (`EmitPost.entry`: model, entry id, locale) and show approved comments only; pending ones live on the provider. A form names its model (`ComponentDef.model`). Honeypot and Turnstile follow the config. No credential is ever emitted. Without a binding, or without a model for a form, the component stays a placeholder and a warning says so. |
44
+ | Runtime components (`comments`, `form`) | With `input.runtime` (`RuntimeBinding`: the provider's origin + project id) these get real implementations: a custom element (`<cr-comments>`, `<cr-form>`) carrying the binding, and a zero-dependency client (`src/lib/embed.ts`) that fetches, renders and submits against the provider's public API — the same contract as `@contentrain/query/cdn`'s `CommentsClient`/`FormsClient`, inlined so the site depends on nothing but Astro. Comments key on the page's entry address (`EmitPost.entry`: model, entry id, locale) and show approved comments only; pending ones live on the provider. A form names its model (`ComponentDef.model`). Honeypot and Turnstile follow the config. No credential is ever emitted. Without a binding, or without a model for a form, the component stays a placeholder and a warning says so. What they say to visitors is content: the `ui-strings` dictionary (`UI_STRINGS_MODEL`, 23 keys in `UI_STRING_DEFAULTS`) at `.contentrain/content/site/ui-strings/{locale}.json` is read at build time for the page's language (`src/lib/ui-strings.ts`), edited in Studio or through MCP, and applied by a rebuild. Missing keys show the English default; a non-English page with no dictionary is reported in the build log, and in the emit report for languages not in `options.uiStrings.locales`. `options.uiStrings.dir` moves the directory. |
45
+ | Regions bound to a query | `ComponentPlacement.query` binds a mounted region (a "recent posts" block) to a query, so it stays current instead of freezing as a clone: the layout reads the result set from `EmitContent.queries[id]` and renders it at the marker through `sections`, else `item_template` — the same path a list page takes. A query missing from the content drops the binding (warned; the region stays cloned). More than one result set is warned and stops the build. No item markup is warned and renders a plain list of links, as a list page does — never an empty region. |
45
46
  | Other components (`src/components/*.astro`) | `<cr-component>` placeholders carrying type/source/variants — the emitter cannot invent an ad slot or a related-posts box; the marker keeps the spot. |
46
47
  | Split viewports | With `viewport_strategy: 'split'`, `build:desktop` / `build:mobile` scripts scaffold per-device production. |
47
48
 
@@ -58,9 +59,21 @@ It also **removes the template page's copies of those tags from the head
58
59
  chrome**, naming them in a warning. Inheriting them is worse than having none: a
59
60
  whole site canonicalised onto the template post's URL de-indexes itself, and
60
61
  every share card shows the same wrong story. Everything else the theme put in
61
- `<head>` stays — charset, preloads, feeds, icons, verification tokens, and any
62
- JSON-LD that is not page-scoped (`Organization`, `WebSite`, `BreadcrumbList`).
63
- JSON-LD that cannot be parsed is left alone rather than guessed at.
62
+ `<head>` stays — charset, preloads, feeds, icons, verification tokens, and the
63
+ structured data that describes the site rather than the page.
64
+
65
+ Page-scoped structured data is every `…Page` and `…Article` type (WebPage,
66
+ CollectionPage, ProfilePage, NewsArticle, …), `BlogPosting` and
67
+ `BreadcrumbList` — a breadcrumb names the template page's trail. An SEO plugin
68
+ writes the site's identity into the **same** `@graph` as those nodes (Yoast and
69
+ Rank Math put WebSite, Organization, WebPage and BreadcrumbList in one block), so
70
+ a mixed block is split rather than removed whole: `WebSite` and `Organization`
71
+ (and its subtypes) stay, with the nodes they refer to by `@id` — the logo, the
72
+ person a personal site is published by — and a page node is never pulled back
73
+ in. A WebSite `SearchAction` is dropped, because it points at WordPress search
74
+ (`/?s=`) and a static site has none. The warning names what was removed and
75
+ what was kept. JSON-LD that cannot be parsed is left alone rather than guessed
76
+ at.
64
77
 
65
78
  | Tag | Source |
66
79
  |---|---|
@@ -68,13 +81,18 @@ JSON-LD that cannot be parsed is left alone rather than guessed at.
68
81
  | `description`, `og:description`, `twitter:description` | `EmitPost.description`, else `excerpt` with markup stripped and cut at a word boundary |
69
82
  | `canonical`, `og:url` | The address Astro generated (`Astro.url` + `site`), so it cannot disagree with the emitted page. `EmitPost.canonical` overrides |
70
83
  | `og:image`, `twitter:image` | `EmitPost.image`, else a `featured` entry that is already a path. A bare file name is skipped — only the producer knows where media is served |
84
+ | `og:image:width` / `height` / `type` | `image_meta` on `EmitPost` or `QueryPage` — the producer measured the file; the emitter never sees it. Printed only beside `image` (a `featured` fallback is a different file), and only values that are true of an image: positive integer sizes, an `image/…` type |
71
85
  | `og:site_name` | `ProjectIR.site.title` |
86
+ | Structured data graph | One JSON-LD `@graph` per page: `WebPage` (`CollectionPage` on a list) with `@id` = the built address, `inLanguage` = the page `lang`, and `isPartOf` → the site's own WebSite `@id` when the kept head declares one (never an invented WebSite); a `BreadcrumbList` from `EmitPost.breadcrumbs` / `QueryPage.breadcrumbs` — the trail **without** the page, which the emitter appends at its own address; and on entries the `Article`, whose `mainEntityOfPage` is that `@id`. A trail with a crumb lacking a name or a site-root-relative path is dropped, with a warning. No trail, no BreadcrumbList. Without `site` there is no page node, only an entry's Article |
87
+ | `<link rel="alternate" hreflang>`, `og:locale:alternate` | Entry pages whose content-store entry (`EmitPost.entry`) exists in another locale on the site. The address is computed from each route's own pattern and the post's parameters — the values `getStaticPaths` uses — and the language is the page's `lang` (post locale, else route locale, else site default). Every page lists itself; `x-default` is the version in the site's default locale. A post two routes generate, or an entry where two pages claim one language, gets none, with a warning. The template page's own hreflang links are removed from the head chrome |
72
88
  | `og:locale` | The page's `lang` |
73
89
  | `article:published_time` / `modified_time`, Article JSON-LD dates | `EmitPost.published_at` / `modified_at` (ISO 8601 — `dates` holds display strings, which schema.org cannot read) |
74
90
 
75
91
  Absolute URLs need `site` in `astro.config.mjs`, which the emitter fills from
76
92
  `ProjectIR.site.url`. Without it the canonical link and the absolute social URLs
77
- are omitted, with a warning, rather than pointing at a build host.
93
+ are omitted, with a warning, rather than pointing at a build host — and the
94
+ `site` key is left out of the config altogether, because Astro refuses to build
95
+ with an empty one.
78
96
 
79
97
  Head-only tags that a faithful clone left in the **body** (a browser that closed
80
98
  `<head>` early puts the template's canonical there) are reported, not removed:
@@ -85,6 +103,32 @@ chunk, or drop them at capture.
85
103
  `options.seo: false` turns all of this off: the source head travels verbatim and
86
104
  the layout prints its own `<title>`, exactly as before.
87
105
 
106
+ ### Sitemap and robots.txt
107
+
108
+ The sitemap is written by [`@astrojs/sitemap`](https://docs.astro.build/en/guides/integrations-guide/sitemap/)
109
+ at build time — `sitemap-index.xml` and the sitemaps it names — not computed by
110
+ the emitter. Only the build knows every address `getStaticPaths` produced; a list
111
+ worked out here would be a second answer that can disagree with the site. The
112
+ 404 page is not listed.
113
+
114
+ `public/robots.txt` allows every crawler and names the sitemap by absolute URL:
115
+
116
+ ```
117
+ User-agent: *
118
+ Allow: /
119
+
120
+ Sitemap: https://example.com/sitemap-index.xml
121
+ ```
122
+
123
+ Nothing is disallowed. A WordPress `robots.txt` typically keeps crawlers out of
124
+ `/wp-admin/`; the migrated site has no such path. Without `site.url` there is no
125
+ sitemap to name — the integration is not added, `robots.txt` carries no
126
+ `Sitemap:` line (a relative one is invalid), and a warning says so.
127
+
128
+ `options.sitemap: false` leaves out both. It is independent of `options.seo`: a
129
+ producer that writes its own meta tags has not thereby said it writes its own
130
+ sitemap.
131
+
88
132
  ## Binding the runtime later
89
133
 
90
134
  `src/data/runtime.json` is the only place the binding lives; the components
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { ComponentDef, EntrySourceRef, ProjectIR, RuntimeBinding, RuntimeBinding as RuntimeBinding$1 } from "@contentrain/types";
1
+ import { ComponentDef, EntrySourceRef, ModelDefinition, ProjectIR, RouteModel, RuntimeBinding, RuntimeBinding as RuntimeBinding$1 } from "@contentrain/types";
2
2
 
3
3
  //#region src/types.d.ts
4
4
  /**
@@ -57,6 +57,8 @@ interface EmitPost {
57
57
  * `og:image` needs this; a `featured` entry that is already a path is used.
58
58
  */
59
59
  image?: string;
60
+ /** What `image` is, for the `og:image:*` tags. Describes `image` only — ignored without it. */
61
+ image_meta?: ImageMeta;
60
62
  /** Canonical override — for a page that should point somewhere else. Default: this page's own address. */
61
63
  canonical?: string;
62
64
  /** ISO 8601, for Article structured data. `dates` holds display strings, which schema.org cannot read. */
@@ -68,6 +70,14 @@ interface EmitPost {
68
70
  marks?: Record<string, unknown>;
69
71
  /** Locale of this entry, on a multilingual site — overrides the route's. */
70
72
  locale?: string;
73
+ /**
74
+ * The trail to this page for BreadcrumbList — home first, the page itself
75
+ * NOT included: the emitter appends it with the address it built, so the
76
+ * last crumb cannot disagree with the page. Each path is site-root-relative.
77
+ * Without a trail no BreadcrumbList is printed; a trail with an invalid
78
+ * crumb is dropped with a warning.
79
+ */
80
+ breadcrumbs?: Breadcrumb[];
71
81
  /**
72
82
  * Content-store address of this post (model, entry id, locale) — what a
73
83
  * mounted `comments` component addresses its thread by. Only the tool that
@@ -122,8 +132,31 @@ interface QueryPage {
122
132
  description?: string;
123
133
  /** Social image, absolute or site-root-relative. */
124
134
  image?: string;
135
+ /** What `image` is, for the `og:image:*` tags. Ignored without `image`. */
136
+ image_meta?: ImageMeta;
125
137
  /** Canonical override; default is this page's own address. */
126
138
  canonical?: string;
139
+ /** The trail to this list page, itself excluded — see `EmitPost.breadcrumbs`. */
140
+ breadcrumbs?: Breadcrumb[];
141
+ }
142
+ /** One step of a breadcrumb trail. */
143
+ interface Breadcrumb {
144
+ name: string;
145
+ /** Site-root-relative (`/category/news/`). */
146
+ path: string;
147
+ }
148
+ /**
149
+ * A social image's size and type. Share cards lay out before they fetch the
150
+ * image; with `og:image:width` / `height` the first share renders at the right
151
+ * aspect instead of as a placeholder. The producer measured the file — the
152
+ * emitter cannot, it never sees it.
153
+ */
154
+ interface ImageMeta {
155
+ /** Pixels. A value that is not a positive integer is left out. */
156
+ width?: number;
157
+ height?: number;
158
+ /** MIME type, `image/…`. Anything else is left out. */
159
+ type?: string;
127
160
  }
128
161
  interface EmitContent {
129
162
  /** The default (`posts`) collection — shorthand for `collections.posts`. */
@@ -155,6 +188,33 @@ interface EmitOptions {
155
188
  * source head verbatim and own these tags in the producer.
156
189
  */
157
190
  seo?: boolean;
191
+ /**
192
+ * Emit a sitemap and a `robots.txt` that points at it. Default true.
193
+ *
194
+ * The sitemap is generated by `@astrojs/sitemap` at build time rather than
195
+ * computed here, because only the build knows the real set of URLs — every
196
+ * dynamic route, every paginated page, whatever `getStaticPaths` returned. A
197
+ * sitemap the emitter worked out on its own would be a second source of truth
198
+ * that can disagree with the site it describes.
199
+ *
200
+ * Independent of `seo`: a producer that writes its own meta tags has not
201
+ * thereby said it writes its own sitemap, and coupling the two would drop the
202
+ * sitemap silently for exactly that producer.
203
+ */
204
+ sitemap?: boolean;
205
+ /**
206
+ * The site's interface-text dictionary (see \`UI_STRINGS_MODEL\`). Comments
207
+ * and forms read it at build time from \`dir\` (default
208
+ * \`.contentrain/content/site/ui-strings\`, project-relative).
209
+ *
210
+ * \`locales\` declares which languages the producer wrote. The emitter cannot
211
+ * see the store, so this is how it can say at emit time that a page's
212
+ * language has no dictionary; the build reports the same from the files.
213
+ */
214
+ uiStrings?: {
215
+ dir?: string;
216
+ locales?: string[];
217
+ };
158
218
  }
159
219
  interface EmitInput {
160
220
  ir: ProjectIR;
@@ -217,10 +277,18 @@ declare const RUNTIME_IMPLEMENTED: Set<"nav" | "related" | "comments" | "form" |
217
277
  declare function isRuntimeImplemented(c: ComponentDef, runtime: RuntimeBinding$1 | undefined): boolean;
218
278
  //#endregion
219
279
  //#region src/seo.d.ts
280
+ /**
281
+ * \`@id\` of the WebSite node in a head's structured data, if it declares one
282
+ * — the node the page's WebPage says it is part of. Only what the head says:
283
+ * no id, no link.
284
+ */
285
+ declare function websiteIdOf(html: string): string | undefined;
220
286
  interface StripResult {
221
287
  html: string;
222
288
  /** What was taken out, for the emit warning — never a silent removal. */
223
289
  removed: string[];
290
+ /** Site-wide structured-data types kept out of a block that was otherwise page-scoped. */
291
+ kept: string[];
224
292
  }
225
293
  /** Remove the source head's per-page SEO tags so the emitter's own are the only ones. */
226
294
  declare function stripSeoTags(html: string): StripResult;
@@ -240,10 +308,73 @@ declare function bodySeoLeaks(html: string): string[];
240
308
  * `Astro.site` rather than from data: it is then, by construction, the address
241
309
  * Astro actually generated, and no producer has to recompute a permalink.
242
310
  */
243
- 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, jsonLd, seoDescription } 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 /** `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 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 desc = seoDescription(description)\nconst article = type === 'article'\nconst structured = article\n ? {\n '@context': 'https://schema.org',\n '@type': 'Article',\n headline: title,\n ...(desc ? { description: desc } : {}),\n ...(imageUrl ? { image: [imageUrl] } : {}),\n ...(publishedAt ? { datePublished: publishedAt } : {}),\n ...(modifiedAt ? { dateModified: modifiedAt } : {}),\n ...(author ? { author: { '@type': 'Person', name: author } } : {}),\n ...(url ? { mainEntityOfPage: { '@type': 'WebPage', '@id': url } } : {}),\n ...(siteName ? { publisher: { '@type': 'Organization', name: siteName } } : {}),\n }\n : undefined\n---\n<title>{title}</title>\n{desc && <meta name=\"description\" content={desc} />}\n{url && <link rel=\"canonical\" href={url} />}\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{siteName && <meta property=\"og:site_name\" content={siteName} />}\n{locale && <meta property=\"og:locale\" content={locale} />}\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";
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";
312
+ //#endregion
313
+ //#region src/alternates.d.ts
314
+ interface Alternate {
315
+ /** A language tag, or `x-default`. */
316
+ lang: string;
317
+ /** Site-root-relative path, as the build serves it. */
318
+ path: string;
319
+ }
320
+ /**
321
+ * The path Astro builds for a route pattern and a set of parameters, in the
322
+ * directory format the scaffold configures (`/en/hello/`). Null when a
323
+ * parameter has no value — that page has no address to name.
324
+ */
325
+ declare function entryPath(pattern: string, params: Record<string, string | undefined>): string | null;
326
+ interface AlternatesResult {
327
+ /** The content with `alternates` on every post that has translations. */
328
+ content: EmitContent;
329
+ warnings: string[];
330
+ }
331
+ /**
332
+ * Attach `alternates` to every entry that has translations on the site.
333
+ *
334
+ * The language of a page is the one its `<html lang>` gets: the post's own
335
+ * locale, else its route's, else the site's. A site with one language has no
336
+ * alternates and nothing is attached.
337
+ */
338
+ declare function withAlternates(routes: RouteModel[], content: EmitContent, siteLocale: string): AlternatesResult;
244
339
  //#endregion
245
340
  //#region src/embed.d.ts
246
- 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/** Visitor-facing strings \u2014 override from the site (`strings.send = 'G\u00F6nder'`). */\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\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 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 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";
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";
342
+ //#endregion
343
+ //#region src/ui-strings.d.ts
344
+ /** Where the dictionary lives in an emitted project, relative to its root. */
345
+ declare const UI_STRINGS_DIR = ".contentrain/content/site/ui-strings";
346
+ /** Every key the runtime components read, with the text they show without a dictionary. */
347
+ declare const UI_STRING_DEFAULTS: {
348
+ readonly 'comments.cancel': "Cancel";
349
+ readonly 'comments.closed': "Comments are closed.";
350
+ readonly 'comments.empty': "No comments yet.";
351
+ readonly 'comments.field.comment': "Comment";
352
+ readonly 'comments.field.email': "Email";
353
+ readonly 'comments.field.email_note': "(never shown)";
354
+ readonly 'comments.field.name': "Name";
355
+ readonly 'comments.field.website': "Website";
356
+ readonly 'comments.load_more': "Load more comments";
357
+ readonly 'comments.moderator': "Moderator";
358
+ readonly 'comments.noscript': "Comments need JavaScript.";
359
+ readonly 'comments.pending': "Thank you — your comment is awaiting moderation.";
360
+ readonly 'comments.post': "Post comment";
361
+ readonly 'comments.posted': "Your comment has been posted.";
362
+ readonly 'comments.replying_to': "Replying to";
363
+ readonly 'comments.reply': "Reply";
364
+ readonly 'comments.title': "Comments";
365
+ readonly 'common.failed': "Something went wrong. Please try again.";
366
+ readonly 'common.loading': "Loading…";
367
+ readonly 'form.honeypot': "Leave this field empty";
368
+ readonly 'form.noscript': "This form needs JavaScript.";
369
+ readonly 'form.send': "Send";
370
+ readonly 'form.sending': "Sending…";
371
+ };
372
+ type UiStringKey = keyof typeof UI_STRING_DEFAULTS;
373
+ /**
374
+ * The dictionary model a producer creates in the site's store. Exported so the
375
+ * migration and the emitter cannot disagree about its id, kind or domain.
376
+ */
377
+ declare const UI_STRINGS_MODEL: ModelDefinition;
247
378
  //#endregion
248
379
  //#region src/balance.d.ts
249
380
  interface BalanceReport {
@@ -261,5 +392,5 @@ declare function emitAstroProject(input: EmitInput): EmitResult;
261
392
  /** Write an EmitResult to disk. Separate from emit so the core stays pure. */
262
393
  declare function writeEmit(result: EmitResult, dir: string): Promise<void>;
263
394
  //#endregion
264
- export { type BalanceReport, EMBED_TS, type EmitContent, type EmitCssFile, type EmitInput, type EmitOptions, type EmitPost, type EmitResult, type EmitTermRef, type EntrySourceRef, type MountRef, type QueryPage, RUNTIME_IMPLEMENTED, type RuntimeBinding, SEO_COMPONENT, type StripResult, balanceWarning, bodySeoLeaks, checkBalance, componentMarkers, emitAstroProject, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, stripSeoTags, wrapLegacyCss, writeEmit };
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 };
265
396
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/css.ts","../src/util.ts","../src/layouts.ts","../src/components.ts","../src/seo.ts","../src/embed.ts","../src/balance.ts","../src/index.ts"],"mappings":";;;;;AAYA;;;;;AAKA;AAAA,UALiB,WAAA;EACf,IAAA;EACA,IAAA;AAAA;AAAA,UAGe,QAAA;EACf,IAAA;EACA,KAAA;EA2DsB;EAzDtB,IAAA;EAHA;;;;;;EAUA,MAAA,GAAS,MAAA;EAQT;;;;EAHA,GAAA;EAWuB;EATvB,KAAA;EACA,MAAA;EACA,YAAA;EACA,WAAA;EAsBA;;;;;EAhBA,KAAA,GAAQ,KAAA,UAAe,WAAA;EAyBf;EAvBR,QAAA;EACA,OAAA;EA+BQ;EA7BR,YAAA;EA6BsB;AAWxB;;;EAnCE,WAAA;EAqCA;;;;;EA/BA,KAAA;EAuCwB;EArCxB,SAAA;EAsCQ;EApCR,YAAA;EACA,WAAA;EAwDW;EAtDX,OAAA;EAsDsB;EApDtB,KAAA,GAAQ,MAAA;EA+BA;EA7BR,MAAA;EA8BO;;;;;;EAvBP,KAAA,GAAQ,cAAA;AAAA;;;;;;AAyDV;;;UA9CiB,WAAA;EAsDc;EApD7B,QAAA;EAsDyB;EApDzB,OAAA;EAoDgB;EAlDhB,KAAA;AAAA;;UAIe,SAAA;EACf,MAAA,EAAQ,MAAA;EACR,KAAA,EAAO,QAAA;EA4CP;;;;;EAtCA,KAAA,GAAQ,MAAA;EA0CkB;EAxC1B,GAAA;EAyCA;;AAIF;;;;;;;EAnCE,aAAA;EA+CG;EA7CH,QAAA,GAAW,WAAA;EAgDa;EA9CxB,KAAA;EA+CI;EA7CJ,WAAA;EA+CM;EA7CN,KAAA;EAoDU;EAlDV,SAAA;AAAA;AAAA,UAMe,WAAA;EAoCL;EAlCV,KAAA,GAAQ,QAAA;EAmCF;;;;;EA7BN,WAAA,GAAc,MAAA,SAAe,QAAA;EAoCL;EAlCxB,OAAA,GAAU,MAAA,SAAe,SAAA;AAAA;;UAIV,WAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,WAAA;EAqCP;EAnCR,WAAA;;EAEA,QAAA;EC9Ie;;;;;AAOjB;;ED+IE,GAAA;AAAA;AAAA,UAGe,SAAA;EACf,EAAA,EAAI,SAAA;EACJ,OAAA,GAAU,WAAA;EACV,GAAA,GAAM,WAAA;EACN,OAAA,GAAU,WAAA;;;;AE5KZ;;EFkLE,OAAA,GAAU,cAAA;AAAA;;AElKZ;;;;UF4KiB,UAAA;EACf,KAAA,EAAO,MAAA;EACP,QAAA;AAAA;;;UC/Ke,aAAA;EACf,OAAA;EACA,OAAA;AAAA;AAAA,iBAKc,aAAA,CAAc,IAAA,UAAc,GAAA,WAAc,aAAA;;;;iBCtB1C,UAAA,CAAW,EAAA;;AFW3B;;;;;AAKA;;iBEAgB,iBAAA,CAAkB,OAAA;;iBAqClB,UAAA,CAAW,KAAA;;;UCtBV,QAAA;EACf,EAAA;EACA,IAAA;EACA,OAAA;;EAEA,KAAA;AAAA;;iBAOc,gBAAA,CAAiB,IAAA;;;;cCpBpB,mBAAA,EAAmB,GAAA;AAAA,iBAEhB,oBAAA,CAAqB,CAAA,EAAG,YAAA,EAAc,OAAA,EAAS,gBAAA;;;UCmB9C,WAAA;EACf,IAAA;;EAEA,OAAA;AAAA;;iBAIc,YAAA,CAAa,IAAA,WAAe,WAAA;;ALnC5C;;;;;;;;;iBKuEgB,YAAA,CAAa,IAAA;;;;;;cAahB,aAAA;;;cC7FA,QAAA;;;UC0EI,aAAA;;EAEf,QAAA;EPxEe;EO0Ef,QAAA;AAAA;AAAA,iBAGc,YAAA,CAAa,IAAA,WAAe,aAAA;;iBAiB5B,cAAA,CAAe,IAAA;;;iBCrFf,gBAAA,CAAiB,KAAA,EAAO,SAAA,GAAY,UAAA;;iBAqM9B,SAAA,CAAU,MAAA,EAAQ,UAAA,EAAY,GAAA,WAAc,OAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/css.ts","../src/util.ts","../src/layouts.ts","../src/components.ts","../src/seo.ts","../src/alternates.ts","../src/embed.ts","../src/ui-strings.ts","../src/balance.ts","../src/index.ts"],"mappings":";;;;;AAYA;;;;;AAKA;AAAA,UALiB,WAAA;EACf,IAAA;EACA,IAAA;AAAA;AAAA,UAGe,QAAA;EACf,IAAA;EACA,KAAA;EA8Dc;EA5Dd,IAAA;EAmEsB;;;;;;EA5DtB,MAAA,GAAS,MAAA;EAKT;;;;EAAA,GAAA;EAWA;EATA,KAAA;EACA,MAAA;EACA,YAAA;EACA,WAAA;EAWA;;;;;EALA,KAAA,GAAQ,KAAA,UAAe,WAAA;EAsBvB;EApBA,QAAA;EACA,OAAA;EAwBA;EAtBA,YAAA;EAwBA;;;;EAnBA,WAAA;EAkCsB;;AAWxB;;;EAvCE,KAAA;EAyCA;EAvCA,UAAA,GAAa,SAAA;EA2Cb;EAzCA,SAAA;EAyCK;EAvCL,YAAA;EACA,WAAA;;EAEA,OAAA;EA0CO;EAxCP,KAAA,GAAQ,MAAA;EA4DG;EA1DX,MAAA;EAsEc;;;;;;;EA9Dd,WAAA,GAAc,UAAA;EAoCN;;;;;;EA7BR,KAAA,GAAQ,cAAA;AAAA;;;;;;;;AA2DV;UAhDiB,WAAA;;EAEf,QAAA;EAiDI;EA/CJ,OAAA;EAwDwB;EAtDxB,KAAA;AAAA;;UAIe,SAAA;EACf,MAAA,EAAQ,MAAA;EACR,KAAA,EAAO,QAAA;EAqDH;AAMN;;;;EArDE,KAAA,GAAQ,MAAA;EA6DM;EA3Dd,GAAA;EA6DU;;;;;;;;;EAnDV,aAAA;EAmDyB;EAjDzB,QAAA,GAAW,WAAA;EAiDuB;EA/ClC,KAAA;EAmD0B;EAjD1B,WAAA;EAkDA;EAhDA,KAAA;EAoDe;EAlDf,UAAA,GAAa,SAAA;;EAEb,SAAA;EAkDA;EAhDA,WAAA,GAAc,UAAA;AAAA;;UAIC,UAAA;EACf,IAAA;EA6E4B;EA3E5B,IAAA;AAAA;AA8EF;;;;;;AAAA,UArEiB,SAAA;EA+EL;EA7EV,KAAA;EACA,MAAA;EAmEA;EAjEA,IAAA;AAAA;AAAA,UAMe,WAAA;EA8Df;EA5DA,KAAA,GAAQ,QAAA;EAkER;;;;AAUF;EAtEE,WAAA,GAAc,MAAA,SAAe,QAAA;;EAE7B,OAAA,GAAU,MAAA,SAAe,SAAA;AAAA;;UAIV,WAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,WAAA;;EAEf,WAAA;EC/K4B;EDiL5B,QAAA;EChLA;;AAMF;;;;;EDkLE,GAAA;EClLwD;;;;;;ACtB1D;;;;;AAgBA;;EFsME,OAAA;EEtMgC;;AAqClC;;;;;;;EF2KE,SAAA;IAAc,GAAA;IAAc,OAAA;EAAA;AAAA;AAAA,UAGb,SAAA;EACf,EAAA,EAAI,SAAA;EACJ,OAAA,GAAU,WAAA;EACV,GAAA,GAAM,WAAA;EACN,OAAA,GAAU,WAAA;EGnML;AAOP;;;;EHkME,OAAA,GAAU,cAAA;AAAA;;;AIrNZ;;;UJ+NiB,UAAA;EACf,KAAA,EAAO,MAAA;EACP,QAAA;AAAA;;;UC1Oe,aAAA;EACf,OAAA;EACA,OAAA;AAAA;AAAA,iBAKc,aAAA,CAAc,IAAA,UAAc,GAAA,WAAc,aAAA;;;;iBCtB1C,UAAA,CAAW,EAAA;;AFW3B;;;;;AAKA;;iBEAgB,iBAAA,CAAkB,OAAA;;iBAqClB,UAAA,CAAW,KAAA;;;UCtBV,QAAA;EACf,EAAA;EACA,IAAA;EACA,OAAA;;EAEA,KAAA;AAAA;;iBAOc,gBAAA,CAAiB,IAAA;;;;cCnBpB,mBAAA,EAAmB,GAAA;AAAA,iBAEhB,oBAAA,CAAqB,CAAA,EAAG,YAAA,EAAc,OAAA,EAAS,gBAAA;;;;;;AJf/D;;iBKgJgB,WAAA,CAAY,IAAA;AAAA,UAkBX,WAAA;EACf,IAAA;EL9Je;EKgKf,OAAA;;EAEA,IAAA;AAAA;;iBAIc,YAAA,CAAa,IAAA,WAAe,WAAA;;;;;;;;;;;iBA8C5B,YAAA,CAAa,IAAA;;;;;;cAahB,aAAA;;;UC3NI,SAAA;ENXA;EMaf,IAAA;;EAEA,IAAA;AAAA;ANVF;;;;;AAAA,iBMkBgB,SAAA,CAAU,OAAA,UAAiB,MAAA,EAAQ,MAAA;AAAA,UAuBlC,gBAAA;ENaP;EMXR,OAAA,EAAS,WAAA;EACT,QAAA;AAAA;;;;;;;;iBAYc,cAAA,CAAe,MAAA,EAAQ,UAAA,IAAc,OAAA,EAAS,WAAA,EAAa,UAAA,WAAqB,gBAAA;;;cCjEnF,QAAA;;;;cCSA,cAAA;ARLb;AAAA,cQQa,kBAAA;EAAA;;;;;;;;;;;;;;;;;;;;;;;;KA0BD,WAAA,gBAA2B,kBAAA;;;;;cAM1B,gBAAA,EAAkB,eAAA;;;UC8Bd,aAAA;;EAEf,QAAA;ETxEe;ES0Ef,QAAA;AAAA;AAAA,iBAGc,YAAA,CAAa,IAAA,WAAe,aAAA;;iBAiB5B,cAAA,CAAe,IAAA;;;iBC1Ef,gBAAA,CAAiB,KAAA,EAAO,SAAA,GAAY,UAAA;;iBAqQ9B,SAAA,CAAU,MAAA,EAAQ,UAAA,EAAY,GAAA,WAAc,OAAA"}