@contentrain/emitter-astro 0.18.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/dist/index.d.mts CHANGED
@@ -668,7 +668,7 @@ interface AlternatesResult {
668
668
  declare function withAlternates(routes: RouteModel[], content: EmitContent, siteLocale: string): AlternatesResult;
669
669
  //#endregion
670
670
  //#region src/embed.d.ts
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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n\n// \u2500\u2500\u2500 Transport \u2500\u2500\u2500\n\n/** `base` without a trailing slash, segments URL-encoded, empty query values dropped. */\nexport function publicUrl(base: string, segments: string[], query?: Record<string, string | number | undefined>): string {\n const path = segments.map((s) => encodeURIComponent(s)).join('/')\n const params = new URLSearchParams()\n for (const [key, value] of Object.entries(query ?? {})) {\n if (value !== undefined && value !== '') params.set(key, String(value))\n }\n const qs = params.toString()\n return base.replace(/\\/+$/, '') + '/' + path + (qs ? '?' + qs : '')\n}\n\nasync function failure(res: Response): Promise<EmbedError> {\n const text = await res.text().catch(() => '')\n let message = text || 'Request failed'\n let code: string | undefined\n try {\n const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown; data?: { code?: unknown } }\n const m = parsed.message ?? parsed.statusMessage\n if (typeof m === 'string' && m) message = m\n if (typeof parsed.data?.code === 'string') code = parsed.data.code\n } catch {\n // not JSON \u2014 the raw text is the message\n }\n return new EmbedError(res.status, message, code)\n}\n\nexport async function getJson<T>(url: string): Promise<T> {\n const res = await fetch(url)\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport async function postJson<T>(url: string, body: unknown): Promise<T> {\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport const formsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/forms/v1'\nexport const commentsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/comments/v1'\n\n/** The hidden input Cloudflare Turnstile adds to the enclosing form. */\nexport const CAPTCHA_FIELD = 'cf-turnstile-response'\n\n// \u2500\u2500\u2500 Forms \u2500\u2500\u2500\n\nexport interface FieldDef {\n type: string\n required?: boolean\n label?: string\n options?: string[]\n min?: number\n max?: number\n pattern?: string\n default?: unknown\n}\n\nexport interface FormConfig {\n modelId: string\n locale: string\n /** Exposed fields only, keyed by field id. */\n fields: Record<string, FieldDef>\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n successMessage?: string\n honeypotField: string | null\n}\n\nexport interface FieldError {\n field: string\n message: string\n}\n\nexport interface FormSubmitResult {\n success: boolean\n message?: string\n errors?: FieldError[]\n}\n\n/** The documented request body: values under `data`, control fields beside it. */\nexport interface FormPayload {\n data: Record<string, unknown>\n captchaToken?: string\n _hp?: string\n}\n\nexport function fetchFormConfig(rt: Runtime, model: string): Promise<FormConfig> {\n return getJson<FormConfig>(publicUrl(formsRoot(rt), [rt.project_id, model, 'config']))\n}\n\nexport function submitForm(rt: Runtime, model: string, payload: FormPayload): Promise<FormSubmitResult> {\n return postJson<FormSubmitResult>(publicUrl(formsRoot(rt), [rt.project_id, model, 'submit']), payload)\n}\n\nfunction coerce(def: FieldDef, value: string): unknown {\n switch (def.type) {\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return value === '' ? '' : Number(value)\n case 'boolean':\n return value === 'on' || value === 'true'\n default:\n return value\n }\n}\n\n/**\n * Form entries (`new FormData(form)`) \u2192 request body. Only exposed fields\n * reach `data`; the captcha token and the honeypot value travel beside it, so\n * a control field can never collide with a model field. Files are skipped \u2014\n * uploads are not part of the public contract.\n */\nexport function formPayload(\n entries: Iterable<[string, unknown]>,\n config: { fields: Record<string, FieldDef>; honeypotField: string | null },\n): FormPayload {\n const payload: FormPayload = { data: {} }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (config.honeypotField && name === config.honeypotField) {\n payload._hp = raw\n continue\n }\n const def = config.fields[name]\n if (!def) continue\n payload.data[name] = coerce(def, raw)\n }\n return payload\n}\n\nexport function labelFor(id: string, def: FieldDef): string {\n if (def.label) return def.label\n const words = id.replace(/[_-]+/g, ' ').trim()\n return words.charAt(0).toUpperCase() + words.slice(1)\n}\n\n/** One input for one exposed field, chosen by field type. */\nexport function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string {\n const common =\n ' id=\"' + esc(prefix + '-' + id) + '\" name=\"' + esc(id) + '\"' +\n (def.required ? ' required' : '') +\n (def.pattern ? ' pattern=\"' + esc(def.pattern) + '\"' : '')\n const range =\n (def.min !== undefined ? ' min=\"' + esc(def.min) + '\"' : '') +\n (def.max !== undefined ? ' max=\"' + esc(def.max) + '\"' : '')\n const length = def.max !== undefined ? ' maxlength=\"' + esc(def.max) + '\"' : ''\n switch (def.type) {\n case 'text':\n case 'markdown':\n case 'richtext':\n case 'code':\n return '<textarea' + common + length + ' rows=\"5\"></textarea>'\n case 'select': {\n const blank = def.required ? '' : '<option value=\"\"></option>'\n const options = (def.options ?? []).map((o) => '<option value=\"' + esc(o) + '\">' + esc(o) + '</option>').join('')\n return '<select' + common + '>' + blank + options + '</select>'\n }\n case 'boolean':\n return '<input type=\"checkbox\"' + common + ' />'\n case 'number':\n case 'integer':\n case 'rating':\n return '<input type=\"number\"' + common + range + (def.type === 'integer' ? ' step=\"1\"' : '') + ' />'\n case 'decimal':\n case 'percent':\n return '<input type=\"number\" step=\"any\"' + common + range + ' />'\n case 'email':\n return '<input type=\"email\"' + common + length + ' />'\n case 'url':\n return '<input type=\"url\"' + common + length + ' />'\n case 'phone':\n return '<input type=\"tel\"' + common + length + ' />'\n case 'date':\n return '<input type=\"date\"' + common + ' />'\n case 'datetime':\n return '<input type=\"datetime-local\"' + common + ' />'\n case 'color':\n return '<input type=\"color\"' + common + ' />'\n default:\n return '<input type=\"text\"' + common + length + ' />'\n }\n}\n\nexport function honeypotHtml(field: string | null): string {\n if (!field) return ''\n return (\n '<p class=\"cr-hp\" aria-hidden=\"true\" style=\"position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden\">' +\n '<label>' + esc(strings.honeypot) + ' <input type=\"text\" name=\"' + esc(field) + '\" tabindex=\"-1\" autocomplete=\"off\" /></label></p>'\n )\n}\n\nexport function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null): string {\n if (captcha !== 'turnstile' || !siteKey) return ''\n return '<div class=\"cf-turnstile\" data-sitekey=\"' + esc(siteKey) + '\"></div>'\n}\n\nexport function errorsHtml(errors: FieldError[]): string {\n return (\n '<ul class=\"cr-errors\" role=\"alert\">' +\n errors.map((e) => '<li data-field=\"' + esc(e.field) + '\">' + esc(e.field) + ': ' + esc(e.message) + '</li>').join('') +\n '</ul>'\n )\n}\n\n/** The whole form for a public form config: one control per exposed field, honeypot, captcha, submit. */\nexport function formHtml(config: FormConfig, prefix = 'cr-field'): string {\n const fields = Object.entries(config.fields)\n .map(([id, def]) => {\n const control = fieldControl(id, def, prefix)\n const label = '<label for=\"' + esc(prefix + '-' + id) + '\">' + esc(labelFor(id, def)) + (def.required ? ' <span aria-hidden=\"true\">*</span>' : '') + '</label>'\n return '<p class=\"cr-field cr-field--' + esc(def.type) + '\">' + (def.type === 'boolean' ? control + ' ' + label : label + control) + '</p>'\n })\n .join('')\n return (\n '<form class=\"cr-form\" method=\"post\" data-model=\"' + esc(config.modelId) + '\">' +\n fields +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.send) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// \u2500\u2500\u2500 Comments \u2500\u2500\u2500\n\nexport interface CommentAuthor {\n name: string\n url: string | null\n isModerator: boolean\n}\n\nexport interface PublicComment {\n id: string\n parentId: string | null\n depth: number\n author: CommentAuthor\n /** Plain text. */\n body: string\n type: 'comment' | 'pingback' | 'trackback'\n createdAt: string\n replies: PublicComment[]\n}\n\nexport interface ThreadConfig {\n closed: boolean\n requireApproval: boolean\n requireEmail: boolean\n maxDepth: number\n maxBodyLength: number\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n honeypotField: string | null\n}\n\nexport interface CommentThread {\n entry: { modelId: string; entryId: string; locale: string }\n config: ThreadConfig\n comments: PublicComment[]\n total: number\n page: number\n limit: number\n}\n\nexport interface CommentSubmitBody {\n author: { name: string; email?: string; url?: string }\n body: string\n /** null for a root comment \u2014 sent explicitly, as the provider's own fixture does. */\n parentId: string | null\n captchaToken?: string\n _hp?: string\n}\n\nexport interface CommentSubmitResult {\n success: boolean\n status?: 'pending' | 'approved'\n comment?: PublicComment\n errors?: FieldError[]\n}\n\nexport interface ThreadQuery {\n page?: number\n limit?: number\n sort?: 'oldest' | 'newest'\n}\n\nexport function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise<CommentThread> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], {\n locale: entry.locale,\n page: query?.page,\n limit: query?.limit,\n sort: query?.sort,\n })\n return getJson<CommentThread>(url)\n}\n\nexport function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise<CommentSubmitResult> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale })\n return postJson<CommentSubmitResult>(url, body)\n}\n\n/** Comment-form entries \u2192 the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */\nexport function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody {\n const values: Record<string, string> = {}\n const payload: CommentSubmitBody = { author: { name: '' }, body: '', parentId: null }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (honeypotField && name === honeypotField) {\n payload._hp = raw\n continue\n }\n values[name] = raw\n }\n payload.author.name = (values.author_name ?? '').trim()\n if (values.author_email?.trim()) payload.author.email = values.author_email.trim()\n if (values.author_url?.trim()) payload.author.url = values.author_url.trim()\n payload.body = (values.body ?? '').trim()\n payload.parentId = values.parent_id?.trim() || null\n return payload\n}\n\n/** Plain text \u2192 paragraphs; blank lines separate paragraphs, single newlines break lines. */\nexport function bodyHtml(text: string): string {\n return text\n .split(/\\n{2,}/)\n .map((p) => p.trim())\n .filter(Boolean)\n .map((p) => '<p>' + esc(p).replace(/\\n/g, '<br />') + '</p>')\n .join('')\n}\n\nexport function hasMore(thread: CommentThread): boolean {\n return thread.page * thread.limit < thread.total\n}\n\n/** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */\nexport function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string {\n const author = c.author.url\n ? '<a href=\"' + esc(c.author.url) + '\" rel=\"nofollow ugc noopener\" target=\"_blank\">' + esc(c.author.name) + '</a>'\n : esc(c.author.name)\n const badge = c.author.isModerator ? ' <span class=\"cr-moderator\">' + esc(strings.moderator) + '</span>' : ''\n const date = '<time datetime=\"' + esc(c.createdAt) + '\">' + esc(c.createdAt.slice(0, 10)) + '</time>'\n const canReply = !config.closed && c.depth < config.maxDepth\n const reply = canReply\n ? '<button type=\"button\" class=\"cr-reply\" data-parent=\"' + esc(c.id) + '\" data-author=\"' + esc(c.author.name) + '\">' + esc(strings.reply) + '</button>'\n : ''\n const replies = c.replies.length\n ? '<ol class=\"cr-replies\">' + c.replies.map((r) => commentHtml(r, config)).join('') + '</ol>'\n : ''\n return (\n '<li class=\"cr-comment cr-comment--' + esc(c.type) + '\" id=\"cr-comment-' + esc(c.id) + '\" data-depth=\"' + esc(c.depth) + '\">' +\n '<div class=\"cr-comment-meta\"><span class=\"cr-comment-author\">' + author + '</span>' + badge + ' ' + date + '</div>' +\n '<div class=\"cr-comment-body\">' + bodyHtml(c.body) + '</div>' +\n (reply ? '<div class=\"cr-comment-actions\">' + reply + '</div>' : '') +\n replies +\n '</li>'\n )\n}\n\nexport function threadHtml(thread: CommentThread): string {\n if (!thread.comments.length) return '<p class=\"cr-empty\">' + esc(strings.noComments) + '</p>'\n return '<ol class=\"cr-comment-list\">' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '</ol>'\n}\n\n/** The comment form; a reply carries its parent in a hidden input. */\nexport function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string {\n const emailLabel = esc(strings.email) + (config.requireEmail ? ' <span aria-hidden=\"true\">*</span>' : '') + ' <small>' + esc(strings.emailNote) + '</small>'\n return (\n '<form class=\"cr-comment-form\" method=\"post\">' +\n '<input type=\"hidden\" name=\"parent_id\" value=\"\" />' +\n '<p class=\"cr-replying\" hidden><span class=\"cr-replying-to\"></span> <button type=\"button\" class=\"cr-cancel-reply\">' + esc(strings.cancel) + '</button></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-body\">' + esc(strings.comment) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<textarea id=\"' + prefix + '-body\" name=\"body\" required maxlength=\"' + esc(config.maxBodyLength) + '\" rows=\"5\"></textarea></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-name\">' + esc(strings.name) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<input type=\"text\" id=\"' + prefix + '-name\" name=\"author_name\" required maxlength=\"120\" autocomplete=\"name\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-email\">' + emailLabel + '</label>' +\n '<input type=\"email\" id=\"' + prefix + '-email\" name=\"author_email\"' + (config.requireEmail ? ' required' : '') + ' maxlength=\"254\" autocomplete=\"email\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-url\">' + esc(strings.website) + '</label>' +\n '<input type=\"url\" id=\"' + prefix + '-url\" name=\"author_url\" maxlength=\"2048\" autocomplete=\"url\" /></p>' +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.postComment) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// \u2500\u2500\u2500 Turnstile (explicit render, so widgets added after load still render) \u2500\u2500\u2500\n\ninterface TurnstileApi {\n render: (el: Element, options: { sitekey: string }) => string\n reset: (id?: string) => void\n}\n\nconst TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady'\nconst widgets = new WeakMap<Element, string>()\nconst waiting: Element[] = []\n\nfunction turnstileApi(): TurnstileApi | undefined {\n return (globalThis as { turnstile?: TurnstileApi }).turnstile\n}\n\nfunction renderWidget(api: TurnstileApi, el: Element): void {\n if (widgets.has(el)) return\n widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' }))\n}\n\n/** Render every `.cf-turnstile` under `root`, loading the Turnstile script once on first use. */\nexport function renderCaptcha(root: ParentNode): void {\n const els = Array.from(root.querySelectorAll('.cf-turnstile'))\n if (!els.length) return\n const api = turnstileApi()\n if (api) {\n for (const el of els) renderWidget(api, el)\n return\n }\n waiting.push(...els)\n if (document.querySelector('script[data-cr-turnstile]')) return\n ;(globalThis as Record<string, unknown>).__crTurnstileReady = () => {\n const ready = turnstileApi()\n if (!ready) return\n for (const el of waiting.splice(0)) renderWidget(ready, el)\n }\n const script = document.createElement('script')\n script.src = TURNSTILE_SRC\n script.async = true\n script.defer = true\n script.setAttribute('data-cr-turnstile', '1')\n document.head.appendChild(script)\n}\n\nexport function resetCaptcha(root: ParentNode): void {\n const api = turnstileApi()\n if (!api) return\n for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el))\n}\n\n// \u2500\u2500\u2500 Mounting (the only DOM code) \u2500\u2500\u2500\n\n/** ui-strings dictionary key \u2192 the `strings` entry it sets. */\nexport const STRING_KEYS: ReadonlyArray<readonly [string, keyof typeof strings]> = [\n ['common.loading', 'loading'],\n ['common.failed', 'failed'],\n ['form.send', 'send'],\n ['form.sending', 'sending'],\n ['form.honeypot', 'honeypot'],\n ['comments.title', 'commentsTitle'],\n ['comments.empty', 'noComments'],\n ['comments.closed', 'closed'],\n ['comments.post', 'postComment'],\n ['comments.reply', 'reply'],\n ['comments.replying_to', 'replyingTo'],\n ['comments.cancel', 'cancel'],\n ['comments.load_more', 'loadMore'],\n ['comments.moderator', 'moderator'],\n ['comments.pending', 'pending'],\n ['comments.posted', 'posted'],\n ['comments.field.name', 'name'],\n ['comments.field.email', 'email'],\n ['comments.field.email_note', 'emailNote'],\n ['comments.field.website', 'website'],\n ['comments.field.comment', 'comment'],\n]\n\n/**\n * Take the page's interface text from the host element. A value that is not a\n * non-empty string leaves the default; text that cannot be parsed changes\n * nothing, because a mount that fails over a label is worse than an English one.\n */\nexport function applyStrings(host: HTMLElement): void {\n const raw = host.dataset.strings\n if (!raw) return\n let text: unknown\n try {\n text = JSON.parse(raw)\n } catch {\n return\n }\n if (!text || typeof text !== 'object') return\n for (const [key, name] of STRING_KEYS) {\n const value = (text as Record<string, unknown>)[key]\n if (typeof value === 'string' && value !== '') strings[name] = value\n }\n}\n\nexport function runtimeOf(host: HTMLElement): Runtime {\n return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }\n}\n\nfunction entryOf(host: HTMLElement): EntryRef {\n return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', locale: host.dataset.locale || undefined }\n}\n\nfunction setStatus(form: HTMLFormElement, html: string): void {\n const status = form.querySelector('.cr-status')\n if (status) status.innerHTML = html\n}\n\nfunction busy(form: HTMLFormElement, on: boolean): void {\n const button = form.querySelector<HTMLButtonElement>('button[type=\"submit\"]')\n if (!button) return\n button.disabled = on\n if (on) {\n button.dataset.label = button.textContent ?? ''\n button.textContent = strings.sending\n } else {\n button.textContent = button.dataset.label ?? button.textContent\n }\n}\n\nfunction entriesOf(form: HTMLFormElement): Array<[string, unknown]> {\n const out: Array<[string, unknown]> = []\n new FormData(form).forEach((value, key) => out.push([key, value]))\n return out\n}\n\n/** `<cr-form data-base-url data-project data-model>` \u2192 fetch the config, render, submit. */\nexport async function mountForm(host: HTMLElement): Promise<void> {\n applyStrings(host)\n const rt = runtimeOf(host)\n const model = host.dataset.model ?? ''\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let config: FormConfig\n try {\n config = await fetchFormConfig(rt, model)\n } catch (error) {\n if (isPaymentRequired(error)) return hideUnavailable(host, 'form')\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n host.innerHTML = formHtml(config)\n renderCaptcha(host)\n const form = host.querySelector('form')\n if (!form) return\n form.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitForm(rt, model, formPayload(entriesOf(form), config))\n if (result.success) {\n host.innerHTML = '<p class=\"cr-success\" role=\"status\">' + esc(result.message ?? config.successMessage ?? '') + '</p>'\n return\n }\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n } catch (error) {\n if (isPaymentRequired(error)) return hideUnavailable(host, 'form')\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n\n/** `<cr-comments data-base-url data-project data-model data-entry data-locale>` \u2192 thread + form. */\nexport async function mountComments(host: HTMLElement): Promise<void> {\n applyStrings(host)\n const rt = runtimeOf(host)\n const entry = entryOf(host)\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let thread: CommentThread\n try {\n thread = await fetchThread(rt, entry)\n } catch (error) {\n if (isPaymentRequired(error)) return hideUnavailable(host, 'comments')\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n\n host.innerHTML =\n '<section class=\"cr-comments-section\">' +\n '<h2 class=\"cr-comments-title\">' + esc(strings.commentsTitle) + ' <span class=\"cr-count\">(' + esc(thread.total) + ')</span></h2>' +\n '<div class=\"cr-thread\">' + threadHtml(thread) + '</div>' +\n (hasMore(thread) ? '<p class=\"cr-more\"><button type=\"button\" class=\"cr-load-more\">' + esc(strings.loadMore) + '</button></p>' : '') +\n (thread.config.closed ? '<p class=\"cr-closed\">' + esc(strings.closed) + '</p>' : commentFormHtml(thread.config)) +\n '</section>'\n renderCaptcha(host)\n\n const threadEl = host.querySelector('.cr-thread')\n const form = host.querySelector<HTMLFormElement>('form.cr-comment-form')\n let page = thread.page\n\n host.addEventListener('click', async (event) => {\n const target = event.target as HTMLElement | null\n if (!target) return\n const replyButton = target.closest<HTMLElement>('.cr-reply')\n if (replyButton && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = replyButton.dataset.parent ?? ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n const to = form.querySelector('.cr-replying-to')\n if (to) to.textContent = strings.replyingTo + ' ' + (replyButton.dataset.author ?? '')\n if (replying) replying.hidden = false\n replyButton.closest('.cr-comment')?.appendChild(form)\n form.querySelector<HTMLTextAreaElement>('textarea[name=\"body\"]')?.focus()\n return\n }\n if (target.closest('.cr-cancel-reply') && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n host.querySelector('.cr-comments-section')?.appendChild(form)\n return\n }\n const more = target.closest<HTMLButtonElement>('.cr-load-more')\n if (more && threadEl) {\n more.disabled = true\n try {\n const next = await fetchThread(rt, entry, { page: page + 1 })\n page = next.page\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join(''))\n if (!hasMore(next)) more.closest('.cr-more')?.remove()\n } catch (error) {\n if (isPaymentRequired(error)) return hideUnavailable(host, 'comments')\n // Anything else: the button comes back for another try.\n } finally {\n more.disabled = false\n }\n }\n })\n\n form?.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitComment(rt, entry, commentPayload(entriesOf(form), thread.config.honeypotField))\n if (!result.success) {\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n return\n }\n const parentId = result.comment?.parentId ?? null\n if (result.status === 'approved' && result.comment && threadEl) {\n const html = commentHtml(result.comment, thread.config)\n const parent = parentId ? threadEl.querySelector('#cr-comment-' + parentId) : null\n if (parent) {\n let replies = parent.querySelector(':scope > .cr-replies')\n if (!replies) {\n parent.insertAdjacentHTML('beforeend', '<ol class=\"cr-replies\"></ol>')\n replies = parent.querySelector(':scope > .cr-replies')\n }\n replies?.insertAdjacentHTML('beforeend', html)\n } else {\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', html)\n else threadEl.innerHTML = '<ol class=\"cr-comment-list\">' + html + '</ol>'\n }\n }\n form.reset()\n host.querySelector('.cr-comments-section')?.appendChild(form)\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n resetCaptcha(form)\n setStatus(form, '<p class=\"cr-success\" role=\"status\">' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '</p>')\n } catch (error) {\n if (isPaymentRequired(error)) return hideUnavailable(host, 'comments')\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n";
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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n\n// \u2500\u2500\u2500 Transport \u2500\u2500\u2500\n\n/** `base` without a trailing slash, segments URL-encoded, empty query values dropped. */\nexport function publicUrl(base: string, segments: string[], query?: Record<string, string | number | undefined>): string {\n const path = segments.map((s) => encodeURIComponent(s)).join('/')\n const params = new URLSearchParams()\n for (const [key, value] of Object.entries(query ?? {})) {\n if (value !== undefined && value !== '') params.set(key, String(value))\n }\n const qs = params.toString()\n return base.replace(/\\/+$/, '') + '/' + path + (qs ? '?' + qs : '')\n}\n\nasync function failure(res: Response): Promise<EmbedError> {\n const text = await res.text().catch(() => '')\n let message = text || 'Request failed'\n let code: string | undefined\n try {\n const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown; data?: { code?: unknown } }\n const m = parsed.message ?? parsed.statusMessage\n if (typeof m === 'string' && m) message = m\n if (typeof parsed.data?.code === 'string') code = parsed.data.code\n } catch {\n // not JSON \u2014 the raw text is the message\n }\n return new EmbedError(res.status, message, code)\n}\n\nexport async function getJson<T>(url: string): Promise<T> {\n const res = await fetch(url)\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport async function postJson<T>(url: string, body: unknown): Promise<T> {\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport const formsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/forms/v1'\nexport const commentsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/comments/v1'\n\n/** The hidden input Cloudflare Turnstile adds to the enclosing form. */\nexport const CAPTCHA_FIELD = 'cf-turnstile-response'\n\n// \u2500\u2500\u2500 Forms \u2500\u2500\u2500\n\nexport interface FieldDef {\n type: string\n required?: boolean\n label?: string\n options?: string[]\n min?: number\n max?: number\n pattern?: string\n default?: unknown\n}\n\nexport interface FormConfig {\n modelId: string\n locale: string\n /** Exposed fields only, keyed by field id. */\n fields: Record<string, FieldDef>\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n successMessage?: string\n honeypotField: string | null\n}\n\nexport interface FieldError {\n field: string\n message: string\n}\n\nexport interface FormSubmitResult {\n success: boolean\n message?: string\n errors?: FieldError[]\n}\n\n/** The documented request body: values under `data`, control fields beside it. */\nexport interface FormPayload {\n data: Record<string, unknown>\n captchaToken?: string\n _hp?: string\n}\n\nexport function fetchFormConfig(rt: Runtime, model: string): Promise<FormConfig> {\n return getJson<FormConfig>(publicUrl(formsRoot(rt), [rt.project_id, model, 'config']))\n}\n\nexport function submitForm(rt: Runtime, model: string, payload: FormPayload): Promise<FormSubmitResult> {\n return postJson<FormSubmitResult>(publicUrl(formsRoot(rt), [rt.project_id, model, 'submit']), payload)\n}\n\nfunction coerce(def: FieldDef, value: string): unknown {\n switch (def.type) {\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return value === '' ? '' : Number(value)\n case 'boolean':\n return value === 'on' || value === 'true'\n default:\n return value\n }\n}\n\n/**\n * Form entries (`new FormData(form)`) \u2192 request body. Only exposed fields\n * reach `data`; the captcha token and the honeypot value travel beside it, so\n * a control field can never collide with a model field. Files are skipped \u2014\n * uploads are not part of the public contract.\n */\nexport function formPayload(\n entries: Iterable<[string, unknown]>,\n config: { fields: Record<string, FieldDef>; honeypotField: string | null },\n): FormPayload {\n const payload: FormPayload = { data: {} }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (config.honeypotField && name === config.honeypotField) {\n payload._hp = raw\n continue\n }\n const def = config.fields[name]\n if (!def) continue\n payload.data[name] = coerce(def, raw)\n }\n return payload\n}\n\nexport function labelFor(id: string, def: FieldDef): string {\n if (def.label) return def.label\n const words = id.replace(/[_-]+/g, ' ').trim()\n return words.charAt(0).toUpperCase() + words.slice(1)\n}\n\n/** One input for one exposed field, chosen by field type. */\nexport function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string {\n const common =\n ' id=\"' + esc(prefix + '-' + id) + '\" name=\"' + esc(id) + '\"' +\n (def.required ? ' required' : '') +\n (def.pattern ? ' pattern=\"' + esc(def.pattern) + '\"' : '')\n const range =\n (def.min !== undefined ? ' min=\"' + esc(def.min) + '\"' : '') +\n (def.max !== undefined ? ' max=\"' + esc(def.max) + '\"' : '')\n const length = def.max !== undefined ? ' maxlength=\"' + esc(def.max) + '\"' : ''\n switch (def.type) {\n case 'text':\n case 'markdown':\n case 'richtext':\n case 'code':\n return '<textarea' + common + length + ' rows=\"5\"></textarea>'\n case 'select': {\n const blank = def.required ? '' : '<option value=\"\"></option>'\n const options = (def.options ?? []).map((o) => '<option value=\"' + esc(o) + '\">' + esc(o) + '</option>').join('')\n return '<select' + common + '>' + blank + options + '</select>'\n }\n case 'boolean':\n return '<input type=\"checkbox\"' + common + ' />'\n case 'number':\n case 'integer':\n case 'rating':\n return '<input type=\"number\"' + common + range + (def.type === 'integer' ? ' step=\"1\"' : '') + ' />'\n case 'decimal':\n case 'percent':\n return '<input type=\"number\" step=\"any\"' + common + range + ' />'\n case 'email':\n return '<input type=\"email\"' + common + length + ' />'\n case 'url':\n return '<input type=\"url\"' + common + length + ' />'\n case 'phone':\n return '<input type=\"tel\"' + common + length + ' />'\n case 'date':\n return '<input type=\"date\"' + common + ' />'\n case 'datetime':\n return '<input type=\"datetime-local\"' + common + ' />'\n case 'color':\n return '<input type=\"color\"' + common + ' />'\n default:\n return '<input type=\"text\"' + common + length + ' />'\n }\n}\n\nexport function honeypotHtml(field: string | null): string {\n if (!field) return ''\n return (\n '<p class=\"cr-hp\" aria-hidden=\"true\" style=\"position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden\">' +\n '<label>' + esc(strings.honeypot) + ' <input type=\"text\" name=\"' + esc(field) + '\" tabindex=\"-1\" autocomplete=\"off\" /></label></p>'\n )\n}\n\nexport function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null): string {\n if (captcha !== 'turnstile' || !siteKey) return ''\n return '<div class=\"cf-turnstile\" data-sitekey=\"' + esc(siteKey) + '\"></div>'\n}\n\nexport function errorsHtml(errors: FieldError[]): string {\n return (\n '<ul class=\"cr-errors\" role=\"alert\">' +\n errors.map((e) => '<li data-field=\"' + esc(e.field) + '\">' + esc(e.field) + ': ' + esc(e.message) + '</li>').join('') +\n '</ul>'\n )\n}\n\n/** The whole form for a public form config: one control per exposed field, honeypot, captcha, submit. */\nexport function formHtml(config: FormConfig, prefix = 'cr-field'): string {\n const fields = Object.entries(config.fields)\n .map(([id, def]) => {\n const control = fieldControl(id, def, prefix)\n const label = '<label for=\"' + esc(prefix + '-' + id) + '\">' + esc(labelFor(id, def)) + (def.required ? ' <span aria-hidden=\"true\">*</span>' : '') + '</label>'\n return '<p class=\"cr-field cr-field--' + esc(def.type) + '\">' + (def.type === 'boolean' ? control + ' ' + label : label + control) + '</p>'\n })\n .join('')\n return (\n '<form class=\"cr-form\" method=\"post\" data-model=\"' + esc(config.modelId) + '\">' +\n fields +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.send) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// \u2500\u2500\u2500 Comments \u2500\u2500\u2500\n\nexport interface CommentAuthor {\n name: string\n url: string | null\n isModerator: boolean\n}\n\nexport interface PublicComment {\n id: string\n parentId: string | null\n depth: number\n author: CommentAuthor\n /** Plain text. */\n body: string\n type: 'comment' | 'pingback' | 'trackback'\n createdAt: string\n replies: PublicComment[]\n}\n\nexport interface ThreadConfig {\n closed: boolean\n requireApproval: boolean\n requireEmail: boolean\n maxDepth: number\n maxBodyLength: number\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n honeypotField: string | null\n}\n\nexport interface CommentThread {\n entry: { modelId: string; entryId: string; locale: string }\n config: ThreadConfig\n comments: PublicComment[]\n total: number\n page: number\n limit: number\n}\n\nexport interface CommentSubmitBody {\n author: { name: string; email?: string; url?: string }\n body: string\n /** null for a root comment \u2014 sent explicitly, as the provider's own fixture does. */\n parentId: string | null\n captchaToken?: string\n _hp?: string\n}\n\nexport interface CommentSubmitResult {\n success: boolean\n status?: 'pending' | 'approved'\n comment?: PublicComment\n errors?: FieldError[]\n}\n\nexport interface ThreadQuery {\n page?: number\n limit?: number\n sort?: 'oldest' | 'newest'\n}\n\nexport function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise<CommentThread> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], {\n locale: entry.locale,\n page: query?.page,\n limit: query?.limit,\n sort: query?.sort,\n })\n return getJson<CommentThread>(url)\n}\n\nexport function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise<CommentSubmitResult> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale })\n return postJson<CommentSubmitResult>(url, body)\n}\n\n/** Comment-form entries \u2192 the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */\nexport function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody {\n const values: Record<string, string> = {}\n const payload: CommentSubmitBody = { author: { name: '' }, body: '', parentId: null }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (honeypotField && name === honeypotField) {\n payload._hp = raw\n continue\n }\n values[name] = raw\n }\n payload.author.name = (values.author_name ?? '').trim()\n if (values.author_email?.trim()) payload.author.email = values.author_email.trim()\n if (values.author_url?.trim()) payload.author.url = values.author_url.trim()\n payload.body = (values.body ?? '').trim()\n payload.parentId = values.parent_id?.trim() || null\n return payload\n}\n\n/** Plain text \u2192 paragraphs; blank lines separate paragraphs, single newlines break lines. */\nexport function bodyHtml(text: string): string {\n return text\n .split(/\\n{2,}/)\n .map((p) => p.trim())\n .filter(Boolean)\n .map((p) => '<p>' + esc(p).replace(/\\n/g, '<br />') + '</p>')\n .join('')\n}\n\nexport function hasMore(thread: CommentThread): boolean {\n return thread.page * thread.limit < thread.total\n}\n\n/** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */\nexport function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string {\n const author = c.author.url\n ? '<a href=\"' + esc(c.author.url) + '\" rel=\"nofollow ugc noopener\" target=\"_blank\">' + esc(c.author.name) + '</a>'\n : esc(c.author.name)\n const badge = c.author.isModerator ? ' <span class=\"cr-moderator\">' + esc(strings.moderator) + '</span>' : ''\n const date = '<time datetime=\"' + esc(c.createdAt) + '\">' + esc(c.createdAt.slice(0, 10)) + '</time>'\n const canReply = !config.closed && c.depth < config.maxDepth\n const reply = canReply\n ? '<button type=\"button\" class=\"cr-reply\" data-parent=\"' + esc(c.id) + '\" data-author=\"' + esc(c.author.name) + '\">' + esc(strings.reply) + '</button>'\n : ''\n const replies = c.replies.length\n ? '<ol class=\"cr-replies\">' + c.replies.map((r) => commentHtml(r, config)).join('') + '</ol>'\n : ''\n return (\n '<li class=\"cr-comment cr-comment--' + esc(c.type) + '\" id=\"cr-comment-' + esc(c.id) + '\" data-depth=\"' + esc(c.depth) + '\">' +\n '<div class=\"cr-comment-meta\"><span class=\"cr-comment-author\">' + author + '</span>' + badge + ' ' + date + '</div>' +\n '<div class=\"cr-comment-body\">' + bodyHtml(c.body) + '</div>' +\n (reply ? '<div class=\"cr-comment-actions\">' + reply + '</div>' : '') +\n replies +\n '</li>'\n )\n}\n\nexport function threadHtml(thread: CommentThread): string {\n if (!thread.comments.length) return '<p class=\"cr-empty\">' + esc(strings.noComments) + '</p>'\n return '<ol class=\"cr-comment-list\">' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '</ol>'\n}\n\n/** The comment form; a reply carries its parent in a hidden input. */\nexport function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string {\n const emailLabel = esc(strings.email) + (config.requireEmail ? ' <span aria-hidden=\"true\">*</span>' : '') + ' <small>' + esc(strings.emailNote) + '</small>'\n return (\n '<form class=\"cr-comment-form\" method=\"post\">' +\n '<input type=\"hidden\" name=\"parent_id\" value=\"\" />' +\n '<p class=\"cr-replying\" hidden><span class=\"cr-replying-to\"></span> <button type=\"button\" class=\"cr-cancel-reply\">' + esc(strings.cancel) + '</button></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-body\">' + esc(strings.comment) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<textarea id=\"' + prefix + '-body\" name=\"body\" required maxlength=\"' + esc(config.maxBodyLength) + '\" rows=\"5\"></textarea></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-name\">' + esc(strings.name) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<input type=\"text\" id=\"' + prefix + '-name\" name=\"author_name\" required maxlength=\"120\" autocomplete=\"name\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-email\">' + emailLabel + '</label>' +\n '<input type=\"email\" id=\"' + prefix + '-email\" name=\"author_email\"' + (config.requireEmail ? ' required' : '') + ' maxlength=\"254\" autocomplete=\"email\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-url\">' + esc(strings.website) + '</label>' +\n '<input type=\"url\" id=\"' + prefix + '-url\" name=\"author_url\" maxlength=\"2048\" autocomplete=\"url\" /></p>' +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.postComment) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// \u2500\u2500\u2500 Turnstile (explicit render, so widgets added after load still render) \u2500\u2500\u2500\n\ninterface TurnstileApi {\n render: (el: Element, options: { sitekey: string }) => string\n reset: (id?: string) => void\n}\n\nconst TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady'\nconst widgets = new WeakMap<Element, string>()\nconst waiting: Element[] = []\n\nfunction turnstileApi(): TurnstileApi | undefined {\n return (globalThis as { turnstile?: TurnstileApi }).turnstile\n}\n\nfunction renderWidget(api: TurnstileApi, el: Element): void {\n if (widgets.has(el)) return\n widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' }))\n}\n\n/** Render every `.cf-turnstile` under `root`, loading the Turnstile script once on first use. */\nexport function renderCaptcha(root: ParentNode): void {\n const els = Array.from(root.querySelectorAll('.cf-turnstile'))\n if (!els.length) return\n const api = turnstileApi()\n if (api) {\n for (const el of els) renderWidget(api, el)\n return\n }\n waiting.push(...els)\n if (document.querySelector('script[data-cr-turnstile]')) return\n ;(globalThis as Record<string, unknown>).__crTurnstileReady = () => {\n const ready = turnstileApi()\n if (!ready) return\n for (const el of waiting.splice(0)) renderWidget(ready, el)\n }\n const script = document.createElement('script')\n script.src = TURNSTILE_SRC\n script.async = true\n script.defer = true\n script.setAttribute('data-cr-turnstile', '1')\n document.head.appendChild(script)\n}\n\nexport function resetCaptcha(root: ParentNode): void {\n const api = turnstileApi()\n if (!api) return\n for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el))\n}\n\n// \u2500\u2500\u2500 Mounting (the only DOM code) \u2500\u2500\u2500\n\n/** ui-strings dictionary key \u2192 the `strings` entry it sets. */\nexport const STRING_KEYS: ReadonlyArray<readonly [string, keyof typeof strings]> = [\n ['common.loading', 'loading'],\n ['common.failed', 'failed'],\n ['form.send', 'send'],\n ['form.sending', 'sending'],\n ['form.honeypot', 'honeypot'],\n ['comments.title', 'commentsTitle'],\n ['comments.empty', 'noComments'],\n ['comments.closed', 'closed'],\n ['comments.post', 'postComment'],\n ['comments.reply', 'reply'],\n ['comments.replying_to', 'replyingTo'],\n ['comments.cancel', 'cancel'],\n ['comments.load_more', 'loadMore'],\n ['comments.moderator', 'moderator'],\n ['comments.pending', 'pending'],\n ['comments.posted', 'posted'],\n ['comments.field.name', 'name'],\n ['comments.field.email', 'email'],\n ['comments.field.email_note', 'emailNote'],\n ['comments.field.website', 'website'],\n ['comments.field.comment', 'comment'],\n]\n\n/**\n * Take the page's interface text from the host element. A value that is not a\n * non-empty string leaves the default; text that cannot be parsed changes\n * nothing, because a mount that fails over a label is worse than an English one.\n */\nexport function applyStrings(host: HTMLElement): void {\n const raw = host.dataset.strings\n if (!raw) return\n let text: unknown\n try {\n text = JSON.parse(raw)\n } catch {\n return\n }\n if (!text || typeof text !== 'object') return\n for (const [key, name] of STRING_KEYS) {\n const value = (text as Record<string, unknown>)[key]\n if (typeof value === 'string' && value !== '') strings[name] = value\n }\n}\n\nexport function runtimeOf(host: HTMLElement): Runtime {\n return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }\n}\n\nfunction entryOf(host: HTMLElement): EntryRef {\n 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";
672
672
  //#endregion
673
673
  //#region src/ui-strings.d.ts
674
674
  /** Where the dictionary lives in an emitted project, relative to its root. */
package/dist/index.mjs CHANGED
@@ -3228,7 +3228,8 @@ export function runtimeOf(host: HTMLElement): Runtime {
3228
3228
  }
3229
3229
 
3230
3230
  function entryOf(host: HTMLElement): EntryRef {
3231
- return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', locale: host.dataset.locale || undefined }
3231
+ const locale = host.dataset.locale
3232
+ return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', ...(locale ? { locale } : {}) }
3232
3233
  }
3233
3234
 
3234
3235
  function setStatus(form: HTMLFormElement, html: string): void {