@contentrain/emitter-astro 0.6.1 → 0.8.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 +13 -2
- package/dist/index.d.mts +44 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1158 -28
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { CHROME_BODY_SLOT, LEGACY_CSS_LAYER } from "@contentrain/types";
|
|
1
|
+
import { CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN, LEGACY_CSS_LAYER } from "@contentrain/types";
|
|
2
|
+
//#region src/types.ts
|
|
3
|
+
/** Default collection name when a `single` route does not name one. */
|
|
4
|
+
const DEFAULT_COLLECTION = "posts";
|
|
5
|
+
//#endregion
|
|
2
6
|
//#region src/util.ts
|
|
3
7
|
/** "f-archive" → "FArchive", "posts" → "Posts" — Astro component file names. */
|
|
4
8
|
function pascalCase(id) {
|
|
@@ -191,6 +195,36 @@ export function composeBody(chromeBody: string, values: Values, content: string)
|
|
|
191
195
|
.join(content)
|
|
192
196
|
}
|
|
193
197
|
|
|
198
|
+
/** Component mount marker — must match @contentrain/types CHROME_COMPONENT_OPEN / CHROME_COMPONENT_CLOSE. */
|
|
199
|
+
export const COMPONENT_OPEN = '<!--@@component:'
|
|
200
|
+
export const COMPONENT_CLOSE = '@@-->'
|
|
201
|
+
const COMPONENT_RE = /<!--@@component:([^@\\s]+)@@-->/g
|
|
202
|
+
|
|
203
|
+
export interface BodyPart {
|
|
204
|
+
html: string
|
|
205
|
+
/** A mount point: the component id the marker named. */
|
|
206
|
+
component?: string
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Split rendered chrome at component markers into html parts interleaved with
|
|
211
|
+
* mount points, so the layout can render a real component where the theme's
|
|
212
|
+
* comments form (or contact form) stood. The parts are output in order, so the
|
|
213
|
+
* page's HTML is exactly the chrome with the component's markup at the marker —
|
|
214
|
+
* no parser sees the pieces separately.
|
|
215
|
+
*/
|
|
216
|
+
export function splitComponents(html: string): BodyPart[] {
|
|
217
|
+
const parts: BodyPart[] = []
|
|
218
|
+
let last = 0
|
|
219
|
+
for (const match of html.matchAll(COMPONENT_RE)) {
|
|
220
|
+
parts.push({ html: html.slice(last, match.index) })
|
|
221
|
+
parts.push({ html: '', component: match[1] ?? '' })
|
|
222
|
+
last = match.index + match[0].length
|
|
223
|
+
}
|
|
224
|
+
parts.push({ html: html.slice(last) })
|
|
225
|
+
return parts
|
|
226
|
+
}
|
|
227
|
+
|
|
194
228
|
/** Where a list section's wrapper takes its items — must match @contentrain/types LIST_ITEMS_SLOT. */
|
|
195
229
|
export const ITEMS_SLOT = '<!--@@items@@-->'
|
|
196
230
|
|
|
@@ -266,13 +300,172 @@ export function postMarks(post: MarkablePost): Values {
|
|
|
266
300
|
for (const [i, f] of (post.featured ?? []).entries()) values['feat' + i] = f
|
|
267
301
|
return { ...values, ...(post.marks ?? {}) }
|
|
268
302
|
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The shapes of the emitted data files. Pages import JSON and assert these
|
|
306
|
+
* rather than letting TypeScript infer the shape from the file's contents:
|
|
307
|
+
* inference reads only the fields the data HAPPENS to carry, so a site whose
|
|
308
|
+
* posts need no extra route parameters produced a type without \`params\` and
|
|
309
|
+
* \`astro check\` — which the build runs first — failed on the page that reads
|
|
310
|
+
* it. The contract is what the emitter may write, not what one site wrote.
|
|
311
|
+
*/
|
|
312
|
+
/** Content-store address of an entry — what a mounted comments component keys its thread by. */
|
|
313
|
+
export interface EntryRef {
|
|
314
|
+
model_id: string
|
|
315
|
+
entry_id: string
|
|
316
|
+
locale?: string
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface EmittedPost extends MarkablePost {
|
|
320
|
+
body: string
|
|
321
|
+
/** Route parameters beyond slug — the date parts of a dated permalink, a post id. */
|
|
322
|
+
params?: Record<string, string>
|
|
323
|
+
/** Stylesheets only this page loads. */
|
|
324
|
+
css?: string[]
|
|
325
|
+
locale?: string
|
|
326
|
+
/** Present when the producer bound this post to the content store. */
|
|
327
|
+
entry?: EntryRef
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** One static path of a list route, as it appears in the emitted query data. */
|
|
331
|
+
export interface EmittedQueryPage {
|
|
332
|
+
params: Record<string, string>
|
|
333
|
+
items: EmittedPost[]
|
|
334
|
+
marks?: Record<string, unknown>
|
|
335
|
+
css?: string[]
|
|
336
|
+
item_template?: string
|
|
337
|
+
sections?: ListSection[]
|
|
338
|
+
title?: string
|
|
339
|
+
}
|
|
269
340
|
`;
|
|
270
341
|
//#endregion
|
|
342
|
+
//#region src/balance.ts
|
|
343
|
+
const VOID_TAGS = new Set([
|
|
344
|
+
"area",
|
|
345
|
+
"base",
|
|
346
|
+
"br",
|
|
347
|
+
"col",
|
|
348
|
+
"embed",
|
|
349
|
+
"hr",
|
|
350
|
+
"img",
|
|
351
|
+
"input",
|
|
352
|
+
"link",
|
|
353
|
+
"meta",
|
|
354
|
+
"param",
|
|
355
|
+
"source",
|
|
356
|
+
"track",
|
|
357
|
+
"wbr"
|
|
358
|
+
]);
|
|
359
|
+
/** Tags a browser closes for you. Theme markup is full of them, so counting
|
|
360
|
+
* them would report every real page as broken. */
|
|
361
|
+
const OPTIONAL_END_TAGS = new Set([
|
|
362
|
+
"p",
|
|
363
|
+
"li",
|
|
364
|
+
"dt",
|
|
365
|
+
"dd",
|
|
366
|
+
"option",
|
|
367
|
+
"optgroup",
|
|
368
|
+
"thead",
|
|
369
|
+
"tbody",
|
|
370
|
+
"tfoot",
|
|
371
|
+
"tr",
|
|
372
|
+
"td",
|
|
373
|
+
"th",
|
|
374
|
+
"rt",
|
|
375
|
+
"rp"
|
|
376
|
+
]);
|
|
377
|
+
/** Elements whose content is text, not markup — `<script>if (a < b)<\/script>`. */
|
|
378
|
+
const RAW_TEXT_TAGS = new Set([
|
|
379
|
+
"script",
|
|
380
|
+
"style",
|
|
381
|
+
"textarea",
|
|
382
|
+
"title"
|
|
383
|
+
]);
|
|
384
|
+
function scanTags(html) {
|
|
385
|
+
const tags = [];
|
|
386
|
+
let i = 0;
|
|
387
|
+
while (i < html.length) {
|
|
388
|
+
const lt = html.indexOf("<", i);
|
|
389
|
+
if (lt === -1) break;
|
|
390
|
+
if (html.startsWith("<!--", lt)) {
|
|
391
|
+
const end = html.indexOf("-->", lt + 4);
|
|
392
|
+
i = end === -1 ? html.length : end + 3;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
|
|
396
|
+
const end = html.indexOf(">", lt);
|
|
397
|
+
i = end === -1 ? html.length : end + 1;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const [matched = "", slash = "", rawName = ""] = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(html.slice(lt, lt + 64)) ?? [];
|
|
401
|
+
if (!rawName) {
|
|
402
|
+
i = lt + 1;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
let j = lt + matched.length;
|
|
406
|
+
let quote = "";
|
|
407
|
+
while (j < html.length) {
|
|
408
|
+
const ch = html[j];
|
|
409
|
+
if (quote) {
|
|
410
|
+
if (ch === quote) quote = "";
|
|
411
|
+
} else if (ch === "\"" || ch === "'") quote = ch;
|
|
412
|
+
else if (ch === ">") break;
|
|
413
|
+
j++;
|
|
414
|
+
}
|
|
415
|
+
const name = rawName.toLowerCase();
|
|
416
|
+
const closing = slash === "/";
|
|
417
|
+
tags.push({
|
|
418
|
+
name,
|
|
419
|
+
closing,
|
|
420
|
+
selfClosing: html.slice(lt, j + 1).endsWith("/>")
|
|
421
|
+
});
|
|
422
|
+
i = j + 1;
|
|
423
|
+
if (!closing && RAW_TEXT_TAGS.has(name)) {
|
|
424
|
+
const close = html.toLowerCase().indexOf(`</${name}`, i);
|
|
425
|
+
i = close === -1 ? html.length : close;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return tags;
|
|
429
|
+
}
|
|
430
|
+
function checkBalance(html) {
|
|
431
|
+
const stack = [];
|
|
432
|
+
const unopened = [];
|
|
433
|
+
for (const tag of scanTags(html)) {
|
|
434
|
+
if (VOID_TAGS.has(tag.name) || tag.selfClosing || OPTIONAL_END_TAGS.has(tag.name)) continue;
|
|
435
|
+
if (!tag.closing) {
|
|
436
|
+
stack.push(tag.name);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const at = stack.lastIndexOf(tag.name);
|
|
440
|
+
if (at === -1) unopened.push(tag.name);
|
|
441
|
+
else stack.length = at;
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
unclosed: stack.toReversed(),
|
|
445
|
+
unopened
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/** Human-readable balance complaint, or null when the fragment is sound. */
|
|
449
|
+
function balanceWarning(html) {
|
|
450
|
+
const { unclosed, unopened } = checkBalance(html);
|
|
451
|
+
const parts = [];
|
|
452
|
+
if (unclosed.length) parts.push(`never closed: ${unclosed.join(", ")}`);
|
|
453
|
+
if (unopened.length) parts.push(`closed but never opened: ${unopened.join(", ")}`);
|
|
454
|
+
return parts.length ? parts.join("; ") : null;
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
271
457
|
//#region src/layouts.ts
|
|
272
|
-
|
|
458
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
459
|
+
const MARKER_RE = new RegExp(`${escapeRe(CHROME_COMPONENT_OPEN)}([^@\\s]+)${escapeRe(CHROME_COMPONENT_CLOSE)}`, "g");
|
|
460
|
+
/** Component ids the chrome names, in order of first appearance. */
|
|
461
|
+
function componentMarkers(html) {
|
|
462
|
+
return [...new Set([...html.matchAll(MARKER_RE)].map((m) => m[1] ?? ""))];
|
|
463
|
+
}
|
|
464
|
+
function familyFiles(family, lang, components = [], definitions = /* @__PURE__ */ new Map()) {
|
|
273
465
|
const name = pascalCase(family.id);
|
|
274
|
-
const chunks = family.chrome ?? [];
|
|
466
|
+
const chunks = (family.chrome ?? []).filter((c) => c.position !== "header" && c.position !== "footer");
|
|
275
467
|
const warnings = [];
|
|
468
|
+
for (const chunk of family.chrome ?? []) if ((chunk.position === "header" || chunk.position === "footer") && componentMarkers(chunk.html).length) warnings.push(`family ${family.id}: ${chunk.position} chrome carries a component marker — components mount in body chrome only; the marker renders as a comment`);
|
|
276
469
|
const joined = (position) => chunks.filter((c) => c.position === position).map((c) => c.html).join("\n");
|
|
277
470
|
const bodyChunks = chunks.filter((c) => c.position === "body");
|
|
278
471
|
let body;
|
|
@@ -283,6 +476,25 @@ function familyFiles(family, lang) {
|
|
|
283
476
|
body += CHROME_BODY_SLOT;
|
|
284
477
|
}
|
|
285
478
|
} else body = `${joined("before_body")}${CHROME_BODY_SLOT}${joined("after_body")}`;
|
|
479
|
+
const unbalanced = balanceWarning(body);
|
|
480
|
+
if (unbalanced) warnings.push(`family ${family.id}: body chrome is not balanced (${unbalanced}) — the browser will repair it and the page loses its layout`);
|
|
481
|
+
const placements = new Map((family.components ?? []).map((p) => [p.component, p]));
|
|
482
|
+
const mounts = [];
|
|
483
|
+
for (const id of componentMarkers(body)) {
|
|
484
|
+
const def = definitions.get(id);
|
|
485
|
+
if (!def) {
|
|
486
|
+
warnings.push(`family ${family.id}: component marker "${id}" has no component definition — marker dropped`);
|
|
487
|
+
body = body.split(`${CHROME_COMPONENT_OPEN}${id}${CHROME_COMPONENT_CLOSE}`).join("");
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const variant = placements.get(id)?.variant ?? def.variants?.[0]?.key ?? "default";
|
|
491
|
+
mounts.push({
|
|
492
|
+
id,
|
|
493
|
+
name: pascalCase(id),
|
|
494
|
+
variant
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
for (const placement of family.components ?? []) if (!mounts.some((m) => m.id === placement.component)) warnings.push(`family ${family.id}: component placement "${placement.component}" has no marker in the body chrome — not mounted`);
|
|
286
498
|
const files = {};
|
|
287
499
|
files[`src/data/chrome/${family.id}.json`] = stableJson({
|
|
288
500
|
head: joined("head"),
|
|
@@ -291,11 +503,26 @@ function familyFiles(family, lang) {
|
|
|
291
503
|
body_attrs: family.root_attrs?.body ?? {}
|
|
292
504
|
});
|
|
293
505
|
const cssLinks = (family.css.files ?? []).map((f) => `<link rel="stylesheet" href="/styles/legacy/${f.split("/").pop()}" />`).join("\n");
|
|
506
|
+
const componentImports = [...new Set([...components.map((c) => c.name), ...mounts.map((m) => m.name)])].map((n) => `import ${n} from '../components/${n}.astro'`).join("\n");
|
|
507
|
+
const mountType = [...new Set(mounts.map((m) => `typeof ${m.name}`))].join(" | ");
|
|
508
|
+
const mountTable = mounts.length ? `// Components the chrome mounts at <!--@@component:ID@@--> markers; the page's
|
|
509
|
+
// entry address travels to each so a comments thread knows which entry it is.
|
|
510
|
+
const mounts: Record<string, { Mount: ${mountType}; variant: string } | undefined> = {
|
|
511
|
+
${mounts.map((m) => ` ${JSON.stringify(m.id)}: { Mount: ${m.name}, variant: ${JSON.stringify(m.variant)} },`).join("\n")}
|
|
512
|
+
}
|
|
513
|
+
const parts = splitComponents(html).map((part) => ({
|
|
514
|
+
html: part.html,
|
|
515
|
+
Mount: part.component ? mounts[part.component]?.Mount : undefined,
|
|
516
|
+
variant: part.component ? (mounts[part.component]?.variant ?? 'default') : 'default',
|
|
517
|
+
}))
|
|
518
|
+
` : "";
|
|
519
|
+
const bodyRender = mounts.length ? ` {parts.map(({ html: part, Mount, variant }) => (Mount ? <Mount entry={entry} variant={variant} /> : <Fragment set:html={part} />))}` : ` <Fragment set:html={html} />`;
|
|
520
|
+
const renderRefs = (position) => components.filter((c) => c.position === position).map((c) => ` <${c.name} marks={marks} />`).join("\n");
|
|
294
521
|
files[`src/layouts/${name}.astro`] = `---
|
|
295
522
|
// Family: ${family.id}${family.name ? ` (${family.name})` : ""} — emitted by @contentrain/emitter-astro
|
|
296
523
|
import chrome from '../data/chrome/${family.id}.json'
|
|
297
|
-
import { cssHref, fillAttrs, renderTemplate, composeBody } from '../lib/fill'
|
|
298
|
-
|
|
524
|
+
import { cssHref, fillAttrs, renderTemplate, composeBody${mounts.length ? ", splitComponents" : ""} } from '../lib/fill'
|
|
525
|
+
${componentImports ? `${componentImports}\n` : ""}
|
|
299
526
|
interface Props {
|
|
300
527
|
title?: string
|
|
301
528
|
marks?: Record<string, unknown>
|
|
@@ -305,8 +532,10 @@ interface Props {
|
|
|
305
532
|
css?: string[]
|
|
306
533
|
/** Document language — a multilingual site's routes each pass their own. */
|
|
307
534
|
lang?: string
|
|
535
|
+
/** Content-store address of the page's entry — mounted components (comments) key on it. */
|
|
536
|
+
entry?: { model_id: string; entry_id: string; locale?: string }
|
|
308
537
|
}
|
|
309
|
-
const { title = '', marks = {}, body, css = [], lang = ${JSON.stringify(lang)} } = Astro.props
|
|
538
|
+
const { title = '', marks = {}, body, css = [], lang = ${JSON.stringify(lang)}${mounts.length ? ", entry" : ""} } = Astro.props
|
|
310
539
|
const head = renderTemplate(chrome.head, marks)
|
|
311
540
|
// Root attributes carry the theme's layout hooks; values may hold @@marks@@.
|
|
312
541
|
// An explicit lang from the source wins over the project default.
|
|
@@ -316,7 +545,7 @@ const content = body ?? (Astro.slots.has('default') ? await Astro.slots.render('
|
|
|
316
545
|
// Split at the marker FIRST, then fill marks per side — filling first would
|
|
317
546
|
// eat the @@body@@ inside the marker and silently drop the content.
|
|
318
547
|
const html = composeBody(chrome.body, marks, content)
|
|
319
|
-
---
|
|
548
|
+
${mountTable}---
|
|
320
549
|
<!doctype html>
|
|
321
550
|
<html {...htmlAttrs}>
|
|
322
551
|
<head>
|
|
@@ -330,8 +559,8 @@ ${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
|
|
|
330
559
|
<title>{title}</title>
|
|
331
560
|
</head>
|
|
332
561
|
<body {...bodyAttrs}>
|
|
333
|
-
|
|
334
|
-
</body>
|
|
562
|
+
${renderRefs("header") ? `${renderRefs("header")}\n` : ""}${bodyRender}
|
|
563
|
+
${renderRefs("footer") ? `${renderRefs("footer")}\n` : ""} </body>
|
|
335
564
|
</html>
|
|
336
565
|
`;
|
|
337
566
|
return {
|
|
@@ -398,16 +627,22 @@ function singlePage(route, layout, up, collection, siteLocale) {
|
|
|
398
627
|
return `---
|
|
399
628
|
// Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro
|
|
400
629
|
import Layout from '${up}layouts/${layout}.astro'
|
|
401
|
-
import
|
|
402
|
-
import { postMarks } from '${up}lib/fill'
|
|
630
|
+
import data from '${up}data/${collection}.json'
|
|
631
|
+
import { postMarks, type EmittedPost } from '${up}lib/fill'
|
|
403
632
|
|
|
404
633
|
export function getStaticPaths() {
|
|
634
|
+
// The JSON is data; its contract is EmittedPost. Inferring the type from the
|
|
635
|
+
// file would type only the fields this site happens to carry, and "astro
|
|
636
|
+
// check" (which the build runs first) would then reject post.params on a site
|
|
637
|
+
// whose permalinks need none. The cast lives INSIDE getStaticPaths because
|
|
638
|
+
// Astro hoists this function: it sees imports, not the component scope.
|
|
639
|
+
const posts = data as EmittedPost[]
|
|
405
640
|
// A post carries its own route parameters (date parts of a dated permalink,
|
|
406
641
|
// a post id) — using the template post's would send every other post to the
|
|
407
642
|
// wrong address.
|
|
408
643
|
return posts.map((post) => ({ params: { ...(post.params ?? {}), slug: post.slug }, props: { post } }))
|
|
409
644
|
}
|
|
410
|
-
type Props = { post:
|
|
645
|
+
type Props = { post: EmittedPost }
|
|
411
646
|
const { post } = Astro.props as Props
|
|
412
647
|
---
|
|
413
648
|
<Layout
|
|
@@ -416,22 +651,24 @@ const { post } = Astro.props as Props
|
|
|
416
651
|
body={post.body}
|
|
417
652
|
css={post.css ?? []}
|
|
418
653
|
lang={post.locale ?? ${locale}}
|
|
654
|
+
entry={post.entry}
|
|
419
655
|
/>
|
|
420
656
|
`;
|
|
421
657
|
}
|
|
422
658
|
function listPage(route, layout, up, hasParams, siteLocale) {
|
|
423
659
|
const locale = JSON.stringify(route.locale ?? siteLocale);
|
|
424
|
-
const routeTitle = JSON.stringify(route.title
|
|
660
|
+
const routeTitle = route.title === void 0 ? `''` : JSON.stringify(route.title);
|
|
425
661
|
const paths = hasParams ? `export function getStaticPaths() {
|
|
662
|
+
const pages = data as EmittedQueryPage[]
|
|
426
663
|
return pages.map((page) => ({ params: page.params, props: { page } }))
|
|
427
664
|
}
|
|
428
|
-
type Props = { page:
|
|
429
|
-
const { page } = Astro.props as Props` : `const page =
|
|
665
|
+
type Props = { page: EmittedQueryPage }
|
|
666
|
+
const { page } = Astro.props as Props` : `const page = (data as EmittedQueryPage[])[0] ?? { params: {}, items: [], title: undefined }`;
|
|
430
667
|
return `---
|
|
431
668
|
// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
|
|
432
669
|
import Layout from '${up}layouts/${layout}.astro'
|
|
433
|
-
import
|
|
434
|
-
import { esc, postMarks, renderSections,
|
|
670
|
+
import data from '${up}data/queries/${route.query}.json'
|
|
671
|
+
import { esc, postMarks, renderSections, type EmittedQueryPage } from '${up}lib/fill'
|
|
435
672
|
|
|
436
673
|
${paths}
|
|
437
674
|
// A list renders in sections (a big card then a grid) — one template is the
|
|
@@ -446,7 +683,7 @@ const content = sections.length
|
|
|
446
683
|
// Route params carry slugs; page marks carry what the chrome needs to SHOW
|
|
447
684
|
// (a term's display name, description, count).
|
|
448
685
|
const marks = { ...page.params, ...(page.marks ?? {}) }
|
|
449
|
-
const title = page.title ?? ${routeTitle}
|
|
686
|
+
const title = page.title ?? ${routeTitle}
|
|
450
687
|
---
|
|
451
688
|
<Layout
|
|
452
689
|
title={title || String(marks.term_name ?? page.params.term ?? '')}
|
|
@@ -471,25 +708,904 @@ ${paths}---
|
|
|
471
708
|
`;
|
|
472
709
|
}
|
|
473
710
|
//#endregion
|
|
711
|
+
//#region src/embed.ts
|
|
712
|
+
const EMBED_TS = `// Emitted by @contentrain/emitter-astro — the runtime-component client.
|
|
713
|
+
//
|
|
714
|
+
// Comments and forms are the two regions of a migrated site that need a live
|
|
715
|
+
// service. This module is the browser side of the provider's PUBLIC API
|
|
716
|
+
// (Studio: /api/forms/v1 and /api/comments/v1) — the same contract that
|
|
717
|
+
// @contentrain/query/cdn implements, inlined so the generated site depends on
|
|
718
|
+
// nothing but Astro. No credential travels with a request: the endpoints are
|
|
719
|
+
// unauthenticated by design, and a page cannot keep a secret.
|
|
720
|
+
//
|
|
721
|
+
// Rendering helpers are pure (strings in, HTML out, escaped by default) so the
|
|
722
|
+
// mount functions at the bottom are the only code that touches the DOM.
|
|
723
|
+
|
|
724
|
+
export interface Runtime {
|
|
725
|
+
base_url: string
|
|
726
|
+
project_id: string
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export interface EntryRef {
|
|
730
|
+
model_id: string
|
|
731
|
+
entry_id: string
|
|
732
|
+
locale?: string
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export class EmbedError extends Error {
|
|
736
|
+
status: number
|
|
737
|
+
constructor(status: number, message: string) {
|
|
738
|
+
super(message)
|
|
739
|
+
this.name = 'EmbedError'
|
|
740
|
+
this.status = status
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Visitor-facing strings — override from the site (\`strings.send = 'Gönder'\`). */
|
|
745
|
+
export const strings = {
|
|
746
|
+
loading: 'Loading…',
|
|
747
|
+
send: 'Send',
|
|
748
|
+
sending: 'Sending…',
|
|
749
|
+
commentsTitle: 'Comments',
|
|
750
|
+
noComments: 'No comments yet.',
|
|
751
|
+
closed: 'Comments are closed.',
|
|
752
|
+
postComment: 'Post comment',
|
|
753
|
+
reply: 'Reply',
|
|
754
|
+
replyingTo: 'Replying to',
|
|
755
|
+
cancel: 'Cancel',
|
|
756
|
+
loadMore: 'Load more comments',
|
|
757
|
+
moderator: 'Moderator',
|
|
758
|
+
pending: 'Thank you — your comment is awaiting moderation.',
|
|
759
|
+
posted: 'Your comment has been posted.',
|
|
760
|
+
name: 'Name',
|
|
761
|
+
email: 'Email',
|
|
762
|
+
emailNote: '(never shown)',
|
|
763
|
+
website: 'Website',
|
|
764
|
+
comment: 'Comment',
|
|
765
|
+
failed: 'Something went wrong. Please try again.',
|
|
766
|
+
honeypot: 'Leave this field empty',
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
export const esc = (value: unknown): string =>
|
|
770
|
+
String(value ?? '')
|
|
771
|
+
.replace(/&/g, '&')
|
|
772
|
+
.replace(/</g, '<')
|
|
773
|
+
.replace(/>/g, '>')
|
|
774
|
+
.replace(/"/g, '"')
|
|
775
|
+
|
|
776
|
+
// ─── Transport ───
|
|
777
|
+
|
|
778
|
+
/** \`base\` without a trailing slash, segments URL-encoded, empty query values dropped. */
|
|
779
|
+
export function publicUrl(base: string, segments: string[], query?: Record<string, string | number | undefined>): string {
|
|
780
|
+
const path = segments.map((s) => encodeURIComponent(s)).join('/')
|
|
781
|
+
const params = new URLSearchParams()
|
|
782
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
783
|
+
if (value !== undefined && value !== '') params.set(key, String(value))
|
|
784
|
+
}
|
|
785
|
+
const qs = params.toString()
|
|
786
|
+
return base.replace(/\\/+$/, '') + '/' + path + (qs ? '?' + qs : '')
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function failure(res: Response): Promise<EmbedError> {
|
|
790
|
+
const text = await res.text().catch(() => '')
|
|
791
|
+
let message = text || 'Request failed'
|
|
792
|
+
try {
|
|
793
|
+
const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown }
|
|
794
|
+
const m = parsed.message ?? parsed.statusMessage
|
|
795
|
+
if (typeof m === 'string' && m) message = m
|
|
796
|
+
} catch {
|
|
797
|
+
// not JSON — the raw text is the message
|
|
798
|
+
}
|
|
799
|
+
return new EmbedError(res.status, message)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export async function getJson<T>(url: string): Promise<T> {
|
|
803
|
+
const res = await fetch(url)
|
|
804
|
+
if (!res.ok) throw await failure(res)
|
|
805
|
+
return (await res.json()) as T
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export async function postJson<T>(url: string, body: unknown): Promise<T> {
|
|
809
|
+
const res = await fetch(url, {
|
|
810
|
+
method: 'POST',
|
|
811
|
+
headers: { 'Content-Type': 'application/json' },
|
|
812
|
+
body: JSON.stringify(body),
|
|
813
|
+
})
|
|
814
|
+
if (!res.ok) throw await failure(res)
|
|
815
|
+
return (await res.json()) as T
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export const formsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/forms/v1'
|
|
819
|
+
export const commentsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/comments/v1'
|
|
820
|
+
|
|
821
|
+
/** The hidden input Cloudflare Turnstile adds to the enclosing form. */
|
|
822
|
+
export const CAPTCHA_FIELD = 'cf-turnstile-response'
|
|
823
|
+
|
|
824
|
+
// ─── Forms ───
|
|
825
|
+
|
|
826
|
+
export interface FieldDef {
|
|
827
|
+
type: string
|
|
828
|
+
required?: boolean
|
|
829
|
+
label?: string
|
|
830
|
+
options?: string[]
|
|
831
|
+
min?: number
|
|
832
|
+
max?: number
|
|
833
|
+
pattern?: string
|
|
834
|
+
default?: unknown
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export interface FormConfig {
|
|
838
|
+
modelId: string
|
|
839
|
+
locale: string
|
|
840
|
+
/** Exposed fields only, keyed by field id. */
|
|
841
|
+
fields: Record<string, FieldDef>
|
|
842
|
+
captcha: 'turnstile' | null
|
|
843
|
+
captchaSiteKey: string | null
|
|
844
|
+
successMessage?: string
|
|
845
|
+
honeypotField: string | null
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export interface FieldError {
|
|
849
|
+
field: string
|
|
850
|
+
message: string
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export interface FormSubmitResult {
|
|
854
|
+
success: boolean
|
|
855
|
+
message?: string
|
|
856
|
+
errors?: FieldError[]
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** The documented request body: values under \`data\`, control fields beside it. */
|
|
860
|
+
export interface FormPayload {
|
|
861
|
+
data: Record<string, unknown>
|
|
862
|
+
captchaToken?: string
|
|
863
|
+
_hp?: string
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export function fetchFormConfig(rt: Runtime, model: string): Promise<FormConfig> {
|
|
867
|
+
return getJson<FormConfig>(publicUrl(formsRoot(rt), [rt.project_id, model, 'config']))
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export function submitForm(rt: Runtime, model: string, payload: FormPayload): Promise<FormSubmitResult> {
|
|
871
|
+
return postJson<FormSubmitResult>(publicUrl(formsRoot(rt), [rt.project_id, model, 'submit']), payload)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function coerce(def: FieldDef, value: string): unknown {
|
|
875
|
+
switch (def.type) {
|
|
876
|
+
case 'number':
|
|
877
|
+
case 'integer':
|
|
878
|
+
case 'decimal':
|
|
879
|
+
case 'percent':
|
|
880
|
+
case 'rating':
|
|
881
|
+
return value === '' ? '' : Number(value)
|
|
882
|
+
case 'boolean':
|
|
883
|
+
return value === 'on' || value === 'true'
|
|
884
|
+
default:
|
|
885
|
+
return value
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Form entries (\`new FormData(form)\`) → request body. Only exposed fields
|
|
891
|
+
* reach \`data\`; the captcha token and the honeypot value travel beside it, so
|
|
892
|
+
* a control field can never collide with a model field. Files are skipped —
|
|
893
|
+
* uploads are not part of the public contract.
|
|
894
|
+
*/
|
|
895
|
+
export function formPayload(
|
|
896
|
+
entries: Iterable<[string, unknown]>,
|
|
897
|
+
config: { fields: Record<string, FieldDef>; honeypotField: string | null },
|
|
898
|
+
): FormPayload {
|
|
899
|
+
const payload: FormPayload = { data: {} }
|
|
900
|
+
for (const [name, raw] of entries) {
|
|
901
|
+
if (typeof raw !== 'string') continue
|
|
902
|
+
if (name === CAPTCHA_FIELD) {
|
|
903
|
+
if (raw) payload.captchaToken = raw
|
|
904
|
+
continue
|
|
905
|
+
}
|
|
906
|
+
if (config.honeypotField && name === config.honeypotField) {
|
|
907
|
+
payload._hp = raw
|
|
908
|
+
continue
|
|
909
|
+
}
|
|
910
|
+
const def = config.fields[name]
|
|
911
|
+
if (!def) continue
|
|
912
|
+
payload.data[name] = coerce(def, raw)
|
|
913
|
+
}
|
|
914
|
+
return payload
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export function labelFor(id: string, def: FieldDef): string {
|
|
918
|
+
if (def.label) return def.label
|
|
919
|
+
const words = id.replace(/[_-]+/g, ' ').trim()
|
|
920
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** One input for one exposed field, chosen by field type. */
|
|
924
|
+
export function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string {
|
|
925
|
+
const common =
|
|
926
|
+
' id="' + esc(prefix + '-' + id) + '" name="' + esc(id) + '"' +
|
|
927
|
+
(def.required ? ' required' : '') +
|
|
928
|
+
(def.pattern ? ' pattern="' + esc(def.pattern) + '"' : '')
|
|
929
|
+
const range =
|
|
930
|
+
(def.min !== undefined ? ' min="' + esc(def.min) + '"' : '') +
|
|
931
|
+
(def.max !== undefined ? ' max="' + esc(def.max) + '"' : '')
|
|
932
|
+
const length = def.max !== undefined ? ' maxlength="' + esc(def.max) + '"' : ''
|
|
933
|
+
switch (def.type) {
|
|
934
|
+
case 'text':
|
|
935
|
+
case 'markdown':
|
|
936
|
+
case 'richtext':
|
|
937
|
+
case 'code':
|
|
938
|
+
return '<textarea' + common + length + ' rows="5"></textarea>'
|
|
939
|
+
case 'select': {
|
|
940
|
+
const blank = def.required ? '' : '<option value=""></option>'
|
|
941
|
+
const options = (def.options ?? []).map((o) => '<option value="' + esc(o) + '">' + esc(o) + '</option>').join('')
|
|
942
|
+
return '<select' + common + '>' + blank + options + '</select>'
|
|
943
|
+
}
|
|
944
|
+
case 'boolean':
|
|
945
|
+
return '<input type="checkbox"' + common + ' />'
|
|
946
|
+
case 'number':
|
|
947
|
+
case 'integer':
|
|
948
|
+
case 'rating':
|
|
949
|
+
return '<input type="number"' + common + range + (def.type === 'integer' ? ' step="1"' : '') + ' />'
|
|
950
|
+
case 'decimal':
|
|
951
|
+
case 'percent':
|
|
952
|
+
return '<input type="number" step="any"' + common + range + ' />'
|
|
953
|
+
case 'email':
|
|
954
|
+
return '<input type="email"' + common + length + ' />'
|
|
955
|
+
case 'url':
|
|
956
|
+
return '<input type="url"' + common + length + ' />'
|
|
957
|
+
case 'phone':
|
|
958
|
+
return '<input type="tel"' + common + length + ' />'
|
|
959
|
+
case 'date':
|
|
960
|
+
return '<input type="date"' + common + ' />'
|
|
961
|
+
case 'datetime':
|
|
962
|
+
return '<input type="datetime-local"' + common + ' />'
|
|
963
|
+
case 'color':
|
|
964
|
+
return '<input type="color"' + common + ' />'
|
|
965
|
+
default:
|
|
966
|
+
return '<input type="text"' + common + length + ' />'
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
export function honeypotHtml(field: string | null): string {
|
|
971
|
+
if (!field) return ''
|
|
972
|
+
return (
|
|
973
|
+
'<p class="cr-hp" aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">' +
|
|
974
|
+
'<label>' + esc(strings.honeypot) + ' <input type="text" name="' + esc(field) + '" tabindex="-1" autocomplete="off" /></label></p>'
|
|
975
|
+
)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null): string {
|
|
979
|
+
if (captcha !== 'turnstile' || !siteKey) return ''
|
|
980
|
+
return '<div class="cf-turnstile" data-sitekey="' + esc(siteKey) + '"></div>'
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export function errorsHtml(errors: FieldError[]): string {
|
|
984
|
+
return (
|
|
985
|
+
'<ul class="cr-errors" role="alert">' +
|
|
986
|
+
errors.map((e) => '<li data-field="' + esc(e.field) + '">' + esc(e.field) + ': ' + esc(e.message) + '</li>').join('') +
|
|
987
|
+
'</ul>'
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** The whole form for a public form config: one control per exposed field, honeypot, captcha, submit. */
|
|
992
|
+
export function formHtml(config: FormConfig, prefix = 'cr-field'): string {
|
|
993
|
+
const fields = Object.entries(config.fields)
|
|
994
|
+
.map(([id, def]) => {
|
|
995
|
+
const control = fieldControl(id, def, prefix)
|
|
996
|
+
const label = '<label for="' + esc(prefix + '-' + id) + '">' + esc(labelFor(id, def)) + (def.required ? ' <span aria-hidden="true">*</span>' : '') + '</label>'
|
|
997
|
+
return '<p class="cr-field cr-field--' + esc(def.type) + '">' + (def.type === 'boolean' ? control + ' ' + label : label + control) + '</p>'
|
|
998
|
+
})
|
|
999
|
+
.join('')
|
|
1000
|
+
return (
|
|
1001
|
+
'<form class="cr-form" method="post" data-model="' + esc(config.modelId) + '">' +
|
|
1002
|
+
fields +
|
|
1003
|
+
honeypotHtml(config.honeypotField) +
|
|
1004
|
+
captchaHtml(config.captcha, config.captchaSiteKey) +
|
|
1005
|
+
'<p class="cr-actions"><button type="submit">' + esc(strings.send) + '</button></p>' +
|
|
1006
|
+
'<div class="cr-status" aria-live="polite"></div>' +
|
|
1007
|
+
'</form>'
|
|
1008
|
+
)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// ─── Comments ───
|
|
1012
|
+
|
|
1013
|
+
export interface CommentAuthor {
|
|
1014
|
+
name: string
|
|
1015
|
+
url: string | null
|
|
1016
|
+
isModerator: boolean
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
export interface PublicComment {
|
|
1020
|
+
id: string
|
|
1021
|
+
parentId: string | null
|
|
1022
|
+
depth: number
|
|
1023
|
+
author: CommentAuthor
|
|
1024
|
+
/** Plain text. */
|
|
1025
|
+
body: string
|
|
1026
|
+
type: 'comment' | 'pingback' | 'trackback'
|
|
1027
|
+
createdAt: string
|
|
1028
|
+
replies: PublicComment[]
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export interface ThreadConfig {
|
|
1032
|
+
closed: boolean
|
|
1033
|
+
requireApproval: boolean
|
|
1034
|
+
requireEmail: boolean
|
|
1035
|
+
maxDepth: number
|
|
1036
|
+
maxBodyLength: number
|
|
1037
|
+
captcha: 'turnstile' | null
|
|
1038
|
+
captchaSiteKey: string | null
|
|
1039
|
+
honeypotField: string | null
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
export interface CommentThread {
|
|
1043
|
+
entry: { modelId: string; entryId: string; locale: string }
|
|
1044
|
+
config: ThreadConfig
|
|
1045
|
+
comments: PublicComment[]
|
|
1046
|
+
total: number
|
|
1047
|
+
page: number
|
|
1048
|
+
limit: number
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
export interface CommentSubmitBody {
|
|
1052
|
+
author: { name: string; email?: string; url?: string }
|
|
1053
|
+
body: string
|
|
1054
|
+
parentId?: string
|
|
1055
|
+
captchaToken?: string
|
|
1056
|
+
_hp?: string
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export interface CommentSubmitResult {
|
|
1060
|
+
success: boolean
|
|
1061
|
+
status?: 'pending' | 'approved'
|
|
1062
|
+
comment?: PublicComment
|
|
1063
|
+
errors?: FieldError[]
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export interface ThreadQuery {
|
|
1067
|
+
page?: number
|
|
1068
|
+
limit?: number
|
|
1069
|
+
sort?: 'oldest' | 'newest'
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
export function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise<CommentThread> {
|
|
1073
|
+
const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], {
|
|
1074
|
+
locale: entry.locale,
|
|
1075
|
+
page: query?.page,
|
|
1076
|
+
limit: query?.limit,
|
|
1077
|
+
sort: query?.sort,
|
|
1078
|
+
})
|
|
1079
|
+
return getJson<CommentThread>(url)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
export function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise<CommentSubmitResult> {
|
|
1083
|
+
const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale })
|
|
1084
|
+
return postJson<CommentSubmitResult>(url, body)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Comment-form entries → the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */
|
|
1088
|
+
export function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody {
|
|
1089
|
+
const values: Record<string, string> = {}
|
|
1090
|
+
const payload: CommentSubmitBody = { author: { name: '' }, body: '' }
|
|
1091
|
+
for (const [name, raw] of entries) {
|
|
1092
|
+
if (typeof raw !== 'string') continue
|
|
1093
|
+
if (name === CAPTCHA_FIELD) {
|
|
1094
|
+
if (raw) payload.captchaToken = raw
|
|
1095
|
+
continue
|
|
1096
|
+
}
|
|
1097
|
+
if (honeypotField && name === honeypotField) {
|
|
1098
|
+
payload._hp = raw
|
|
1099
|
+
continue
|
|
1100
|
+
}
|
|
1101
|
+
values[name] = raw
|
|
1102
|
+
}
|
|
1103
|
+
payload.author.name = (values.author_name ?? '').trim()
|
|
1104
|
+
if (values.author_email?.trim()) payload.author.email = values.author_email.trim()
|
|
1105
|
+
if (values.author_url?.trim()) payload.author.url = values.author_url.trim()
|
|
1106
|
+
payload.body = (values.body ?? '').trim()
|
|
1107
|
+
if (values.parent_id?.trim()) payload.parentId = values.parent_id.trim()
|
|
1108
|
+
return payload
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Plain text → paragraphs; blank lines separate paragraphs, single newlines break lines. */
|
|
1112
|
+
export function bodyHtml(text: string): string {
|
|
1113
|
+
return text
|
|
1114
|
+
.split(/\\n{2,}/)
|
|
1115
|
+
.map((p) => p.trim())
|
|
1116
|
+
.filter(Boolean)
|
|
1117
|
+
.map((p) => '<p>' + esc(p).replace(/\\n/g, '<br />') + '</p>')
|
|
1118
|
+
.join('')
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
export function hasMore(thread: CommentThread): boolean {
|
|
1122
|
+
return thread.page * thread.limit < thread.total
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */
|
|
1126
|
+
export function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string {
|
|
1127
|
+
const author = c.author.url
|
|
1128
|
+
? '<a href="' + esc(c.author.url) + '" rel="nofollow ugc noopener" target="_blank">' + esc(c.author.name) + '</a>'
|
|
1129
|
+
: esc(c.author.name)
|
|
1130
|
+
const badge = c.author.isModerator ? ' <span class="cr-moderator">' + esc(strings.moderator) + '</span>' : ''
|
|
1131
|
+
const date = '<time datetime="' + esc(c.createdAt) + '">' + esc(c.createdAt.slice(0, 10)) + '</time>'
|
|
1132
|
+
const canReply = !config.closed && c.depth < config.maxDepth
|
|
1133
|
+
const reply = canReply
|
|
1134
|
+
? '<button type="button" class="cr-reply" data-parent="' + esc(c.id) + '" data-author="' + esc(c.author.name) + '">' + esc(strings.reply) + '</button>'
|
|
1135
|
+
: ''
|
|
1136
|
+
const replies = c.replies.length
|
|
1137
|
+
? '<ol class="cr-replies">' + c.replies.map((r) => commentHtml(r, config)).join('') + '</ol>'
|
|
1138
|
+
: ''
|
|
1139
|
+
return (
|
|
1140
|
+
'<li class="cr-comment cr-comment--' + esc(c.type) + '" id="cr-comment-' + esc(c.id) + '" data-depth="' + esc(c.depth) + '">' +
|
|
1141
|
+
'<div class="cr-comment-meta"><span class="cr-comment-author">' + author + '</span>' + badge + ' ' + date + '</div>' +
|
|
1142
|
+
'<div class="cr-comment-body">' + bodyHtml(c.body) + '</div>' +
|
|
1143
|
+
(reply ? '<div class="cr-comment-actions">' + reply + '</div>' : '') +
|
|
1144
|
+
replies +
|
|
1145
|
+
'</li>'
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
export function threadHtml(thread: CommentThread): string {
|
|
1150
|
+
if (!thread.comments.length) return '<p class="cr-empty">' + esc(strings.noComments) + '</p>'
|
|
1151
|
+
return '<ol class="cr-comment-list">' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '</ol>'
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** The comment form; a reply carries its parent in a hidden input. */
|
|
1155
|
+
export function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string {
|
|
1156
|
+
const emailLabel = esc(strings.email) + (config.requireEmail ? ' <span aria-hidden="true">*</span>' : '') + ' <small>' + esc(strings.emailNote) + '</small>'
|
|
1157
|
+
return (
|
|
1158
|
+
'<form class="cr-comment-form" method="post">' +
|
|
1159
|
+
'<input type="hidden" name="parent_id" value="" />' +
|
|
1160
|
+
'<p class="cr-replying" hidden><span class="cr-replying-to"></span> <button type="button" class="cr-cancel-reply">' + esc(strings.cancel) + '</button></p>' +
|
|
1161
|
+
'<p class="cr-field"><label for="' + prefix + '-body">' + esc(strings.comment) + ' <span aria-hidden="true">*</span></label>' +
|
|
1162
|
+
'<textarea id="' + prefix + '-body" name="body" required maxlength="' + esc(config.maxBodyLength) + '" rows="5"></textarea></p>' +
|
|
1163
|
+
'<p class="cr-field"><label for="' + prefix + '-name">' + esc(strings.name) + ' <span aria-hidden="true">*</span></label>' +
|
|
1164
|
+
'<input type="text" id="' + prefix + '-name" name="author_name" required maxlength="120" autocomplete="name" /></p>' +
|
|
1165
|
+
'<p class="cr-field"><label for="' + prefix + '-email">' + emailLabel + '</label>' +
|
|
1166
|
+
'<input type="email" id="' + prefix + '-email" name="author_email"' + (config.requireEmail ? ' required' : '') + ' maxlength="254" autocomplete="email" /></p>' +
|
|
1167
|
+
'<p class="cr-field"><label for="' + prefix + '-url">' + esc(strings.website) + '</label>' +
|
|
1168
|
+
'<input type="url" id="' + prefix + '-url" name="author_url" maxlength="2048" autocomplete="url" /></p>' +
|
|
1169
|
+
honeypotHtml(config.honeypotField) +
|
|
1170
|
+
captchaHtml(config.captcha, config.captchaSiteKey) +
|
|
1171
|
+
'<p class="cr-actions"><button type="submit">' + esc(strings.postComment) + '</button></p>' +
|
|
1172
|
+
'<div class="cr-status" aria-live="polite"></div>' +
|
|
1173
|
+
'</form>'
|
|
1174
|
+
)
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// ─── Turnstile (explicit render, so widgets added after load still render) ───
|
|
1178
|
+
|
|
1179
|
+
interface TurnstileApi {
|
|
1180
|
+
render: (el: Element, options: { sitekey: string }) => string
|
|
1181
|
+
reset: (id?: string) => void
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady'
|
|
1185
|
+
const widgets = new WeakMap<Element, string>()
|
|
1186
|
+
const waiting: Element[] = []
|
|
1187
|
+
|
|
1188
|
+
function turnstileApi(): TurnstileApi | undefined {
|
|
1189
|
+
return (globalThis as { turnstile?: TurnstileApi }).turnstile
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function renderWidget(api: TurnstileApi, el: Element): void {
|
|
1193
|
+
if (widgets.has(el)) return
|
|
1194
|
+
widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' }))
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Render every \`.cf-turnstile\` under \`root\`, loading the Turnstile script once on first use. */
|
|
1198
|
+
export function renderCaptcha(root: ParentNode): void {
|
|
1199
|
+
const els = Array.from(root.querySelectorAll('.cf-turnstile'))
|
|
1200
|
+
if (!els.length) return
|
|
1201
|
+
const api = turnstileApi()
|
|
1202
|
+
if (api) {
|
|
1203
|
+
for (const el of els) renderWidget(api, el)
|
|
1204
|
+
return
|
|
1205
|
+
}
|
|
1206
|
+
waiting.push(...els)
|
|
1207
|
+
if (document.querySelector('script[data-cr-turnstile]')) return
|
|
1208
|
+
;(globalThis as Record<string, unknown>).__crTurnstileReady = () => {
|
|
1209
|
+
const ready = turnstileApi()
|
|
1210
|
+
if (!ready) return
|
|
1211
|
+
for (const el of waiting.splice(0)) renderWidget(ready, el)
|
|
1212
|
+
}
|
|
1213
|
+
const script = document.createElement('script')
|
|
1214
|
+
script.src = TURNSTILE_SRC
|
|
1215
|
+
script.async = true
|
|
1216
|
+
script.defer = true
|
|
1217
|
+
script.setAttribute('data-cr-turnstile', '1')
|
|
1218
|
+
document.head.appendChild(script)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
export function resetCaptcha(root: ParentNode): void {
|
|
1222
|
+
const api = turnstileApi()
|
|
1223
|
+
if (!api) return
|
|
1224
|
+
for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el))
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// ─── Mounting (the only DOM code) ───
|
|
1228
|
+
|
|
1229
|
+
export function runtimeOf(host: HTMLElement): Runtime {
|
|
1230
|
+
return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function entryOf(host: HTMLElement): EntryRef {
|
|
1234
|
+
return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', locale: host.dataset.locale || undefined }
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function setStatus(form: HTMLFormElement, html: string): void {
|
|
1238
|
+
const status = form.querySelector('.cr-status')
|
|
1239
|
+
if (status) status.innerHTML = html
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function busy(form: HTMLFormElement, on: boolean): void {
|
|
1243
|
+
const button = form.querySelector<HTMLButtonElement>('button[type="submit"]')
|
|
1244
|
+
if (!button) return
|
|
1245
|
+
button.disabled = on
|
|
1246
|
+
if (on) {
|
|
1247
|
+
button.dataset.label = button.textContent ?? ''
|
|
1248
|
+
button.textContent = strings.sending
|
|
1249
|
+
} else {
|
|
1250
|
+
button.textContent = button.dataset.label ?? button.textContent
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function entriesOf(form: HTMLFormElement): Array<[string, unknown]> {
|
|
1255
|
+
const out: Array<[string, unknown]> = []
|
|
1256
|
+
new FormData(form).forEach((value, key) => out.push([key, value]))
|
|
1257
|
+
return out
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** \`<cr-form data-base-url data-project data-model>\` → fetch the config, render, submit. */
|
|
1261
|
+
export async function mountForm(host: HTMLElement): Promise<void> {
|
|
1262
|
+
const rt = runtimeOf(host)
|
|
1263
|
+
const model = host.dataset.model ?? ''
|
|
1264
|
+
host.innerHTML = '<p class="cr-loading">' + esc(strings.loading) + '</p>'
|
|
1265
|
+
let config: FormConfig
|
|
1266
|
+
try {
|
|
1267
|
+
config = await fetchFormConfig(rt, model)
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
host.innerHTML = '<p class="cr-error">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'
|
|
1270
|
+
return
|
|
1271
|
+
}
|
|
1272
|
+
host.innerHTML = formHtml(config)
|
|
1273
|
+
renderCaptcha(host)
|
|
1274
|
+
const form = host.querySelector('form')
|
|
1275
|
+
if (!form) return
|
|
1276
|
+
form.addEventListener('submit', async (event) => {
|
|
1277
|
+
event.preventDefault()
|
|
1278
|
+
busy(form, true)
|
|
1279
|
+
setStatus(form, '')
|
|
1280
|
+
try {
|
|
1281
|
+
const result = await submitForm(rt, model, formPayload(entriesOf(form), config))
|
|
1282
|
+
if (result.success) {
|
|
1283
|
+
host.innerHTML = '<p class="cr-success" role="status">' + esc(result.message ?? config.successMessage ?? '') + '</p>'
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
setStatus(form, errorsHtml(result.errors ?? []))
|
|
1287
|
+
resetCaptcha(form)
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
setStatus(form, '<p class="cr-error" role="alert">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')
|
|
1290
|
+
resetCaptcha(form)
|
|
1291
|
+
} finally {
|
|
1292
|
+
busy(form, false)
|
|
1293
|
+
}
|
|
1294
|
+
})
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** \`<cr-comments data-base-url data-project data-model data-entry data-locale>\` → thread + form. */
|
|
1298
|
+
export async function mountComments(host: HTMLElement): Promise<void> {
|
|
1299
|
+
const rt = runtimeOf(host)
|
|
1300
|
+
const entry = entryOf(host)
|
|
1301
|
+
host.innerHTML = '<p class="cr-loading">' + esc(strings.loading) + '</p>'
|
|
1302
|
+
let thread: CommentThread
|
|
1303
|
+
try {
|
|
1304
|
+
thread = await fetchThread(rt, entry)
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
host.innerHTML = '<p class="cr-error">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
host.innerHTML =
|
|
1311
|
+
'<section class="cr-comments-section">' +
|
|
1312
|
+
'<h2 class="cr-comments-title">' + esc(strings.commentsTitle) + ' <span class="cr-count">(' + esc(thread.total) + ')</span></h2>' +
|
|
1313
|
+
'<div class="cr-thread">' + threadHtml(thread) + '</div>' +
|
|
1314
|
+
(hasMore(thread) ? '<p class="cr-more"><button type="button" class="cr-load-more">' + esc(strings.loadMore) + '</button></p>' : '') +
|
|
1315
|
+
(thread.config.closed ? '<p class="cr-closed">' + esc(strings.closed) + '</p>' : commentFormHtml(thread.config)) +
|
|
1316
|
+
'</section>'
|
|
1317
|
+
renderCaptcha(host)
|
|
1318
|
+
|
|
1319
|
+
const threadEl = host.querySelector('.cr-thread')
|
|
1320
|
+
const form = host.querySelector<HTMLFormElement>('form.cr-comment-form')
|
|
1321
|
+
let page = thread.page
|
|
1322
|
+
|
|
1323
|
+
host.addEventListener('click', async (event) => {
|
|
1324
|
+
const target = event.target as HTMLElement | null
|
|
1325
|
+
if (!target) return
|
|
1326
|
+
const replyButton = target.closest<HTMLElement>('.cr-reply')
|
|
1327
|
+
if (replyButton && form) {
|
|
1328
|
+
const parent = form.querySelector<HTMLInputElement>('input[name="parent_id"]')
|
|
1329
|
+
if (parent) parent.value = replyButton.dataset.parent ?? ''
|
|
1330
|
+
const replying = form.querySelector<HTMLElement>('.cr-replying')
|
|
1331
|
+
const to = form.querySelector('.cr-replying-to')
|
|
1332
|
+
if (to) to.textContent = strings.replyingTo + ' ' + (replyButton.dataset.author ?? '')
|
|
1333
|
+
if (replying) replying.hidden = false
|
|
1334
|
+
replyButton.closest('.cr-comment')?.appendChild(form)
|
|
1335
|
+
form.querySelector<HTMLTextAreaElement>('textarea[name="body"]')?.focus()
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1338
|
+
if (target.closest('.cr-cancel-reply') && form) {
|
|
1339
|
+
const parent = form.querySelector<HTMLInputElement>('input[name="parent_id"]')
|
|
1340
|
+
if (parent) parent.value = ''
|
|
1341
|
+
const replying = form.querySelector<HTMLElement>('.cr-replying')
|
|
1342
|
+
if (replying) replying.hidden = true
|
|
1343
|
+
host.querySelector('.cr-comments-section')?.appendChild(form)
|
|
1344
|
+
return
|
|
1345
|
+
}
|
|
1346
|
+
const more = target.closest<HTMLButtonElement>('.cr-load-more')
|
|
1347
|
+
if (more && threadEl) {
|
|
1348
|
+
more.disabled = true
|
|
1349
|
+
try {
|
|
1350
|
+
const next = await fetchThread(rt, entry, { page: page + 1 })
|
|
1351
|
+
page = next.page
|
|
1352
|
+
const list = threadEl.querySelector('.cr-comment-list')
|
|
1353
|
+
if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join(''))
|
|
1354
|
+
if (!hasMore(next)) more.closest('.cr-more')?.remove()
|
|
1355
|
+
} finally {
|
|
1356
|
+
more.disabled = false
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
})
|
|
1360
|
+
|
|
1361
|
+
form?.addEventListener('submit', async (event) => {
|
|
1362
|
+
event.preventDefault()
|
|
1363
|
+
busy(form, true)
|
|
1364
|
+
setStatus(form, '')
|
|
1365
|
+
try {
|
|
1366
|
+
const result = await submitComment(rt, entry, commentPayload(entriesOf(form), thread.config.honeypotField))
|
|
1367
|
+
if (!result.success) {
|
|
1368
|
+
setStatus(form, errorsHtml(result.errors ?? []))
|
|
1369
|
+
resetCaptcha(form)
|
|
1370
|
+
return
|
|
1371
|
+
}
|
|
1372
|
+
const parentId = result.comment?.parentId ?? null
|
|
1373
|
+
if (result.status === 'approved' && result.comment && threadEl) {
|
|
1374
|
+
const html = commentHtml(result.comment, thread.config)
|
|
1375
|
+
const parent = parentId ? threadEl.querySelector('#cr-comment-' + parentId) : null
|
|
1376
|
+
if (parent) {
|
|
1377
|
+
let replies = parent.querySelector(':scope > .cr-replies')
|
|
1378
|
+
if (!replies) {
|
|
1379
|
+
parent.insertAdjacentHTML('beforeend', '<ol class="cr-replies"></ol>')
|
|
1380
|
+
replies = parent.querySelector(':scope > .cr-replies')
|
|
1381
|
+
}
|
|
1382
|
+
replies?.insertAdjacentHTML('beforeend', html)
|
|
1383
|
+
} else {
|
|
1384
|
+
const list = threadEl.querySelector('.cr-comment-list')
|
|
1385
|
+
if (list) list.insertAdjacentHTML('beforeend', html)
|
|
1386
|
+
else threadEl.innerHTML = '<ol class="cr-comment-list">' + html + '</ol>'
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
form.reset()
|
|
1390
|
+
host.querySelector('.cr-comments-section')?.appendChild(form)
|
|
1391
|
+
const replying = form.querySelector<HTMLElement>('.cr-replying')
|
|
1392
|
+
if (replying) replying.hidden = true
|
|
1393
|
+
resetCaptcha(form)
|
|
1394
|
+
setStatus(form, '<p class="cr-success" role="status">' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '</p>')
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
setStatus(form, '<p class="cr-error" role="alert">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')
|
|
1397
|
+
resetCaptcha(form)
|
|
1398
|
+
} finally {
|
|
1399
|
+
busy(form, false)
|
|
1400
|
+
}
|
|
1401
|
+
})
|
|
1402
|
+
}
|
|
1403
|
+
`;
|
|
1404
|
+
//#endregion
|
|
474
1405
|
//#region src/components.ts
|
|
475
|
-
|
|
1406
|
+
/** Component types this emitter implements against a runtime binding. */
|
|
1407
|
+
const RUNTIME_IMPLEMENTED = new Set(["comments", "form"]);
|
|
1408
|
+
function isRuntimeImplemented(c, runtime) {
|
|
1409
|
+
if (!runtime || c.source !== "runtime" || !RUNTIME_IMPLEMENTED.has(c.type)) return false;
|
|
1410
|
+
return c.type !== "form" || Boolean(c.model);
|
|
1411
|
+
}
|
|
1412
|
+
function componentFiles(components, runtime) {
|
|
476
1413
|
const files = {};
|
|
1414
|
+
const warnings = [];
|
|
1415
|
+
let needsRuntime = false;
|
|
477
1416
|
for (const c of components) {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
1417
|
+
const name = pascalCase(c.id);
|
|
1418
|
+
if (isRuntimeImplemented(c, runtime)) {
|
|
1419
|
+
needsRuntime = true;
|
|
1420
|
+
files[`src/components/${name}.astro`] = c.type === "comments" ? commentsSource(c) : formSource(c);
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
if (c.source === "runtime" && RUNTIME_IMPLEMENTED.has(c.type)) warnings.push(!runtime ? `component ${c.id} (${c.type}): no runtime binding given — emitted as a placeholder; pass input.runtime to mount it` : `component ${c.id} (form): no model named — a form must say which collection it submits to; emitted as a placeholder`);
|
|
1424
|
+
files[`src/components/${name}.astro`] = placeholderSource(c);
|
|
1425
|
+
}
|
|
1426
|
+
if (needsRuntime && runtime) {
|
|
1427
|
+
files["src/data/runtime.json"] = stableJson({
|
|
1428
|
+
base_url: runtime.base_url,
|
|
1429
|
+
project_id: runtime.project_id
|
|
1430
|
+
});
|
|
1431
|
+
files["src/lib/embed.ts"] = EMBED_TS;
|
|
1432
|
+
}
|
|
1433
|
+
return {
|
|
1434
|
+
files,
|
|
1435
|
+
warnings
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
function header(c, extra = "") {
|
|
1439
|
+
return `/**
|
|
481
1440
|
* cr-component: type=${c.type} source=${c.source}${c.name ? ` — ${c.name}` : ""}
|
|
482
|
-
* Emitted by @contentrain/emitter-astro
|
|
483
|
-
|
|
1441
|
+
* Emitted by @contentrain/emitter-astro.${extra}
|
|
1442
|
+
*/`;
|
|
1443
|
+
}
|
|
1444
|
+
function placeholderSource(c) {
|
|
1445
|
+
const variants = (c.variants ?? []).map((v) => v.key);
|
|
1446
|
+
return `---
|
|
1447
|
+
${header(c, `${c.source === "runtime" ? "\n * Placeholder — needs a live provider; see the migration handoff offers." : " Placeholder."}${variants.length ? `\n * Variants: ${variants.join(", ")}` : ""}`)}
|
|
484
1448
|
interface Props {
|
|
485
1449
|
variant?: string
|
|
1450
|
+
/** Content-store address of the page's entry; unused by a placeholder. */
|
|
1451
|
+
entry?: { model_id: string; entry_id: string; locale?: string }
|
|
486
1452
|
}
|
|
487
1453
|
const { variant = ${JSON.stringify(variants[0] ?? "default")} } = Astro.props
|
|
488
1454
|
---
|
|
489
1455
|
<cr-component data-type=${JSON.stringify(c.type)} data-variant={variant}></cr-component>
|
|
490
1456
|
`;
|
|
1457
|
+
}
|
|
1458
|
+
function commentsSource(c) {
|
|
1459
|
+
return `---
|
|
1460
|
+
${header(c, `
|
|
1461
|
+
* Mounted comments thread: reads the entry's approved comments from the runtime
|
|
1462
|
+
* provider's public API and posts new ones. Pending comments are never shown —
|
|
1463
|
+
* moderation happens on the provider. Renders nothing on a page without an
|
|
1464
|
+
* entry address (a list, a static page).`)}
|
|
1465
|
+
import runtime from '../data/runtime.json'
|
|
1466
|
+
|
|
1467
|
+
interface Props {
|
|
1468
|
+
/** Content-store address of the page's entry — the thread's key. */
|
|
1469
|
+
entry?: { model_id: string; entry_id: string; locale?: string }
|
|
1470
|
+
variant?: string
|
|
1471
|
+
}
|
|
1472
|
+
const { entry, variant = 'default' } = Astro.props
|
|
1473
|
+
---
|
|
1474
|
+
{entry && (
|
|
1475
|
+
<cr-comments
|
|
1476
|
+
class={\`cr-comments cr-comments--\${variant}\`}
|
|
1477
|
+
data-base-url={runtime.base_url}
|
|
1478
|
+
data-project={runtime.project_id}
|
|
1479
|
+
data-model={entry.model_id}
|
|
1480
|
+
data-entry={entry.entry_id}
|
|
1481
|
+
data-locale={entry.locale}
|
|
1482
|
+
data-variant={variant}
|
|
1483
|
+
>
|
|
1484
|
+
<noscript><p class="cr-noscript">Comments need JavaScript.</p></noscript>
|
|
1485
|
+
</cr-comments>
|
|
1486
|
+
)}
|
|
1487
|
+
|
|
1488
|
+
<script>
|
|
1489
|
+
import { mountComments } from '../lib/embed'
|
|
1490
|
+
|
|
1491
|
+
class CrComments extends HTMLElement {
|
|
1492
|
+
connectedCallback() {
|
|
1493
|
+
void mountComments(this)
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
if (!customElements.get('cr-comments')) customElements.define('cr-comments', CrComments)
|
|
1497
|
+
<\/script>
|
|
1498
|
+
`;
|
|
1499
|
+
}
|
|
1500
|
+
function formSource(c) {
|
|
1501
|
+
return `---
|
|
1502
|
+
${header(c, `
|
|
1503
|
+
* Mounted public form for the ${JSON.stringify(c.model)} model: fetches the
|
|
1504
|
+
* exposed fields from the runtime provider, renders one control per field
|
|
1505
|
+
* (honeypot and captcha included when the model asks for them) and submits.
|
|
1506
|
+
* Submissions land on the provider for moderation; an approved one becomes a
|
|
1507
|
+
* content entry there.`)}
|
|
1508
|
+
import runtime from '../data/runtime.json'
|
|
1509
|
+
|
|
1510
|
+
interface Props {
|
|
1511
|
+
variant?: string
|
|
1512
|
+
/** Accepted for uniform mounting; a form is addressed by its model, not by the page's entry. */
|
|
1513
|
+
entry?: { model_id: string; entry_id: string; locale?: string }
|
|
1514
|
+
}
|
|
1515
|
+
const { variant = 'default' } = Astro.props
|
|
1516
|
+
---
|
|
1517
|
+
<cr-form
|
|
1518
|
+
class={\`cr-form-mount cr-form-mount--\${variant}\`}
|
|
1519
|
+
data-base-url={runtime.base_url}
|
|
1520
|
+
data-project={runtime.project_id}
|
|
1521
|
+
data-model=${JSON.stringify(c.model)}
|
|
1522
|
+
data-variant={variant}
|
|
1523
|
+
>
|
|
1524
|
+
<noscript><p class="cr-noscript">This form needs JavaScript.</p></noscript>
|
|
1525
|
+
</cr-form>
|
|
1526
|
+
|
|
1527
|
+
<script>
|
|
1528
|
+
import { mountForm } from '../lib/embed'
|
|
1529
|
+
|
|
1530
|
+
class CrForm extends HTMLElement {
|
|
1531
|
+
connectedCallback() {
|
|
1532
|
+
void mountForm(this)
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
if (!customElements.get('cr-form')) customElements.define('cr-form', CrForm)
|
|
1536
|
+
<\/script>
|
|
1537
|
+
`;
|
|
1538
|
+
}
|
|
1539
|
+
//#endregion
|
|
1540
|
+
//#region src/chrome.ts
|
|
1541
|
+
const DEFAULT_NAME = {
|
|
1542
|
+
header: "SiteHeader",
|
|
1543
|
+
footer: "SiteFooter"
|
|
1544
|
+
};
|
|
1545
|
+
/** A component name Astro can import: PascalCase and starting with a letter. */
|
|
1546
|
+
function componentName(raw) {
|
|
1547
|
+
const pascal = pascalCase(raw);
|
|
1548
|
+
return /^[A-Za-z]/.test(pascal) ? pascal : `Chrome${pascal}`;
|
|
1549
|
+
}
|
|
1550
|
+
function componentSource(name) {
|
|
1551
|
+
return `---
|
|
1552
|
+
/**
|
|
1553
|
+
* Chrome component: ${name} — emitted by @contentrain/emitter-astro.
|
|
1554
|
+
* Markup travels as data so the Astro compiler never parses theme markup, and
|
|
1555
|
+
* fills the same \`@@mark@@\` placeholders the rest of the chrome does.
|
|
1556
|
+
*/
|
|
1557
|
+
import chunk from '../data/components/${name}.json'
|
|
1558
|
+
import { renderTemplate } from '../lib/fill'
|
|
1559
|
+
|
|
1560
|
+
interface Props {
|
|
1561
|
+
/** Page marks — the region's own placeholders (site name, current-page class). */
|
|
1562
|
+
marks?: Record<string, unknown>
|
|
1563
|
+
}
|
|
1564
|
+
const { marks = {} } = Astro.props
|
|
1565
|
+
---
|
|
1566
|
+
<Fragment set:html={renderTemplate(chunk.html, marks)} />
|
|
1567
|
+
`;
|
|
1568
|
+
}
|
|
1569
|
+
function chromeComponents(families) {
|
|
1570
|
+
const files = {};
|
|
1571
|
+
const byFamily = /* @__PURE__ */ new Map();
|
|
1572
|
+
const warnings = [];
|
|
1573
|
+
/** html → emitted component name, so identical regions collapse into one. */
|
|
1574
|
+
const byHtml = /* @__PURE__ */ new Map();
|
|
1575
|
+
/** emitted name → its html, so a name clash can be detected and renamed. */
|
|
1576
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1577
|
+
for (const family of families) {
|
|
1578
|
+
const refs = [];
|
|
1579
|
+
for (const chunk of family.chrome ?? []) {
|
|
1580
|
+
if (chunk.position !== "header" && chunk.position !== "footer") continue;
|
|
1581
|
+
const position = chunk.position;
|
|
1582
|
+
const where = `family ${family.id}: ${position} chrome`;
|
|
1583
|
+
if (chunk.html.includes(CHROME_BODY_SLOT)) warnings.push(`${where} carries ${CHROME_BODY_SLOT} — content goes in the body chunk; the marker is rendered as a comment`);
|
|
1584
|
+
const unbalanced = balanceWarning(chunk.html);
|
|
1585
|
+
if (unbalanced) warnings.push(`${where} is not balanced (${unbalanced}) — a lifted region must close what it opens; keep it in the body chunk`);
|
|
1586
|
+
const wanted = componentName(chunk.component ?? DEFAULT_NAME[position]);
|
|
1587
|
+
let name = byHtml.get(chunk.html);
|
|
1588
|
+
if (name === void 0) {
|
|
1589
|
+
name = wanted;
|
|
1590
|
+
for (let n = 2; byName.has(name) && byName.get(name) !== chunk.html; n++) name = `${wanted}${n}`;
|
|
1591
|
+
if (name !== wanted) warnings.push(`${where}: component name "${wanted}" is already used by different markup — emitted as "${name}"`);
|
|
1592
|
+
byHtml.set(chunk.html, name);
|
|
1593
|
+
byName.set(name, chunk.html);
|
|
1594
|
+
files[`src/data/components/${name}.json`] = stableJson({ html: chunk.html });
|
|
1595
|
+
files[`src/components/${name}.astro`] = componentSource(name);
|
|
1596
|
+
}
|
|
1597
|
+
refs.push({
|
|
1598
|
+
name,
|
|
1599
|
+
position
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
if (refs.length) byFamily.set(family.id, refs);
|
|
491
1603
|
}
|
|
492
|
-
return
|
|
1604
|
+
return {
|
|
1605
|
+
files,
|
|
1606
|
+
byFamily,
|
|
1607
|
+
warnings
|
|
1608
|
+
};
|
|
493
1609
|
}
|
|
494
1610
|
//#endregion
|
|
495
1611
|
//#region src/css.ts
|
|
@@ -520,8 +1636,12 @@ function emitAstroProject(input) {
|
|
|
520
1636
|
add(scaffoldFiles(ir, input.options ?? {}));
|
|
521
1637
|
const familiesById = new Map(ir.families.map((f) => [f.id, f]));
|
|
522
1638
|
const lang = ir.site.locales?.[0] ?? "en";
|
|
1639
|
+
const chrome = chromeComponents(ir.families);
|
|
1640
|
+
add(chrome.files);
|
|
1641
|
+
warnings.push(...chrome.warnings);
|
|
1642
|
+
const definitions = new Map((ir.components ?? []).map((c) => [c.id, c]));
|
|
523
1643
|
for (const family of ir.families) {
|
|
524
|
-
const fam = familyFiles(family, lang);
|
|
1644
|
+
const fam = familyFiles(family, lang, chrome.byFamily.get(family.id), definitions);
|
|
525
1645
|
add(fam.files);
|
|
526
1646
|
warnings.push(...fam.warnings);
|
|
527
1647
|
}
|
|
@@ -543,7 +1663,17 @@ function emitAstroProject(input) {
|
|
|
543
1663
|
add(result.files);
|
|
544
1664
|
warnings.push(...result.warnings);
|
|
545
1665
|
}
|
|
546
|
-
|
|
1666
|
+
const components = componentFiles(ir.components ?? [], input.runtime);
|
|
1667
|
+
add(components.files);
|
|
1668
|
+
warnings.push(...components.warnings);
|
|
1669
|
+
if ((ir.components ?? []).some((c) => isRuntimeImplemented(c, input.runtime) && c.type === "comments")) {
|
|
1670
|
+
const collections = { ...input.content?.collections };
|
|
1671
|
+
if (input.content?.posts && !collections["posts"]) collections[DEFAULT_COLLECTION] = input.content.posts;
|
|
1672
|
+
for (const [name, posts] of Object.entries(collections)) {
|
|
1673
|
+
const unbound = posts.filter((p) => !p.entry).length;
|
|
1674
|
+
if (unbound) warnings.push(`collection ${name}: ${unbound} of ${posts.length} posts carry no entry address — the comments component renders nothing on those pages`);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
547
1677
|
return {
|
|
548
1678
|
files,
|
|
549
1679
|
warnings
|
|
@@ -558,6 +1688,6 @@ async function writeEmit(result, dir) {
|
|
|
558
1688
|
await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
|
|
559
1689
|
}
|
|
560
1690
|
//#endregion
|
|
561
|
-
export { emitAstroProject, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
|
|
1691
|
+
export { EMBED_TS, RUNTIME_IMPLEMENTED, balanceWarning, checkBalance, componentMarkers, emitAstroProject, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
|
|
562
1692
|
|
|
563
1693
|
//# sourceMappingURL=index.mjs.map
|