@contentrain/emitter-astro 0.17.0 → 0.18.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 +34 -1
- package/dist/index.d.mts +91 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +223 -27
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -185,6 +185,39 @@ The feed lists every post, as WordPress's does — a post the source kept out of
|
|
|
185
185
|
|
|
186
186
|
**Archive feeds.** WordPress serves a feed for every archive page — `/category/news/feed/`, `/tag/x/feed/`, `/author/ada/feed/`. Every dynamic archive route with a query in the site's default language (not its paginated continuation) gets one per page it builds, at `<archive>/feed.xml` (`src/pages/<archive>/feed.xml.ts`): the 10 newest posts that page lists, each at its own address, titled with the page's title. Each archive page links its feed in its head, and `<archive>/feed/` gets a 301 to it in `astro.config` and the host's redirect file, as the main feed does. The feed redirects pass the checks the site's own rules do — none over a page the site builds or a case twin of one, and a source rule for the same address wins — and in a host file with a rule limit they come after the site's own rules, `/feed/` first, so an overflow drops a feed redirect, which the warning counts. The template head's own archive feed link names one term's feed on every page, so it is removed with the other feed links the site does not build (comments, Atom). `options.feed: false` turns these off with the main feed.
|
|
187
187
|
|
|
188
|
+
## Unpublished entries
|
|
189
|
+
|
|
190
|
+
The content store keeps every entry the source had; only the published ones are
|
|
191
|
+
built. `EmitPost.status` is the entry's `EntryMeta.status` (unset reads as
|
|
192
|
+
`published`) and `publish_at` its `EntryMeta.publish_at` — a WordPress post
|
|
193
|
+
scheduled for later is imported as `published` with a `publish_at` ahead. An
|
|
194
|
+
entry is built when its status is `published` and its `publish_at`, if set, is
|
|
195
|
+
not after `options.now` (default: the time of the emit). `EmitPost.visibility`
|
|
196
|
+
(the store's `visibility` field) other than `public` — a password-protected or
|
|
197
|
+
private post — is never built, whatever its status. Every other entry — a
|
|
198
|
+
draft, one in review, rejected or archived, one scheduled for later, one whose
|
|
199
|
+
`publish_at` is not a date, one that is not public — is held back before anything reads the content: no
|
|
200
|
+
page, sitemap line, feed item, llms.txt link, list card or hreflang alternate.
|
|
201
|
+
`EmitResult.withheld.entries` names each with its reason, and a warning counts
|
|
202
|
+
them.
|
|
203
|
+
|
|
204
|
+
An entry with no `status` is published, as before statuses existed — which
|
|
205
|
+
fails open if a producer forgets to pass one. `options.requireStatus: true`
|
|
206
|
+
turns that around: every entry (in posts, collections and list items) must
|
|
207
|
+
carry a status, and one without it is held back (`status_missing`) and
|
|
208
|
+
reported. A producer that reads statuses from the content store sets it.
|
|
209
|
+
|
|
210
|
+
The data files hold only what is live at the emit, so a scheduled entry is
|
|
211
|
+
built by the first emit after its time; rebuilding the same files does not
|
|
212
|
+
publish it.
|
|
213
|
+
|
|
214
|
+
A link on a published page (its body or `excerpt_html`, on its own page or as a
|
|
215
|
+
list card) to a held-back entry would 404: the anchor is replaced by its text
|
|
216
|
+
and listed in `EmitResult.withheld.links`. A link to an address a published
|
|
217
|
+
entry still builds is left alone. A redirect (`redirects` or `hostRedirects`)
|
|
218
|
+
whose target is a held-back entry is not written; it is returned in
|
|
219
|
+
`EmitResult.redirects.manual` with the reason.
|
|
220
|
+
|
|
188
221
|
## Binding the runtime later
|
|
189
222
|
|
|
190
223
|
`src/data/runtime.json` is the only place the binding lives; the components
|
|
@@ -285,4 +318,4 @@ literal routes.
|
|
|
285
318
|
|
|
286
319
|
## Checking the emitted types
|
|
287
320
|
|
|
288
|
-
The unit suite asserts what the emitter writes; `pnpm --filter @contentrain/emitter-astro test:astro` (after `pnpm build`) checks it the way a migrated site's build does. It emits a site with every kind of page and endpoint — posts, pages, nested category and tag archives with their feeds, an author profile, a list, a static page, the feed, llms.txt, redirects — once with trailing slashes and once without, installs it with npm from its own `package.json`, and runs `astro check`. A pass is a result naming 0 errors. CI and the release workflow run it on every change.
|
|
321
|
+
The unit suite asserts what the emitter writes; `pnpm --filter @contentrain/emitter-astro test:astro` (after `pnpm build`) checks it the way a migrated site's build does. It emits a site with every kind of page and endpoint — posts, pages, nested category and tag archives with their feeds, an author profile, a list, a static page, the feed, llms.txt, redirects, and a draft, an entry in review, a post scheduled for later and password-protected and private posts beside published ones — once with trailing slashes and once without, installs it with npm from its own `package.json` the way a production host does (`NODE_ENV=production`, `CI` set, no terminal), and runs `astro check` and then the site's own `npm run build`. A pass is a result naming 0 errors and a completed build; Astro's "requires @astrojs/check" notice fails it even though Astro exits 0 there. The first site is then built with `astro build`, and the output is read: the published posts have pages, sitemap lines and feed items, the held-back ones have none (nor llms.txt links or list cards), no file holds the password-protected post's text, and the live page's links to held-back entries are text. CI and the release workflow run it on every change.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ComponentDef, EntrySourceRef, ModelDefinition, ProjectIR, RawRedirect, RawRedirect as RawRedirect$1, RawSeoEntry, RouteModel, RuntimeBinding, RuntimeBinding as RuntimeBinding$1, SeoProvider } from "@contentrain/types";
|
|
1
|
+
import { ComponentDef, ContentStatus, 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
|
/**
|
|
@@ -120,6 +120,49 @@ interface EmitPost {
|
|
|
120
120
|
noindex?: boolean;
|
|
121
121
|
/** The source page was `nofollow`: `<meta name="robots" content="nofollow">`. */
|
|
122
122
|
nofollow?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* The entry's workflow status in the content store (`EntryMeta.status`).
|
|
125
|
+
* Only `published` is built; unset reads as published unless
|
|
126
|
+
* `EmitOptions.requireStatus` is set. Any other status
|
|
127
|
+
* keeps the entry out of the site — no page, sitemap line, feed item,
|
|
128
|
+
* llms.txt link or list card — and `EmitResult.withheld` names it.
|
|
129
|
+
*/
|
|
130
|
+
status?: ContentStatus;
|
|
131
|
+
/**
|
|
132
|
+
* When a published entry goes live (`EntryMeta.publish_at`, ISO 8601) — a
|
|
133
|
+
* WordPress post scheduled for later. Until then it is held back like a
|
|
134
|
+
* draft; compared with `EmitOptions.now`. A value that is not a date holds
|
|
135
|
+
* the entry back too.
|
|
136
|
+
*/
|
|
137
|
+
publish_at?: string;
|
|
138
|
+
/**
|
|
139
|
+
* Who may read the entry (the store's `visibility` field — WordPress
|
|
140
|
+
* `public`, `private`, or `password` for a password-protected post). Anything
|
|
141
|
+
* but `public` is never built, whatever `status` says: no page, sitemap
|
|
142
|
+
* line, feed item, list card, share card or link to it. Unset reads as public.
|
|
143
|
+
*/
|
|
144
|
+
visibility?: 'public' | 'private' | 'password' | (string & {});
|
|
145
|
+
}
|
|
146
|
+
/** An entry the build leaves out because it is not published yet. */
|
|
147
|
+
interface WithheldEntry {
|
|
148
|
+
collection: string;
|
|
149
|
+
slug: string;
|
|
150
|
+
status?: ContentStatus;
|
|
151
|
+
publish_at?: string;
|
|
152
|
+
visibility?: string;
|
|
153
|
+
/**
|
|
154
|
+
* `visibility`: password-protected or private · `status`: not published ·
|
|
155
|
+
* `status_missing`: no status, with `EmitOptions.requireStatus` ·
|
|
156
|
+
* `scheduled`: publish_at is ahead · `publish_at_invalid`: publish_at is not a date.
|
|
157
|
+
*/
|
|
158
|
+
reason: 'visibility' | 'status' | 'status_missing' | 'scheduled' | 'publish_at_invalid';
|
|
159
|
+
}
|
|
160
|
+
/** A link on a published page to a held-back entry, kept as its text. */
|
|
161
|
+
interface WithheldLink {
|
|
162
|
+
/** The page it was on (site-root path), or `<collection>/<slug>` when no route builds it. */
|
|
163
|
+
page: string;
|
|
164
|
+
/** The address it pointed at. */
|
|
165
|
+
href: string;
|
|
123
166
|
}
|
|
124
167
|
/**
|
|
125
168
|
* One block of a list: a template, optional wrapper, and how many items it takes.
|
|
@@ -345,6 +388,20 @@ interface EmitOptions {
|
|
|
345
388
|
* Cloudflare Pages site must name its host (no forced rules there).
|
|
346
389
|
*/
|
|
347
390
|
redirectHost?: 'netlify' | 'cloudflare' | 'vercel';
|
|
391
|
+
/**
|
|
392
|
+
* The moment `EmitPost.publish_at` is compared with, ISO 8601. Default: the
|
|
393
|
+
* time of the emit. The data files hold only what is live then, so a
|
|
394
|
+
* scheduled entry is built by the first emit after its time.
|
|
395
|
+
*/
|
|
396
|
+
now?: string;
|
|
397
|
+
/**
|
|
398
|
+
* Hold back every entry — in posts, collections and list items — that
|
|
399
|
+
* carries no `EmitPost.status`, and say so. Default false: an entry with
|
|
400
|
+
* no status is published, as before statuses existed. A producer that
|
|
401
|
+
* reads statuses from the content store sets it, so an entry it failed to
|
|
402
|
+
* give one fails closed instead of going out as published.
|
|
403
|
+
*/
|
|
404
|
+
requireStatus?: boolean;
|
|
348
405
|
}
|
|
349
406
|
interface EmitInput {
|
|
350
407
|
ir: ProjectIR;
|
|
@@ -401,6 +458,15 @@ interface EmitResult {
|
|
|
401
458
|
*/
|
|
402
459
|
host_over_limit: string[];
|
|
403
460
|
};
|
|
461
|
+
/**
|
|
462
|
+
* Present when entries were held back (`EmitPost.status`, `publish_at`):
|
|
463
|
+
* each one, and each link to one that a published page carried — kept as
|
|
464
|
+
* its text. Redirects to a held-back entry are in `redirects.manual`.
|
|
465
|
+
*/
|
|
466
|
+
withheld?: {
|
|
467
|
+
entries: WithheldEntry[];
|
|
468
|
+
links: WithheldLink[];
|
|
469
|
+
};
|
|
404
470
|
}
|
|
405
471
|
//#endregion
|
|
406
472
|
//#region src/css.d.ts
|
|
@@ -602,7 +668,7 @@ interface AlternatesResult {
|
|
|
602
668
|
declare function withAlternates(routes: RouteModel[], content: EmitContent, siteLocale: string): AlternatesResult;
|
|
603
669
|
//#endregion
|
|
604
670
|
//#region src/embed.d.ts
|
|
605
|
-
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, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\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";
|
|
671
|
+
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, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\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 const locale = host.dataset.locale\n return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', ...(locale ? { locale } : {}) }\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";
|
|
606
672
|
//#endregion
|
|
607
673
|
//#region src/ui-strings.d.ts
|
|
608
674
|
/** Where the dictionary lives in an emitted project, relative to its root. */
|
|
@@ -655,6 +721,28 @@ declare function balanceWarning(html: string): string | null;
|
|
|
655
721
|
/** Site-root paths (directory format, `/a/b/`) of every noindex page, sorted. */
|
|
656
722
|
declare function noindexPaths(routes: RouteModel[], content: EmitContent): string[];
|
|
657
723
|
//#endregion
|
|
724
|
+
//#region src/published.d.ts
|
|
725
|
+
/**
|
|
726
|
+
* Why an entry is not built, or null when it is. `now` is epoch ms. With
|
|
727
|
+
* `requireStatus` an entry that carries no status is held back: the producer
|
|
728
|
+
* said it passes every entry's status, so a missing one is a gap, not a
|
|
729
|
+
* published entry.
|
|
730
|
+
*/
|
|
731
|
+
declare function holdReason(post: Pick<EmitPost, 'status' | 'publish_at' | 'visibility'>, now: number, requireStatus?: boolean): WithheldEntry['reason'] | null;
|
|
732
|
+
interface PublishedContent {
|
|
733
|
+
content: EmitContent;
|
|
734
|
+
withheld: WithheldEntry[];
|
|
735
|
+
links: WithheldLink[];
|
|
736
|
+
/** The addresses held-back entries would have had and no published entry builds (`/a/b/`). */
|
|
737
|
+
gone: Set<string>;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* The content with every unpublished entry removed — from its collection and
|
|
741
|
+
* from the list pages that carry it as an item — and links to them unwrapped.
|
|
742
|
+
* Content with nothing held back is returned as given.
|
|
743
|
+
*/
|
|
744
|
+
declare function publishedContent(routes: RouteModel[], content: EmitContent, now: number, siteUrl?: string, requireStatus?: boolean): PublishedContent;
|
|
745
|
+
//#endregion
|
|
658
746
|
//#region src/redirects.d.ts
|
|
659
747
|
/** The statuses written to `astro.config`; any other is a manual rule. */
|
|
660
748
|
declare const REDIRECT_STATUSES: readonly [301, 302, 307, 308];
|
|
@@ -731,5 +819,5 @@ declare function emitAstroProject(input: EmitInput): EmitResult;
|
|
|
731
819
|
/** Write an EmitResult to disk. Separate from emit so the core stays pure. */
|
|
732
820
|
declare function writeEmit(result: EmitResult, dir: string): Promise<void>;
|
|
733
821
|
//#endregion
|
|
734
|
-
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 };
|
|
822
|
+
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 PublishedContent, 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, type WithheldEntry, type WithheldLink, astroRedirectsConfig, balanceWarning, bodySeoLeaks, builtAddresses, checkBalance, componentMarkers, emitAstroProject, entryPath, holdReason, hostRedirectFiles, isRuntimeImplemented, linkSources, noindexPaths, pascalCase, patternToPagePath, planRedirects, publishedContent, publisherIdOf, rewriteFeedLinks, seoFromRawEntry, stableJson, stripSeoTags, websiteIdOf, withAlternates, wrapLegacyCss, writeEmit };
|
|
735
823
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/css.ts","../src/feed.ts","../src/util.ts","../src/layouts.ts","../src/components.ts","../src/seo.ts","../src/seo-entry.ts","../src/alternates.ts","../src/embed.ts","../src/ui-strings.ts","../src/balance.ts","../src/noindex.ts","../src/redirects.ts","../src/index.ts"],"mappings":";;;;;AAYA;;;;;AAKA;AAAA,UALiB,WAAA;EACf,IAAA;EACA,IAAA;AAAA;AAAA,UAGe,QAAA;EACf,IAAA;EACA,KAAA;EA8DU;EA5DV,IAAA;EAuFc;;;;;;EAhFd,MAAA,GAAS,MAAA;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/css.ts","../src/feed.ts","../src/util.ts","../src/layouts.ts","../src/components.ts","../src/seo.ts","../src/seo-entry.ts","../src/alternates.ts","../src/embed.ts","../src/ui-strings.ts","../src/balance.ts","../src/noindex.ts","../src/published.ts","../src/redirects.ts","../src/index.ts"],"mappings":"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"}
|