@ampless/runtime 1.0.0-alpha.50 → 1.0.0-alpha.51

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.js CHANGED
@@ -239,8 +239,8 @@ function createSeo(cmsConfig, settingsApi) {
239
239
 
240
240
  // src/plugin-head.ts
241
241
  import {
242
- Fragment,
243
- createElement
242
+ Fragment as Fragment2,
243
+ createElement as createElement2
244
244
  } from "react";
245
245
  import sanitizeHtml from "sanitize-html";
246
246
  import {
@@ -248,1100 +248,1435 @@ import {
248
248
  resolvePluginSettings
249
249
  } from "ampless";
250
250
 
251
- // src/plugin-package-manifest.ts
252
- var TRUST_LEVELS = ["untrusted", "trusted", "privileged"];
253
- function getNodeApi() {
254
- if (typeof window !== "undefined") return null;
255
- const proc = typeof process !== "undefined" ? process : void 0;
256
- if (typeof proc?.getBuiltinModule !== "function") return null;
257
- if (typeof import.meta.resolve !== "function") return null;
258
- try {
259
- const fs = proc.getBuiltinModule("node:fs");
260
- const url = proc.getBuiltinModule("node:url");
261
- if (!fs || !url) return null;
262
- const resolve = import.meta.resolve.bind(import.meta);
263
- return {
264
- readFile: (u) => fs.readFileSync(url.fileURLToPath(u), "utf8"),
265
- resolve
266
- };
267
- } catch {
268
- return null;
269
- }
251
+ // src/rendering.ts
252
+ import {
253
+ Fragment,
254
+ createElement
255
+ } from "react";
256
+ import { marked } from "marked";
257
+ function escape(s) {
258
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
270
259
  }
271
- function isValidManifest(value) {
272
- if (typeof value !== "object" || value === null) return false;
273
- const v = value;
274
- if (typeof v.apiVersion !== "number") return false;
275
- if (typeof v.name !== "string") return false;
276
- if (typeof v.trustLevel !== "string") return false;
277
- if (!TRUST_LEVELS.includes(v.trustLevel)) return false;
278
- if (!Array.isArray(v.capabilities)) return false;
279
- for (const c of v.capabilities) {
280
- if (typeof c !== "string") return false;
260
+ function textAlignStyle(attrs) {
261
+ const v = attrs?.textAlign;
262
+ if (v === "left" || v === "center" || v === "right" || v === "justify") {
263
+ return ` style="text-align: ${v}"`;
281
264
  }
282
- return true;
265
+ return "";
283
266
  }
284
- function loadPackageManifest(packageName) {
285
- const node = getNodeApi();
286
- if (!node) return null;
287
- let resolvedUrl;
288
- try {
289
- resolvedUrl = node.resolve(`${packageName}/package.json`);
290
- } catch {
291
- return null;
292
- }
293
- let raw;
294
- try {
295
- raw = node.readFile(resolvedUrl);
296
- } catch {
297
- return null;
298
- }
299
- let pkg;
300
- try {
301
- pkg = JSON.parse(raw);
302
- } catch {
303
- return null;
267
+ function renderTiptapString(node) {
268
+ if (node.type === "text") {
269
+ let html = escape(node.text ?? "");
270
+ for (const mark of node.marks ?? []) {
271
+ if (mark.type === "bold") html = `<strong>${html}</strong>`;
272
+ else if (mark.type === "italic") html = `<em>${html}</em>`;
273
+ else if (mark.type === "code") html = `<code>${html}</code>`;
274
+ else if (mark.type === "strike") html = `<s>${html}</s>`;
275
+ else if (mark.type === "underline") html = `<u>${html}</u>`;
276
+ else if (mark.type === "highlight") html = `<mark>${html}</mark>`;
277
+ else if (mark.type === "link") {
278
+ const href = escape(String(mark.attrs?.href ?? "#"));
279
+ html = `<a href="${href}" target="_blank" rel="noopener">${html}</a>`;
280
+ }
281
+ }
282
+ return html;
304
283
  }
305
- if (typeof pkg !== "object" || pkg === null || !("amplessPlugin" in pkg)) {
306
- return null;
284
+ const children = (node.content ?? []).map(renderTiptapString).join("");
285
+ switch (node.type) {
286
+ case "doc":
287
+ return children;
288
+ case "paragraph":
289
+ return `<p${textAlignStyle(node.attrs)}>${children}</p>`;
290
+ case "heading": {
291
+ const level = Number(node.attrs?.level ?? 1);
292
+ return `<h${level}${textAlignStyle(node.attrs)}>${children}</h${level}>`;
293
+ }
294
+ case "bulletList":
295
+ return `<ul>${children}</ul>`;
296
+ case "orderedList":
297
+ return `<ol>${children}</ol>`;
298
+ case "listItem":
299
+ return `<li>${children}</li>`;
300
+ case "codeBlock": {
301
+ const lang = node.attrs?.language ? ` class="language-${escape(String(node.attrs.language))}"` : "";
302
+ return `<pre><code${lang}>${children}</code></pre>`;
303
+ }
304
+ case "blockquote":
305
+ return `<blockquote>${children}</blockquote>`;
306
+ case "hardBreak":
307
+ return "<br />";
308
+ case "horizontalRule":
309
+ return "<hr />";
310
+ case "image": {
311
+ const src = escape(String(node.attrs?.src ?? ""));
312
+ const alt = escape(String(node.attrs?.alt ?? ""));
313
+ const title = node.attrs?.title ? ` title="${escape(String(node.attrs.title))}"` : "";
314
+ const display = node.attrs?.display ? ` data-display="${escape(String(node.attrs.display))}"` : "";
315
+ return `<img src="${src}" alt="${alt}"${title}${display} loading="lazy" />`;
316
+ }
317
+ case "table":
318
+ return `<table class="tiptap-table"><tbody>${children}</tbody></table>`;
319
+ case "tableRow":
320
+ return `<tr>${children}</tr>`;
321
+ case "tableHeader":
322
+ return `<th${tableCellAttrs(node.attrs)}>${children}</th>`;
323
+ case "tableCell":
324
+ return `<td${tableCellAttrs(node.attrs)}>${children}</td>`;
325
+ case "taskList":
326
+ return `<ul data-type="taskList">${children}</ul>`;
327
+ case "taskItem": {
328
+ const checked = node.attrs?.checked === true ? "true" : "false";
329
+ return `<li data-type="taskItem" data-checked="${checked}">${children}</li>`;
330
+ }
331
+ default:
332
+ return children;
307
333
  }
308
- const manifest = pkg.amplessPlugin;
309
- if (!isValidManifest(manifest)) return null;
310
- return manifest;
311
- }
312
- var SUPPORTED_API_VERSION = 1;
313
-
314
- // src/plugin-head.ts
315
- function isPlugin2(p) {
316
- return typeof p === "object" && p !== null && "apiVersion" in p;
317
- }
318
- var ALLOWED_ATTRS = /* @__PURE__ */ new Set([
319
- "crossorigin",
320
- "referrerpolicy",
321
- "integrity",
322
- "fetchpriority",
323
- // `nonce` is intentionally NOT in the allowlist for Phase 1. CSP
324
- // nonce propagation is scoped out of Phase 1 (see spec §7); attrs
325
- // shouldn't let plugins smuggle nonces past the design decision.
326
- // The CSP nonce RFP will reintroduce it through `cspNonce` on
327
- // PluginPublicRenderContext + `inlineScript.nonce: 'auto'`, not via
328
- // the `attrs` bag.
329
- "loading",
330
- // iframe lazy-loading
331
- "sandbox",
332
- // iframe sandbox attribute
333
- "allow",
334
- // iframe permissions policy
335
- "allowfullscreen"
336
- // iframe fullscreen
337
- ]);
338
- function isAllowedAttr(name) {
339
- if (name.startsWith("data-")) return true;
340
- return ALLOWED_ATTRS.has(name.toLowerCase());
341
- }
342
- function isSafeUrl(value) {
343
- const trimmed = value.trim();
344
- if (trimmed.length === 0) return false;
345
- const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
346
- if (!schemeMatch) return true;
347
- const scheme = schemeMatch[1].toLowerCase();
348
- return scheme === "http" || scheme === "https";
349
- }
350
- function isDev() {
351
- const env = typeof process !== "undefined" && process.env ? process.env.NODE_ENV : void 0;
352
- return env !== "production";
353
- }
354
- function warn(message) {
355
- if (!isDev()) return;
356
- console.warn(`[ampless plugin-head] ${message}`);
357
334
  }
358
- function crossCheckStaticManifest(plugin, label) {
359
- const packageName = plugin.packageName;
360
- const manifest = loadPackageManifest(packageName);
361
- if (!manifest) return;
362
- if (typeof manifest.apiVersion !== "number") {
363
- throw new Error(
364
- `${label}: package.json#amplessPlugin.apiVersion is not a number (got ${JSON.stringify(manifest.apiVersion)}). Update the plugin's package.json or remove the amplessPlugin field.`
365
- );
366
- }
367
- if (manifest.apiVersion > SUPPORTED_API_VERSION) {
368
- throw new Error(
369
- `${label}: package.json#amplessPlugin.apiVersion ${manifest.apiVersion} is newer than this runtime supports (max ${SUPPORTED_API_VERSION}). Upgrade @ampless/runtime, or pin an older version of the plugin.`
370
- );
371
- }
372
- if (manifest.apiVersion !== plugin.apiVersion) {
373
- throw new Error(
374
- `${label}: apiVersion mismatch \u2014 package.json declares ${manifest.apiVersion}, factory declares ${plugin.apiVersion}. The two must agree.`
375
- );
376
- }
377
- if (manifest.name !== plugin.name) {
378
- warn(
379
- `${label}: name mismatch \u2014 package.json#amplessPlugin.name is "${manifest.name}", factory returns name="${plugin.name}". Align them so admin UI / capability gates can identify the plugin consistently.`
380
- );
381
- }
382
- if (manifest.trustLevel !== plugin.trust_level) {
383
- warn(
384
- `${label}: trustLevel mismatch \u2014 package.json declares "${manifest.trustLevel}", factory declares trust_level="${plugin.trust_level}". The processor IAM policies are wired off trust_level; drift here usually means the deployment lambda runs in the wrong context.`
385
- );
386
- }
387
- const factoryCaps = Array.isArray(plugin.capabilities) ? plugin.capabilities : [];
388
- const manifestCaps = manifest.capabilities;
389
- if (!setsEqual(factoryCaps, manifestCaps)) {
390
- warn(
391
- `${label}: capabilities mismatch \u2014 package.json declares [${manifestCaps.join(", ")}], factory declares [${factoryCaps.join(", ")}]. The static manifest is what npm registry / admin UI surfaces show before the plugin loads, so it should match what the factory actually returns.`
392
- );
335
+ function tableCellAttrs(attrs) {
336
+ let out = "";
337
+ const colspan = Number(attrs?.colspan ?? 1);
338
+ if (colspan > 1) out += ` colspan="${colspan}"`;
339
+ const rowspan = Number(attrs?.rowspan ?? 1);
340
+ if (rowspan > 1) out += ` rowspan="${rowspan}"`;
341
+ const colwidth = attrs?.colwidth;
342
+ if (Array.isArray(colwidth) && colwidth.length > 0) {
343
+ const w = Number(colwidth[0]);
344
+ if (Number.isFinite(w) && w > 0) out += ` style="width: ${w}px"`;
393
345
  }
346
+ return out;
394
347
  }
395
- function setsEqual(a, b) {
396
- const sa = new Set(a);
397
- const sb = new Set(b);
398
- if (sa.size !== sb.size) return false;
399
- for (const c of sb) if (!sa.has(c)) return false;
400
- return true;
348
+ function renderMarkdownString(md) {
349
+ return marked.parse(md, { gfm: true, breaks: false, async: false });
401
350
  }
402
- function applyAttrs(target, attrs, ownerLabel) {
403
- if (!attrs) return;
404
- for (const [k, v] of Object.entries(attrs)) {
405
- if (!isAllowedAttr(k)) {
406
- warn(
407
- `${ownerLabel}: attr "${k}" not in allowlist (data-* / crossorigin / referrerpolicy / integrity / fetchpriority / loading / sandbox / allow / allowfullscreen). skipping.`
408
- );
409
- continue;
351
+ function buildContentFieldRegistry(plugins) {
352
+ const tiptap = /* @__PURE__ */ new Map();
353
+ const seenMarkdownPatterns = /* @__PURE__ */ new Set();
354
+ const markdownUrl = [];
355
+ for (const plugin of plugins) {
356
+ const fields = plugin.contentFields;
357
+ if (!fields) continue;
358
+ for (const field of fields) {
359
+ if (field.kind === "tiptap") {
360
+ if (tiptap.has(field.nodeType)) {
361
+ throw new Error(
362
+ `[ampless contentFields] duplicate tiptap nodeType "${field.nodeType}" \u2014 already registered by another plugin. Each nodeType may be claimed by at most one plugin.`
363
+ );
364
+ }
365
+ tiptap.set(field.nodeType, { plugin, renderer: field });
366
+ } else if (field.kind === "markdown-url") {
367
+ const key = field.pattern.source;
368
+ if (seenMarkdownPatterns.has(key)) {
369
+ throw new Error(
370
+ `[ampless contentFields] duplicate markdown-url pattern "${key}" \u2014 already registered by another plugin. Each pattern may be claimed by at most one plugin.`
371
+ );
372
+ }
373
+ seenMarkdownPatterns.add(key);
374
+ markdownUrl.push({ plugin, renderer: field });
375
+ }
410
376
  }
411
- target[k] = v;
412
377
  }
378
+ return { tiptap, markdownUrl };
413
379
  }
414
- function escapeJsonLdInlineBody(value) {
415
- return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
380
+ function htmlPassthrough(html) {
381
+ return createElement("span", { dangerouslySetInnerHTML: { __html: html } });
416
382
  }
417
- function inlineScriptTypeAllowed(surface, scriptType) {
418
- if (scriptType === "application/ld+json") return true;
419
- if (scriptType === void 0) {
420
- return surface === "head" || surface === "body-end";
421
- }
422
- return false;
383
+ function htmlPassthroughBlock(html, key) {
384
+ const props = { dangerouslySetInnerHTML: { __html: html } };
385
+ if (key !== void 0) props.key = key;
386
+ return createElement("div", props);
423
387
  }
424
- function renderInlineScript(descriptor, pluginLabel, index, surface) {
425
- if (!descriptor.id) {
426
- warn(
427
- `${pluginLabel}: inlineScript descriptor #${index} dropped \u2014 missing required "id".`
428
- );
429
- return null;
388
+ function renderTiptapNode(node, opts, key) {
389
+ const reg = opts.contentFields?.tiptap.get(node.type);
390
+ if (reg) {
391
+ const ctx = opts.ctxForPlugin?.(reg.plugin);
392
+ if (!ctx) {
393
+ return null;
394
+ }
395
+ try {
396
+ const out = reg.renderer.render(node, ctx);
397
+ return createElement(Fragment, { key }, out);
398
+ } catch (err) {
399
+ console.warn(
400
+ `[ampless renderBody] plugin "${reg.plugin.instanceId ?? reg.plugin.name}" threw inside contentFields tiptap renderer for nodeType "${node.type}": ${err instanceof Error ? err.message : String(err)}`
401
+ );
402
+ return null;
403
+ }
430
404
  }
431
- if (!inlineScriptTypeAllowed(surface, descriptor.scriptType)) {
432
- const got = descriptor.scriptType === void 0 ? "undefined" : `"${descriptor.scriptType}"`;
433
- const allowed = surface === "body-for-post" ? `"application/ld+json" (required on publicBodyForPost \u2014 the per-post surface is scoped to JSON-LD)` : `undefined or "application/ld+json"`;
434
- warn(
435
- `${pluginLabel}: inlineScript "${descriptor.id}" dropped \u2014 scriptType ${got} not allowed on ${surface}. Allowed: ${allowed}.`
436
- );
437
- return null;
405
+ if (node.type === "text") {
406
+ return htmlPassthrough(renderTiptapString(node));
438
407
  }
439
- const body = descriptor.scriptType === "application/ld+json" ? escapeJsonLdInlineBody(descriptor.body) : descriptor.body;
440
- const props = {
441
- id: descriptor.id,
442
- dangerouslySetInnerHTML: { __html: body }
443
- };
444
- if (descriptor.scriptType) props.type = descriptor.scriptType;
445
- return {
446
- id: descriptor.id,
447
- element: createElement("script", props)
448
- };
449
- }
450
- function renderHeadDescriptor(descriptor, pluginLabel, index) {
451
- switch (descriptor.type) {
452
- case "script": {
453
- if (!isSafeUrl(descriptor.src)) {
454
- warn(
455
- `${pluginLabel}: script descriptor #${index} dropped \u2014 unsafe src "${descriptor.src}".`
456
- );
457
- return null;
458
- }
459
- const props = {
460
- src: descriptor.src
461
- };
462
- if (descriptor.id) props.id = descriptor.id;
463
- const hasAsync = typeof descriptor.async === "boolean";
464
- const hasDefer = typeof descriptor.defer === "boolean";
465
- if (hasAsync) props.async = descriptor.async;
466
- if (hasDefer) props.defer = descriptor.defer;
467
- if (!hasAsync && !hasDefer) {
468
- if (descriptor.strategy === "lazyOnload") {
469
- props.defer = true;
470
- } else {
471
- props.async = true;
472
- }
473
- }
474
- applyAttrs(props, descriptor.attrs, `${pluginLabel} script#${descriptor.id ?? index}`);
475
- return {
476
- id: descriptor.id ?? null,
477
- element: createElement("script", props)
478
- };
408
+ const childNodes = node.content ?? [];
409
+ const children = childNodes.map(
410
+ (c, i) => renderTiptapNode(c, opts, `${key}.${i}`)
411
+ );
412
+ switch (node.type) {
413
+ case "doc":
414
+ return createElement(Fragment, { key }, ...children);
415
+ case "paragraph": {
416
+ const align = textAlignStyleProp(node.attrs);
417
+ return createElement("p", { key, ...align }, ...children);
479
418
  }
480
- case "inlineScript":
481
- return renderInlineScript(descriptor, pluginLabel, index, "head");
482
- case "meta": {
483
- const props = { content: descriptor.content };
484
- if (descriptor.name) props.name = descriptor.name;
485
- if (descriptor.property) props.property = descriptor.property;
486
- return {
487
- // meta has no id channel in the descriptor. Don't derive a
488
- // dedup id from name/property — multiple `<meta name=...>`
489
- // entries with the same name are legitimate (e.g. theme-color
490
- // media variants, and two plugins emitting overlapping names
491
- // is a real case the runtime shouldn't silently collapse).
492
- // Position-based React keys handle the stable-key requirement.
493
- id: null,
494
- element: createElement("meta", props)
495
- };
419
+ case "heading": {
420
+ const level = Number(node.attrs?.level ?? 1);
421
+ const tag = `h${level}`;
422
+ const align = textAlignStyleProp(node.attrs);
423
+ return createElement(tag, { key, ...align }, ...children);
496
424
  }
497
- case "link": {
498
- if (!isSafeUrl(descriptor.href)) {
499
- warn(
500
- `${pluginLabel}: link descriptor #${index} dropped \u2014 unsafe href "${descriptor.href}".`
501
- );
502
- return null;
425
+ case "bulletList":
426
+ return createElement("ul", { key }, ...children);
427
+ case "orderedList":
428
+ return createElement("ol", { key }, ...children);
429
+ case "listItem":
430
+ return createElement("li", { key }, ...children);
431
+ case "codeBlock": {
432
+ const codeProps = {};
433
+ if (node.attrs?.language) {
434
+ codeProps.className = `language-${String(node.attrs.language)}`;
503
435
  }
504
- const props = {
505
- rel: descriptor.rel,
506
- href: descriptor.href
507
- };
508
- if (descriptor.as) props.as = descriptor.as;
509
- if (descriptor.typeAttr) props.type = descriptor.typeAttr;
510
- return {
511
- id: null,
512
- element: createElement("link", props)
513
- };
436
+ return createElement(
437
+ "pre",
438
+ { key },
439
+ createElement("code", codeProps, ...children)
440
+ );
514
441
  }
515
- case "noscript": {
442
+ case "blockquote":
443
+ return createElement("blockquote", { key }, ...children);
444
+ case "hardBreak":
445
+ return createElement("br", { key });
446
+ case "horizontalRule":
447
+ return createElement("hr", { key });
448
+ case "image": {
449
+ const src = String(node.attrs?.src ?? "");
450
+ const alt = String(node.attrs?.alt ?? "");
451
+ const title = node.attrs?.title ? String(node.attrs.title) : void 0;
452
+ const display = node.attrs?.display ? String(node.attrs.display) : void 0;
516
453
  const props = {
517
- dangerouslySetInnerHTML: { __html: descriptor.html }
518
- };
519
- if (descriptor.id) props.id = descriptor.id;
520
- return {
521
- id: descriptor.id ?? null,
522
- element: createElement("noscript", props)
454
+ key,
455
+ src,
456
+ alt,
457
+ loading: "lazy"
523
458
  };
459
+ if (title) props.title = title;
460
+ if (display) props["data-display"] = display;
461
+ return createElement("img", props);
524
462
  }
525
- }
526
- }
527
- function renderBodyDescriptor(descriptor, pluginLabel, index) {
528
- if (descriptor.type === "iframe") {
529
- if (!isSafeUrl(descriptor.src)) {
530
- warn(
531
- `${pluginLabel}: iframe descriptor #${index} dropped \u2014 unsafe src "${descriptor.src}".`
463
+ case "table":
464
+ return createElement(
465
+ "table",
466
+ { key, className: "tiptap-table" },
467
+ createElement("tbody", null, ...children)
468
+ );
469
+ case "tableRow":
470
+ return createElement("tr", { key }, ...children);
471
+ case "tableHeader":
472
+ return createElement("th", { key, ...tableCellProps(node.attrs) }, ...children);
473
+ case "tableCell":
474
+ return createElement("td", { key, ...tableCellProps(node.attrs) }, ...children);
475
+ case "taskList":
476
+ return createElement("ul", { key, "data-type": "taskList" }, ...children);
477
+ case "taskItem": {
478
+ const checked = node.attrs?.checked === true ? "true" : "false";
479
+ return createElement(
480
+ "li",
481
+ { key, "data-type": "taskItem", "data-checked": checked },
482
+ ...children
532
483
  );
533
- return null;
534
484
  }
535
- const props = {
536
- src: descriptor.src
537
- };
538
- if (descriptor.id) props.id = descriptor.id;
539
- if (descriptor.title) props.title = descriptor.title;
540
- if (typeof descriptor.width === "number") props.width = descriptor.width;
541
- if (typeof descriptor.height === "number") props.height = descriptor.height;
542
- applyAttrs(props, descriptor.attrs, `${pluginLabel} iframe#${descriptor.id ?? index}`);
543
- return {
544
- id: descriptor.id ?? null,
545
- element: createElement("iframe", props)
546
- };
547
- }
548
- if (descriptor.type === "inlineScript") {
549
- return renderInlineScript(descriptor, pluginLabel, index, "body-end");
485
+ default:
486
+ return createElement(Fragment, { key }, ...children);
550
487
  }
551
- return renderHeadDescriptor(descriptor, pluginLabel, index);
552
488
  }
553
- function renderPostBodyDescriptor(descriptor, pluginLabel, index) {
554
- if (descriptor.type !== "inlineScript") {
555
- warn(
556
- `${pluginLabel}: publicBodyForPost descriptor #${index} dropped \u2014 only inlineScript with scriptType "application/ld+json" is allowed on this surface.`
557
- );
558
- return null;
489
+ function textAlignStyleProp(attrs) {
490
+ const v = attrs?.textAlign;
491
+ if (v === "left" || v === "center" || v === "right" || v === "justify") {
492
+ return { style: { textAlign: v } };
559
493
  }
560
- return renderInlineScript(descriptor, pluginLabel, index, "body-for-post");
494
+ return {};
561
495
  }
562
- function dedupeAndKey(entries) {
563
- const lastIndexById = /* @__PURE__ */ new Map();
564
- for (let i = 0; i < entries.length; i++) {
565
- const id = entries[i].id;
566
- if (id === null) continue;
567
- if (lastIndexById.has(id)) {
568
- warn(`duplicate descriptor id "${id}" \u2014 keeping the last occurrence.`);
569
- }
570
- lastIndexById.set(id, i);
571
- }
572
- const kept = [];
573
- for (let i = 0; i < entries.length; i++) {
574
- const { id, element } = entries[i];
575
- if (id !== null && lastIndexById.get(id) !== i) continue;
576
- const key = id ?? `__pos-${i}`;
577
- const existingProps = element.props;
578
- kept.push(createElement(element.type, { ...existingProps, key }));
496
+ function tableCellProps(attrs) {
497
+ const out = {};
498
+ const colspan = Number(attrs?.colspan ?? 1);
499
+ if (colspan > 1) out.colSpan = colspan;
500
+ const rowspan = Number(attrs?.rowspan ?? 1);
501
+ if (rowspan > 1) out.rowSpan = rowspan;
502
+ const colwidth = attrs?.colwidth;
503
+ if (Array.isArray(colwidth) && colwidth.length > 0) {
504
+ const w = Number(colwidth[0]);
505
+ if (Number.isFinite(w) && w > 0) out.style = { width: `${w}px` };
579
506
  }
580
- return kept;
507
+ return out;
581
508
  }
582
- function makeCtx(plugin, site, snapshot) {
583
- const instanceId = plugin.instanceId ?? plugin.name;
584
- const stored = snapshot.get(instanceId) ?? {};
585
- const resolved = resolvePluginSettings(plugin.settings, stored);
586
- return {
587
- site,
588
- setting(key) {
589
- if (!isValidPluginKey(key)) return void 0;
590
- const v = resolved[key];
591
- return v === void 0 ? void 0 : v;
592
- }
509
+ function renderMarkdownNode(md, opts) {
510
+ const tokens = marked.lexer(md, { gfm: true, breaks: false });
511
+ const children = [];
512
+ let pending = [];
513
+ let chunk = 0;
514
+ const flush = () => {
515
+ if (pending.length === 0) return;
516
+ const html = marked.parser(pending, { gfm: true, breaks: false });
517
+ children.push(htmlPassthroughBlock(html, `md-html-${chunk++}`));
518
+ pending = [];
593
519
  };
520
+ tokens.forEach((token, i) => {
521
+ const match = matchMarkdownUrlEmbed(token, opts);
522
+ if (match) {
523
+ flush();
524
+ children.push(createElement(Fragment, { key: `md-embed-${i}` }, match));
525
+ } else {
526
+ pending.push(token);
527
+ }
528
+ });
529
+ flush();
530
+ return createElement(Fragment, null, ...children);
594
531
  }
595
- function collectFor(plugins, site, snapshot, surface, renderOne) {
596
- const entries = [];
597
- for (const plugin of plugins) {
598
- const factory = surface(plugin);
599
- if (!factory) continue;
600
- const ctx = makeCtx(plugin, site, snapshot);
601
- let descriptors;
532
+ function matchMarkdownUrlEmbed(token, opts) {
533
+ if (!opts.contentFields || opts.contentFields.markdownUrl.length === 0) {
534
+ return null;
535
+ }
536
+ if (token.type !== "paragraph") return null;
537
+ const para = token;
538
+ const url = extractSingleUrl(para);
539
+ if (!url) return null;
540
+ for (const entry of opts.contentFields.markdownUrl) {
541
+ const m = url.match(entry.renderer.pattern);
542
+ if (!m) continue;
543
+ const ctx = opts.ctxForPlugin?.(entry.plugin);
544
+ if (!ctx) return null;
602
545
  try {
603
- descriptors = factory.call(plugin, ctx) ?? [];
546
+ return entry.renderer.render({ match: m, raw: url }, ctx);
604
547
  } catch (err) {
605
- warn(
606
- `plugin "${plugin.instanceId ?? plugin.name}" threw inside descriptor callback: ${err instanceof Error ? err.message : String(err)}`
548
+ console.warn(
549
+ `[ampless renderBody] plugin "${entry.plugin.instanceId ?? entry.plugin.name}" threw inside contentFields markdown-url renderer for pattern "${entry.renderer.pattern.source}": ${err instanceof Error ? err.message : String(err)}`
607
550
  );
608
- continue;
609
- }
610
- const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
611
- for (let i = 0; i < descriptors.length; i++) {
612
- const entry = renderOne(descriptors[i], label, i);
613
- if (entry) entries.push(entry);
551
+ return null;
614
552
  }
615
553
  }
616
- if (entries.length === 0) return null;
617
- const keyed = dedupeAndKey(entries);
618
- return createElement(Fragment, null, ...keyed);
554
+ return null;
619
555
  }
620
- var SANITIZE_OPTIONS = {
621
- allowedTags: ["p", "span", "strong", "em", "a", "code", "br", "ul", "ol", "li"],
622
- allowedAttributes: {
623
- "*": ["class", "data-words", "data-minutes", "data-ampless-*"],
624
- a: ["href", "rel", "target"]
625
- },
626
- allowedSchemes: ["http", "https"],
627
- allowedSchemesAppliedToAttributes: ["href"],
628
- allowProtocolRelative: false,
629
- transformTags: {
630
- a: (tagName, attribs) => {
631
- const out = { ...attribs };
632
- if (out["target"] === "_blank") {
633
- const parts = (out["rel"] ?? "").split(/\s+/).filter(Boolean);
634
- if (!parts.includes("noopener")) parts.push("noopener");
635
- if (!parts.includes("noreferrer")) parts.push("noreferrer");
636
- out["rel"] = parts.join(" ");
637
- }
638
- return { tagName, attribs: out };
639
- }
556
+ function extractSingleUrl(para) {
557
+ const tokens = para.tokens ?? [];
558
+ if (tokens.length === 0) return null;
559
+ const trimmed = tokens.filter((t2) => {
560
+ if (t2.type === "text") return (t2.raw ?? "").trim().length > 0;
561
+ return true;
562
+ });
563
+ if (trimmed.length !== 1) return null;
564
+ const t = trimmed[0];
565
+ if (t.type === "link") {
566
+ const link = t;
567
+ const href = (link.href ?? "").trim();
568
+ const raw = (link.raw ?? "").trim();
569
+ if (!href || raw !== href) return null;
570
+ return href;
640
571
  }
641
- };
642
- function validateHtmlDescriptor(descriptor, pluginLabel, index) {
643
- if (descriptor === null || typeof descriptor !== "object") {
644
- warn(
645
- `${pluginLabel}: publicHtmlForPost descriptor #${index} dropped \u2014 must be an object.`
646
- );
647
- return false;
572
+ if (t.type === "text") {
573
+ const text = (t.text ?? "").trim();
574
+ return text || null;
648
575
  }
649
- const d = descriptor;
650
- if (d.type !== "html") {
651
- warn(
652
- `${pluginLabel}: publicHtmlForPost descriptor #${index} dropped \u2014 "type" must be "html" (got ${typeof d.type === "string" ? `"${d.type}"` : typeof d.type}).`
653
- );
654
- return false;
576
+ return null;
577
+ }
578
+ function renderBody(post, opts = {}) {
579
+ if (post.format === "html") {
580
+ return htmlPassthroughBlock(String(post.body));
655
581
  }
656
- if (typeof d.id !== "string") {
657
- warn(
658
- `${pluginLabel}: publicHtmlForPost descriptor #${index} dropped \u2014 "id" must be a string.`
659
- );
660
- return false;
582
+ if (post.format === "markdown") {
583
+ return renderMarkdownNode(String(post.body), opts);
661
584
  }
662
- if (typeof d.body !== "string") {
663
- warn(
664
- `${pluginLabel}: publicHtmlForPost descriptor "${d.id}" dropped \u2014 "body" must be a string.`
665
- );
666
- return false;
667
- }
668
- if (d.position !== "beforeContent" && d.position !== "afterContent") {
669
- warn(
670
- `${pluginLabel}: publicHtmlForPost descriptor "${d.id}" dropped \u2014 "position" must be "beforeContent" or "afterContent" (got ${typeof d.position === "string" ? `"${d.position}"` : typeof d.position}).`
671
- );
672
- return false;
585
+ if (post.format === "tiptap") {
586
+ if (typeof post.body === "string") {
587
+ return htmlPassthroughBlock(post.body);
588
+ }
589
+ return renderTiptapNode(post.body, opts, "root");
673
590
  }
674
- return true;
591
+ return null;
675
592
  }
676
- function validateHtmlId(id, pluginLabel) {
677
- if (id.length === 0) {
678
- warn(`${pluginLabel}: publicHtmlForPost descriptor dropped \u2014 "id" must not be empty.`);
679
- return false;
680
- }
681
- if (/[\x00-\x1f]/.test(id)) {
682
- warn(
683
- `${pluginLabel}: publicHtmlForPost descriptor "${id}" dropped \u2014 "id" contains control characters.`
684
- );
685
- return false;
686
- }
687
- if (id.length > 64) {
688
- warn(
689
- `${pluginLabel}: publicHtmlForPost descriptor "${id.slice(0, 32)}\u2026" dropped \u2014 "id" exceeds 64 characters.`
690
- );
691
- return false;
593
+ function renderBodyHtmlString(post) {
594
+ if (post.format === "html") return String(post.body);
595
+ if (post.format === "markdown") return renderMarkdownString(String(post.body));
596
+ if (post.format === "tiptap") {
597
+ if (typeof post.body === "string") return post.body;
598
+ return renderTiptapString(post.body);
692
599
  }
693
- return true;
600
+ return "";
694
601
  }
695
- function collectForPost(plugins, site, snapshot, post) {
696
- const entries = [];
697
- for (const plugin of plugins) {
698
- const factory = plugin.publicBodyForPost;
699
- if (!factory) continue;
700
- const ctx = makeCtx(plugin, site, snapshot);
701
- let descriptors;
702
- try {
703
- descriptors = factory.call(plugin, post, ctx) ?? [];
704
- } catch (err) {
705
- warn(
706
- `plugin "${plugin.instanceId ?? plugin.name}" threw inside publicBodyForPost callback: ${err instanceof Error ? err.message : String(err)}`
707
- );
708
- continue;
709
- }
710
- const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
711
- for (let i = 0; i < descriptors.length; i++) {
712
- const entry = renderPostBodyDescriptor(descriptors[i], label, i);
713
- if (entry) entries.push(entry);
714
- }
715
- }
716
- if (entries.length === 0) return null;
717
- const keyed = dedupeAndKey(entries);
718
- return createElement(Fragment, null, ...keyed);
602
+ function tiptapToHtml(doc) {
603
+ if (typeof doc === "string") return doc;
604
+ return renderTiptapString(doc);
719
605
  }
720
- function collectHtmlForPost(plugins, site, snapshot, post) {
721
- const before = [];
722
- const after = [];
723
- const seenBefore = /* @__PURE__ */ new Set();
724
- const seenAfter = /* @__PURE__ */ new Set();
725
- for (const plugin of plugins) {
726
- const factory = plugin.publicHtmlForPost;
727
- if (!factory) continue;
728
- const namespace = plugin.instanceId ?? plugin.name;
729
- const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
730
- const ctx = makeCtx(plugin, site, snapshot);
731
- let descriptors;
732
- try {
733
- descriptors = factory.call(plugin, post, ctx) ?? [];
734
- } catch (err) {
735
- warn(
736
- `${label}: threw inside publicHtmlForPost callback: ${err instanceof Error ? err.message : String(err)}`
737
- );
738
- continue;
739
- }
740
- for (let i = 0; i < descriptors.length; i++) {
741
- const raw = descriptors[i];
742
- if (!validateHtmlDescriptor(raw, label, i)) continue;
743
- const descriptor = raw;
744
- if (!validateHtmlId(descriptor.id, label)) continue;
745
- const dedupeKey = `${namespace}:${descriptor.id}`;
746
- const position = descriptor.position;
747
- const seenSet = position === "beforeContent" ? seenBefore : seenAfter;
748
- const bucket = position === "beforeContent" ? before : after;
749
- if (seenSet.has(dedupeKey)) {
750
- warn(
751
- `${label}: publicHtmlForPost descriptor "${descriptor.id}" at position "${position}" is a duplicate \u2014 keeping the first occurrence.`
752
- );
753
- continue;
754
- }
755
- seenSet.add(dedupeKey);
756
- const cleanHtml = sanitizeHtml(descriptor.body, SANITIZE_OPTIONS);
757
- bucket.push({ key: dedupeKey, cleanHtml, namespace });
606
+ function markdownToHtml(md) {
607
+ return renderMarkdownString(md);
608
+ }
609
+ function tiptapToMarkdown(doc) {
610
+ if (typeof doc === "string") return htmlToMarkdown(doc);
611
+ const node = doc;
612
+ return tiptapNodeToMarkdown(node).trim() + "\n";
613
+ }
614
+ function tiptapNodeToMarkdown(node) {
615
+ if (node.type === "text") {
616
+ let txt = node.text ?? "";
617
+ for (const mark of node.marks ?? []) {
618
+ if (mark.type === "bold") txt = `**${txt}**`;
619
+ else if (mark.type === "italic") txt = `*${txt}*`;
620
+ else if (mark.type === "code") txt = `\`${txt}\``;
621
+ else if (mark.type === "strike") txt = `~~${txt}~~`;
622
+ else if (mark.type === "underline") txt = `<u>${txt}</u>`;
623
+ else if (mark.type === "highlight") txt = `<mark>${txt}</mark>`;
624
+ else if (mark.type === "link") txt = `[${txt}](${String(mark.attrs?.href ?? "#")})`;
758
625
  }
626
+ return txt;
759
627
  }
760
- function toReactNode(entries, position) {
761
- if (entries.length === 0) return null;
762
- const elements = entries.map(
763
- ({ key, cleanHtml, namespace }) => createElement("div", {
764
- key,
765
- "data-ampless-plugin": namespace,
766
- "data-ampless-position": position,
767
- dangerouslySetInnerHTML: { __html: cleanHtml }
768
- })
769
- );
770
- return createElement(Fragment, null, ...elements);
771
- }
772
- return {
773
- beforeContent: toReactNode(before, "beforeContent"),
774
- afterContent: toReactNode(after, "afterContent")
775
- };
776
- }
777
- function createPluginHead(cmsConfig, pluginSettings) {
778
- const plugins = (cmsConfig.plugins ?? []).filter(isPlugin2);
779
- const validPlugins = [];
780
- const seenNamespaces = /* @__PURE__ */ new Set();
781
- for (const plugin of plugins) {
782
- const ns = plugin.instanceId ?? plugin.name;
783
- const label = plugin.instanceId ? `${plugin.name}#${plugin.instanceId}` : plugin.name;
784
- if (!isValidPluginKey(ns)) {
785
- warn(
786
- `${label}: plugin namespace "${ns}" violates ${`/^[a-zA-Z0-9_-]+$/`}. Use a simple identifier (letters / digits / "-" / "_"). Skipping plugin.`
787
- );
788
- continue;
628
+ const children = (node.content ?? []).map(tiptapNodeToMarkdown).join("");
629
+ switch (node.type) {
630
+ case "doc":
631
+ return children;
632
+ case "paragraph":
633
+ return children + "\n\n";
634
+ case "heading": {
635
+ const level = Math.max(1, Math.min(6, Number(node.attrs?.level ?? 1)));
636
+ return "#".repeat(level) + " " + children + "\n\n";
789
637
  }
790
- if (seenNamespaces.has(ns)) {
791
- warn(
792
- `duplicate plugin namespace "${ns}" detected in cms.config.plugins. Set distinct \`instanceId\` on each instance to disambiguate.`
793
- );
638
+ case "bulletList":
639
+ return children + "\n";
640
+ case "orderedList":
641
+ return children + "\n";
642
+ case "listItem": {
643
+ const trimmed = children.replace(/\n+$/, "");
644
+ return "- " + trimmed + "\n";
794
645
  }
795
- seenNamespaces.add(ns);
796
- if (plugin.settings?.public) {
797
- for (const field of plugin.settings.public) {
798
- if (!isValidPluginKey(field.key)) {
799
- warn(
800
- `${label}: settings.public field key "${field.key}" violates ${`/^[a-zA-Z0-9_-]+$/`}. The field will not be readable through ctx.setting(). Rename the field key.`
801
- );
802
- }
803
- }
646
+ case "codeBlock": {
647
+ const lang = node.attrs?.language ? String(node.attrs.language) : "";
648
+ return "```" + lang + "\n" + children + "\n```\n\n";
804
649
  }
805
- validPlugins.push(plugin);
806
- if (plugin.packageName) {
807
- crossCheckStaticManifest(plugin, label);
650
+ case "blockquote":
651
+ return children.replace(/\n+$/, "").split("\n").map((l) => "> " + l).join("\n") + "\n\n";
652
+ case "hardBreak":
653
+ return " \n";
654
+ case "horizontalRule":
655
+ return "\n---\n\n";
656
+ case "image": {
657
+ const src = String(node.attrs?.src ?? "");
658
+ const alt = String(node.attrs?.alt ?? "");
659
+ return `![${alt}](${src})`;
808
660
  }
809
- const caps = plugin.capabilities;
810
- if (caps) {
811
- if (caps.includes("publicHead") && !plugin.publicHead) {
812
- warn(
813
- `${label}: declares capability "publicHead" but no \`publicHead\` implementation. Drop the capability or add the function.`
814
- );
815
- }
816
- if (caps.includes("publicBody") && !plugin.publicBodyEnd) {
817
- warn(
818
- `${label}: declares capability "publicBody" but no \`publicBodyEnd\` implementation. Drop the capability or add the function.`
819
- );
820
- }
821
- if (plugin.publicHead && !caps.includes("publicHead")) {
822
- warn(
823
- `${label}: implements \`publicHead\` but "publicHead" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
824
- );
825
- }
826
- if (plugin.publicBodyEnd && !caps.includes("publicBody")) {
827
- warn(
828
- `${label}: implements \`publicBodyEnd\` but "publicBody" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
829
- );
830
- }
831
- if (caps.includes("schema") && !plugin.publicBodyForPost) {
832
- warn(
833
- `${label}: declares capability "schema" but no \`publicBodyForPost\` implementation. Drop the capability or add the function.`
834
- );
835
- }
836
- if (plugin.publicBodyForPost && !caps.includes("schema")) {
837
- warn(
838
- `${label}: implements \`publicBodyForPost\` but "schema" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
839
- );
840
- }
841
- if (caps.includes("publicHtmlForPost") && !plugin.publicHtmlForPost) {
842
- warn(
843
- `${label}: declares capability "publicHtmlForPost" but no \`publicHtmlForPost\` implementation. Drop the capability or add the function.`
844
- );
845
- }
846
- if (plugin.publicHtmlForPost && !caps.includes("publicHtmlForPost")) {
847
- warn(
848
- `${label}: implements \`publicHtmlForPost\` but "publicHtmlForPost" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
849
- );
850
- }
661
+ case "table":
662
+ return tiptapTableToMarkdown(node);
663
+ case "taskList":
664
+ return children + "\n";
665
+ case "taskItem": {
666
+ const checked = node.attrs?.checked === true ? "x" : " ";
667
+ const inner = children.replace(/\n+$/, "");
668
+ const [first, ...rest] = inner.split("\n");
669
+ const cont = rest.map((l) => l ? " " + l : l).join("\n");
670
+ return `- [${checked}] ${first ?? ""}${cont ? "\n" + cont : ""}
671
+ `;
851
672
  }
673
+ default:
674
+ return children;
852
675
  }
853
- return {
854
- async renderHead() {
855
- const snapshot = await pluginSettings.loadAll();
856
- return collectFor(
857
- validPlugins,
858
- cmsConfig.site,
859
- snapshot,
860
- (p) => p.publicHead,
861
- renderHeadDescriptor
862
- );
863
- },
864
- async renderBodyEnd() {
865
- const snapshot = await pluginSettings.loadAll();
866
- return collectFor(
867
- validPlugins,
868
- cmsConfig.site,
869
- snapshot,
870
- (p) => p.publicBodyEnd,
871
- renderBodyDescriptor
676
+ }
677
+ function tiptapTableToMarkdown(node) {
678
+ const rows = node.content ?? [];
679
+ if (rows.length === 0) return "";
680
+ const renderedRows = [];
681
+ let headerIdx = -1;
682
+ for (let i = 0; i < rows.length; i++) {
683
+ const row = rows[i];
684
+ const cells = row.content ?? [];
685
+ const cellTexts = cells.map((c) => {
686
+ const inner = (c.content ?? []).map(tiptapNodeToMarkdown).join("");
687
+ return inner.replace(/\n+$/, "").replace(/\n/g, "<br>").replace(/\|/g, "\\|");
688
+ });
689
+ renderedRows.push(cellTexts);
690
+ if (headerIdx === -1 && cells.some((c) => c.type === "tableHeader")) headerIdx = i;
691
+ }
692
+ if (headerIdx === -1) headerIdx = 0;
693
+ const header = renderedRows[headerIdx] ?? [];
694
+ const body = renderedRows.filter((_, i) => i !== headerIdx);
695
+ const cols = header.length;
696
+ const headerLine = "| " + header.join(" | ") + " |";
697
+ const sepLine = "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |";
698
+ const bodyLines = body.map((r) => {
699
+ const cells = Array.from({ length: cols }, (_, i) => r[i] ?? "");
700
+ return "| " + cells.join(" | ") + " |";
701
+ });
702
+ return "\n" + [headerLine, sepLine, ...bodyLines].join("\n") + "\n\n";
703
+ }
704
+ function htmlToMarkdown(html) {
705
+ let md = html;
706
+ md = md.replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (_, inner) => {
707
+ return "\n" + convertHtmlTable(String(inner)) + "\n";
708
+ });
709
+ md = md.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, text) => {
710
+ return "\n" + "#".repeat(Number(level)) + " " + String(text).trim() + "\n\n";
711
+ });
712
+ md = md.replace(
713
+ /<pre[^>]*><code[^>]*(?:\sclass="language-([^"]+)")?[^>]*>([\s\S]*?)<\/code><\/pre>/gi,
714
+ (_, lang, code) => {
715
+ return "\n```" + (lang ?? "") + "\n" + String(code) + "\n```\n\n";
716
+ }
717
+ );
718
+ md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, content) => {
719
+ return "\n" + String(content).trim().split("\n").map((l) => "> " + l).join("\n") + "\n\n";
720
+ });
721
+ md = md.replace(/<ul[^>]*data-type="taskList"[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
722
+ return "\n" + convertHtmlTaskList(String(items)) + "\n";
723
+ });
724
+ md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
725
+ return "\n" + String(items).replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "- $1\n") + "\n";
726
+ });
727
+ md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (_, items) => {
728
+ let i = 1;
729
+ return "\n" + String(items).replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, () => `${i++}. $1
730
+ `) + "\n";
731
+ });
732
+ md = md.replace(/<hr\s*\/?>/gi, "\n---\n\n");
733
+ md = md.replace(/<br\s*\/?>/gi, " \n");
734
+ md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, "$1\n\n");
735
+ md = md.replace(/<img[^>]*?src="([^"]*)"[^>]*?alt="([^"]*)"[^>]*?\/?>/gi, "![$2]($1)");
736
+ md = md.replace(/<img[^>]*?alt="([^"]*)"[^>]*?src="([^"]*)"[^>]*?\/?>/gi, "![$1]($2)");
737
+ md = md.replace(/<img[^>]*?src="([^"]*)"[^>]*?\/?>/gi, "![]($1)");
738
+ md = md.replace(/<a[^>]*?href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)");
739
+ md = md.replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, "**$2**");
740
+ md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, "*$2*");
741
+ md = md.replace(/<s>([\s\S]*?)<\/s>/gi, "~~$1~~");
742
+ md = md.replace(/<code>([\s\S]*?)<\/code>/gi, "`$1`");
743
+ const PH_U_OPEN = "AMP_U_OPEN";
744
+ const PH_U_CLOSE = "AMP_U_CLOSE";
745
+ const PH_MARK_OPEN = "AMP_MARK_OPEN";
746
+ const PH_MARK_CLOSE = "AMP_MARK_CLOSE";
747
+ md = md.replace(/<u>([\s\S]*?)<\/u>/gi, `${PH_U_OPEN}$1${PH_U_CLOSE}`);
748
+ md = md.replace(/<mark>([\s\S]*?)<\/mark>/gi, `${PH_MARK_OPEN}$1${PH_MARK_CLOSE}`);
749
+ md = md.replace(/<\/?[^>]+>/g, "");
750
+ md = md.split(PH_U_OPEN).join("<u>").split(PH_U_CLOSE).join("</u>").split(PH_MARK_OPEN).join("<mark>").split(PH_MARK_CLOSE).join("</mark>");
751
+ md = md.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ");
752
+ md = md.replace(/\n{3,}/g, "\n\n");
753
+ return md.trim() + "\n";
754
+ }
755
+ function convertHtmlTable(inner) {
756
+ const stripped = inner.replace(/<\/?(thead|tbody)[^>]*>/gi, "");
757
+ const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
758
+ const rows = [];
759
+ let m;
760
+ while ((m = rowRe.exec(stripped)) !== null) {
761
+ const rowHtml = m[1] ?? "";
762
+ const cellRe = /<(th|td)[^>]*>([\s\S]*?)<\/\1>/gi;
763
+ const cells = [];
764
+ let isHeader = false;
765
+ let cm;
766
+ while ((cm = cellRe.exec(rowHtml)) !== null) {
767
+ if ((cm[1] ?? "").toLowerCase() === "th") isHeader = true;
768
+ cells.push(normalizeTableCell(cm[2] ?? ""));
769
+ }
770
+ if (cells.length > 0) rows.push({ isHeader, cells });
771
+ }
772
+ if (rows.length === 0) return "";
773
+ let headerIdx = rows.findIndex((r) => r.isHeader);
774
+ if (headerIdx === -1) headerIdx = 0;
775
+ const header = rows[headerIdx].cells;
776
+ const body = rows.filter((_, i) => i !== headerIdx).map((r) => r.cells);
777
+ const cols = header.length;
778
+ const headerLine = "| " + header.join(" | ") + " |";
779
+ const sepLine = "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |";
780
+ const bodyLines = body.map((cells) => {
781
+ const padded = Array.from({ length: cols }, (_, i) => cells[i] ?? "");
782
+ return "| " + padded.join(" | ") + " |";
783
+ });
784
+ return [headerLine, sepLine, ...bodyLines].join("\n") + "\n";
785
+ }
786
+ function normalizeTableCell(html) {
787
+ return html.replace(/<br\s*\/?>/gi, " ").replace(/<\/?[^>]+>/g, "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
788
+ }
789
+ function convertHtmlTaskList(items) {
790
+ const liRe = /<li[^>]*data-checked="(true|false)"[^>]*>([\s\S]*?)<\/li>/gi;
791
+ const out = [];
792
+ let m;
793
+ while ((m = liRe.exec(items)) !== null) {
794
+ const checked = m[1] === "true" ? "x" : " ";
795
+ const inner = String(m[2] ?? "").replace(/<\/?p[^>]*>/gi, "").trim();
796
+ out.push(`- [${checked}] ${inner}`);
797
+ }
798
+ return out.join("\n");
799
+ }
800
+
801
+ // src/plugin-package-manifest.ts
802
+ var TRUST_LEVELS = ["untrusted", "trusted", "privileged"];
803
+ function getNodeApi() {
804
+ if (typeof window !== "undefined") return null;
805
+ const proc = typeof process !== "undefined" ? process : void 0;
806
+ if (typeof proc?.getBuiltinModule !== "function") return null;
807
+ if (typeof import.meta.resolve !== "function") return null;
808
+ try {
809
+ const fs = proc.getBuiltinModule("node:fs");
810
+ const url = proc.getBuiltinModule("node:url");
811
+ if (!fs || !url) return null;
812
+ const resolve = import.meta.resolve.bind(import.meta);
813
+ return {
814
+ readFile: (u) => fs.readFileSync(url.fileURLToPath(u), "utf8"),
815
+ resolve
816
+ };
817
+ } catch {
818
+ return null;
819
+ }
820
+ }
821
+ function isValidManifest(value) {
822
+ if (typeof value !== "object" || value === null) return false;
823
+ const v = value;
824
+ if (typeof v.apiVersion !== "number") return false;
825
+ if (typeof v.name !== "string") return false;
826
+ if (typeof v.trustLevel !== "string") return false;
827
+ if (!TRUST_LEVELS.includes(v.trustLevel)) return false;
828
+ if (!Array.isArray(v.capabilities)) return false;
829
+ for (const c of v.capabilities) {
830
+ if (typeof c !== "string") return false;
831
+ }
832
+ return true;
833
+ }
834
+ function loadPackageManifest(packageName) {
835
+ const node = getNodeApi();
836
+ if (!node) return null;
837
+ let resolvedUrl;
838
+ try {
839
+ resolvedUrl = node.resolve(`${packageName}/package.json`);
840
+ } catch {
841
+ return null;
842
+ }
843
+ let raw;
844
+ try {
845
+ raw = node.readFile(resolvedUrl);
846
+ } catch {
847
+ return null;
848
+ }
849
+ let pkg;
850
+ try {
851
+ pkg = JSON.parse(raw);
852
+ } catch {
853
+ return null;
854
+ }
855
+ if (typeof pkg !== "object" || pkg === null || !("amplessPlugin" in pkg)) {
856
+ return null;
857
+ }
858
+ const manifest = pkg.amplessPlugin;
859
+ if (!isValidManifest(manifest)) return null;
860
+ return manifest;
861
+ }
862
+ var SUPPORTED_API_VERSION = 1;
863
+
864
+ // src/plugin-head.ts
865
+ function isPlugin2(p) {
866
+ return typeof p === "object" && p !== null && "apiVersion" in p;
867
+ }
868
+ var ALLOWED_ATTRS = /* @__PURE__ */ new Set([
869
+ "crossorigin",
870
+ "referrerpolicy",
871
+ "integrity",
872
+ "fetchpriority",
873
+ // `nonce` is intentionally NOT in the allowlist for Phase 1. CSP
874
+ // nonce propagation is scoped out of Phase 1 (see spec §7); attrs
875
+ // shouldn't let plugins smuggle nonces past the design decision.
876
+ // The CSP nonce RFP will reintroduce it through `cspNonce` on
877
+ // PluginPublicRenderContext + `inlineScript.nonce: 'auto'`, not via
878
+ // the `attrs` bag.
879
+ "loading",
880
+ // iframe lazy-loading
881
+ "sandbox",
882
+ // iframe sandbox attribute
883
+ "allow",
884
+ // iframe permissions policy
885
+ "allowfullscreen"
886
+ // iframe fullscreen
887
+ ]);
888
+ function isAllowedAttr(name) {
889
+ if (name.startsWith("data-")) return true;
890
+ return ALLOWED_ATTRS.has(name.toLowerCase());
891
+ }
892
+ function isSafeUrl(value) {
893
+ const trimmed = value.trim();
894
+ if (trimmed.length === 0) return false;
895
+ const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
896
+ if (!schemeMatch) return true;
897
+ const scheme = schemeMatch[1].toLowerCase();
898
+ return scheme === "http" || scheme === "https";
899
+ }
900
+ function isDev() {
901
+ const env = typeof process !== "undefined" && process.env ? process.env.NODE_ENV : void 0;
902
+ return env !== "production";
903
+ }
904
+ function warn(message) {
905
+ if (!isDev()) return;
906
+ console.warn(`[ampless plugin-head] ${message}`);
907
+ }
908
+ function crossCheckStaticManifest(plugin, label) {
909
+ const packageName = plugin.packageName;
910
+ const manifest = loadPackageManifest(packageName);
911
+ if (!manifest) return;
912
+ if (typeof manifest.apiVersion !== "number") {
913
+ throw new Error(
914
+ `${label}: package.json#amplessPlugin.apiVersion is not a number (got ${JSON.stringify(manifest.apiVersion)}). Update the plugin's package.json or remove the amplessPlugin field.`
915
+ );
916
+ }
917
+ if (manifest.apiVersion > SUPPORTED_API_VERSION) {
918
+ throw new Error(
919
+ `${label}: package.json#amplessPlugin.apiVersion ${manifest.apiVersion} is newer than this runtime supports (max ${SUPPORTED_API_VERSION}). Upgrade @ampless/runtime, or pin an older version of the plugin.`
920
+ );
921
+ }
922
+ if (manifest.apiVersion !== plugin.apiVersion) {
923
+ throw new Error(
924
+ `${label}: apiVersion mismatch \u2014 package.json declares ${manifest.apiVersion}, factory declares ${plugin.apiVersion}. The two must agree.`
925
+ );
926
+ }
927
+ if (manifest.name !== plugin.name) {
928
+ warn(
929
+ `${label}: name mismatch \u2014 package.json#amplessPlugin.name is "${manifest.name}", factory returns name="${plugin.name}". Align them so admin UI / capability gates can identify the plugin consistently.`
930
+ );
931
+ }
932
+ if (manifest.trustLevel !== plugin.trust_level) {
933
+ warn(
934
+ `${label}: trustLevel mismatch \u2014 package.json declares "${manifest.trustLevel}", factory declares trust_level="${plugin.trust_level}". The processor IAM policies are wired off trust_level; drift here usually means the deployment lambda runs in the wrong context.`
935
+ );
936
+ }
937
+ const factoryCaps = Array.isArray(plugin.capabilities) ? plugin.capabilities : [];
938
+ const manifestCaps = manifest.capabilities;
939
+ if (!setsEqual(factoryCaps, manifestCaps)) {
940
+ warn(
941
+ `${label}: capabilities mismatch \u2014 package.json declares [${manifestCaps.join(", ")}], factory declares [${factoryCaps.join(", ")}]. The static manifest is what npm registry / admin UI surfaces show before the plugin loads, so it should match what the factory actually returns.`
942
+ );
943
+ }
944
+ }
945
+ function setsEqual(a, b) {
946
+ const sa = new Set(a);
947
+ const sb = new Set(b);
948
+ if (sa.size !== sb.size) return false;
949
+ for (const c of sb) if (!sa.has(c)) return false;
950
+ return true;
951
+ }
952
+ function applyAttrs(target, attrs, ownerLabel) {
953
+ if (!attrs) return;
954
+ for (const [k, v] of Object.entries(attrs)) {
955
+ if (!isAllowedAttr(k)) {
956
+ warn(
957
+ `${ownerLabel}: attr "${k}" not in allowlist (data-* / crossorigin / referrerpolicy / integrity / fetchpriority / loading / sandbox / allow / allowfullscreen). skipping.`
958
+ );
959
+ continue;
960
+ }
961
+ target[k] = v;
962
+ }
963
+ }
964
+ function escapeJsonLdInlineBody(value) {
965
+ return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
966
+ }
967
+ function inlineScriptTypeAllowed(surface, scriptType) {
968
+ if (scriptType === "application/ld+json") return true;
969
+ if (scriptType === void 0) {
970
+ return surface === "head" || surface === "body-end";
971
+ }
972
+ return false;
973
+ }
974
+ function renderInlineScript(descriptor, pluginLabel, index, surface) {
975
+ if (!descriptor.id) {
976
+ warn(
977
+ `${pluginLabel}: inlineScript descriptor #${index} dropped \u2014 missing required "id".`
978
+ );
979
+ return null;
980
+ }
981
+ if (!inlineScriptTypeAllowed(surface, descriptor.scriptType)) {
982
+ const got = descriptor.scriptType === void 0 ? "undefined" : `"${descriptor.scriptType}"`;
983
+ const allowed = surface === "body-for-post" ? `"application/ld+json" (required on publicBodyForPost \u2014 the per-post surface is scoped to JSON-LD)` : `undefined or "application/ld+json"`;
984
+ warn(
985
+ `${pluginLabel}: inlineScript "${descriptor.id}" dropped \u2014 scriptType ${got} not allowed on ${surface}. Allowed: ${allowed}.`
986
+ );
987
+ return null;
988
+ }
989
+ const body = descriptor.scriptType === "application/ld+json" ? escapeJsonLdInlineBody(descriptor.body) : descriptor.body;
990
+ const props = {
991
+ id: descriptor.id,
992
+ dangerouslySetInnerHTML: { __html: body }
993
+ };
994
+ if (descriptor.scriptType) props.type = descriptor.scriptType;
995
+ return {
996
+ id: descriptor.id,
997
+ element: createElement2("script", props)
998
+ };
999
+ }
1000
+ function renderHeadDescriptor(descriptor, pluginLabel, index) {
1001
+ switch (descriptor.type) {
1002
+ case "script": {
1003
+ if (!isSafeUrl(descriptor.src)) {
1004
+ warn(
1005
+ `${pluginLabel}: script descriptor #${index} dropped \u2014 unsafe src "${descriptor.src}".`
1006
+ );
1007
+ return null;
1008
+ }
1009
+ const props = {
1010
+ src: descriptor.src
1011
+ };
1012
+ if (descriptor.id) props.id = descriptor.id;
1013
+ const hasAsync = typeof descriptor.async === "boolean";
1014
+ const hasDefer = typeof descriptor.defer === "boolean";
1015
+ if (hasAsync) props.async = descriptor.async;
1016
+ if (hasDefer) props.defer = descriptor.defer;
1017
+ if (!hasAsync && !hasDefer) {
1018
+ if (descriptor.strategy === "lazyOnload") {
1019
+ props.defer = true;
1020
+ } else {
1021
+ props.async = true;
1022
+ }
1023
+ }
1024
+ applyAttrs(props, descriptor.attrs, `${pluginLabel} script#${descriptor.id ?? index}`);
1025
+ return {
1026
+ id: descriptor.id ?? null,
1027
+ element: createElement2("script", props)
1028
+ };
1029
+ }
1030
+ case "inlineScript":
1031
+ return renderInlineScript(descriptor, pluginLabel, index, "head");
1032
+ case "meta": {
1033
+ const props = { content: descriptor.content };
1034
+ if (descriptor.name) props.name = descriptor.name;
1035
+ if (descriptor.property) props.property = descriptor.property;
1036
+ return {
1037
+ // meta has no id channel in the descriptor. Don't derive a
1038
+ // dedup id from name/property — multiple `<meta name=...>`
1039
+ // entries with the same name are legitimate (e.g. theme-color
1040
+ // media variants, and two plugins emitting overlapping names
1041
+ // is a real case the runtime shouldn't silently collapse).
1042
+ // Position-based React keys handle the stable-key requirement.
1043
+ id: null,
1044
+ element: createElement2("meta", props)
1045
+ };
1046
+ }
1047
+ case "link": {
1048
+ if (!isSafeUrl(descriptor.href)) {
1049
+ warn(
1050
+ `${pluginLabel}: link descriptor #${index} dropped \u2014 unsafe href "${descriptor.href}".`
1051
+ );
1052
+ return null;
1053
+ }
1054
+ const props = {
1055
+ rel: descriptor.rel,
1056
+ href: descriptor.href
1057
+ };
1058
+ if (descriptor.as) props.as = descriptor.as;
1059
+ if (descriptor.typeAttr) props.type = descriptor.typeAttr;
1060
+ return {
1061
+ id: null,
1062
+ element: createElement2("link", props)
1063
+ };
1064
+ }
1065
+ case "noscript": {
1066
+ const props = {
1067
+ dangerouslySetInnerHTML: { __html: descriptor.html }
1068
+ };
1069
+ if (descriptor.id) props.id = descriptor.id;
1070
+ return {
1071
+ id: descriptor.id ?? null,
1072
+ element: createElement2("noscript", props)
1073
+ };
1074
+ }
1075
+ }
1076
+ }
1077
+ function renderBodyDescriptor(descriptor, pluginLabel, index) {
1078
+ if (descriptor.type === "iframe") {
1079
+ if (!isSafeUrl(descriptor.src)) {
1080
+ warn(
1081
+ `${pluginLabel}: iframe descriptor #${index} dropped \u2014 unsafe src "${descriptor.src}".`
1082
+ );
1083
+ return null;
1084
+ }
1085
+ const props = {
1086
+ src: descriptor.src
1087
+ };
1088
+ if (descriptor.id) props.id = descriptor.id;
1089
+ if (descriptor.title) props.title = descriptor.title;
1090
+ if (typeof descriptor.width === "number") props.width = descriptor.width;
1091
+ if (typeof descriptor.height === "number") props.height = descriptor.height;
1092
+ applyAttrs(props, descriptor.attrs, `${pluginLabel} iframe#${descriptor.id ?? index}`);
1093
+ return {
1094
+ id: descriptor.id ?? null,
1095
+ element: createElement2("iframe", props)
1096
+ };
1097
+ }
1098
+ if (descriptor.type === "inlineScript") {
1099
+ return renderInlineScript(descriptor, pluginLabel, index, "body-end");
1100
+ }
1101
+ return renderHeadDescriptor(descriptor, pluginLabel, index);
1102
+ }
1103
+ function renderPostBodyDescriptor(descriptor, pluginLabel, index) {
1104
+ if (descriptor.type !== "inlineScript") {
1105
+ warn(
1106
+ `${pluginLabel}: publicBodyForPost descriptor #${index} dropped \u2014 only inlineScript with scriptType "application/ld+json" is allowed on this surface.`
1107
+ );
1108
+ return null;
1109
+ }
1110
+ return renderInlineScript(descriptor, pluginLabel, index, "body-for-post");
1111
+ }
1112
+ function dedupeAndKey(entries) {
1113
+ const lastIndexById = /* @__PURE__ */ new Map();
1114
+ for (let i = 0; i < entries.length; i++) {
1115
+ const id = entries[i].id;
1116
+ if (id === null) continue;
1117
+ if (lastIndexById.has(id)) {
1118
+ warn(`duplicate descriptor id "${id}" \u2014 keeping the last occurrence.`);
1119
+ }
1120
+ lastIndexById.set(id, i);
1121
+ }
1122
+ const kept = [];
1123
+ for (let i = 0; i < entries.length; i++) {
1124
+ const { id, element } = entries[i];
1125
+ if (id !== null && lastIndexById.get(id) !== i) continue;
1126
+ const key = id ?? `__pos-${i}`;
1127
+ const existingProps = element.props;
1128
+ kept.push(createElement2(element.type, { ...existingProps, key }));
1129
+ }
1130
+ return kept;
1131
+ }
1132
+ function makeCtx(plugin, site, snapshot) {
1133
+ const instanceId = plugin.instanceId ?? plugin.name;
1134
+ const stored = snapshot.get(instanceId) ?? {};
1135
+ const resolved = resolvePluginSettings(plugin.settings, stored);
1136
+ return {
1137
+ site,
1138
+ setting(key) {
1139
+ if (!isValidPluginKey(key)) return void 0;
1140
+ const v = resolved[key];
1141
+ return v === void 0 ? void 0 : v;
1142
+ }
1143
+ };
1144
+ }
1145
+ function collectFor(plugins, site, snapshot, surface, renderOne) {
1146
+ const entries = [];
1147
+ for (const plugin of plugins) {
1148
+ const factory = surface(plugin);
1149
+ if (!factory) continue;
1150
+ const ctx = makeCtx(plugin, site, snapshot);
1151
+ let descriptors;
1152
+ try {
1153
+ descriptors = factory.call(plugin, ctx) ?? [];
1154
+ } catch (err) {
1155
+ warn(
1156
+ `plugin "${plugin.instanceId ?? plugin.name}" threw inside descriptor callback: ${err instanceof Error ? err.message : String(err)}`
1157
+ );
1158
+ continue;
1159
+ }
1160
+ const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
1161
+ for (let i = 0; i < descriptors.length; i++) {
1162
+ const entry = renderOne(descriptors[i], label, i);
1163
+ if (entry) entries.push(entry);
1164
+ }
1165
+ }
1166
+ if (entries.length === 0) return null;
1167
+ const keyed = dedupeAndKey(entries);
1168
+ return createElement2(Fragment2, null, ...keyed);
1169
+ }
1170
+ var SANITIZE_OPTIONS = {
1171
+ allowedTags: ["p", "span", "strong", "em", "a", "code", "br", "ul", "ol", "li"],
1172
+ allowedAttributes: {
1173
+ "*": ["class", "data-words", "data-minutes", "data-ampless-*"],
1174
+ a: ["href", "rel", "target"]
1175
+ },
1176
+ allowedSchemes: ["http", "https"],
1177
+ allowedSchemesAppliedToAttributes: ["href"],
1178
+ allowProtocolRelative: false,
1179
+ transformTags: {
1180
+ a: (tagName, attribs) => {
1181
+ const out = { ...attribs };
1182
+ if (out["target"] === "_blank") {
1183
+ const parts = (out["rel"] ?? "").split(/\s+/).filter(Boolean);
1184
+ if (!parts.includes("noopener")) parts.push("noopener");
1185
+ if (!parts.includes("noreferrer")) parts.push("noreferrer");
1186
+ out["rel"] = parts.join(" ");
1187
+ }
1188
+ return { tagName, attribs: out };
1189
+ }
1190
+ }
1191
+ };
1192
+ function validateHtmlDescriptor(descriptor, pluginLabel, index) {
1193
+ if (descriptor === null || typeof descriptor !== "object") {
1194
+ warn(
1195
+ `${pluginLabel}: publicHtmlForPost descriptor #${index} dropped \u2014 must be an object.`
1196
+ );
1197
+ return false;
1198
+ }
1199
+ const d = descriptor;
1200
+ if (d.type !== "html") {
1201
+ warn(
1202
+ `${pluginLabel}: publicHtmlForPost descriptor #${index} dropped \u2014 "type" must be "html" (got ${typeof d.type === "string" ? `"${d.type}"` : typeof d.type}).`
1203
+ );
1204
+ return false;
1205
+ }
1206
+ if (typeof d.id !== "string") {
1207
+ warn(
1208
+ `${pluginLabel}: publicHtmlForPost descriptor #${index} dropped \u2014 "id" must be a string.`
1209
+ );
1210
+ return false;
1211
+ }
1212
+ if (typeof d.body !== "string") {
1213
+ warn(
1214
+ `${pluginLabel}: publicHtmlForPost descriptor "${d.id}" dropped \u2014 "body" must be a string.`
1215
+ );
1216
+ return false;
1217
+ }
1218
+ if (d.position !== "beforeContent" && d.position !== "afterContent") {
1219
+ warn(
1220
+ `${pluginLabel}: publicHtmlForPost descriptor "${d.id}" dropped \u2014 "position" must be "beforeContent" or "afterContent" (got ${typeof d.position === "string" ? `"${d.position}"` : typeof d.position}).`
1221
+ );
1222
+ return false;
1223
+ }
1224
+ return true;
1225
+ }
1226
+ function validateHtmlId(id, pluginLabel) {
1227
+ if (id.length === 0) {
1228
+ warn(`${pluginLabel}: publicHtmlForPost descriptor dropped \u2014 "id" must not be empty.`);
1229
+ return false;
1230
+ }
1231
+ if (/[\x00-\x1f]/.test(id)) {
1232
+ warn(
1233
+ `${pluginLabel}: publicHtmlForPost descriptor "${id}" dropped \u2014 "id" contains control characters.`
1234
+ );
1235
+ return false;
1236
+ }
1237
+ if (id.length > 64) {
1238
+ warn(
1239
+ `${pluginLabel}: publicHtmlForPost descriptor "${id.slice(0, 32)}\u2026" dropped \u2014 "id" exceeds 64 characters.`
1240
+ );
1241
+ return false;
1242
+ }
1243
+ return true;
1244
+ }
1245
+ function collectForPost(plugins, site, snapshot, post) {
1246
+ const entries = [];
1247
+ for (const plugin of plugins) {
1248
+ const factory = plugin.publicBodyForPost;
1249
+ if (!factory) continue;
1250
+ const ctx = makeCtx(plugin, site, snapshot);
1251
+ let descriptors;
1252
+ try {
1253
+ descriptors = factory.call(plugin, post, ctx) ?? [];
1254
+ } catch (err) {
1255
+ warn(
1256
+ `plugin "${plugin.instanceId ?? plugin.name}" threw inside publicBodyForPost callback: ${err instanceof Error ? err.message : String(err)}`
872
1257
  );
873
- },
874
- async renderBodyForPost(post) {
875
- const snapshot = await pluginSettings.loadAll();
876
- return collectForPost(validPlugins, cmsConfig.site, snapshot, post);
877
- },
878
- async renderHtmlForPost(post) {
879
- const snapshot = await pluginSettings.loadAll();
880
- return collectHtmlForPost(validPlugins, cmsConfig.site, snapshot, post);
1258
+ continue;
881
1259
  }
882
- };
883
- }
884
-
885
- // src/plugin-settings.ts
886
- import { unflattenSettings as unflattenSettings2 } from "ampless";
887
- function createPluginSettings(storage) {
888
- return {
889
- async loadAll() {
890
- const out = /* @__PURE__ */ new Map();
891
- if (!storage.isStorageConfigured()) return out;
892
- let url;
893
- try {
894
- url = storage.publicAssetUrl("public/site-settings.json");
895
- } catch {
896
- return out;
897
- }
898
- let flat;
899
- try {
900
- const res = await fetch(url, {
901
- // Same revalidate + tag as site-settings.ts so a single
902
- // request to the public route hits the JSON once and both
903
- // helpers share the cached body.
904
- next: { revalidate: 60, tags: ["site-settings"] }
905
- });
906
- if (!res.ok) return out;
907
- flat = await res.json();
908
- } catch {
909
- return out;
910
- }
911
- const nested = unflattenSettings2(flat);
912
- const pluginsBlock = nested.plugins;
913
- if (!pluginsBlock || typeof pluginsBlock !== "object") return out;
914
- for (const [instanceId, block] of Object.entries(pluginsBlock)) {
915
- if (!block || typeof block !== "object" || Array.isArray(block)) continue;
916
- out.set(instanceId, { ...block });
917
- }
918
- return out;
1260
+ const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
1261
+ for (let i = 0; i < descriptors.length; i++) {
1262
+ const entry = renderPostBodyDescriptor(descriptors[i], label, i);
1263
+ if (entry) entries.push(entry);
919
1264
  }
920
- };
1265
+ }
1266
+ if (entries.length === 0) return null;
1267
+ const keyed = dedupeAndKey(entries);
1268
+ return createElement2(Fragment2, null, ...keyed);
921
1269
  }
922
-
923
- // src/theme-active.ts
924
- import { headers } from "next/headers";
925
- function createThemeActive(registry, storage) {
926
- function settingsUrl() {
927
- if (!storage.isStorageConfigured()) return null;
1270
+ function collectHtmlForPost(plugins, site, snapshot, post) {
1271
+ const before = [];
1272
+ const after = [];
1273
+ const seenBefore = /* @__PURE__ */ new Set();
1274
+ const seenAfter = /* @__PURE__ */ new Set();
1275
+ for (const plugin of plugins) {
1276
+ const factory = plugin.publicHtmlForPost;
1277
+ if (!factory) continue;
1278
+ const namespace = plugin.instanceId ?? plugin.name;
1279
+ const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
1280
+ const ctx = makeCtx(plugin, site, snapshot);
1281
+ let descriptors;
928
1282
  try {
929
- return storage.publicAssetUrl("public/site-settings.json");
930
- } catch {
931
- return null;
1283
+ descriptors = factory.call(plugin, post, ctx) ?? [];
1284
+ } catch (err) {
1285
+ warn(
1286
+ `${label}: threw inside publicHtmlForPost callback: ${err instanceof Error ? err.message : String(err)}`
1287
+ );
1288
+ continue;
932
1289
  }
933
- }
934
- async function fetchActiveFromCache() {
935
- const url = settingsUrl();
936
- if (!url) return null;
937
- const res = await fetch(url, {
938
- next: { revalidate: 60, tags: ["site-settings"] }
939
- });
940
- if (!res.ok) return null;
941
- const flat = await res.json();
942
- const v = flat["theme.active"];
943
- return typeof v === "string" ? v : null;
944
- }
945
- async function fetchActiveFresh() {
946
- const url = settingsUrl();
947
- if (!url) return null;
948
- const res = await fetch(url, { cache: "no-store" });
949
- if (!res.ok) return null;
950
- const flat = await res.json();
951
- const v = flat["theme.active"];
952
- return typeof v === "string" ? v : null;
953
- }
954
- return {
955
- readStoredActiveThemeFresh: () => fetchActiveFresh(),
956
- async resolveActiveTheme() {
957
- let previewOverride = null;
958
- try {
959
- const h = await headers();
960
- previewOverride = h.get("x-preview-theme");
961
- } catch {
962
- }
963
- if (previewOverride && previewOverride in registry.themes) {
964
- const mod2 = registry.themes[previewOverride];
965
- if (mod2) return { name: previewOverride, module: mod2 };
966
- }
967
- const stored = await fetchActiveFromCache().catch(() => null);
968
- const name = stored && stored in registry.themes ? stored : registry.defaultTheme;
969
- const mod = registry.themes[name] ?? registry.themes[registry.defaultTheme];
970
- if (!mod) {
971
- throw new Error(
972
- `themes registry is empty \u2014 at least one theme must be registered before resolveActiveTheme is called`
1290
+ for (let i = 0; i < descriptors.length; i++) {
1291
+ const raw = descriptors[i];
1292
+ if (!validateHtmlDescriptor(raw, label, i)) continue;
1293
+ const descriptor = raw;
1294
+ if (!validateHtmlId(descriptor.id, label)) continue;
1295
+ const dedupeKey = `${namespace}:${descriptor.id}`;
1296
+ const position = descriptor.position;
1297
+ const seenSet = position === "beforeContent" ? seenBefore : seenAfter;
1298
+ const bucket = position === "beforeContent" ? before : after;
1299
+ if (seenSet.has(dedupeKey)) {
1300
+ warn(
1301
+ `${label}: publicHtmlForPost descriptor "${descriptor.id}" at position "${position}" is a duplicate \u2014 keeping the first occurrence.`
973
1302
  );
1303
+ continue;
974
1304
  }
975
- return { name, module: mod };
1305
+ seenSet.add(dedupeKey);
1306
+ const cleanHtml = sanitizeHtml(descriptor.body, SANITIZE_OPTIONS);
1307
+ bucket.push({ key: dedupeKey, cleanHtml, namespace });
976
1308
  }
1309
+ }
1310
+ function toReactNode(entries, position) {
1311
+ if (entries.length === 0) return null;
1312
+ const elements = entries.map(
1313
+ ({ key, cleanHtml, namespace }) => createElement2("div", {
1314
+ key,
1315
+ "data-ampless-plugin": namespace,
1316
+ "data-ampless-position": position,
1317
+ dangerouslySetInnerHTML: { __html: cleanHtml }
1318
+ })
1319
+ );
1320
+ return createElement2(Fragment2, null, ...elements);
1321
+ }
1322
+ return {
1323
+ beforeContent: toReactNode(before, "beforeContent"),
1324
+ afterContent: toReactNode(after, "afterContent")
977
1325
  };
978
1326
  }
979
-
980
- // src/theme-config.ts
981
- import {
982
- resolveThemeValues,
983
- themeSettingKey
984
- } from "ampless";
985
- var DEFAULT_COLOR_SCHEME = "auto";
986
- var COLOR_SCHEME_SETTING_KEY = "theme.colorScheme";
987
- function validateColorScheme(raw) {
988
- if (raw === "light" || raw === "dark" || raw === "auto") return raw;
989
- return DEFAULT_COLOR_SCHEME;
990
- }
991
- function createThemeConfig(themeActive, storage) {
992
- async function fetchRemote() {
993
- if (!storage.isStorageConfigured()) return null;
994
- let url;
995
- try {
996
- url = storage.publicAssetUrl("public/site-settings.json");
997
- } catch {
998
- return null;
1327
+ function createPluginHead(cmsConfig, pluginSettings) {
1328
+ const plugins = (cmsConfig.plugins ?? []).filter(isPlugin2);
1329
+ const validPlugins = [];
1330
+ const seenNamespaces = /* @__PURE__ */ new Set();
1331
+ for (const plugin of plugins) {
1332
+ const ns = plugin.instanceId ?? plugin.name;
1333
+ const label = plugin.instanceId ? `${plugin.name}#${plugin.instanceId}` : plugin.name;
1334
+ if (!isValidPluginKey(ns)) {
1335
+ warn(
1336
+ `${label}: plugin namespace "${ns}" violates ${`/^[a-zA-Z0-9_-]+$/`}. Use a simple identifier (letters / digits / "-" / "_"). Skipping plugin.`
1337
+ );
1338
+ continue;
999
1339
  }
1000
- const res = await fetch(url, { next: { revalidate: 60, tags: ["site-settings"] } });
1001
- if (!res.ok) return null;
1002
- return await res.json();
1003
- }
1004
- return {
1005
- async loadThemeConfig() {
1006
- const [active, flat] = await Promise.all([
1007
- themeActive.resolveActiveTheme(),
1008
- fetchRemote().catch(() => null)
1009
- ]);
1010
- const manifest = active.module.manifest;
1011
- const stored = {};
1012
- if (flat) {
1013
- for (const field of manifest.fields) {
1014
- const k = themeSettingKey(field.key);
1015
- if (k in flat) stored[k] = flat[k];
1340
+ if (seenNamespaces.has(ns)) {
1341
+ warn(
1342
+ `duplicate plugin namespace "${ns}" detected in cms.config.plugins. Set distinct \`instanceId\` on each instance to disambiguate.`
1343
+ );
1344
+ }
1345
+ seenNamespaces.add(ns);
1346
+ if (plugin.settings?.public) {
1347
+ for (const field of plugin.settings.public) {
1348
+ if (!isValidPluginKey(field.key)) {
1349
+ warn(
1350
+ `${label}: settings.public field key "${field.key}" violates ${`/^[a-zA-Z0-9_-]+$/`}. The field will not be readable through ctx.setting(). Rename the field key.`
1351
+ );
1016
1352
  }
1017
1353
  }
1018
- const values = resolveThemeValues(manifest, stored);
1019
- const cssVars = collectCssVars(manifest.fields, values);
1020
- const colorScheme = validateColorScheme(flat?.[COLOR_SCHEME_SETTING_KEY]);
1021
- return { activeTheme: active.name, manifest, values, cssVars, colorScheme };
1022
- }
1023
- };
1024
- }
1025
- function collectCssVars(fields, values) {
1026
- const out = {};
1027
- for (const field of fields) {
1028
- if (field.type === "linkList") continue;
1029
- if (!field.cssVar) continue;
1030
- const v = values[field.key];
1031
- if (v) out[field.cssVar] = v;
1032
- }
1033
- return out;
1034
- }
1035
- function renderThemeCss(cssVars) {
1036
- const lines = Object.entries(cssVars).map(([name, value]) => ` ${name}: ${value};`);
1037
- if (lines.length === 0) return "";
1038
- return `:root {
1039
- ${lines.join("\n")}
1040
- }`;
1041
- }
1042
-
1043
- // src/rendering.ts
1044
- import { marked } from "marked";
1045
- function escape(s) {
1046
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1047
- }
1048
- function textAlignStyle(attrs) {
1049
- const v = attrs?.textAlign;
1050
- if (v === "left" || v === "center" || v === "right" || v === "justify") {
1051
- return ` style="text-align: ${v}"`;
1052
- }
1053
- return "";
1054
- }
1055
- function renderTiptap(node) {
1056
- if (node.type === "text") {
1057
- let html = escape(node.text ?? "");
1058
- for (const mark of node.marks ?? []) {
1059
- if (mark.type === "bold") html = `<strong>${html}</strong>`;
1060
- else if (mark.type === "italic") html = `<em>${html}</em>`;
1061
- else if (mark.type === "code") html = `<code>${html}</code>`;
1062
- else if (mark.type === "strike") html = `<s>${html}</s>`;
1063
- else if (mark.type === "underline") html = `<u>${html}</u>`;
1064
- else if (mark.type === "highlight") html = `<mark>${html}</mark>`;
1065
- else if (mark.type === "link") {
1066
- const href = escape(String(mark.attrs?.href ?? "#"));
1067
- html = `<a href="${href}" target="_blank" rel="noopener">${html}</a>`;
1354
+ }
1355
+ validPlugins.push(plugin);
1356
+ if (plugin.packageName) {
1357
+ crossCheckStaticManifest(plugin, label);
1358
+ }
1359
+ const caps = plugin.capabilities;
1360
+ if (caps) {
1361
+ if (caps.includes("publicHead") && !plugin.publicHead) {
1362
+ warn(
1363
+ `${label}: declares capability "publicHead" but no \`publicHead\` implementation. Drop the capability or add the function.`
1364
+ );
1365
+ }
1366
+ if (caps.includes("publicBody") && !plugin.publicBodyEnd) {
1367
+ warn(
1368
+ `${label}: declares capability "publicBody" but no \`publicBodyEnd\` implementation. Drop the capability or add the function.`
1369
+ );
1370
+ }
1371
+ if (plugin.publicHead && !caps.includes("publicHead")) {
1372
+ warn(
1373
+ `${label}: implements \`publicHead\` but "publicHead" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
1374
+ );
1375
+ }
1376
+ if (plugin.publicBodyEnd && !caps.includes("publicBody")) {
1377
+ warn(
1378
+ `${label}: implements \`publicBodyEnd\` but "publicBody" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
1379
+ );
1380
+ }
1381
+ if (caps.includes("schema") && !plugin.publicBodyForPost) {
1382
+ warn(
1383
+ `${label}: declares capability "schema" but no \`publicBodyForPost\` implementation. Drop the capability or add the function.`
1384
+ );
1385
+ }
1386
+ if (plugin.publicBodyForPost && !caps.includes("schema")) {
1387
+ warn(
1388
+ `${label}: implements \`publicBodyForPost\` but "schema" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
1389
+ );
1390
+ }
1391
+ if (caps.includes("publicHtmlForPost") && !plugin.publicHtmlForPost) {
1392
+ warn(
1393
+ `${label}: declares capability "publicHtmlForPost" but no \`publicHtmlForPost\` implementation. Drop the capability or add the function.`
1394
+ );
1395
+ }
1396
+ if (plugin.publicHtmlForPost && !caps.includes("publicHtmlForPost")) {
1397
+ warn(
1398
+ `${label}: implements \`publicHtmlForPost\` but "publicHtmlForPost" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
1399
+ );
1068
1400
  }
1069
1401
  }
1070
- return html;
1071
1402
  }
1072
- const children = (node.content ?? []).map(renderTiptap).join("");
1073
- switch (node.type) {
1074
- case "doc":
1075
- return children;
1076
- case "paragraph":
1077
- return `<p${textAlignStyle(node.attrs)}>${children}</p>`;
1078
- case "heading": {
1079
- const level = Number(node.attrs?.level ?? 1);
1080
- return `<h${level}${textAlignStyle(node.attrs)}>${children}</h${level}>`;
1403
+ const contentFieldsRegistry = buildContentFieldRegistry(validPlugins);
1404
+ for (const plugin of validPlugins) {
1405
+ const caps = plugin.capabilities;
1406
+ if (!caps) continue;
1407
+ const label = plugin.instanceId ? `${plugin.name}#${plugin.instanceId}` : plugin.name;
1408
+ if (caps.includes("contentFields") && !plugin.contentFields) {
1409
+ warn(
1410
+ `${label}: declares capability "contentFields" but no \`contentFields\` array. Drop the capability or add the renderers.`
1411
+ );
1081
1412
  }
1082
- case "bulletList":
1083
- return `<ul>${children}</ul>`;
1084
- case "orderedList":
1085
- return `<ol>${children}</ol>`;
1086
- case "listItem":
1087
- return `<li>${children}</li>`;
1088
- case "codeBlock": {
1089
- const lang = node.attrs?.language ? ` class="language-${escape(String(node.attrs.language))}"` : "";
1090
- return `<pre><code${lang}>${children}</code></pre>`;
1413
+ if (plugin.contentFields && !caps.includes("contentFields")) {
1414
+ warn(
1415
+ `${label}: implements \`contentFields\` but "contentFields" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
1416
+ );
1091
1417
  }
1092
- case "blockquote":
1093
- return `<blockquote>${children}</blockquote>`;
1094
- case "hardBreak":
1095
- return "<br />";
1096
- case "horizontalRule":
1097
- return "<hr />";
1098
- case "image": {
1099
- const src = escape(String(node.attrs?.src ?? ""));
1100
- const alt = escape(String(node.attrs?.alt ?? ""));
1101
- const title = node.attrs?.title ? ` title="${escape(String(node.attrs.title))}"` : "";
1102
- const display = node.attrs?.display ? ` data-display="${escape(String(node.attrs.display))}"` : "";
1103
- return `<img src="${src}" alt="${alt}"${title}${display} loading="lazy" />`;
1418
+ if (caps.includes("publicPostScript") && !plugin.publicPostScript) {
1419
+ warn(
1420
+ `${label}: declares capability "publicPostScript" but no \`publicPostScript\` implementation. Drop the capability or add the function.`
1421
+ );
1104
1422
  }
1105
- case "table":
1106
- return `<table class="tiptap-table"><tbody>${children}</tbody></table>`;
1107
- case "tableRow":
1108
- return `<tr>${children}</tr>`;
1109
- case "tableHeader":
1110
- return `<th${tableCellAttrs(node.attrs)}>${children}</th>`;
1111
- case "tableCell":
1112
- return `<td${tableCellAttrs(node.attrs)}>${children}</td>`;
1113
- case "taskList":
1114
- return `<ul data-type="taskList">${children}</ul>`;
1115
- case "taskItem": {
1116
- const checked = node.attrs?.checked === true ? "true" : "false";
1117
- return `<li data-type="taskItem" data-checked="${checked}">${children}</li>`;
1423
+ if (plugin.publicPostScript && !caps.includes("publicPostScript")) {
1424
+ warn(
1425
+ `${label}: implements \`publicPostScript\` but "publicPostScript" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
1426
+ );
1118
1427
  }
1119
- default:
1120
- return children;
1121
- }
1122
- }
1123
- function tableCellAttrs(attrs) {
1124
- let out = "";
1125
- const colspan = Number(attrs?.colspan ?? 1);
1126
- if (colspan > 1) out += ` colspan="${colspan}"`;
1127
- const rowspan = Number(attrs?.rowspan ?? 1);
1128
- if (rowspan > 1) out += ` rowspan="${rowspan}"`;
1129
- const colwidth = attrs?.colwidth;
1130
- if (Array.isArray(colwidth) && colwidth.length > 0) {
1131
- const w = Number(colwidth[0]);
1132
- if (Number.isFinite(w) && w > 0) out += ` style="width: ${w}px"`;
1133
- }
1134
- return out;
1135
- }
1136
- function renderMarkdown(md) {
1137
- return marked.parse(md, { gfm: true, breaks: false, async: false });
1138
- }
1139
- function renderBody(post) {
1140
- if (post.format === "html") return String(post.body);
1141
- if (post.format === "markdown") return renderMarkdown(String(post.body));
1142
- if (post.format === "tiptap") {
1143
- if (typeof post.body === "string") return post.body;
1144
- return renderTiptap(post.body);
1145
1428
  }
1146
- return "";
1147
- }
1148
- function tiptapToHtml(doc) {
1149
- if (typeof doc === "string") return doc;
1150
- return renderTiptap(doc);
1151
- }
1152
- function markdownToHtml(md) {
1153
- return renderMarkdown(md);
1154
- }
1155
- function tiptapToMarkdown(doc) {
1156
- if (typeof doc === "string") return htmlToMarkdown(doc);
1157
- const node = doc;
1158
- return tiptapNodeToMarkdown(node).trim() + "\n";
1429
+ return {
1430
+ async renderHead() {
1431
+ const snapshot = await pluginSettings.loadAll();
1432
+ return collectFor(
1433
+ validPlugins,
1434
+ cmsConfig.site,
1435
+ snapshot,
1436
+ (p) => p.publicHead,
1437
+ renderHeadDescriptor
1438
+ );
1439
+ },
1440
+ async renderBodyEnd() {
1441
+ const snapshot = await pluginSettings.loadAll();
1442
+ return collectFor(
1443
+ validPlugins,
1444
+ cmsConfig.site,
1445
+ snapshot,
1446
+ (p) => p.publicBodyEnd,
1447
+ renderBodyDescriptor
1448
+ );
1449
+ },
1450
+ async renderBodyForPost(post) {
1451
+ const snapshot = await pluginSettings.loadAll();
1452
+ return collectForPost(validPlugins, cmsConfig.site, snapshot, post);
1453
+ },
1454
+ async renderHtmlForPost(post) {
1455
+ const snapshot = await pluginSettings.loadAll();
1456
+ return collectHtmlForPost(validPlugins, cmsConfig.site, snapshot, post);
1457
+ },
1458
+ async renderPostScriptsForPage(posts) {
1459
+ const snapshot = await pluginSettings.loadAll();
1460
+ return collectPostScriptsForPage(validPlugins, cmsConfig.site, snapshot, posts);
1461
+ },
1462
+ contentFieldsRegistry,
1463
+ async contextForPlugins() {
1464
+ const snapshot = await pluginSettings.loadAll();
1465
+ return (plugin) => makeCtx(plugin, cmsConfig.site, snapshot);
1466
+ }
1467
+ };
1159
1468
  }
1160
- function tiptapNodeToMarkdown(node) {
1161
- if (node.type === "text") {
1162
- let txt = node.text ?? "";
1163
- for (const mark of node.marks ?? []) {
1164
- if (mark.type === "bold") txt = `**${txt}**`;
1165
- else if (mark.type === "italic") txt = `*${txt}*`;
1166
- else if (mark.type === "code") txt = `\`${txt}\``;
1167
- else if (mark.type === "strike") txt = `~~${txt}~~`;
1168
- else if (mark.type === "underline") txt = `<u>${txt}</u>`;
1169
- else if (mark.type === "highlight") txt = `<mark>${txt}</mark>`;
1170
- else if (mark.type === "link") txt = `[${txt}](${String(mark.attrs?.href ?? "#")})`;
1469
+ function collectPostScriptsForPage(plugins, site, snapshot, posts) {
1470
+ const seen = /* @__PURE__ */ new Set();
1471
+ const elements = [];
1472
+ for (const post of posts) {
1473
+ for (const plugin of plugins) {
1474
+ const factory = plugin.publicPostScript;
1475
+ if (!factory) continue;
1476
+ const ctx = makeCtx(plugin, site, snapshot);
1477
+ const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
1478
+ let descriptors;
1479
+ try {
1480
+ descriptors = factory.call(plugin, post, ctx) ?? [];
1481
+ } catch (err) {
1482
+ warn(
1483
+ `${label}: threw inside publicPostScript callback: ${err instanceof Error ? err.message : String(err)}`
1484
+ );
1485
+ continue;
1486
+ }
1487
+ for (let i = 0; i < descriptors.length; i++) {
1488
+ const d = descriptors[i];
1489
+ if (!d || typeof d !== "object") {
1490
+ warn(`${label}: publicPostScript descriptor #${i} dropped \u2014 not an object.`);
1491
+ continue;
1492
+ }
1493
+ if (typeof d.id !== "string" || d.id.length === 0) {
1494
+ warn(
1495
+ `${label}: publicPostScript descriptor #${i} dropped \u2014 "id" must be a non-empty string.`
1496
+ );
1497
+ continue;
1498
+ }
1499
+ if (typeof d.src !== "string" || !isSafeUrl(d.src)) {
1500
+ warn(
1501
+ `${label}: publicPostScript descriptor "${d.id}" dropped \u2014 unsafe / missing src.`
1502
+ );
1503
+ continue;
1504
+ }
1505
+ if (seen.has(d.id)) continue;
1506
+ seen.add(d.id);
1507
+ const props = { key: d.id, src: d.src };
1508
+ const hasAsync = typeof d.async === "boolean";
1509
+ const hasDefer = typeof d.defer === "boolean";
1510
+ if (hasAsync) props.async = d.async;
1511
+ if (hasDefer) props.defer = d.defer;
1512
+ if (!hasAsync && !hasDefer) {
1513
+ props.async = true;
1514
+ props.defer = true;
1515
+ }
1516
+ elements.push(createElement2("script", props));
1517
+ }
1171
1518
  }
1172
- return txt;
1173
1519
  }
1174
- const children = (node.content ?? []).map(tiptapNodeToMarkdown).join("");
1175
- switch (node.type) {
1176
- case "doc":
1177
- return children;
1178
- case "paragraph":
1179
- return children + "\n\n";
1180
- case "heading": {
1181
- const level = Math.max(1, Math.min(6, Number(node.attrs?.level ?? 1)));
1182
- return "#".repeat(level) + " " + children + "\n\n";
1183
- }
1184
- case "bulletList":
1185
- return children + "\n";
1186
- case "orderedList":
1187
- return children + "\n";
1188
- case "listItem": {
1189
- const trimmed = children.replace(/\n+$/, "");
1190
- return "- " + trimmed + "\n";
1191
- }
1192
- case "codeBlock": {
1193
- const lang = node.attrs?.language ? String(node.attrs.language) : "";
1194
- return "```" + lang + "\n" + children + "\n```\n\n";
1195
- }
1196
- case "blockquote":
1197
- return children.replace(/\n+$/, "").split("\n").map((l) => "> " + l).join("\n") + "\n\n";
1198
- case "hardBreak":
1199
- return " \n";
1200
- case "horizontalRule":
1201
- return "\n---\n\n";
1202
- case "image": {
1203
- const src = String(node.attrs?.src ?? "");
1204
- const alt = String(node.attrs?.alt ?? "");
1205
- return `![${alt}](${src})`;
1520
+ if (elements.length === 0) return null;
1521
+ return createElement2(Fragment2, null, ...elements);
1522
+ }
1523
+
1524
+ // src/plugin-settings.ts
1525
+ import { unflattenSettings as unflattenSettings2 } from "ampless";
1526
+ function createPluginSettings(storage) {
1527
+ return {
1528
+ async loadAll() {
1529
+ const out = /* @__PURE__ */ new Map();
1530
+ if (!storage.isStorageConfigured()) return out;
1531
+ let url;
1532
+ try {
1533
+ url = storage.publicAssetUrl("public/site-settings.json");
1534
+ } catch {
1535
+ return out;
1536
+ }
1537
+ let flat;
1538
+ try {
1539
+ const res = await fetch(url, {
1540
+ // Same revalidate + tag as site-settings.ts so a single
1541
+ // request to the public route hits the JSON once and both
1542
+ // helpers share the cached body.
1543
+ next: { revalidate: 60, tags: ["site-settings"] }
1544
+ });
1545
+ if (!res.ok) return out;
1546
+ flat = await res.json();
1547
+ } catch {
1548
+ return out;
1549
+ }
1550
+ const nested = unflattenSettings2(flat);
1551
+ const pluginsBlock = nested.plugins;
1552
+ if (!pluginsBlock || typeof pluginsBlock !== "object") return out;
1553
+ for (const [instanceId, block] of Object.entries(pluginsBlock)) {
1554
+ if (!block || typeof block !== "object" || Array.isArray(block)) continue;
1555
+ out.set(instanceId, { ...block });
1556
+ }
1557
+ return out;
1206
1558
  }
1207
- case "table":
1208
- return tiptapTableToMarkdown(node);
1209
- case "taskList":
1210
- return children + "\n";
1211
- case "taskItem": {
1212
- const checked = node.attrs?.checked === true ? "x" : " ";
1213
- const inner = children.replace(/\n+$/, "");
1214
- const [first, ...rest] = inner.split("\n");
1215
- const cont = rest.map((l) => l ? " " + l : l).join("\n");
1216
- return `- [${checked}] ${first ?? ""}${cont ? "\n" + cont : ""}
1217
- `;
1559
+ };
1560
+ }
1561
+
1562
+ // src/theme-active.ts
1563
+ import { headers } from "next/headers";
1564
+ function createThemeActive(registry, storage) {
1565
+ function settingsUrl() {
1566
+ if (!storage.isStorageConfigured()) return null;
1567
+ try {
1568
+ return storage.publicAssetUrl("public/site-settings.json");
1569
+ } catch {
1570
+ return null;
1218
1571
  }
1219
- default:
1220
- return children;
1221
1572
  }
1222
- }
1223
- function tiptapTableToMarkdown(node) {
1224
- const rows = node.content ?? [];
1225
- if (rows.length === 0) return "";
1226
- const renderedRows = [];
1227
- let headerIdx = -1;
1228
- for (let i = 0; i < rows.length; i++) {
1229
- const row = rows[i];
1230
- const cells = row.content ?? [];
1231
- const cellTexts = cells.map((c) => {
1232
- const inner = (c.content ?? []).map(tiptapNodeToMarkdown).join("");
1233
- return inner.replace(/\n+$/, "").replace(/\n/g, "<br>").replace(/\|/g, "\\|");
1573
+ async function fetchActiveFromCache() {
1574
+ const url = settingsUrl();
1575
+ if (!url) return null;
1576
+ const res = await fetch(url, {
1577
+ next: { revalidate: 60, tags: ["site-settings"] }
1234
1578
  });
1235
- renderedRows.push(cellTexts);
1236
- if (headerIdx === -1 && cells.some((c) => c.type === "tableHeader")) headerIdx = i;
1579
+ if (!res.ok) return null;
1580
+ const flat = await res.json();
1581
+ const v = flat["theme.active"];
1582
+ return typeof v === "string" ? v : null;
1237
1583
  }
1238
- if (headerIdx === -1) headerIdx = 0;
1239
- const header = renderedRows[headerIdx] ?? [];
1240
- const body = renderedRows.filter((_, i) => i !== headerIdx);
1241
- const cols = header.length;
1242
- const headerLine = "| " + header.join(" | ") + " |";
1243
- const sepLine = "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |";
1244
- const bodyLines = body.map((r) => {
1245
- const cells = Array.from({ length: cols }, (_, i) => r[i] ?? "");
1246
- return "| " + cells.join(" | ") + " |";
1247
- });
1248
- return "\n" + [headerLine, sepLine, ...bodyLines].join("\n") + "\n\n";
1249
- }
1250
- function htmlToMarkdown(html) {
1251
- let md = html;
1252
- md = md.replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (_, inner) => {
1253
- return "\n" + convertHtmlTable(String(inner)) + "\n";
1254
- });
1255
- md = md.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, text) => {
1256
- return "\n" + "#".repeat(Number(level)) + " " + String(text).trim() + "\n\n";
1257
- });
1258
- md = md.replace(
1259
- /<pre[^>]*><code[^>]*(?:\sclass="language-([^"]+)")?[^>]*>([\s\S]*?)<\/code><\/pre>/gi,
1260
- (_, lang, code) => {
1261
- return "\n```" + (lang ?? "") + "\n" + String(code) + "\n```\n\n";
1584
+ async function fetchActiveFresh() {
1585
+ const url = settingsUrl();
1586
+ if (!url) return null;
1587
+ const res = await fetch(url, { cache: "no-store" });
1588
+ if (!res.ok) return null;
1589
+ const flat = await res.json();
1590
+ const v = flat["theme.active"];
1591
+ return typeof v === "string" ? v : null;
1592
+ }
1593
+ return {
1594
+ readStoredActiveThemeFresh: () => fetchActiveFresh(),
1595
+ async resolveActiveTheme() {
1596
+ let previewOverride = null;
1597
+ try {
1598
+ const h = await headers();
1599
+ previewOverride = h.get("x-preview-theme");
1600
+ } catch {
1601
+ }
1602
+ if (previewOverride && previewOverride in registry.themes) {
1603
+ const mod2 = registry.themes[previewOverride];
1604
+ if (mod2) return { name: previewOverride, module: mod2 };
1605
+ }
1606
+ const stored = await fetchActiveFromCache().catch(() => null);
1607
+ const name = stored && stored in registry.themes ? stored : registry.defaultTheme;
1608
+ const mod = registry.themes[name] ?? registry.themes[registry.defaultTheme];
1609
+ if (!mod) {
1610
+ throw new Error(
1611
+ `themes registry is empty \u2014 at least one theme must be registered before resolveActiveTheme is called`
1612
+ );
1613
+ }
1614
+ return { name, module: mod };
1262
1615
  }
1263
- );
1264
- md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, content) => {
1265
- return "\n" + String(content).trim().split("\n").map((l) => "> " + l).join("\n") + "\n\n";
1266
- });
1267
- md = md.replace(/<ul[^>]*data-type="taskList"[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
1268
- return "\n" + convertHtmlTaskList(String(items)) + "\n";
1269
- });
1270
- md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
1271
- return "\n" + String(items).replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "- $1\n") + "\n";
1272
- });
1273
- md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (_, items) => {
1274
- let i = 1;
1275
- return "\n" + String(items).replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, () => `${i++}. $1
1276
- `) + "\n";
1277
- });
1278
- md = md.replace(/<hr\s*\/?>/gi, "\n---\n\n");
1279
- md = md.replace(/<br\s*\/?>/gi, " \n");
1280
- md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, "$1\n\n");
1281
- md = md.replace(/<img[^>]*?src="([^"]*)"[^>]*?alt="([^"]*)"[^>]*?\/?>/gi, "![$2]($1)");
1282
- md = md.replace(/<img[^>]*?alt="([^"]*)"[^>]*?src="([^"]*)"[^>]*?\/?>/gi, "![$1]($2)");
1283
- md = md.replace(/<img[^>]*?src="([^"]*)"[^>]*?\/?>/gi, "![]($1)");
1284
- md = md.replace(/<a[^>]*?href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)");
1285
- md = md.replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, "**$2**");
1286
- md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, "*$2*");
1287
- md = md.replace(/<s>([\s\S]*?)<\/s>/gi, "~~$1~~");
1288
- md = md.replace(/<code>([\s\S]*?)<\/code>/gi, "`$1`");
1289
- const PH_U_OPEN = "AMP_U_OPEN";
1290
- const PH_U_CLOSE = "AMP_U_CLOSE";
1291
- const PH_MARK_OPEN = "AMP_MARK_OPEN";
1292
- const PH_MARK_CLOSE = "AMP_MARK_CLOSE";
1293
- md = md.replace(/<u>([\s\S]*?)<\/u>/gi, `${PH_U_OPEN}$1${PH_U_CLOSE}`);
1294
- md = md.replace(/<mark>([\s\S]*?)<\/mark>/gi, `${PH_MARK_OPEN}$1${PH_MARK_CLOSE}`);
1295
- md = md.replace(/<\/?[^>]+>/g, "");
1296
- md = md.split(PH_U_OPEN).join("<u>").split(PH_U_CLOSE).join("</u>").split(PH_MARK_OPEN).join("<mark>").split(PH_MARK_CLOSE).join("</mark>");
1297
- md = md.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ");
1298
- md = md.replace(/\n{3,}/g, "\n\n");
1299
- return md.trim() + "\n";
1616
+ };
1300
1617
  }
1301
- function convertHtmlTable(inner) {
1302
- const stripped = inner.replace(/<\/?(thead|tbody)[^>]*>/gi, "");
1303
- const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
1304
- const rows = [];
1305
- let m;
1306
- while ((m = rowRe.exec(stripped)) !== null) {
1307
- const rowHtml = m[1] ?? "";
1308
- const cellRe = /<(th|td)[^>]*>([\s\S]*?)<\/\1>/gi;
1309
- const cells = [];
1310
- let isHeader = false;
1311
- let cm;
1312
- while ((cm = cellRe.exec(rowHtml)) !== null) {
1313
- if ((cm[1] ?? "").toLowerCase() === "th") isHeader = true;
1314
- cells.push(normalizeTableCell(cm[2] ?? ""));
1618
+
1619
+ // src/theme-config.ts
1620
+ import {
1621
+ resolveThemeValues,
1622
+ themeSettingKey
1623
+ } from "ampless";
1624
+ var DEFAULT_COLOR_SCHEME = "auto";
1625
+ var COLOR_SCHEME_SETTING_KEY = "theme.colorScheme";
1626
+ function validateColorScheme(raw) {
1627
+ if (raw === "light" || raw === "dark" || raw === "auto") return raw;
1628
+ return DEFAULT_COLOR_SCHEME;
1629
+ }
1630
+ function createThemeConfig(themeActive, storage) {
1631
+ async function fetchRemote() {
1632
+ if (!storage.isStorageConfigured()) return null;
1633
+ let url;
1634
+ try {
1635
+ url = storage.publicAssetUrl("public/site-settings.json");
1636
+ } catch {
1637
+ return null;
1315
1638
  }
1316
- if (cells.length > 0) rows.push({ isHeader, cells });
1639
+ const res = await fetch(url, { next: { revalidate: 60, tags: ["site-settings"] } });
1640
+ if (!res.ok) return null;
1641
+ return await res.json();
1317
1642
  }
1318
- if (rows.length === 0) return "";
1319
- let headerIdx = rows.findIndex((r) => r.isHeader);
1320
- if (headerIdx === -1) headerIdx = 0;
1321
- const header = rows[headerIdx].cells;
1322
- const body = rows.filter((_, i) => i !== headerIdx).map((r) => r.cells);
1323
- const cols = header.length;
1324
- const headerLine = "| " + header.join(" | ") + " |";
1325
- const sepLine = "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |";
1326
- const bodyLines = body.map((cells) => {
1327
- const padded = Array.from({ length: cols }, (_, i) => cells[i] ?? "");
1328
- return "| " + padded.join(" | ") + " |";
1329
- });
1330
- return [headerLine, sepLine, ...bodyLines].join("\n") + "\n";
1331
- }
1332
- function normalizeTableCell(html) {
1333
- return html.replace(/<br\s*\/?>/gi, " ").replace(/<\/?[^>]+>/g, "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
1643
+ return {
1644
+ async loadThemeConfig() {
1645
+ const [active, flat] = await Promise.all([
1646
+ themeActive.resolveActiveTheme(),
1647
+ fetchRemote().catch(() => null)
1648
+ ]);
1649
+ const manifest = active.module.manifest;
1650
+ const stored = {};
1651
+ if (flat) {
1652
+ for (const field of manifest.fields) {
1653
+ const k = themeSettingKey(field.key);
1654
+ if (k in flat) stored[k] = flat[k];
1655
+ }
1656
+ }
1657
+ const values = resolveThemeValues(manifest, stored);
1658
+ const cssVars = collectCssVars(manifest.fields, values);
1659
+ const colorScheme = validateColorScheme(flat?.[COLOR_SCHEME_SETTING_KEY]);
1660
+ return { activeTheme: active.name, manifest, values, cssVars, colorScheme };
1661
+ }
1662
+ };
1334
1663
  }
1335
- function convertHtmlTaskList(items) {
1336
- const liRe = /<li[^>]*data-checked="(true|false)"[^>]*>([\s\S]*?)<\/li>/gi;
1337
- const out = [];
1338
- let m;
1339
- while ((m = liRe.exec(items)) !== null) {
1340
- const checked = m[1] === "true" ? "x" : " ";
1341
- const inner = String(m[2] ?? "").replace(/<\/?p[^>]*>/gi, "").trim();
1342
- out.push(`- [${checked}] ${inner}`);
1664
+ function collectCssVars(fields, values) {
1665
+ const out = {};
1666
+ for (const field of fields) {
1667
+ if (field.type === "linkList") continue;
1668
+ if (!field.cssVar) continue;
1669
+ const v = values[field.key];
1670
+ if (v) out[field.cssVar] = v;
1343
1671
  }
1344
- return out.join("\n");
1672
+ return out;
1673
+ }
1674
+ function renderThemeCss(cssVars) {
1675
+ const lines = Object.entries(cssVars).map(([name, value]) => ` ${name}: ${value};`);
1676
+ if (lines.length === 0) return "";
1677
+ return `:root {
1678
+ ${lines.join("\n")}
1679
+ }`;
1345
1680
  }
1346
1681
 
1347
1682
  // src/index.ts
@@ -1371,7 +1706,15 @@ function createAmpless(opts) {
1371
1706
  publicBodyEnd: () => pluginHead.renderBodyEnd(),
1372
1707
  publicBodyForPost: (post) => pluginHead.renderBodyForPost(post),
1373
1708
  publicHtmlForPost: (post) => pluginHead.renderHtmlForPost(post),
1374
- renderBody: (post) => renderBody(post),
1709
+ publicPostScriptsForPage: (posts2) => pluginHead.renderPostScriptsForPage(posts2),
1710
+ renderBody: async (post) => {
1711
+ const ctxForPlugin = await pluginHead.contextForPlugins();
1712
+ return renderBody(post, {
1713
+ contentFields: pluginHead.contentFieldsRegistry,
1714
+ ctxForPlugin
1715
+ });
1716
+ },
1717
+ renderBodyHtmlString: (post) => renderBodyHtmlString(post),
1375
1718
  renderThemeCss: (cssVars) => renderThemeCss(cssVars),
1376
1719
  publicAssetUrl: (key) => storage.publicAssetUrl(key),
1377
1720
  isStorageConfigured: () => storage.isStorageConfigured(),
@@ -1394,6 +1737,7 @@ export {
1394
1737
  DEFAULT_COLOR_SCHEME,
1395
1738
  SUPPORTED_API_VERSION,
1396
1739
  _resetStreamS3Cache,
1740
+ buildContentFieldRegistry,
1397
1741
  createAmpless,
1398
1742
  createPluginHead,
1399
1743
  createPluginSettings,
@@ -1402,6 +1746,7 @@ export {
1402
1746
  loadPackageManifest,
1403
1747
  markdownToHtml,
1404
1748
  renderBody,
1749
+ renderBodyHtmlString,
1405
1750
  renderThemeCss,
1406
1751
  streamS3Object,
1407
1752
  streamS3ObjectWithRunner,