@contentrain/emitter-astro 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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
 
@@ -275,6 +309,13 @@ export function postMarks(post: MarkablePost): Values {
275
309
  * \`astro check\` — which the build runs first — failed on the page that reads
276
310
  * it. The contract is what the emitter may write, not what one site wrote.
277
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
+
278
319
  export interface EmittedPost extends MarkablePost {
279
320
  body: string
280
321
  /** Route parameters beyond slug — the date parts of a dated permalink, a post id. */
@@ -282,6 +323,8 @@ export interface EmittedPost extends MarkablePost {
282
323
  /** Stylesheets only this page loads. */
283
324
  css?: string[]
284
325
  locale?: string
326
+ /** Present when the producer bound this post to the content store. */
327
+ entry?: EntryRef
285
328
  }
286
329
 
287
330
  /** One static path of a list route, as it appears in the emitted query data. */
@@ -412,10 +455,17 @@ function balanceWarning(html) {
412
455
  }
413
456
  //#endregion
414
457
  //#region src/layouts.ts
415
- function familyFiles(family, lang, components = []) {
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()) {
416
465
  const name = pascalCase(family.id);
417
466
  const chunks = (family.chrome ?? []).filter((c) => c.position !== "header" && c.position !== "footer");
418
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`);
419
469
  const joined = (position) => chunks.filter((c) => c.position === position).map((c) => c.html).join("\n");
420
470
  const bodyChunks = chunks.filter((c) => c.position === "body");
421
471
  let body;
@@ -428,6 +478,23 @@ function familyFiles(family, lang, components = []) {
428
478
  } else body = `${joined("before_body")}${CHROME_BODY_SLOT}${joined("after_body")}`;
429
479
  const unbalanced = balanceWarning(body);
430
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`);
431
498
  const files = {};
432
499
  files[`src/data/chrome/${family.id}.json`] = stableJson({
433
500
  head: joined("head"),
@@ -436,12 +503,25 @@ function familyFiles(family, lang, components = []) {
436
503
  body_attrs: family.root_attrs?.body ?? {}
437
504
  });
438
505
  const cssLinks = (family.css.files ?? []).map((f) => `<link rel="stylesheet" href="/styles/legacy/${f.split("/").pop()}" />`).join("\n");
439
- const componentImports = [...new Set(components.map((c) => c.name))].map((n) => `import ${n} from '../components/${n}.astro'`).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} />`;
440
520
  const renderRefs = (position) => components.filter((c) => c.position === position).map((c) => ` <${c.name} marks={marks} />`).join("\n");
441
521
  files[`src/layouts/${name}.astro`] = `---
442
522
  // Family: ${family.id}${family.name ? ` (${family.name})` : ""} — emitted by @contentrain/emitter-astro
443
523
  import chrome from '../data/chrome/${family.id}.json'
444
- import { cssHref, fillAttrs, renderTemplate, composeBody } from '../lib/fill'
524
+ import { cssHref, fillAttrs, renderTemplate, composeBody${mounts.length ? ", splitComponents" : ""} } from '../lib/fill'
445
525
  ${componentImports ? `${componentImports}\n` : ""}
446
526
  interface Props {
447
527
  title?: string
@@ -452,8 +532,10 @@ interface Props {
452
532
  css?: string[]
453
533
  /** Document language — a multilingual site's routes each pass their own. */
454
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 }
455
537
  }
456
- 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
457
539
  const head = renderTemplate(chrome.head, marks)
458
540
  // Root attributes carry the theme's layout hooks; values may hold @@marks@@.
459
541
  // An explicit lang from the source wins over the project default.
@@ -463,7 +545,7 @@ const content = body ?? (Astro.slots.has('default') ? await Astro.slots.render('
463
545
  // Split at the marker FIRST, then fill marks per side — filling first would
464
546
  // eat the @@body@@ inside the marker and silently drop the content.
465
547
  const html = composeBody(chrome.body, marks, content)
466
- ---
548
+ ${mountTable}---
467
549
  <!doctype html>
468
550
  <html {...htmlAttrs}>
469
551
  <head>
@@ -477,7 +559,7 @@ ${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
477
559
  <title>{title}</title>
478
560
  </head>
479
561
  <body {...bodyAttrs}>
480
- ${renderRefs("header") ? `${renderRefs("header")}\n` : ""} <Fragment set:html={html} />
562
+ ${renderRefs("header") ? `${renderRefs("header")}\n` : ""}${bodyRender}
481
563
  ${renderRefs("footer") ? `${renderRefs("footer")}\n` : ""} </body>
482
564
  </html>
483
565
  `;
@@ -569,6 +651,7 @@ const { post } = Astro.props as Props
569
651
  body={post.body}
570
652
  css={post.css ?? []}
571
653
  lang={post.locale ?? ${locale}}
654
+ entry={post.entry}
572
655
  />
573
656
  `;
574
657
  }
@@ -625,25 +708,834 @@ ${paths}---
625
708
  `;
626
709
  }
627
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, '&amp;')
772
+ .replace(/</g, '&lt;')
773
+ .replace(/>/g, '&gt;')
774
+ .replace(/"/g, '&quot;')
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
+ /** null for a root comment — sent explicitly, as the provider's own fixture does. */
1055
+ parentId: string | null
1056
+ captchaToken?: string
1057
+ _hp?: string
1058
+ }
1059
+
1060
+ export interface CommentSubmitResult {
1061
+ success: boolean
1062
+ status?: 'pending' | 'approved'
1063
+ comment?: PublicComment
1064
+ errors?: FieldError[]
1065
+ }
1066
+
1067
+ export interface ThreadQuery {
1068
+ page?: number
1069
+ limit?: number
1070
+ sort?: 'oldest' | 'newest'
1071
+ }
1072
+
1073
+ export function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise<CommentThread> {
1074
+ const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], {
1075
+ locale: entry.locale,
1076
+ page: query?.page,
1077
+ limit: query?.limit,
1078
+ sort: query?.sort,
1079
+ })
1080
+ return getJson<CommentThread>(url)
1081
+ }
1082
+
1083
+ export function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise<CommentSubmitResult> {
1084
+ const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale })
1085
+ return postJson<CommentSubmitResult>(url, body)
1086
+ }
1087
+
1088
+ /** Comment-form entries → the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */
1089
+ export function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody {
1090
+ const values: Record<string, string> = {}
1091
+ const payload: CommentSubmitBody = { author: { name: '' }, body: '', parentId: null }
1092
+ for (const [name, raw] of entries) {
1093
+ if (typeof raw !== 'string') continue
1094
+ if (name === CAPTCHA_FIELD) {
1095
+ if (raw) payload.captchaToken = raw
1096
+ continue
1097
+ }
1098
+ if (honeypotField && name === honeypotField) {
1099
+ payload._hp = raw
1100
+ continue
1101
+ }
1102
+ values[name] = raw
1103
+ }
1104
+ payload.author.name = (values.author_name ?? '').trim()
1105
+ if (values.author_email?.trim()) payload.author.email = values.author_email.trim()
1106
+ if (values.author_url?.trim()) payload.author.url = values.author_url.trim()
1107
+ payload.body = (values.body ?? '').trim()
1108
+ payload.parentId = values.parent_id?.trim() || null
1109
+ return payload
1110
+ }
1111
+
1112
+ /** Plain text → paragraphs; blank lines separate paragraphs, single newlines break lines. */
1113
+ export function bodyHtml(text: string): string {
1114
+ return text
1115
+ .split(/\\n{2,}/)
1116
+ .map((p) => p.trim())
1117
+ .filter(Boolean)
1118
+ .map((p) => '<p>' + esc(p).replace(/\\n/g, '<br />') + '</p>')
1119
+ .join('')
1120
+ }
1121
+
1122
+ export function hasMore(thread: CommentThread): boolean {
1123
+ return thread.page * thread.limit < thread.total
1124
+ }
1125
+
1126
+ /** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */
1127
+ export function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string {
1128
+ const author = c.author.url
1129
+ ? '<a href="' + esc(c.author.url) + '" rel="nofollow ugc noopener" target="_blank">' + esc(c.author.name) + '</a>'
1130
+ : esc(c.author.name)
1131
+ const badge = c.author.isModerator ? ' <span class="cr-moderator">' + esc(strings.moderator) + '</span>' : ''
1132
+ const date = '<time datetime="' + esc(c.createdAt) + '">' + esc(c.createdAt.slice(0, 10)) + '</time>'
1133
+ const canReply = !config.closed && c.depth < config.maxDepth
1134
+ const reply = canReply
1135
+ ? '<button type="button" class="cr-reply" data-parent="' + esc(c.id) + '" data-author="' + esc(c.author.name) + '">' + esc(strings.reply) + '</button>'
1136
+ : ''
1137
+ const replies = c.replies.length
1138
+ ? '<ol class="cr-replies">' + c.replies.map((r) => commentHtml(r, config)).join('') + '</ol>'
1139
+ : ''
1140
+ return (
1141
+ '<li class="cr-comment cr-comment--' + esc(c.type) + '" id="cr-comment-' + esc(c.id) + '" data-depth="' + esc(c.depth) + '">' +
1142
+ '<div class="cr-comment-meta"><span class="cr-comment-author">' + author + '</span>' + badge + ' ' + date + '</div>' +
1143
+ '<div class="cr-comment-body">' + bodyHtml(c.body) + '</div>' +
1144
+ (reply ? '<div class="cr-comment-actions">' + reply + '</div>' : '') +
1145
+ replies +
1146
+ '</li>'
1147
+ )
1148
+ }
1149
+
1150
+ export function threadHtml(thread: CommentThread): string {
1151
+ if (!thread.comments.length) return '<p class="cr-empty">' + esc(strings.noComments) + '</p>'
1152
+ return '<ol class="cr-comment-list">' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '</ol>'
1153
+ }
1154
+
1155
+ /** The comment form; a reply carries its parent in a hidden input. */
1156
+ export function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string {
1157
+ const emailLabel = esc(strings.email) + (config.requireEmail ? ' <span aria-hidden="true">*</span>' : '') + ' <small>' + esc(strings.emailNote) + '</small>'
1158
+ return (
1159
+ '<form class="cr-comment-form" method="post">' +
1160
+ '<input type="hidden" name="parent_id" value="" />' +
1161
+ '<p class="cr-replying" hidden><span class="cr-replying-to"></span> <button type="button" class="cr-cancel-reply">' + esc(strings.cancel) + '</button></p>' +
1162
+ '<p class="cr-field"><label for="' + prefix + '-body">' + esc(strings.comment) + ' <span aria-hidden="true">*</span></label>' +
1163
+ '<textarea id="' + prefix + '-body" name="body" required maxlength="' + esc(config.maxBodyLength) + '" rows="5"></textarea></p>' +
1164
+ '<p class="cr-field"><label for="' + prefix + '-name">' + esc(strings.name) + ' <span aria-hidden="true">*</span></label>' +
1165
+ '<input type="text" id="' + prefix + '-name" name="author_name" required maxlength="120" autocomplete="name" /></p>' +
1166
+ '<p class="cr-field"><label for="' + prefix + '-email">' + emailLabel + '</label>' +
1167
+ '<input type="email" id="' + prefix + '-email" name="author_email"' + (config.requireEmail ? ' required' : '') + ' maxlength="254" autocomplete="email" /></p>' +
1168
+ '<p class="cr-field"><label for="' + prefix + '-url">' + esc(strings.website) + '</label>' +
1169
+ '<input type="url" id="' + prefix + '-url" name="author_url" maxlength="2048" autocomplete="url" /></p>' +
1170
+ honeypotHtml(config.honeypotField) +
1171
+ captchaHtml(config.captcha, config.captchaSiteKey) +
1172
+ '<p class="cr-actions"><button type="submit">' + esc(strings.postComment) + '</button></p>' +
1173
+ '<div class="cr-status" aria-live="polite"></div>' +
1174
+ '</form>'
1175
+ )
1176
+ }
1177
+
1178
+ // ─── Turnstile (explicit render, so widgets added after load still render) ───
1179
+
1180
+ interface TurnstileApi {
1181
+ render: (el: Element, options: { sitekey: string }) => string
1182
+ reset: (id?: string) => void
1183
+ }
1184
+
1185
+ const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady'
1186
+ const widgets = new WeakMap<Element, string>()
1187
+ const waiting: Element[] = []
1188
+
1189
+ function turnstileApi(): TurnstileApi | undefined {
1190
+ return (globalThis as { turnstile?: TurnstileApi }).turnstile
1191
+ }
1192
+
1193
+ function renderWidget(api: TurnstileApi, el: Element): void {
1194
+ if (widgets.has(el)) return
1195
+ widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' }))
1196
+ }
1197
+
1198
+ /** Render every \`.cf-turnstile\` under \`root\`, loading the Turnstile script once on first use. */
1199
+ export function renderCaptcha(root: ParentNode): void {
1200
+ const els = Array.from(root.querySelectorAll('.cf-turnstile'))
1201
+ if (!els.length) return
1202
+ const api = turnstileApi()
1203
+ if (api) {
1204
+ for (const el of els) renderWidget(api, el)
1205
+ return
1206
+ }
1207
+ waiting.push(...els)
1208
+ if (document.querySelector('script[data-cr-turnstile]')) return
1209
+ ;(globalThis as Record<string, unknown>).__crTurnstileReady = () => {
1210
+ const ready = turnstileApi()
1211
+ if (!ready) return
1212
+ for (const el of waiting.splice(0)) renderWidget(ready, el)
1213
+ }
1214
+ const script = document.createElement('script')
1215
+ script.src = TURNSTILE_SRC
1216
+ script.async = true
1217
+ script.defer = true
1218
+ script.setAttribute('data-cr-turnstile', '1')
1219
+ document.head.appendChild(script)
1220
+ }
1221
+
1222
+ export function resetCaptcha(root: ParentNode): void {
1223
+ const api = turnstileApi()
1224
+ if (!api) return
1225
+ for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el))
1226
+ }
1227
+
1228
+ // ─── Mounting (the only DOM code) ───
1229
+
1230
+ export function runtimeOf(host: HTMLElement): Runtime {
1231
+ return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }
1232
+ }
1233
+
1234
+ function entryOf(host: HTMLElement): EntryRef {
1235
+ return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', locale: host.dataset.locale || undefined }
1236
+ }
1237
+
1238
+ function setStatus(form: HTMLFormElement, html: string): void {
1239
+ const status = form.querySelector('.cr-status')
1240
+ if (status) status.innerHTML = html
1241
+ }
1242
+
1243
+ function busy(form: HTMLFormElement, on: boolean): void {
1244
+ const button = form.querySelector<HTMLButtonElement>('button[type="submit"]')
1245
+ if (!button) return
1246
+ button.disabled = on
1247
+ if (on) {
1248
+ button.dataset.label = button.textContent ?? ''
1249
+ button.textContent = strings.sending
1250
+ } else {
1251
+ button.textContent = button.dataset.label ?? button.textContent
1252
+ }
1253
+ }
1254
+
1255
+ function entriesOf(form: HTMLFormElement): Array<[string, unknown]> {
1256
+ const out: Array<[string, unknown]> = []
1257
+ new FormData(form).forEach((value, key) => out.push([key, value]))
1258
+ return out
1259
+ }
1260
+
1261
+ /** \`<cr-form data-base-url data-project data-model>\` → fetch the config, render, submit. */
1262
+ export async function mountForm(host: HTMLElement): Promise<void> {
1263
+ const rt = runtimeOf(host)
1264
+ const model = host.dataset.model ?? ''
1265
+ host.innerHTML = '<p class="cr-loading">' + esc(strings.loading) + '</p>'
1266
+ let config: FormConfig
1267
+ try {
1268
+ config = await fetchFormConfig(rt, model)
1269
+ } catch (error) {
1270
+ host.innerHTML = '<p class="cr-error">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'
1271
+ return
1272
+ }
1273
+ host.innerHTML = formHtml(config)
1274
+ renderCaptcha(host)
1275
+ const form = host.querySelector('form')
1276
+ if (!form) return
1277
+ form.addEventListener('submit', async (event) => {
1278
+ event.preventDefault()
1279
+ busy(form, true)
1280
+ setStatus(form, '')
1281
+ try {
1282
+ const result = await submitForm(rt, model, formPayload(entriesOf(form), config))
1283
+ if (result.success) {
1284
+ host.innerHTML = '<p class="cr-success" role="status">' + esc(result.message ?? config.successMessage ?? '') + '</p>'
1285
+ return
1286
+ }
1287
+ setStatus(form, errorsHtml(result.errors ?? []))
1288
+ resetCaptcha(form)
1289
+ } catch (error) {
1290
+ setStatus(form, '<p class="cr-error" role="alert">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')
1291
+ resetCaptcha(form)
1292
+ } finally {
1293
+ busy(form, false)
1294
+ }
1295
+ })
1296
+ }
1297
+
1298
+ /** \`<cr-comments data-base-url data-project data-model data-entry data-locale>\` → thread + form. */
1299
+ export async function mountComments(host: HTMLElement): Promise<void> {
1300
+ const rt = runtimeOf(host)
1301
+ const entry = entryOf(host)
1302
+ host.innerHTML = '<p class="cr-loading">' + esc(strings.loading) + '</p>'
1303
+ let thread: CommentThread
1304
+ try {
1305
+ thread = await fetchThread(rt, entry)
1306
+ } catch (error) {
1307
+ host.innerHTML = '<p class="cr-error">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'
1308
+ return
1309
+ }
1310
+
1311
+ host.innerHTML =
1312
+ '<section class="cr-comments-section">' +
1313
+ '<h2 class="cr-comments-title">' + esc(strings.commentsTitle) + ' <span class="cr-count">(' + esc(thread.total) + ')</span></h2>' +
1314
+ '<div class="cr-thread">' + threadHtml(thread) + '</div>' +
1315
+ (hasMore(thread) ? '<p class="cr-more"><button type="button" class="cr-load-more">' + esc(strings.loadMore) + '</button></p>' : '') +
1316
+ (thread.config.closed ? '<p class="cr-closed">' + esc(strings.closed) + '</p>' : commentFormHtml(thread.config)) +
1317
+ '</section>'
1318
+ renderCaptcha(host)
1319
+
1320
+ const threadEl = host.querySelector('.cr-thread')
1321
+ const form = host.querySelector<HTMLFormElement>('form.cr-comment-form')
1322
+ let page = thread.page
1323
+
1324
+ host.addEventListener('click', async (event) => {
1325
+ const target = event.target as HTMLElement | null
1326
+ if (!target) return
1327
+ const replyButton = target.closest<HTMLElement>('.cr-reply')
1328
+ if (replyButton && form) {
1329
+ const parent = form.querySelector<HTMLInputElement>('input[name="parent_id"]')
1330
+ if (parent) parent.value = replyButton.dataset.parent ?? ''
1331
+ const replying = form.querySelector<HTMLElement>('.cr-replying')
1332
+ const to = form.querySelector('.cr-replying-to')
1333
+ if (to) to.textContent = strings.replyingTo + ' ' + (replyButton.dataset.author ?? '')
1334
+ if (replying) replying.hidden = false
1335
+ replyButton.closest('.cr-comment')?.appendChild(form)
1336
+ form.querySelector<HTMLTextAreaElement>('textarea[name="body"]')?.focus()
1337
+ return
1338
+ }
1339
+ if (target.closest('.cr-cancel-reply') && form) {
1340
+ const parent = form.querySelector<HTMLInputElement>('input[name="parent_id"]')
1341
+ if (parent) parent.value = ''
1342
+ const replying = form.querySelector<HTMLElement>('.cr-replying')
1343
+ if (replying) replying.hidden = true
1344
+ host.querySelector('.cr-comments-section')?.appendChild(form)
1345
+ return
1346
+ }
1347
+ const more = target.closest<HTMLButtonElement>('.cr-load-more')
1348
+ if (more && threadEl) {
1349
+ more.disabled = true
1350
+ try {
1351
+ const next = await fetchThread(rt, entry, { page: page + 1 })
1352
+ page = next.page
1353
+ const list = threadEl.querySelector('.cr-comment-list')
1354
+ if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join(''))
1355
+ if (!hasMore(next)) more.closest('.cr-more')?.remove()
1356
+ } finally {
1357
+ more.disabled = false
1358
+ }
1359
+ }
1360
+ })
1361
+
1362
+ form?.addEventListener('submit', async (event) => {
1363
+ event.preventDefault()
1364
+ busy(form, true)
1365
+ setStatus(form, '')
1366
+ try {
1367
+ const result = await submitComment(rt, entry, commentPayload(entriesOf(form), thread.config.honeypotField))
1368
+ if (!result.success) {
1369
+ setStatus(form, errorsHtml(result.errors ?? []))
1370
+ resetCaptcha(form)
1371
+ return
1372
+ }
1373
+ const parentId = result.comment?.parentId ?? null
1374
+ if (result.status === 'approved' && result.comment && threadEl) {
1375
+ const html = commentHtml(result.comment, thread.config)
1376
+ const parent = parentId ? threadEl.querySelector('#cr-comment-' + parentId) : null
1377
+ if (parent) {
1378
+ let replies = parent.querySelector(':scope > .cr-replies')
1379
+ if (!replies) {
1380
+ parent.insertAdjacentHTML('beforeend', '<ol class="cr-replies"></ol>')
1381
+ replies = parent.querySelector(':scope > .cr-replies')
1382
+ }
1383
+ replies?.insertAdjacentHTML('beforeend', html)
1384
+ } else {
1385
+ const list = threadEl.querySelector('.cr-comment-list')
1386
+ if (list) list.insertAdjacentHTML('beforeend', html)
1387
+ else threadEl.innerHTML = '<ol class="cr-comment-list">' + html + '</ol>'
1388
+ }
1389
+ }
1390
+ form.reset()
1391
+ host.querySelector('.cr-comments-section')?.appendChild(form)
1392
+ const replying = form.querySelector<HTMLElement>('.cr-replying')
1393
+ if (replying) replying.hidden = true
1394
+ resetCaptcha(form)
1395
+ setStatus(form, '<p class="cr-success" role="status">' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '</p>')
1396
+ } catch (error) {
1397
+ setStatus(form, '<p class="cr-error" role="alert">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')
1398
+ resetCaptcha(form)
1399
+ } finally {
1400
+ busy(form, false)
1401
+ }
1402
+ })
1403
+ }
1404
+ `;
1405
+ //#endregion
628
1406
  //#region src/components.ts
629
- function componentFiles(components) {
1407
+ /** Component types this emitter implements against a runtime binding. */
1408
+ const RUNTIME_IMPLEMENTED = new Set(["comments", "form"]);
1409
+ function isRuntimeImplemented(c, runtime) {
1410
+ if (!runtime || c.source !== "runtime" || !RUNTIME_IMPLEMENTED.has(c.type)) return false;
1411
+ return c.type !== "form" || Boolean(c.model);
1412
+ }
1413
+ function componentFiles(components, runtime) {
630
1414
  const files = {};
1415
+ const warnings = [];
1416
+ let needsRuntime = false;
631
1417
  for (const c of components) {
632
- const variants = (c.variants ?? []).map((v) => v.key);
633
- files[`src/components/${pascalCase(c.id)}.astro`] = `---
634
- /**
1418
+ const name = pascalCase(c.id);
1419
+ if (isRuntimeImplemented(c, runtime)) {
1420
+ needsRuntime = true;
1421
+ files[`src/components/${name}.astro`] = c.type === "comments" ? commentsSource(c) : formSource(c);
1422
+ continue;
1423
+ }
1424
+ 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`);
1425
+ files[`src/components/${name}.astro`] = placeholderSource(c);
1426
+ }
1427
+ if (needsRuntime && runtime) {
1428
+ files["src/data/runtime.json"] = stableJson({
1429
+ base_url: runtime.base_url,
1430
+ project_id: runtime.project_id
1431
+ });
1432
+ files["src/lib/embed.ts"] = EMBED_TS;
1433
+ }
1434
+ return {
1435
+ files,
1436
+ warnings
1437
+ };
1438
+ }
1439
+ function header(c, extra = "") {
1440
+ return `/**
635
1441
  * cr-component: type=${c.type} source=${c.source}${c.name ? ` — ${c.name}` : ""}
636
- * Emitted by @contentrain/emitter-astro as a placeholder.${c.source === "runtime" ? "\n * Needs a live provider — see the migration handoff offers." : ""}${variants.length ? `\n * Variants: ${variants.join(", ")}` : ""}
637
- */
1442
+ * Emitted by @contentrain/emitter-astro.${extra}
1443
+ */`;
1444
+ }
1445
+ function placeholderSource(c) {
1446
+ const variants = (c.variants ?? []).map((v) => v.key);
1447
+ return `---
1448
+ ${header(c, `${c.source === "runtime" ? "\n * Placeholder — needs a live provider; see the migration handoff offers." : " Placeholder."}${variants.length ? `\n * Variants: ${variants.join(", ")}` : ""}`)}
638
1449
  interface Props {
639
1450
  variant?: string
1451
+ /** Content-store address of the page's entry; unused by a placeholder. */
1452
+ entry?: { model_id: string; entry_id: string; locale?: string }
640
1453
  }
641
1454
  const { variant = ${JSON.stringify(variants[0] ?? "default")} } = Astro.props
642
1455
  ---
643
1456
  <cr-component data-type=${JSON.stringify(c.type)} data-variant={variant}></cr-component>
644
1457
  `;
645
- }
646
- return files;
1458
+ }
1459
+ function commentsSource(c) {
1460
+ return `---
1461
+ ${header(c, `
1462
+ * Mounted comments thread: reads the entry's approved comments from the runtime
1463
+ * provider's public API and posts new ones. Pending comments are never shown —
1464
+ * moderation happens on the provider. Renders nothing on a page without an
1465
+ * entry address (a list, a static page).`)}
1466
+ import runtime from '../data/runtime.json'
1467
+
1468
+ interface Props {
1469
+ /** Content-store address of the page's entry — the thread's key. */
1470
+ entry?: { model_id: string; entry_id: string; locale?: string }
1471
+ variant?: string
1472
+ }
1473
+ const { entry, variant = 'default' } = Astro.props
1474
+ ---
1475
+ {entry && (
1476
+ <cr-comments
1477
+ class={\`cr-comments cr-comments--\${variant}\`}
1478
+ data-base-url={runtime.base_url}
1479
+ data-project={runtime.project_id}
1480
+ data-model={entry.model_id}
1481
+ data-entry={entry.entry_id}
1482
+ data-locale={entry.locale}
1483
+ data-variant={variant}
1484
+ >
1485
+ <noscript><p class="cr-noscript">Comments need JavaScript.</p></noscript>
1486
+ </cr-comments>
1487
+ )}
1488
+
1489
+ <script>
1490
+ import { mountComments } from '../lib/embed'
1491
+
1492
+ class CrComments extends HTMLElement {
1493
+ connectedCallback() {
1494
+ void mountComments(this)
1495
+ }
1496
+ }
1497
+ if (!customElements.get('cr-comments')) customElements.define('cr-comments', CrComments)
1498
+ <\/script>
1499
+ `;
1500
+ }
1501
+ function formSource(c) {
1502
+ return `---
1503
+ ${header(c, `
1504
+ * Mounted public form for the ${JSON.stringify(c.model)} model: fetches the
1505
+ * exposed fields from the runtime provider, renders one control per field
1506
+ * (honeypot and captcha included when the model asks for them) and submits.
1507
+ * Submissions land on the provider for moderation; an approved one becomes a
1508
+ * content entry there.`)}
1509
+ import runtime from '../data/runtime.json'
1510
+
1511
+ interface Props {
1512
+ variant?: string
1513
+ /** Accepted for uniform mounting; a form is addressed by its model, not by the page's entry. */
1514
+ entry?: { model_id: string; entry_id: string; locale?: string }
1515
+ }
1516
+ const { variant = 'default' } = Astro.props
1517
+ ---
1518
+ <cr-form
1519
+ class={\`cr-form-mount cr-form-mount--\${variant}\`}
1520
+ data-base-url={runtime.base_url}
1521
+ data-project={runtime.project_id}
1522
+ data-model=${JSON.stringify(c.model)}
1523
+ data-variant={variant}
1524
+ >
1525
+ <noscript><p class="cr-noscript">This form needs JavaScript.</p></noscript>
1526
+ </cr-form>
1527
+
1528
+ <script>
1529
+ import { mountForm } from '../lib/embed'
1530
+
1531
+ class CrForm extends HTMLElement {
1532
+ connectedCallback() {
1533
+ void mountForm(this)
1534
+ }
1535
+ }
1536
+ if (!customElements.get('cr-form')) customElements.define('cr-form', CrForm)
1537
+ <\/script>
1538
+ `;
647
1539
  }
648
1540
  //#endregion
649
1541
  //#region src/chrome.ts
@@ -748,8 +1640,9 @@ function emitAstroProject(input) {
748
1640
  const chrome = chromeComponents(ir.families);
749
1641
  add(chrome.files);
750
1642
  warnings.push(...chrome.warnings);
1643
+ const definitions = new Map((ir.components ?? []).map((c) => [c.id, c]));
751
1644
  for (const family of ir.families) {
752
- const fam = familyFiles(family, lang, chrome.byFamily.get(family.id));
1645
+ const fam = familyFiles(family, lang, chrome.byFamily.get(family.id), definitions);
753
1646
  add(fam.files);
754
1647
  warnings.push(...fam.warnings);
755
1648
  }
@@ -771,7 +1664,17 @@ function emitAstroProject(input) {
771
1664
  add(result.files);
772
1665
  warnings.push(...result.warnings);
773
1666
  }
774
- add(componentFiles(ir.components ?? []));
1667
+ const components = componentFiles(ir.components ?? [], input.runtime);
1668
+ add(components.files);
1669
+ warnings.push(...components.warnings);
1670
+ if ((ir.components ?? []).some((c) => isRuntimeImplemented(c, input.runtime) && c.type === "comments")) {
1671
+ const collections = { ...input.content?.collections };
1672
+ if (input.content?.posts && !collections["posts"]) collections[DEFAULT_COLLECTION] = input.content.posts;
1673
+ for (const [name, posts] of Object.entries(collections)) {
1674
+ const unbound = posts.filter((p) => !p.entry).length;
1675
+ if (unbound) warnings.push(`collection ${name}: ${unbound} of ${posts.length} posts carry no entry address — the comments component renders nothing on those pages`);
1676
+ }
1677
+ }
775
1678
  return {
776
1679
  files,
777
1680
  warnings
@@ -786,6 +1689,6 @@ async function writeEmit(result, dir) {
786
1689
  await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
787
1690
  }
788
1691
  //#endregion
789
- export { balanceWarning, checkBalance, emitAstroProject, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
1692
+ export { EMBED_TS, RUNTIME_IMPLEMENTED, balanceWarning, checkBalance, componentMarkers, emitAstroProject, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
790
1693
 
791
1694
  //# sourceMappingURL=index.mjs.map