@contentrain/emitter-astro 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/index.d.mts +57 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +299 -21
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@ await writeEmit(result, './out/site')
|
|
|
39
39
|
| Root attributes | `LayoutFamily.root_attrs` lands on `<html>`/`<body>` verbatim — themes key their container rules off `wp-singular`, `single`, `js`, `wf-…`; dropping them costs a correct-content page its whole layout. Values may carry `@@marks@@` (per-page classes like `postid-123`). |
|
|
40
40
|
| Legacy CSS (`public/styles/legacy/`) | Quarantined in `@layer legacy { … }` with leading `@import`s hoisted and layered; a mid-file `@import` is left as-is with a warning. |
|
|
41
41
|
| Evolution layer (`src/styles/modern.css`) | Tailwind 4, CSS-first: extracted design tokens land in `@theme`. Migrated layouts never load it; new pages build on it. |
|
|
42
|
+
| SEO (`src/components/Seo.astro`) | Per-page title, description, canonical, Open Graph, Twitter card and Article structured data from the entry; the template page's copies of those tags are taken out of the head chrome and named in a warning. See **SEO** below. |
|
|
42
43
|
| Component mount points | `<!--@@component:ID@@-->` in the body chrome or an entry’s content body (`componentSlot(id)` from `@contentrain/types`) is where a `ComponentDef` renders: the layout imports the component and mounts it at the marker, with the placement's variant (`LayoutFamily.components`). A marker nobody defined is dropped with a warning; a placement without a marker is warned; header/footer regions are not mount points. Emitting a component file alone is not integration — the marker is what puts it on the page. |
|
|
43
44
|
| Runtime components (`comments`, `form`) | With `input.runtime` (`RuntimeBinding`: the provider's origin + project id) these get real implementations: a custom element (`<cr-comments>`, `<cr-form>`) carrying the binding, and a zero-dependency client (`src/lib/embed.ts`) that fetches, renders and submits against the provider's public API — the same contract as `@contentrain/query/cdn`'s `CommentsClient`/`FormsClient`, inlined so the site depends on nothing but Astro. Comments key on the page's entry address (`EmitPost.entry`: model, entry id, locale) and show approved comments only; pending ones live on the provider. A form names its model (`ComponentDef.model`). Honeypot and Turnstile follow the config. No credential is ever emitted. Without a binding, or without a model for a form, the component stays a placeholder and a warning says so. |
|
|
44
45
|
| Other components (`src/components/*.astro`) | `<cr-component>` placeholders carrying type/source/variants — the emitter cannot invent an ad slot or a related-posts box; the marker keeps the spot. |
|
|
@@ -46,6 +47,44 @@ await writeEmit(result, './out/site')
|
|
|
46
47
|
|
|
47
48
|
Everything content-shaped flows through JSON data files, never through generated template source — determinism and safe escaping fall out of that one rule.
|
|
48
49
|
|
|
50
|
+
## SEO
|
|
51
|
+
|
|
52
|
+
SEO continuity is the reason a migration keeps the source addresses at all, so
|
|
53
|
+
the emitter owns the head tags that describe a page and renders them per entry:
|
|
54
|
+
`<title>`, `description`, `canonical`, Open Graph, Twitter card and — on entry
|
|
55
|
+
pages — Article structured data (`src/components/Seo.astro`).
|
|
56
|
+
|
|
57
|
+
It also **removes the template page's copies of those tags from the head
|
|
58
|
+
chrome**, naming them in a warning. Inheriting them is worse than having none: a
|
|
59
|
+
whole site canonicalised onto the template post's URL de-indexes itself, and
|
|
60
|
+
every share card shows the same wrong story. Everything else the theme put in
|
|
61
|
+
`<head>` stays — charset, preloads, feeds, icons, verification tokens, and any
|
|
62
|
+
JSON-LD that is not page-scoped (`Organization`, `WebSite`, `BreadcrumbList`).
|
|
63
|
+
JSON-LD that cannot be parsed is left alone rather than guessed at.
|
|
64
|
+
|
|
65
|
+
| Tag | Source |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `<title>` | `EmitPost.title` · `QueryPage.title` · `RouteModel.title` |
|
|
68
|
+
| `description`, `og:description`, `twitter:description` | `EmitPost.description`, else `excerpt` with markup stripped and cut at a word boundary |
|
|
69
|
+
| `canonical`, `og:url` | The address Astro generated (`Astro.url` + `site`), so it cannot disagree with the emitted page. `EmitPost.canonical` overrides |
|
|
70
|
+
| `og:image`, `twitter:image` | `EmitPost.image`, else a `featured` entry that is already a path. A bare file name is skipped — only the producer knows where media is served |
|
|
71
|
+
| `og:site_name` | `ProjectIR.site.title` |
|
|
72
|
+
| `og:locale` | The page's `lang` |
|
|
73
|
+
| `article:published_time` / `modified_time`, Article JSON-LD dates | `EmitPost.published_at` / `modified_at` (ISO 8601 — `dates` holds display strings, which schema.org cannot read) |
|
|
74
|
+
|
|
75
|
+
Absolute URLs need `site` in `astro.config.mjs`, which the emitter fills from
|
|
76
|
+
`ProjectIR.site.url`. Without it the canonical link and the absolute social URLs
|
|
77
|
+
are omitted, with a warning, rather than pointing at a build host.
|
|
78
|
+
|
|
79
|
+
Head-only tags that a faithful clone left in the **body** (a browser that closed
|
|
80
|
+
`<head>` early puts the template's canonical there) are reported, not removed:
|
|
81
|
+
the body is page content and `<title>` is legal inside `<svg>`, so cutting into
|
|
82
|
+
it to fix an invisible tag would break real markup. Lift them into the head
|
|
83
|
+
chunk, or drop them at capture.
|
|
84
|
+
|
|
85
|
+
`options.seo: false` turns all of this off: the source head travels verbatim and
|
|
86
|
+
the layout prints its own `<title>`, exactly as before.
|
|
87
|
+
|
|
49
88
|
## Binding the runtime later
|
|
50
89
|
|
|
51
90
|
`src/data/runtime.json` is the only place the binding lives; the components
|
package/dist/index.d.mts
CHANGED
|
@@ -46,6 +46,22 @@ interface EmitPost {
|
|
|
46
46
|
excerpt?: string;
|
|
47
47
|
/** Excerpt as source HTML, for themes whose cards keep links and formatting. */
|
|
48
48
|
excerpt_html?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Meta description. Falls back to `excerpt` with markup stripped — a card
|
|
51
|
+
* and a description tag want the same sentence, so most producers set neither.
|
|
52
|
+
*/
|
|
53
|
+
description?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Social image, absolute or site-root-relative. `featured` holds bare file
|
|
56
|
+
* names whose serving path only the producer knows, so a crawler-usable
|
|
57
|
+
* `og:image` needs this; a `featured` entry that is already a path is used.
|
|
58
|
+
*/
|
|
59
|
+
image?: string;
|
|
60
|
+
/** Canonical override — for a page that should point somewhere else. Default: this page's own address. */
|
|
61
|
+
canonical?: string;
|
|
62
|
+
/** ISO 8601, for Article structured data. `dates` holds display strings, which schema.org cannot read. */
|
|
63
|
+
published_at?: string;
|
|
64
|
+
modified_at?: string;
|
|
49
65
|
/** Every author, for repeat blocks — `author` stays the first one. */
|
|
50
66
|
authors?: string[];
|
|
51
67
|
/** Producer-supplied extra marks, merged last. */
|
|
@@ -102,6 +118,12 @@ interface QueryPage {
|
|
|
102
118
|
sections?: ListSection[];
|
|
103
119
|
/** Document title for this page (`<title>`), e.g. "Category: News – Site". */
|
|
104
120
|
title?: string;
|
|
121
|
+
/** Meta description for this list page. */
|
|
122
|
+
description?: string;
|
|
123
|
+
/** Social image, absolute or site-root-relative. */
|
|
124
|
+
image?: string;
|
|
125
|
+
/** Canonical override; default is this page's own address. */
|
|
126
|
+
canonical?: string;
|
|
105
127
|
}
|
|
106
128
|
interface EmitContent {
|
|
107
129
|
/** The default (`posts`) collection — shorthand for `collections.posts`. */
|
|
@@ -125,6 +147,14 @@ interface EmitOptions {
|
|
|
125
147
|
projectName?: string;
|
|
126
148
|
/** Include Tailwind 4 as the evolution layer (default true). */
|
|
127
149
|
tailwind?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Emit per-page SEO — title, description, canonical, Open Graph, Twitter card
|
|
152
|
+
* and Article structured data — and remove the template page's copies of those
|
|
153
|
+
* tags from the head chrome. Default true: inheriting one page's canonical and
|
|
154
|
+
* og:title on every page is worse than having neither. Set false to keep the
|
|
155
|
+
* source head verbatim and own these tags in the producer.
|
|
156
|
+
*/
|
|
157
|
+
seo?: boolean;
|
|
128
158
|
}
|
|
129
159
|
interface EmitInput {
|
|
130
160
|
ir: ProjectIR;
|
|
@@ -185,6 +215,32 @@ declare function componentMarkers(html: string): string[];
|
|
|
185
215
|
declare const RUNTIME_IMPLEMENTED: Set<"nav" | "related" | "comments" | "form" | "ads" | "chrome" | "taxonomy" | "author" | "share" | "breadcrumb" | "meta" | "pagination" | "card" | "custom">;
|
|
186
216
|
declare function isRuntimeImplemented(c: ComponentDef, runtime: RuntimeBinding$1 | undefined): boolean;
|
|
187
217
|
//#endregion
|
|
218
|
+
//#region src/seo.d.ts
|
|
219
|
+
interface StripResult {
|
|
220
|
+
html: string;
|
|
221
|
+
/** What was taken out, for the emit warning — never a silent removal. */
|
|
222
|
+
removed: string[];
|
|
223
|
+
}
|
|
224
|
+
/** Remove the source head's per-page SEO tags so the emitter's own are the only ones. */
|
|
225
|
+
declare function stripSeoTags(html: string): StripResult;
|
|
226
|
+
/**
|
|
227
|
+
* Head-only tags found in BODY chrome. A browser that closed `<head>` early
|
|
228
|
+
* leaves the template's canonical and og tags in the body, and a faithful clone
|
|
229
|
+
* carries them there — so the page ends up with the emitter's correct tags AND
|
|
230
|
+
* the template's stale ones, which no fidelity score can see.
|
|
231
|
+
*
|
|
232
|
+
* They are reported, not removed: the body is page content, and `<title>` is
|
|
233
|
+
* legal inside `<svg>`, so cutting tags out of it would break real markup to
|
|
234
|
+
* fix an invisible one. Only unambiguous head tags are looked for.
|
|
235
|
+
*/
|
|
236
|
+
declare function bodySeoLeaks(html: string): string[];
|
|
237
|
+
/**
|
|
238
|
+
* `src/components/Seo.astro`. The canonical address comes from `Astro.url` and
|
|
239
|
+
* `Astro.site` rather than from data: it is then, by construction, the address
|
|
240
|
+
* Astro actually generated, and no producer has to recompute a permalink.
|
|
241
|
+
*/
|
|
242
|
+
declare const SEO_COMPONENT = "---\n/**\n * Per-page SEO \u2014 emitted by @contentrain/emitter-astro.\n *\n * Title, description, canonical, Open Graph, Twitter card and (on entry pages)\n * Article structured data, derived from the entry. The source theme's copies of\n * these tags are removed from the head chrome at emit time, so each appears once.\n *\n * Canonical and absolute URLs need `site` in astro.config.mjs; without it the\n * canonical link and og:url are omitted rather than pointing at a build host.\n */\nimport { absoluteUrl, jsonLd, seoDescription } from '../lib/fill'\n\ninterface Props {\n title?: string\n /** Meta description; falls back to the entry's excerpt, tags stripped. */\n description?: string\n /** Overrides the generated address \u2014 for a page that canonicalises elsewhere. */\n canonical?: string\n /** Social image: absolute, or site-root-relative. */\n image?: string\n /** `article` on entry pages, `website` on lists and static pages. */\n type?: 'article' | 'website'\n /** ISO 8601 \u2014 structured data only; the displayed date stays a mark. */\n publishedAt?: string\n modifiedAt?: string\n author?: string\n siteName?: string\n locale?: string\n}\n\nconst {\n title = '',\n description,\n canonical,\n image,\n type = 'website',\n publishedAt,\n modifiedAt,\n author,\n siteName,\n locale,\n} = Astro.props\n\nconst site = Astro.site\nconst url = canonical ? absoluteUrl(canonical, site) : absoluteUrl(Astro.url.pathname, site)\nconst imageUrl = absoluteUrl(image, site)\nconst desc = seoDescription(description)\nconst article = type === 'article'\nconst structured = article\n ? {\n '@context': 'https://schema.org',\n '@type': 'Article',\n headline: title,\n ...(desc ? { description: desc } : {}),\n ...(imageUrl ? { image: [imageUrl] } : {}),\n ...(publishedAt ? { datePublished: publishedAt } : {}),\n ...(modifiedAt ? { dateModified: modifiedAt } : {}),\n ...(author ? { author: { '@type': 'Person', name: author } } : {}),\n ...(url ? { mainEntityOfPage: { '@type': 'WebPage', '@id': url } } : {}),\n ...(siteName ? { publisher: { '@type': 'Organization', name: siteName } } : {}),\n }\n : undefined\n---\n<title>{title}</title>\n{desc && <meta name=\"description\" content={desc} />}\n{url && <link rel=\"canonical\" href={url} />}\n<meta property=\"og:type\" content={type} />\n{title && <meta property=\"og:title\" content={title} />}\n{desc && <meta property=\"og:description\" content={desc} />}\n{url && <meta property=\"og:url\" content={url} />}\n{imageUrl && <meta property=\"og:image\" content={imageUrl} />}\n{siteName && <meta property=\"og:site_name\" content={siteName} />}\n{locale && <meta property=\"og:locale\" content={locale} />}\n{article && publishedAt && <meta property=\"article:published_time\" content={publishedAt} />}\n{article && modifiedAt && <meta property=\"article:modified_time\" content={modifiedAt} />}\n{article && author && <meta property=\"article:author\" content={author} />}\n<meta name=\"twitter:card\" content={imageUrl ? 'summary_large_image' : 'summary'} />\n{title && <meta name=\"twitter:title\" content={title} />}\n{desc && <meta name=\"twitter:description\" content={desc} />}\n{imageUrl && <meta name=\"twitter:image\" content={imageUrl} />}\n{structured && <script is:inline type=\"application/ld+json\" set:html={jsonLd(structured)} />}\n";
|
|
243
|
+
//#endregion
|
|
188
244
|
//#region src/embed.d.ts
|
|
189
245
|
declare const EMBED_TS = "// Emitted by @contentrain/emitter-astro \u2014 the runtime-component client.\n//\n// Comments and forms are the two regions of a migrated site that need a live\n// service. This module is the browser side of the provider's PUBLIC API\n// (Studio: /api/forms/v1 and /api/comments/v1) \u2014 the same contract that\n// @contentrain/query/cdn implements, inlined so the generated site depends on\n// nothing but Astro. No credential travels with a request: the endpoints are\n// unauthenticated by design, and a page cannot keep a secret.\n//\n// Rendering helpers are pure (strings in, HTML out, escaped by default) so the\n// mount functions at the bottom are the only code that touches the DOM.\n\nexport interface Runtime {\n base_url: string\n project_id: string\n}\n\nexport interface EntryRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\nexport class EmbedError extends Error {\n status: number\n constructor(status: number, message: string) {\n super(message)\n this.name = 'EmbedError'\n this.status = status\n }\n}\n\n/** Visitor-facing strings \u2014 override from the site (`strings.send = 'G\u00F6nder'`). */\nexport const strings = {\n loading: 'Loading\u2026',\n send: 'Send',\n sending: 'Sending\u2026',\n commentsTitle: 'Comments',\n noComments: 'No comments yet.',\n closed: 'Comments are closed.',\n postComment: 'Post comment',\n reply: 'Reply',\n replyingTo: 'Replying to',\n cancel: 'Cancel',\n loadMore: 'Load more comments',\n moderator: 'Moderator',\n pending: 'Thank you \u2014 your comment is awaiting moderation.',\n posted: 'Your comment has been posted.',\n name: 'Name',\n email: 'Email',\n emailNote: '(never shown)',\n website: 'Website',\n comment: 'Comment',\n failed: 'Something went wrong. Please try again.',\n honeypot: 'Leave this field empty',\n}\n\nexport const esc = (value: unknown): string =>\n String(value ?? '')\n .replace(/&/g, '&')\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 try {\n const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown }\n const m = parsed.message ?? parsed.statusMessage\n if (typeof m === 'string' && m) message = m\n } catch {\n // not JSON \u2014 the raw text is the message\n }\n return new EmbedError(res.status, message)\n}\n\nexport async function getJson<T>(url: string): Promise<T> {\n const res = await fetch(url)\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport async function postJson<T>(url: string, body: unknown): Promise<T> {\n const res = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!res.ok) throw await failure(res)\n return (await res.json()) as T\n}\n\nexport const formsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/forms/v1'\nexport const commentsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/comments/v1'\n\n/** The hidden input Cloudflare Turnstile adds to the enclosing form. */\nexport const CAPTCHA_FIELD = 'cf-turnstile-response'\n\n// \u2500\u2500\u2500 Forms \u2500\u2500\u2500\n\nexport interface FieldDef {\n type: string\n required?: boolean\n label?: string\n options?: string[]\n min?: number\n max?: number\n pattern?: string\n default?: unknown\n}\n\nexport interface FormConfig {\n modelId: string\n locale: string\n /** Exposed fields only, keyed by field id. */\n fields: Record<string, FieldDef>\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n successMessage?: string\n honeypotField: string | null\n}\n\nexport interface FieldError {\n field: string\n message: string\n}\n\nexport interface FormSubmitResult {\n success: boolean\n message?: string\n errors?: FieldError[]\n}\n\n/** The documented request body: values under `data`, control fields beside it. */\nexport interface FormPayload {\n data: Record<string, unknown>\n captchaToken?: string\n _hp?: string\n}\n\nexport function fetchFormConfig(rt: Runtime, model: string): Promise<FormConfig> {\n return getJson<FormConfig>(publicUrl(formsRoot(rt), [rt.project_id, model, 'config']))\n}\n\nexport function submitForm(rt: Runtime, model: string, payload: FormPayload): Promise<FormSubmitResult> {\n return postJson<FormSubmitResult>(publicUrl(formsRoot(rt), [rt.project_id, model, 'submit']), payload)\n}\n\nfunction coerce(def: FieldDef, value: string): unknown {\n switch (def.type) {\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return value === '' ? '' : Number(value)\n case 'boolean':\n return value === 'on' || value === 'true'\n default:\n return value\n }\n}\n\n/**\n * Form entries (`new FormData(form)`) \u2192 request body. Only exposed fields\n * reach `data`; the captcha token and the honeypot value travel beside it, so\n * a control field can never collide with a model field. Files are skipped \u2014\n * uploads are not part of the public contract.\n */\nexport function formPayload(\n entries: Iterable<[string, unknown]>,\n config: { fields: Record<string, FieldDef>; honeypotField: string | null },\n): FormPayload {\n const payload: FormPayload = { data: {} }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (config.honeypotField && name === config.honeypotField) {\n payload._hp = raw\n continue\n }\n const def = config.fields[name]\n if (!def) continue\n payload.data[name] = coerce(def, raw)\n }\n return payload\n}\n\nexport function labelFor(id: string, def: FieldDef): string {\n if (def.label) return def.label\n const words = id.replace(/[_-]+/g, ' ').trim()\n return words.charAt(0).toUpperCase() + words.slice(1)\n}\n\n/** One input for one exposed field, chosen by field type. */\nexport function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string {\n const common =\n ' id=\"' + esc(prefix + '-' + id) + '\" name=\"' + esc(id) + '\"' +\n (def.required ? ' required' : '') +\n (def.pattern ? ' pattern=\"' + esc(def.pattern) + '\"' : '')\n const range =\n (def.min !== undefined ? ' min=\"' + esc(def.min) + '\"' : '') +\n (def.max !== undefined ? ' max=\"' + esc(def.max) + '\"' : '')\n const length = def.max !== undefined ? ' maxlength=\"' + esc(def.max) + '\"' : ''\n switch (def.type) {\n case 'text':\n case 'markdown':\n case 'richtext':\n case 'code':\n return '<textarea' + common + length + ' rows=\"5\"></textarea>'\n case 'select': {\n const blank = def.required ? '' : '<option value=\"\"></option>'\n const options = (def.options ?? []).map((o) => '<option value=\"' + esc(o) + '\">' + esc(o) + '</option>').join('')\n return '<select' + common + '>' + blank + options + '</select>'\n }\n case 'boolean':\n return '<input type=\"checkbox\"' + common + ' />'\n case 'number':\n case 'integer':\n case 'rating':\n return '<input type=\"number\"' + common + range + (def.type === 'integer' ? ' step=\"1\"' : '') + ' />'\n case 'decimal':\n case 'percent':\n return '<input type=\"number\" step=\"any\"' + common + range + ' />'\n case 'email':\n return '<input type=\"email\"' + common + length + ' />'\n case 'url':\n return '<input type=\"url\"' + common + length + ' />'\n case 'phone':\n return '<input type=\"tel\"' + common + length + ' />'\n case 'date':\n return '<input type=\"date\"' + common + ' />'\n case 'datetime':\n return '<input type=\"datetime-local\"' + common + ' />'\n case 'color':\n return '<input type=\"color\"' + common + ' />'\n default:\n return '<input type=\"text\"' + common + length + ' />'\n }\n}\n\nexport function honeypotHtml(field: string | null): string {\n if (!field) return ''\n return (\n '<p class=\"cr-hp\" aria-hidden=\"true\" style=\"position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden\">' +\n '<label>' + esc(strings.honeypot) + ' <input type=\"text\" name=\"' + esc(field) + '\" tabindex=\"-1\" autocomplete=\"off\" /></label></p>'\n )\n}\n\nexport function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null): string {\n if (captcha !== 'turnstile' || !siteKey) return ''\n return '<div class=\"cf-turnstile\" data-sitekey=\"' + esc(siteKey) + '\"></div>'\n}\n\nexport function errorsHtml(errors: FieldError[]): string {\n return (\n '<ul class=\"cr-errors\" role=\"alert\">' +\n errors.map((e) => '<li data-field=\"' + esc(e.field) + '\">' + esc(e.field) + ': ' + esc(e.message) + '</li>').join('') +\n '</ul>'\n )\n}\n\n/** The whole form for a public form config: one control per exposed field, honeypot, captcha, submit. */\nexport function formHtml(config: FormConfig, prefix = 'cr-field'): string {\n const fields = Object.entries(config.fields)\n .map(([id, def]) => {\n const control = fieldControl(id, def, prefix)\n const label = '<label for=\"' + esc(prefix + '-' + id) + '\">' + esc(labelFor(id, def)) + (def.required ? ' <span aria-hidden=\"true\">*</span>' : '') + '</label>'\n return '<p class=\"cr-field cr-field--' + esc(def.type) + '\">' + (def.type === 'boolean' ? control + ' ' + label : label + control) + '</p>'\n })\n .join('')\n return (\n '<form class=\"cr-form\" method=\"post\" data-model=\"' + esc(config.modelId) + '\">' +\n fields +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.send) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// \u2500\u2500\u2500 Comments \u2500\u2500\u2500\n\nexport interface CommentAuthor {\n name: string\n url: string | null\n isModerator: boolean\n}\n\nexport interface PublicComment {\n id: string\n parentId: string | null\n depth: number\n author: CommentAuthor\n /** Plain text. */\n body: string\n type: 'comment' | 'pingback' | 'trackback'\n createdAt: string\n replies: PublicComment[]\n}\n\nexport interface ThreadConfig {\n closed: boolean\n requireApproval: boolean\n requireEmail: boolean\n maxDepth: number\n maxBodyLength: number\n captcha: 'turnstile' | null\n captchaSiteKey: string | null\n honeypotField: string | null\n}\n\nexport interface CommentThread {\n entry: { modelId: string; entryId: string; locale: string }\n config: ThreadConfig\n comments: PublicComment[]\n total: number\n page: number\n limit: number\n}\n\nexport interface CommentSubmitBody {\n author: { name: string; email?: string; url?: string }\n body: string\n /** null for a root comment \u2014 sent explicitly, as the provider's own fixture does. */\n parentId: string | null\n captchaToken?: string\n _hp?: string\n}\n\nexport interface CommentSubmitResult {\n success: boolean\n status?: 'pending' | 'approved'\n comment?: PublicComment\n errors?: FieldError[]\n}\n\nexport interface ThreadQuery {\n page?: number\n limit?: number\n sort?: 'oldest' | 'newest'\n}\n\nexport function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise<CommentThread> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], {\n locale: entry.locale,\n page: query?.page,\n limit: query?.limit,\n sort: query?.sort,\n })\n return getJson<CommentThread>(url)\n}\n\nexport function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise<CommentSubmitResult> {\n const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale })\n return postJson<CommentSubmitResult>(url, body)\n}\n\n/** Comment-form entries \u2192 the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */\nexport function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody {\n const values: Record<string, string> = {}\n const payload: CommentSubmitBody = { author: { name: '' }, body: '', parentId: null }\n for (const [name, raw] of entries) {\n if (typeof raw !== 'string') continue\n if (name === CAPTCHA_FIELD) {\n if (raw) payload.captchaToken = raw\n continue\n }\n if (honeypotField && name === honeypotField) {\n payload._hp = raw\n continue\n }\n values[name] = raw\n }\n payload.author.name = (values.author_name ?? '').trim()\n if (values.author_email?.trim()) payload.author.email = values.author_email.trim()\n if (values.author_url?.trim()) payload.author.url = values.author_url.trim()\n payload.body = (values.body ?? '').trim()\n payload.parentId = values.parent_id?.trim() || null\n return payload\n}\n\n/** Plain text \u2192 paragraphs; blank lines separate paragraphs, single newlines break lines. */\nexport function bodyHtml(text: string): string {\n return text\n .split(/\\n{2,}/)\n .map((p) => p.trim())\n .filter(Boolean)\n .map((p) => '<p>' + esc(p).replace(/\\n/g, '<br />') + '</p>')\n .join('')\n}\n\nexport function hasMore(thread: CommentThread): boolean {\n return thread.page * thread.limit < thread.total\n}\n\n/** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */\nexport function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string {\n const author = c.author.url\n ? '<a href=\"' + esc(c.author.url) + '\" rel=\"nofollow ugc noopener\" target=\"_blank\">' + esc(c.author.name) + '</a>'\n : esc(c.author.name)\n const badge = c.author.isModerator ? ' <span class=\"cr-moderator\">' + esc(strings.moderator) + '</span>' : ''\n const date = '<time datetime=\"' + esc(c.createdAt) + '\">' + esc(c.createdAt.slice(0, 10)) + '</time>'\n const canReply = !config.closed && c.depth < config.maxDepth\n const reply = canReply\n ? '<button type=\"button\" class=\"cr-reply\" data-parent=\"' + esc(c.id) + '\" data-author=\"' + esc(c.author.name) + '\">' + esc(strings.reply) + '</button>'\n : ''\n const replies = c.replies.length\n ? '<ol class=\"cr-replies\">' + c.replies.map((r) => commentHtml(r, config)).join('') + '</ol>'\n : ''\n return (\n '<li class=\"cr-comment cr-comment--' + esc(c.type) + '\" id=\"cr-comment-' + esc(c.id) + '\" data-depth=\"' + esc(c.depth) + '\">' +\n '<div class=\"cr-comment-meta\"><span class=\"cr-comment-author\">' + author + '</span>' + badge + ' ' + date + '</div>' +\n '<div class=\"cr-comment-body\">' + bodyHtml(c.body) + '</div>' +\n (reply ? '<div class=\"cr-comment-actions\">' + reply + '</div>' : '') +\n replies +\n '</li>'\n )\n}\n\nexport function threadHtml(thread: CommentThread): string {\n if (!thread.comments.length) return '<p class=\"cr-empty\">' + esc(strings.noComments) + '</p>'\n return '<ol class=\"cr-comment-list\">' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '</ol>'\n}\n\n/** The comment form; a reply carries its parent in a hidden input. */\nexport function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string {\n const emailLabel = esc(strings.email) + (config.requireEmail ? ' <span aria-hidden=\"true\">*</span>' : '') + ' <small>' + esc(strings.emailNote) + '</small>'\n return (\n '<form class=\"cr-comment-form\" method=\"post\">' +\n '<input type=\"hidden\" name=\"parent_id\" value=\"\" />' +\n '<p class=\"cr-replying\" hidden><span class=\"cr-replying-to\"></span> <button type=\"button\" class=\"cr-cancel-reply\">' + esc(strings.cancel) + '</button></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-body\">' + esc(strings.comment) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<textarea id=\"' + prefix + '-body\" name=\"body\" required maxlength=\"' + esc(config.maxBodyLength) + '\" rows=\"5\"></textarea></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-name\">' + esc(strings.name) + ' <span aria-hidden=\"true\">*</span></label>' +\n '<input type=\"text\" id=\"' + prefix + '-name\" name=\"author_name\" required maxlength=\"120\" autocomplete=\"name\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-email\">' + emailLabel + '</label>' +\n '<input type=\"email\" id=\"' + prefix + '-email\" name=\"author_email\"' + (config.requireEmail ? ' required' : '') + ' maxlength=\"254\" autocomplete=\"email\" /></p>' +\n '<p class=\"cr-field\"><label for=\"' + prefix + '-url\">' + esc(strings.website) + '</label>' +\n '<input type=\"url\" id=\"' + prefix + '-url\" name=\"author_url\" maxlength=\"2048\" autocomplete=\"url\" /></p>' +\n honeypotHtml(config.honeypotField) +\n captchaHtml(config.captcha, config.captchaSiteKey) +\n '<p class=\"cr-actions\"><button type=\"submit\">' + esc(strings.postComment) + '</button></p>' +\n '<div class=\"cr-status\" aria-live=\"polite\"></div>' +\n '</form>'\n )\n}\n\n// \u2500\u2500\u2500 Turnstile (explicit render, so widgets added after load still render) \u2500\u2500\u2500\n\ninterface TurnstileApi {\n render: (el: Element, options: { sitekey: string }) => string\n reset: (id?: string) => void\n}\n\nconst TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady'\nconst widgets = new WeakMap<Element, string>()\nconst waiting: Element[] = []\n\nfunction turnstileApi(): TurnstileApi | undefined {\n return (globalThis as { turnstile?: TurnstileApi }).turnstile\n}\n\nfunction renderWidget(api: TurnstileApi, el: Element): void {\n if (widgets.has(el)) return\n widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' }))\n}\n\n/** Render every `.cf-turnstile` under `root`, loading the Turnstile script once on first use. */\nexport function renderCaptcha(root: ParentNode): void {\n const els = Array.from(root.querySelectorAll('.cf-turnstile'))\n if (!els.length) return\n const api = turnstileApi()\n if (api) {\n for (const el of els) renderWidget(api, el)\n return\n }\n waiting.push(...els)\n if (document.querySelector('script[data-cr-turnstile]')) return\n ;(globalThis as Record<string, unknown>).__crTurnstileReady = () => {\n const ready = turnstileApi()\n if (!ready) return\n for (const el of waiting.splice(0)) renderWidget(ready, el)\n }\n const script = document.createElement('script')\n script.src = TURNSTILE_SRC\n script.async = true\n script.defer = true\n script.setAttribute('data-cr-turnstile', '1')\n document.head.appendChild(script)\n}\n\nexport function resetCaptcha(root: ParentNode): void {\n const api = turnstileApi()\n if (!api) return\n for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el))\n}\n\n// \u2500\u2500\u2500 Mounting (the only DOM code) \u2500\u2500\u2500\n\nexport function runtimeOf(host: HTMLElement): Runtime {\n return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }\n}\n\nfunction entryOf(host: HTMLElement): EntryRef {\n return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', locale: host.dataset.locale || undefined }\n}\n\nfunction setStatus(form: HTMLFormElement, html: string): void {\n const status = form.querySelector('.cr-status')\n if (status) status.innerHTML = html\n}\n\nfunction busy(form: HTMLFormElement, on: boolean): void {\n const button = form.querySelector<HTMLButtonElement>('button[type=\"submit\"]')\n if (!button) return\n button.disabled = on\n if (on) {\n button.dataset.label = button.textContent ?? ''\n button.textContent = strings.sending\n } else {\n button.textContent = button.dataset.label ?? button.textContent\n }\n}\n\nfunction entriesOf(form: HTMLFormElement): Array<[string, unknown]> {\n const out: Array<[string, unknown]> = []\n new FormData(form).forEach((value, key) => out.push([key, value]))\n return out\n}\n\n/** `<cr-form data-base-url data-project data-model>` \u2192 fetch the config, render, submit. */\nexport async function mountForm(host: HTMLElement): Promise<void> {\n const rt = runtimeOf(host)\n const model = host.dataset.model ?? ''\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let config: FormConfig\n try {\n config = await fetchFormConfig(rt, model)\n } catch (error) {\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n host.innerHTML = formHtml(config)\n renderCaptcha(host)\n const form = host.querySelector('form')\n if (!form) return\n form.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitForm(rt, model, formPayload(entriesOf(form), config))\n if (result.success) {\n host.innerHTML = '<p class=\"cr-success\" role=\"status\">' + esc(result.message ?? config.successMessage ?? '') + '</p>'\n return\n }\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n } catch (error) {\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n\n/** `<cr-comments data-base-url data-project data-model data-entry data-locale>` \u2192 thread + form. */\nexport async function mountComments(host: HTMLElement): Promise<void> {\n const rt = runtimeOf(host)\n const entry = entryOf(host)\n host.innerHTML = '<p class=\"cr-loading\">' + esc(strings.loading) + '</p>'\n let thread: CommentThread\n try {\n thread = await fetchThread(rt, entry)\n } catch (error) {\n host.innerHTML = '<p class=\"cr-error\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'\n return\n }\n\n host.innerHTML =\n '<section class=\"cr-comments-section\">' +\n '<h2 class=\"cr-comments-title\">' + esc(strings.commentsTitle) + ' <span class=\"cr-count\">(' + esc(thread.total) + ')</span></h2>' +\n '<div class=\"cr-thread\">' + threadHtml(thread) + '</div>' +\n (hasMore(thread) ? '<p class=\"cr-more\"><button type=\"button\" class=\"cr-load-more\">' + esc(strings.loadMore) + '</button></p>' : '') +\n (thread.config.closed ? '<p class=\"cr-closed\">' + esc(strings.closed) + '</p>' : commentFormHtml(thread.config)) +\n '</section>'\n renderCaptcha(host)\n\n const threadEl = host.querySelector('.cr-thread')\n const form = host.querySelector<HTMLFormElement>('form.cr-comment-form')\n let page = thread.page\n\n host.addEventListener('click', async (event) => {\n const target = event.target as HTMLElement | null\n if (!target) return\n const replyButton = target.closest<HTMLElement>('.cr-reply')\n if (replyButton && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = replyButton.dataset.parent ?? ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n const to = form.querySelector('.cr-replying-to')\n if (to) to.textContent = strings.replyingTo + ' ' + (replyButton.dataset.author ?? '')\n if (replying) replying.hidden = false\n replyButton.closest('.cr-comment')?.appendChild(form)\n form.querySelector<HTMLTextAreaElement>('textarea[name=\"body\"]')?.focus()\n return\n }\n if (target.closest('.cr-cancel-reply') && form) {\n const parent = form.querySelector<HTMLInputElement>('input[name=\"parent_id\"]')\n if (parent) parent.value = ''\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n host.querySelector('.cr-comments-section')?.appendChild(form)\n return\n }\n const more = target.closest<HTMLButtonElement>('.cr-load-more')\n if (more && threadEl) {\n more.disabled = true\n try {\n const next = await fetchThread(rt, entry, { page: page + 1 })\n page = next.page\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join(''))\n if (!hasMore(next)) more.closest('.cr-more')?.remove()\n } finally {\n more.disabled = false\n }\n }\n })\n\n form?.addEventListener('submit', async (event) => {\n event.preventDefault()\n busy(form, true)\n setStatus(form, '')\n try {\n const result = await submitComment(rt, entry, commentPayload(entriesOf(form), thread.config.honeypotField))\n if (!result.success) {\n setStatus(form, errorsHtml(result.errors ?? []))\n resetCaptcha(form)\n return\n }\n const parentId = result.comment?.parentId ?? null\n if (result.status === 'approved' && result.comment && threadEl) {\n const html = commentHtml(result.comment, thread.config)\n const parent = parentId ? threadEl.querySelector('#cr-comment-' + parentId) : null\n if (parent) {\n let replies = parent.querySelector(':scope > .cr-replies')\n if (!replies) {\n parent.insertAdjacentHTML('beforeend', '<ol class=\"cr-replies\"></ol>')\n replies = parent.querySelector(':scope > .cr-replies')\n }\n replies?.insertAdjacentHTML('beforeend', html)\n } else {\n const list = threadEl.querySelector('.cr-comment-list')\n if (list) list.insertAdjacentHTML('beforeend', html)\n else threadEl.innerHTML = '<ol class=\"cr-comment-list\">' + html + '</ol>'\n }\n }\n form.reset()\n host.querySelector('.cr-comments-section')?.appendChild(form)\n const replying = form.querySelector<HTMLElement>('.cr-replying')\n if (replying) replying.hidden = true\n resetCaptcha(form)\n setStatus(form, '<p class=\"cr-success\" role=\"status\">' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '</p>')\n } catch (error) {\n setStatus(form, '<p class=\"cr-error\" role=\"alert\">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')\n resetCaptcha(form)\n } finally {\n busy(form, false)\n }\n })\n}\n";
|
|
190
246
|
//#endregion
|
|
@@ -204,5 +260,5 @@ declare function emitAstroProject(input: EmitInput): EmitResult;
|
|
|
204
260
|
/** Write an EmitResult to disk. Separate from emit so the core stays pure. */
|
|
205
261
|
declare function writeEmit(result: EmitResult, dir: string): Promise<void>;
|
|
206
262
|
//#endregion
|
|
207
|
-
export { type BalanceReport, EMBED_TS, type EmitContent, type EmitCssFile, type EmitInput, type EmitOptions, type EmitPost, type EmitResult, type EmitTermRef, type EntrySourceRef, type MountRef, type QueryPage, RUNTIME_IMPLEMENTED, type RuntimeBinding, balanceWarning, checkBalance, componentMarkers, emitAstroProject, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
|
|
263
|
+
export { type BalanceReport, EMBED_TS, type EmitContent, type EmitCssFile, type EmitInput, type EmitOptions, type EmitPost, type EmitResult, type EmitTermRef, type EntrySourceRef, type MountRef, type QueryPage, RUNTIME_IMPLEMENTED, type RuntimeBinding, SEO_COMPONENT, type StripResult, balanceWarning, bodySeoLeaks, checkBalance, componentMarkers, emitAstroProject, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, stripSeoTags, wrapLegacyCss, writeEmit };
|
|
208
264
|
//# 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/util.ts","../src/layouts.ts","../src/components.ts","../src/embed.ts","../src/balance.ts","../src/index.ts"],"mappings":";;;;;AAYA;;;;;AAKA;AAAA,UALiB,WAAA;EACf,IAAA;EACA,IAAA;AAAA;AAAA,UAGe,QAAA;EACf,IAAA;EACA,KAAA;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/css.ts","../src/util.ts","../src/layouts.ts","../src/components.ts","../src/seo.ts","../src/embed.ts","../src/balance.ts","../src/index.ts"],"mappings":";;;;;AAYA;;;;;AAKA;AAAA,UALiB,WAAA;EACf,IAAA;EACA,IAAA;AAAA;AAAA,UAGe,QAAA;EACf,IAAA;EACA,KAAA;EA2DsB;EAzDtB,IAAA;EAHA;;;;;;EAUA,MAAA,GAAS,MAAA;EAQT;;;;EAHA,GAAA;EAWuB;EATvB,KAAA;EACA,MAAA;EACA,YAAA;EACA,WAAA;EAsBA;;;;;EAhBA,KAAA,GAAQ,KAAA,UAAe,WAAA;EAyBf;EAvBR,QAAA;EACA,OAAA;EA+BQ;EA7BR,YAAA;EA6BsB;AAWxB;;;EAnCE,WAAA;EAqCA;;;;;EA/BA,KAAA;EAuCwB;EArCxB,SAAA;EAsCQ;EApCR,YAAA;EACA,WAAA;EAwDW;EAtDX,OAAA;EAsDsB;EApDtB,KAAA,GAAQ,MAAA;EA+BA;EA7BR,MAAA;EA8BO;;;;;;EAvBP,KAAA,GAAQ,cAAA;AAAA;;;;;;AAyDV;;;UA9CiB,WAAA;EAsDc;EApD7B,QAAA;EAsDyB;EApDzB,OAAA;EAoDgB;EAlDhB,KAAA;AAAA;;UAIe,SAAA;EACf,MAAA,EAAQ,MAAA;EACR,KAAA,EAAO,QAAA;EA4CP;;;;;EAtCA,KAAA,GAAQ,MAAA;EA0CkB;EAxC1B,GAAA;EAyCA;;AAIF;;;;;;;EAnCE,aAAA;EA+CG;EA7CH,QAAA,GAAW,WAAA;EAgDa;EA9CxB,KAAA;EA+CI;EA7CJ,WAAA;EA+CM;EA7CN,KAAA;EAoDU;EAlDV,SAAA;AAAA;AAAA,UAMe,WAAA;EAoCL;EAlCV,KAAA,GAAQ,QAAA;EAmCF;;;;;EA7BN,WAAA,GAAc,MAAA,SAAe,QAAA;EAoCL;EAlCxB,OAAA,GAAU,MAAA,SAAe,SAAA;AAAA;;UAIV,WAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,WAAA;EAqCP;EAnCR,WAAA;;EAEA,QAAA;EC9Ie;;;;;AAOjB;;ED+IE,GAAA;AAAA;AAAA,UAGe,SAAA;EACf,EAAA,EAAI,SAAA;EACJ,OAAA,GAAU,WAAA;EACV,GAAA,GAAM,WAAA;EACN,OAAA,GAAU,WAAA;;;;AE5KZ;;EFkLE,OAAA,GAAU,cAAA;AAAA;;AElKZ;;;;UF4KiB,UAAA;EACf,KAAA,EAAO,MAAA;EACP,QAAA;AAAA;;;UC/Ke,aAAA;EACf,OAAA;EACA,OAAA;AAAA;AAAA,iBAKc,aAAA,CAAc,IAAA,UAAc,GAAA,WAAc,aAAA;;;;iBCtB1C,UAAA,CAAW,EAAA;;AFW3B;;;;;AAKA;;iBEAgB,iBAAA,CAAkB,OAAA;;iBAqClB,UAAA,CAAW,KAAA;;;;UC3BV,QAAA;EACf,EAAA;EACA,IAAA;EACA,OAAA;AAAA;;iBAOc,gBAAA,CAAiB,IAAA;;;;cCbpB,mBAAA,EAAmB,GAAA;AAAA,iBAEhB,oBAAA,CAAqB,CAAA,EAAG,YAAA,EAAc,OAAA,EAAS,gBAAA;;;UCmB9C,WAAA;EACf,IAAA;;EAEA,OAAA;AAAA;;iBAIc,YAAA,CAAa,IAAA,WAAe,WAAA;;ALnC5C;;;;;;;;;iBKuEgB,YAAA,CAAa,IAAA;;;;;;cAahB,aAAA;;;cC7FA,QAAA;;;UC0EI,aAAA;;EAEf,QAAA;EPxEe;EO0Ef,QAAA;AAAA;AAAA,iBAGc,YAAA,CAAa,IAAA,WAAe,aAAA;;iBAiB5B,cAAA,CAAe,IAAA;;;iBCvFf,gBAAA,CAAiB,KAAA,EAAO,SAAA,GAAY,UAAA;;iBAmG9B,SAAA,CAAU,MAAA,EAAQ,UAAA,EAAY,GAAA,WAAc,OAAA"}
|