@contentrain/emitter-astro 0.7.0 → 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/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,833 @@ ${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
+ 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
628
1405
  //#region src/components.ts
629
- function componentFiles(components) {
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) {
630
1413
  const files = {};
1414
+ const warnings = [];
1415
+ let needsRuntime = false;
631
1416
  for (const c of components) {
632
- const variants = (c.variants ?? []).map((v) => v.key);
633
- files[`src/components/${pascalCase(c.id)}.astro`] = `---
634
- /**
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 `/**
635
1440
  * 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
- */
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(", ")}` : ""}`)}
638
1448
  interface Props {
639
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 }
640
1452
  }
641
1453
  const { variant = ${JSON.stringify(variants[0] ?? "default")} } = Astro.props
642
1454
  ---
643
1455
  <cr-component data-type=${JSON.stringify(c.type)} data-variant={variant}></cr-component>
644
1456
  `;
645
- }
646
- return files;
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
+ `;
647
1538
  }
648
1539
  //#endregion
649
1540
  //#region src/chrome.ts
@@ -748,8 +1639,9 @@ function emitAstroProject(input) {
748
1639
  const chrome = chromeComponents(ir.families);
749
1640
  add(chrome.files);
750
1641
  warnings.push(...chrome.warnings);
1642
+ const definitions = new Map((ir.components ?? []).map((c) => [c.id, c]));
751
1643
  for (const family of ir.families) {
752
- const fam = familyFiles(family, lang, chrome.byFamily.get(family.id));
1644
+ const fam = familyFiles(family, lang, chrome.byFamily.get(family.id), definitions);
753
1645
  add(fam.files);
754
1646
  warnings.push(...fam.warnings);
755
1647
  }
@@ -771,7 +1663,17 @@ function emitAstroProject(input) {
771
1663
  add(result.files);
772
1664
  warnings.push(...result.warnings);
773
1665
  }
774
- add(componentFiles(ir.components ?? []));
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
+ }
775
1677
  return {
776
1678
  files,
777
1679
  warnings
@@ -786,6 +1688,6 @@ async function writeEmit(result, dir) {
786
1688
  await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
787
1689
  }
788
1690
  //#endregion
789
- export { balanceWarning, checkBalance, emitAstroProject, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
1691
+ export { EMBED_TS, RUNTIME_IMPLEMENTED, balanceWarning, checkBalance, componentMarkers, emitAstroProject, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
790
1692
 
791
1693
  //# sourceMappingURL=index.mjs.map