@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/README.md +11 -2
- package/dist/index.d.mts +33 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +920 -18
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { CHROME_BODY_SLOT, LEGACY_CSS_LAYER } from "@contentrain/types";
|
|
1
|
+
import { CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN, LEGACY_CSS_LAYER } from "@contentrain/types";
|
|
2
|
+
//#region src/types.ts
|
|
3
|
+
/** Default collection name when a `single` route does not name one. */
|
|
4
|
+
const DEFAULT_COLLECTION = "posts";
|
|
5
|
+
//#endregion
|
|
2
6
|
//#region src/util.ts
|
|
3
7
|
/** "f-archive" → "FArchive", "posts" → "Posts" — Astro component file names. */
|
|
4
8
|
function pascalCase(id) {
|
|
@@ -191,6 +195,36 @@ export function composeBody(chromeBody: string, values: Values, content: string)
|
|
|
191
195
|
.join(content)
|
|
192
196
|
}
|
|
193
197
|
|
|
198
|
+
/** Component mount marker — must match @contentrain/types CHROME_COMPONENT_OPEN / CHROME_COMPONENT_CLOSE. */
|
|
199
|
+
export const COMPONENT_OPEN = '<!--@@component:'
|
|
200
|
+
export const COMPONENT_CLOSE = '@@-->'
|
|
201
|
+
const COMPONENT_RE = /<!--@@component:([^@\\s]+)@@-->/g
|
|
202
|
+
|
|
203
|
+
export interface BodyPart {
|
|
204
|
+
html: string
|
|
205
|
+
/** A mount point: the component id the marker named. */
|
|
206
|
+
component?: string
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Split rendered chrome at component markers into html parts interleaved with
|
|
211
|
+
* mount points, so the layout can render a real component where the theme's
|
|
212
|
+
* comments form (or contact form) stood. The parts are output in order, so the
|
|
213
|
+
* page's HTML is exactly the chrome with the component's markup at the marker —
|
|
214
|
+
* no parser sees the pieces separately.
|
|
215
|
+
*/
|
|
216
|
+
export function splitComponents(html: string): BodyPart[] {
|
|
217
|
+
const parts: BodyPart[] = []
|
|
218
|
+
let last = 0
|
|
219
|
+
for (const match of html.matchAll(COMPONENT_RE)) {
|
|
220
|
+
parts.push({ html: html.slice(last, match.index) })
|
|
221
|
+
parts.push({ html: '', component: match[1] ?? '' })
|
|
222
|
+
last = match.index + match[0].length
|
|
223
|
+
}
|
|
224
|
+
parts.push({ html: html.slice(last) })
|
|
225
|
+
return parts
|
|
226
|
+
}
|
|
227
|
+
|
|
194
228
|
/** Where a list section's wrapper takes its items — must match @contentrain/types LIST_ITEMS_SLOT. */
|
|
195
229
|
export const ITEMS_SLOT = '<!--@@items@@-->'
|
|
196
230
|
|
|
@@ -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
|
-
|
|
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` : ""}
|
|
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, '&')
|
|
772
|
+
.replace(/</g, '<')
|
|
773
|
+
.replace(/>/g, '>')
|
|
774
|
+
.replace(/"/g, '"')
|
|
775
|
+
|
|
776
|
+
// ─── Transport ───
|
|
777
|
+
|
|
778
|
+
/** \`base\` without a trailing slash, segments URL-encoded, empty query values dropped. */
|
|
779
|
+
export function publicUrl(base: string, segments: string[], query?: Record<string, string | number | undefined>): string {
|
|
780
|
+
const path = segments.map((s) => encodeURIComponent(s)).join('/')
|
|
781
|
+
const params = new URLSearchParams()
|
|
782
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
783
|
+
if (value !== undefined && value !== '') params.set(key, String(value))
|
|
784
|
+
}
|
|
785
|
+
const qs = params.toString()
|
|
786
|
+
return base.replace(/\\/+$/, '') + '/' + path + (qs ? '?' + qs : '')
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function failure(res: Response): Promise<EmbedError> {
|
|
790
|
+
const text = await res.text().catch(() => '')
|
|
791
|
+
let message = text || 'Request failed'
|
|
792
|
+
try {
|
|
793
|
+
const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown }
|
|
794
|
+
const m = parsed.message ?? parsed.statusMessage
|
|
795
|
+
if (typeof m === 'string' && m) message = m
|
|
796
|
+
} catch {
|
|
797
|
+
// not JSON — the raw text is the message
|
|
798
|
+
}
|
|
799
|
+
return new EmbedError(res.status, message)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export async function getJson<T>(url: string): Promise<T> {
|
|
803
|
+
const res = await fetch(url)
|
|
804
|
+
if (!res.ok) throw await failure(res)
|
|
805
|
+
return (await res.json()) as T
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export async function postJson<T>(url: string, body: unknown): Promise<T> {
|
|
809
|
+
const res = await fetch(url, {
|
|
810
|
+
method: 'POST',
|
|
811
|
+
headers: { 'Content-Type': 'application/json' },
|
|
812
|
+
body: JSON.stringify(body),
|
|
813
|
+
})
|
|
814
|
+
if (!res.ok) throw await failure(res)
|
|
815
|
+
return (await res.json()) as T
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export const formsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/forms/v1'
|
|
819
|
+
export const commentsRoot = (rt: Runtime): string => rt.base_url.replace(/\\/+$/, '') + '/api/comments/v1'
|
|
820
|
+
|
|
821
|
+
/** The hidden input Cloudflare Turnstile adds to the enclosing form. */
|
|
822
|
+
export const CAPTCHA_FIELD = 'cf-turnstile-response'
|
|
823
|
+
|
|
824
|
+
// ─── Forms ───
|
|
825
|
+
|
|
826
|
+
export interface FieldDef {
|
|
827
|
+
type: string
|
|
828
|
+
required?: boolean
|
|
829
|
+
label?: string
|
|
830
|
+
options?: string[]
|
|
831
|
+
min?: number
|
|
832
|
+
max?: number
|
|
833
|
+
pattern?: string
|
|
834
|
+
default?: unknown
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export interface FormConfig {
|
|
838
|
+
modelId: string
|
|
839
|
+
locale: string
|
|
840
|
+
/** Exposed fields only, keyed by field id. */
|
|
841
|
+
fields: Record<string, FieldDef>
|
|
842
|
+
captcha: 'turnstile' | null
|
|
843
|
+
captchaSiteKey: string | null
|
|
844
|
+
successMessage?: string
|
|
845
|
+
honeypotField: string | null
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export interface FieldError {
|
|
849
|
+
field: string
|
|
850
|
+
message: string
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export interface FormSubmitResult {
|
|
854
|
+
success: boolean
|
|
855
|
+
message?: string
|
|
856
|
+
errors?: FieldError[]
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** The documented request body: values under \`data\`, control fields beside it. */
|
|
860
|
+
export interface FormPayload {
|
|
861
|
+
data: Record<string, unknown>
|
|
862
|
+
captchaToken?: string
|
|
863
|
+
_hp?: string
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export function fetchFormConfig(rt: Runtime, model: string): Promise<FormConfig> {
|
|
867
|
+
return getJson<FormConfig>(publicUrl(formsRoot(rt), [rt.project_id, model, 'config']))
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export function submitForm(rt: Runtime, model: string, payload: FormPayload): Promise<FormSubmitResult> {
|
|
871
|
+
return postJson<FormSubmitResult>(publicUrl(formsRoot(rt), [rt.project_id, model, 'submit']), payload)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function coerce(def: FieldDef, value: string): unknown {
|
|
875
|
+
switch (def.type) {
|
|
876
|
+
case 'number':
|
|
877
|
+
case 'integer':
|
|
878
|
+
case 'decimal':
|
|
879
|
+
case 'percent':
|
|
880
|
+
case 'rating':
|
|
881
|
+
return value === '' ? '' : Number(value)
|
|
882
|
+
case 'boolean':
|
|
883
|
+
return value === 'on' || value === 'true'
|
|
884
|
+
default:
|
|
885
|
+
return value
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Form entries (\`new FormData(form)\`) → request body. Only exposed fields
|
|
891
|
+
* reach \`data\`; the captcha token and the honeypot value travel beside it, so
|
|
892
|
+
* a control field can never collide with a model field. Files are skipped —
|
|
893
|
+
* uploads are not part of the public contract.
|
|
894
|
+
*/
|
|
895
|
+
export function formPayload(
|
|
896
|
+
entries: Iterable<[string, unknown]>,
|
|
897
|
+
config: { fields: Record<string, FieldDef>; honeypotField: string | null },
|
|
898
|
+
): FormPayload {
|
|
899
|
+
const payload: FormPayload = { data: {} }
|
|
900
|
+
for (const [name, raw] of entries) {
|
|
901
|
+
if (typeof raw !== 'string') continue
|
|
902
|
+
if (name === CAPTCHA_FIELD) {
|
|
903
|
+
if (raw) payload.captchaToken = raw
|
|
904
|
+
continue
|
|
905
|
+
}
|
|
906
|
+
if (config.honeypotField && name === config.honeypotField) {
|
|
907
|
+
payload._hp = raw
|
|
908
|
+
continue
|
|
909
|
+
}
|
|
910
|
+
const def = config.fields[name]
|
|
911
|
+
if (!def) continue
|
|
912
|
+
payload.data[name] = coerce(def, raw)
|
|
913
|
+
}
|
|
914
|
+
return payload
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export function labelFor(id: string, def: FieldDef): string {
|
|
918
|
+
if (def.label) return def.label
|
|
919
|
+
const words = id.replace(/[_-]+/g, ' ').trim()
|
|
920
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** One input for one exposed field, chosen by field type. */
|
|
924
|
+
export function fieldControl(id: string, def: FieldDef, prefix = 'cr-field'): string {
|
|
925
|
+
const common =
|
|
926
|
+
' id="' + esc(prefix + '-' + id) + '" name="' + esc(id) + '"' +
|
|
927
|
+
(def.required ? ' required' : '') +
|
|
928
|
+
(def.pattern ? ' pattern="' + esc(def.pattern) + '"' : '')
|
|
929
|
+
const range =
|
|
930
|
+
(def.min !== undefined ? ' min="' + esc(def.min) + '"' : '') +
|
|
931
|
+
(def.max !== undefined ? ' max="' + esc(def.max) + '"' : '')
|
|
932
|
+
const length = def.max !== undefined ? ' maxlength="' + esc(def.max) + '"' : ''
|
|
933
|
+
switch (def.type) {
|
|
934
|
+
case 'text':
|
|
935
|
+
case 'markdown':
|
|
936
|
+
case 'richtext':
|
|
937
|
+
case 'code':
|
|
938
|
+
return '<textarea' + common + length + ' rows="5"></textarea>'
|
|
939
|
+
case 'select': {
|
|
940
|
+
const blank = def.required ? '' : '<option value=""></option>'
|
|
941
|
+
const options = (def.options ?? []).map((o) => '<option value="' + esc(o) + '">' + esc(o) + '</option>').join('')
|
|
942
|
+
return '<select' + common + '>' + blank + options + '</select>'
|
|
943
|
+
}
|
|
944
|
+
case 'boolean':
|
|
945
|
+
return '<input type="checkbox"' + common + ' />'
|
|
946
|
+
case 'number':
|
|
947
|
+
case 'integer':
|
|
948
|
+
case 'rating':
|
|
949
|
+
return '<input type="number"' + common + range + (def.type === 'integer' ? ' step="1"' : '') + ' />'
|
|
950
|
+
case 'decimal':
|
|
951
|
+
case 'percent':
|
|
952
|
+
return '<input type="number" step="any"' + common + range + ' />'
|
|
953
|
+
case 'email':
|
|
954
|
+
return '<input type="email"' + common + length + ' />'
|
|
955
|
+
case 'url':
|
|
956
|
+
return '<input type="url"' + common + length + ' />'
|
|
957
|
+
case 'phone':
|
|
958
|
+
return '<input type="tel"' + common + length + ' />'
|
|
959
|
+
case 'date':
|
|
960
|
+
return '<input type="date"' + common + ' />'
|
|
961
|
+
case 'datetime':
|
|
962
|
+
return '<input type="datetime-local"' + common + ' />'
|
|
963
|
+
case 'color':
|
|
964
|
+
return '<input type="color"' + common + ' />'
|
|
965
|
+
default:
|
|
966
|
+
return '<input type="text"' + common + length + ' />'
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
export function honeypotHtml(field: string | null): string {
|
|
971
|
+
if (!field) return ''
|
|
972
|
+
return (
|
|
973
|
+
'<p class="cr-hp" aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">' +
|
|
974
|
+
'<label>' + esc(strings.honeypot) + ' <input type="text" name="' + esc(field) + '" tabindex="-1" autocomplete="off" /></label></p>'
|
|
975
|
+
)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export function captchaHtml(captcha: 'turnstile' | null, siteKey: string | null): string {
|
|
979
|
+
if (captcha !== 'turnstile' || !siteKey) return ''
|
|
980
|
+
return '<div class="cf-turnstile" data-sitekey="' + esc(siteKey) + '"></div>'
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export function errorsHtml(errors: FieldError[]): string {
|
|
984
|
+
return (
|
|
985
|
+
'<ul class="cr-errors" role="alert">' +
|
|
986
|
+
errors.map((e) => '<li data-field="' + esc(e.field) + '">' + esc(e.field) + ': ' + esc(e.message) + '</li>').join('') +
|
|
987
|
+
'</ul>'
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** The whole form for a public form config: one control per exposed field, honeypot, captcha, submit. */
|
|
992
|
+
export function formHtml(config: FormConfig, prefix = 'cr-field'): string {
|
|
993
|
+
const fields = Object.entries(config.fields)
|
|
994
|
+
.map(([id, def]) => {
|
|
995
|
+
const control = fieldControl(id, def, prefix)
|
|
996
|
+
const label = '<label for="' + esc(prefix + '-' + id) + '">' + esc(labelFor(id, def)) + (def.required ? ' <span aria-hidden="true">*</span>' : '') + '</label>'
|
|
997
|
+
return '<p class="cr-field cr-field--' + esc(def.type) + '">' + (def.type === 'boolean' ? control + ' ' + label : label + control) + '</p>'
|
|
998
|
+
})
|
|
999
|
+
.join('')
|
|
1000
|
+
return (
|
|
1001
|
+
'<form class="cr-form" method="post" data-model="' + esc(config.modelId) + '">' +
|
|
1002
|
+
fields +
|
|
1003
|
+
honeypotHtml(config.honeypotField) +
|
|
1004
|
+
captchaHtml(config.captcha, config.captchaSiteKey) +
|
|
1005
|
+
'<p class="cr-actions"><button type="submit">' + esc(strings.send) + '</button></p>' +
|
|
1006
|
+
'<div class="cr-status" aria-live="polite"></div>' +
|
|
1007
|
+
'</form>'
|
|
1008
|
+
)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// ─── Comments ───
|
|
1012
|
+
|
|
1013
|
+
export interface CommentAuthor {
|
|
1014
|
+
name: string
|
|
1015
|
+
url: string | null
|
|
1016
|
+
isModerator: boolean
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
export interface PublicComment {
|
|
1020
|
+
id: string
|
|
1021
|
+
parentId: string | null
|
|
1022
|
+
depth: number
|
|
1023
|
+
author: CommentAuthor
|
|
1024
|
+
/** Plain text. */
|
|
1025
|
+
body: string
|
|
1026
|
+
type: 'comment' | 'pingback' | 'trackback'
|
|
1027
|
+
createdAt: string
|
|
1028
|
+
replies: PublicComment[]
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export interface ThreadConfig {
|
|
1032
|
+
closed: boolean
|
|
1033
|
+
requireApproval: boolean
|
|
1034
|
+
requireEmail: boolean
|
|
1035
|
+
maxDepth: number
|
|
1036
|
+
maxBodyLength: number
|
|
1037
|
+
captcha: 'turnstile' | null
|
|
1038
|
+
captchaSiteKey: string | null
|
|
1039
|
+
honeypotField: string | null
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
export interface CommentThread {
|
|
1043
|
+
entry: { modelId: string; entryId: string; locale: string }
|
|
1044
|
+
config: ThreadConfig
|
|
1045
|
+
comments: PublicComment[]
|
|
1046
|
+
total: number
|
|
1047
|
+
page: number
|
|
1048
|
+
limit: number
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
export interface CommentSubmitBody {
|
|
1052
|
+
author: { name: string; email?: string; url?: string }
|
|
1053
|
+
body: string
|
|
1054
|
+
parentId?: string
|
|
1055
|
+
captchaToken?: string
|
|
1056
|
+
_hp?: string
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export interface CommentSubmitResult {
|
|
1060
|
+
success: boolean
|
|
1061
|
+
status?: 'pending' | 'approved'
|
|
1062
|
+
comment?: PublicComment
|
|
1063
|
+
errors?: FieldError[]
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export interface ThreadQuery {
|
|
1067
|
+
page?: number
|
|
1068
|
+
limit?: number
|
|
1069
|
+
sort?: 'oldest' | 'newest'
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
export function fetchThread(rt: Runtime, entry: EntryRef, query?: ThreadQuery): Promise<CommentThread> {
|
|
1073
|
+
const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], {
|
|
1074
|
+
locale: entry.locale,
|
|
1075
|
+
page: query?.page,
|
|
1076
|
+
limit: query?.limit,
|
|
1077
|
+
sort: query?.sort,
|
|
1078
|
+
})
|
|
1079
|
+
return getJson<CommentThread>(url)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
export function submitComment(rt: Runtime, entry: EntryRef, body: CommentSubmitBody): Promise<CommentSubmitResult> {
|
|
1083
|
+
const url = publicUrl(commentsRoot(rt), [rt.project_id, entry.model_id, entry.entry_id], { locale: entry.locale })
|
|
1084
|
+
return postJson<CommentSubmitResult>(url, body)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Comment-form entries → the documented request body (inputs: author_name, author_email, author_url, body, parent_id). */
|
|
1088
|
+
export function commentPayload(entries: Iterable<[string, unknown]>, honeypotField: string | null): CommentSubmitBody {
|
|
1089
|
+
const values: Record<string, string> = {}
|
|
1090
|
+
const payload: CommentSubmitBody = { author: { name: '' }, body: '' }
|
|
1091
|
+
for (const [name, raw] of entries) {
|
|
1092
|
+
if (typeof raw !== 'string') continue
|
|
1093
|
+
if (name === CAPTCHA_FIELD) {
|
|
1094
|
+
if (raw) payload.captchaToken = raw
|
|
1095
|
+
continue
|
|
1096
|
+
}
|
|
1097
|
+
if (honeypotField && name === honeypotField) {
|
|
1098
|
+
payload._hp = raw
|
|
1099
|
+
continue
|
|
1100
|
+
}
|
|
1101
|
+
values[name] = raw
|
|
1102
|
+
}
|
|
1103
|
+
payload.author.name = (values.author_name ?? '').trim()
|
|
1104
|
+
if (values.author_email?.trim()) payload.author.email = values.author_email.trim()
|
|
1105
|
+
if (values.author_url?.trim()) payload.author.url = values.author_url.trim()
|
|
1106
|
+
payload.body = (values.body ?? '').trim()
|
|
1107
|
+
if (values.parent_id?.trim()) payload.parentId = values.parent_id.trim()
|
|
1108
|
+
return payload
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Plain text → paragraphs; blank lines separate paragraphs, single newlines break lines. */
|
|
1112
|
+
export function bodyHtml(text: string): string {
|
|
1113
|
+
return text
|
|
1114
|
+
.split(/\\n{2,}/)
|
|
1115
|
+
.map((p) => p.trim())
|
|
1116
|
+
.filter(Boolean)
|
|
1117
|
+
.map((p) => '<p>' + esc(p).replace(/\\n/g, '<br />') + '</p>')
|
|
1118
|
+
.join('')
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
export function hasMore(thread: CommentThread): boolean {
|
|
1122
|
+
return thread.page * thread.limit < thread.total
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** One comment with its replies. A reply button appears only while the thread is open and depth allows it. */
|
|
1126
|
+
export function commentHtml(c: PublicComment, config: { closed: boolean; maxDepth: number }): string {
|
|
1127
|
+
const author = c.author.url
|
|
1128
|
+
? '<a href="' + esc(c.author.url) + '" rel="nofollow ugc noopener" target="_blank">' + esc(c.author.name) + '</a>'
|
|
1129
|
+
: esc(c.author.name)
|
|
1130
|
+
const badge = c.author.isModerator ? ' <span class="cr-moderator">' + esc(strings.moderator) + '</span>' : ''
|
|
1131
|
+
const date = '<time datetime="' + esc(c.createdAt) + '">' + esc(c.createdAt.slice(0, 10)) + '</time>'
|
|
1132
|
+
const canReply = !config.closed && c.depth < config.maxDepth
|
|
1133
|
+
const reply = canReply
|
|
1134
|
+
? '<button type="button" class="cr-reply" data-parent="' + esc(c.id) + '" data-author="' + esc(c.author.name) + '">' + esc(strings.reply) + '</button>'
|
|
1135
|
+
: ''
|
|
1136
|
+
const replies = c.replies.length
|
|
1137
|
+
? '<ol class="cr-replies">' + c.replies.map((r) => commentHtml(r, config)).join('') + '</ol>'
|
|
1138
|
+
: ''
|
|
1139
|
+
return (
|
|
1140
|
+
'<li class="cr-comment cr-comment--' + esc(c.type) + '" id="cr-comment-' + esc(c.id) + '" data-depth="' + esc(c.depth) + '">' +
|
|
1141
|
+
'<div class="cr-comment-meta"><span class="cr-comment-author">' + author + '</span>' + badge + ' ' + date + '</div>' +
|
|
1142
|
+
'<div class="cr-comment-body">' + bodyHtml(c.body) + '</div>' +
|
|
1143
|
+
(reply ? '<div class="cr-comment-actions">' + reply + '</div>' : '') +
|
|
1144
|
+
replies +
|
|
1145
|
+
'</li>'
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
export function threadHtml(thread: CommentThread): string {
|
|
1150
|
+
if (!thread.comments.length) return '<p class="cr-empty">' + esc(strings.noComments) + '</p>'
|
|
1151
|
+
return '<ol class="cr-comment-list">' + thread.comments.map((c) => commentHtml(c, thread.config)).join('') + '</ol>'
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** The comment form; a reply carries its parent in a hidden input. */
|
|
1155
|
+
export function commentFormHtml(config: ThreadConfig, prefix = 'cr-c'): string {
|
|
1156
|
+
const emailLabel = esc(strings.email) + (config.requireEmail ? ' <span aria-hidden="true">*</span>' : '') + ' <small>' + esc(strings.emailNote) + '</small>'
|
|
1157
|
+
return (
|
|
1158
|
+
'<form class="cr-comment-form" method="post">' +
|
|
1159
|
+
'<input type="hidden" name="parent_id" value="" />' +
|
|
1160
|
+
'<p class="cr-replying" hidden><span class="cr-replying-to"></span> <button type="button" class="cr-cancel-reply">' + esc(strings.cancel) + '</button></p>' +
|
|
1161
|
+
'<p class="cr-field"><label for="' + prefix + '-body">' + esc(strings.comment) + ' <span aria-hidden="true">*</span></label>' +
|
|
1162
|
+
'<textarea id="' + prefix + '-body" name="body" required maxlength="' + esc(config.maxBodyLength) + '" rows="5"></textarea></p>' +
|
|
1163
|
+
'<p class="cr-field"><label for="' + prefix + '-name">' + esc(strings.name) + ' <span aria-hidden="true">*</span></label>' +
|
|
1164
|
+
'<input type="text" id="' + prefix + '-name" name="author_name" required maxlength="120" autocomplete="name" /></p>' +
|
|
1165
|
+
'<p class="cr-field"><label for="' + prefix + '-email">' + emailLabel + '</label>' +
|
|
1166
|
+
'<input type="email" id="' + prefix + '-email" name="author_email"' + (config.requireEmail ? ' required' : '') + ' maxlength="254" autocomplete="email" /></p>' +
|
|
1167
|
+
'<p class="cr-field"><label for="' + prefix + '-url">' + esc(strings.website) + '</label>' +
|
|
1168
|
+
'<input type="url" id="' + prefix + '-url" name="author_url" maxlength="2048" autocomplete="url" /></p>' +
|
|
1169
|
+
honeypotHtml(config.honeypotField) +
|
|
1170
|
+
captchaHtml(config.captcha, config.captchaSiteKey) +
|
|
1171
|
+
'<p class="cr-actions"><button type="submit">' + esc(strings.postComment) + '</button></p>' +
|
|
1172
|
+
'<div class="cr-status" aria-live="polite"></div>' +
|
|
1173
|
+
'</form>'
|
|
1174
|
+
)
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// ─── Turnstile (explicit render, so widgets added after load still render) ───
|
|
1178
|
+
|
|
1179
|
+
interface TurnstileApi {
|
|
1180
|
+
render: (el: Element, options: { sitekey: string }) => string
|
|
1181
|
+
reset: (id?: string) => void
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__crTurnstileReady'
|
|
1185
|
+
const widgets = new WeakMap<Element, string>()
|
|
1186
|
+
const waiting: Element[] = []
|
|
1187
|
+
|
|
1188
|
+
function turnstileApi(): TurnstileApi | undefined {
|
|
1189
|
+
return (globalThis as { turnstile?: TurnstileApi }).turnstile
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function renderWidget(api: TurnstileApi, el: Element): void {
|
|
1193
|
+
if (widgets.has(el)) return
|
|
1194
|
+
widgets.set(el, api.render(el, { sitekey: el.getAttribute('data-sitekey') ?? '' }))
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Render every \`.cf-turnstile\` under \`root\`, loading the Turnstile script once on first use. */
|
|
1198
|
+
export function renderCaptcha(root: ParentNode): void {
|
|
1199
|
+
const els = Array.from(root.querySelectorAll('.cf-turnstile'))
|
|
1200
|
+
if (!els.length) return
|
|
1201
|
+
const api = turnstileApi()
|
|
1202
|
+
if (api) {
|
|
1203
|
+
for (const el of els) renderWidget(api, el)
|
|
1204
|
+
return
|
|
1205
|
+
}
|
|
1206
|
+
waiting.push(...els)
|
|
1207
|
+
if (document.querySelector('script[data-cr-turnstile]')) return
|
|
1208
|
+
;(globalThis as Record<string, unknown>).__crTurnstileReady = () => {
|
|
1209
|
+
const ready = turnstileApi()
|
|
1210
|
+
if (!ready) return
|
|
1211
|
+
for (const el of waiting.splice(0)) renderWidget(ready, el)
|
|
1212
|
+
}
|
|
1213
|
+
const script = document.createElement('script')
|
|
1214
|
+
script.src = TURNSTILE_SRC
|
|
1215
|
+
script.async = true
|
|
1216
|
+
script.defer = true
|
|
1217
|
+
script.setAttribute('data-cr-turnstile', '1')
|
|
1218
|
+
document.head.appendChild(script)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
export function resetCaptcha(root: ParentNode): void {
|
|
1222
|
+
const api = turnstileApi()
|
|
1223
|
+
if (!api) return
|
|
1224
|
+
for (const el of Array.from(root.querySelectorAll('.cf-turnstile'))) api.reset(widgets.get(el))
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// ─── Mounting (the only DOM code) ───
|
|
1228
|
+
|
|
1229
|
+
export function runtimeOf(host: HTMLElement): Runtime {
|
|
1230
|
+
return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function entryOf(host: HTMLElement): EntryRef {
|
|
1234
|
+
return { model_id: host.dataset.model ?? '', entry_id: host.dataset.entry ?? '', locale: host.dataset.locale || undefined }
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function setStatus(form: HTMLFormElement, html: string): void {
|
|
1238
|
+
const status = form.querySelector('.cr-status')
|
|
1239
|
+
if (status) status.innerHTML = html
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function busy(form: HTMLFormElement, on: boolean): void {
|
|
1243
|
+
const button = form.querySelector<HTMLButtonElement>('button[type="submit"]')
|
|
1244
|
+
if (!button) return
|
|
1245
|
+
button.disabled = on
|
|
1246
|
+
if (on) {
|
|
1247
|
+
button.dataset.label = button.textContent ?? ''
|
|
1248
|
+
button.textContent = strings.sending
|
|
1249
|
+
} else {
|
|
1250
|
+
button.textContent = button.dataset.label ?? button.textContent
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function entriesOf(form: HTMLFormElement): Array<[string, unknown]> {
|
|
1255
|
+
const out: Array<[string, unknown]> = []
|
|
1256
|
+
new FormData(form).forEach((value, key) => out.push([key, value]))
|
|
1257
|
+
return out
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** \`<cr-form data-base-url data-project data-model>\` → fetch the config, render, submit. */
|
|
1261
|
+
export async function mountForm(host: HTMLElement): Promise<void> {
|
|
1262
|
+
const rt = runtimeOf(host)
|
|
1263
|
+
const model = host.dataset.model ?? ''
|
|
1264
|
+
host.innerHTML = '<p class="cr-loading">' + esc(strings.loading) + '</p>'
|
|
1265
|
+
let config: FormConfig
|
|
1266
|
+
try {
|
|
1267
|
+
config = await fetchFormConfig(rt, model)
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
host.innerHTML = '<p class="cr-error">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'
|
|
1270
|
+
return
|
|
1271
|
+
}
|
|
1272
|
+
host.innerHTML = formHtml(config)
|
|
1273
|
+
renderCaptcha(host)
|
|
1274
|
+
const form = host.querySelector('form')
|
|
1275
|
+
if (!form) return
|
|
1276
|
+
form.addEventListener('submit', async (event) => {
|
|
1277
|
+
event.preventDefault()
|
|
1278
|
+
busy(form, true)
|
|
1279
|
+
setStatus(form, '')
|
|
1280
|
+
try {
|
|
1281
|
+
const result = await submitForm(rt, model, formPayload(entriesOf(form), config))
|
|
1282
|
+
if (result.success) {
|
|
1283
|
+
host.innerHTML = '<p class="cr-success" role="status">' + esc(result.message ?? config.successMessage ?? '') + '</p>'
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
setStatus(form, errorsHtml(result.errors ?? []))
|
|
1287
|
+
resetCaptcha(form)
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
setStatus(form, '<p class="cr-error" role="alert">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')
|
|
1290
|
+
resetCaptcha(form)
|
|
1291
|
+
} finally {
|
|
1292
|
+
busy(form, false)
|
|
1293
|
+
}
|
|
1294
|
+
})
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** \`<cr-comments data-base-url data-project data-model data-entry data-locale>\` → thread + form. */
|
|
1298
|
+
export async function mountComments(host: HTMLElement): Promise<void> {
|
|
1299
|
+
const rt = runtimeOf(host)
|
|
1300
|
+
const entry = entryOf(host)
|
|
1301
|
+
host.innerHTML = '<p class="cr-loading">' + esc(strings.loading) + '</p>'
|
|
1302
|
+
let thread: CommentThread
|
|
1303
|
+
try {
|
|
1304
|
+
thread = await fetchThread(rt, entry)
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
host.innerHTML = '<p class="cr-error">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
host.innerHTML =
|
|
1311
|
+
'<section class="cr-comments-section">' +
|
|
1312
|
+
'<h2 class="cr-comments-title">' + esc(strings.commentsTitle) + ' <span class="cr-count">(' + esc(thread.total) + ')</span></h2>' +
|
|
1313
|
+
'<div class="cr-thread">' + threadHtml(thread) + '</div>' +
|
|
1314
|
+
(hasMore(thread) ? '<p class="cr-more"><button type="button" class="cr-load-more">' + esc(strings.loadMore) + '</button></p>' : '') +
|
|
1315
|
+
(thread.config.closed ? '<p class="cr-closed">' + esc(strings.closed) + '</p>' : commentFormHtml(thread.config)) +
|
|
1316
|
+
'</section>'
|
|
1317
|
+
renderCaptcha(host)
|
|
1318
|
+
|
|
1319
|
+
const threadEl = host.querySelector('.cr-thread')
|
|
1320
|
+
const form = host.querySelector<HTMLFormElement>('form.cr-comment-form')
|
|
1321
|
+
let page = thread.page
|
|
1322
|
+
|
|
1323
|
+
host.addEventListener('click', async (event) => {
|
|
1324
|
+
const target = event.target as HTMLElement | null
|
|
1325
|
+
if (!target) return
|
|
1326
|
+
const replyButton = target.closest<HTMLElement>('.cr-reply')
|
|
1327
|
+
if (replyButton && form) {
|
|
1328
|
+
const parent = form.querySelector<HTMLInputElement>('input[name="parent_id"]')
|
|
1329
|
+
if (parent) parent.value = replyButton.dataset.parent ?? ''
|
|
1330
|
+
const replying = form.querySelector<HTMLElement>('.cr-replying')
|
|
1331
|
+
const to = form.querySelector('.cr-replying-to')
|
|
1332
|
+
if (to) to.textContent = strings.replyingTo + ' ' + (replyButton.dataset.author ?? '')
|
|
1333
|
+
if (replying) replying.hidden = false
|
|
1334
|
+
replyButton.closest('.cr-comment')?.appendChild(form)
|
|
1335
|
+
form.querySelector<HTMLTextAreaElement>('textarea[name="body"]')?.focus()
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1338
|
+
if (target.closest('.cr-cancel-reply') && form) {
|
|
1339
|
+
const parent = form.querySelector<HTMLInputElement>('input[name="parent_id"]')
|
|
1340
|
+
if (parent) parent.value = ''
|
|
1341
|
+
const replying = form.querySelector<HTMLElement>('.cr-replying')
|
|
1342
|
+
if (replying) replying.hidden = true
|
|
1343
|
+
host.querySelector('.cr-comments-section')?.appendChild(form)
|
|
1344
|
+
return
|
|
1345
|
+
}
|
|
1346
|
+
const more = target.closest<HTMLButtonElement>('.cr-load-more')
|
|
1347
|
+
if (more && threadEl) {
|
|
1348
|
+
more.disabled = true
|
|
1349
|
+
try {
|
|
1350
|
+
const next = await fetchThread(rt, entry, { page: page + 1 })
|
|
1351
|
+
page = next.page
|
|
1352
|
+
const list = threadEl.querySelector('.cr-comment-list')
|
|
1353
|
+
if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join(''))
|
|
1354
|
+
if (!hasMore(next)) more.closest('.cr-more')?.remove()
|
|
1355
|
+
} finally {
|
|
1356
|
+
more.disabled = false
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
})
|
|
1360
|
+
|
|
1361
|
+
form?.addEventListener('submit', async (event) => {
|
|
1362
|
+
event.preventDefault()
|
|
1363
|
+
busy(form, true)
|
|
1364
|
+
setStatus(form, '')
|
|
1365
|
+
try {
|
|
1366
|
+
const result = await submitComment(rt, entry, commentPayload(entriesOf(form), thread.config.honeypotField))
|
|
1367
|
+
if (!result.success) {
|
|
1368
|
+
setStatus(form, errorsHtml(result.errors ?? []))
|
|
1369
|
+
resetCaptcha(form)
|
|
1370
|
+
return
|
|
1371
|
+
}
|
|
1372
|
+
const parentId = result.comment?.parentId ?? null
|
|
1373
|
+
if (result.status === 'approved' && result.comment && threadEl) {
|
|
1374
|
+
const html = commentHtml(result.comment, thread.config)
|
|
1375
|
+
const parent = parentId ? threadEl.querySelector('#cr-comment-' + parentId) : null
|
|
1376
|
+
if (parent) {
|
|
1377
|
+
let replies = parent.querySelector(':scope > .cr-replies')
|
|
1378
|
+
if (!replies) {
|
|
1379
|
+
parent.insertAdjacentHTML('beforeend', '<ol class="cr-replies"></ol>')
|
|
1380
|
+
replies = parent.querySelector(':scope > .cr-replies')
|
|
1381
|
+
}
|
|
1382
|
+
replies?.insertAdjacentHTML('beforeend', html)
|
|
1383
|
+
} else {
|
|
1384
|
+
const list = threadEl.querySelector('.cr-comment-list')
|
|
1385
|
+
if (list) list.insertAdjacentHTML('beforeend', html)
|
|
1386
|
+
else threadEl.innerHTML = '<ol class="cr-comment-list">' + html + '</ol>'
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
form.reset()
|
|
1390
|
+
host.querySelector('.cr-comments-section')?.appendChild(form)
|
|
1391
|
+
const replying = form.querySelector<HTMLElement>('.cr-replying')
|
|
1392
|
+
if (replying) replying.hidden = true
|
|
1393
|
+
resetCaptcha(form)
|
|
1394
|
+
setStatus(form, '<p class="cr-success" role="status">' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '</p>')
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
setStatus(form, '<p class="cr-error" role="alert">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')
|
|
1397
|
+
resetCaptcha(form)
|
|
1398
|
+
} finally {
|
|
1399
|
+
busy(form, false)
|
|
1400
|
+
}
|
|
1401
|
+
})
|
|
1402
|
+
}
|
|
1403
|
+
`;
|
|
1404
|
+
//#endregion
|
|
628
1405
|
//#region src/components.ts
|
|
629
|
-
|
|
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
|
|
633
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|