@ampless/runtime 1.0.0-alpha.35 → 1.0.0-alpha.37

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.d.ts CHANGED
@@ -162,6 +162,23 @@ interface PluginHeadApi {
162
162
  * scoped to structured data — see `PublicPostBodyDescriptor`.
163
163
  */
164
164
  renderBodyForPost(post: Post): Promise<ReactNode>;
165
+ /**
166
+ * Per-post visible HTML (Phase 6d). Aggregates all installed plugins'
167
+ * `publicHtmlForPost` descriptors, sanitizes bodies under a strict
168
+ * `sanitize-html` allowlist, resolves namespaces, deduplicates, and
169
+ * returns position-bucketed ReactNodes ready to embed in theme post
170
+ * templates. Themes never call `dangerouslySetInnerHTML` themselves.
171
+ */
172
+ renderHtmlForPost(post: Post): Promise<PublicHtmlForPostResult>;
173
+ }
174
+ /**
175
+ * Position-bucketed result of `renderHtmlForPost`. Themes embed these
176
+ * directly with `{html.beforeContent}` / `{html.afterContent}`. A `null`
177
+ * slot means no plugins contributed HTML to that position.
178
+ */
179
+ interface PublicHtmlForPostResult {
180
+ beforeContent: ReactNode | null;
181
+ afterContent: ReactNode | null;
165
182
  }
166
183
  /**
167
184
  * Escape characters that would let a value break out of an inline
@@ -463,6 +480,16 @@ interface Ampless {
463
480
  * runtime auto-escapes `<`, `>`, `&`, U+2028, U+2029 in the body.
464
481
  */
465
482
  publicBodyForPost(post: Post): Promise<ReactNode>;
483
+ /**
484
+ * Per-post visible HTML aggregated across all installed plugins
485
+ * (Phase 6d `publicHtmlForPost` capability). Returns a position-
486
+ * bucketed result — themes embed `{html.beforeContent}` and
487
+ * `{html.afterContent}` around the post body. The runtime sanitizes
488
+ * every descriptor body with `sanitize-html` under a strict allowlist
489
+ * before wrapping it in a keyed `<div>` — themes never call
490
+ * `dangerouslySetInnerHTML` themselves.
491
+ */
492
+ publicHtmlForPost(post: Post): Promise<PublicHtmlForPostResult>;
466
493
  renderBody(post: Post): string;
467
494
  renderThemeCss(cssVars: Record<string, string>): string;
468
495
  publicAssetUrl(key: string): string;
@@ -489,4 +516,4 @@ interface Ampless {
489
516
  */
490
517
  declare function createAmpless(opts: CreateAmplessOpts): Ampless;
491
518
 
492
- export { type Ampless, type AmplessOutputs, COLOR_SCHEME_SETTING_KEY, type ColorScheme, type CreateAmplessOpts, DEFAULT_COLOR_SCHEME, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type MediaApi, type PluginHeadApi, type PluginSettingsApi, type PluginSettingsSnapshot, type PostsApi, type PublicMediaShape, type PublicPostConnectionShape, type PublicPostShape, type ResolvedAssetMeta, type ResolvedMedia, type ResolvedTheme, SUPPORTED_API_VERSION, type SeoApi, type SiteSettingsApi, type StorageOutput, type StreamS3Options, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, _resetStreamS3Cache, createAmpless, createPluginHead, createPluginSettings, escapeJsonLdInlineBody, htmlToMarkdown, loadPackageManifest, markdownToHtml, renderBody, renderThemeCss, streamS3Object, streamS3ObjectWithRunner, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
519
+ export { type Ampless, type AmplessOutputs, COLOR_SCHEME_SETTING_KEY, type ColorScheme, type CreateAmplessOpts, DEFAULT_COLOR_SCHEME, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type MediaApi, type PluginHeadApi, type PluginSettingsApi, type PluginSettingsSnapshot, type PostsApi, type PublicHtmlForPostResult, type PublicMediaShape, type PublicPostConnectionShape, type PublicPostShape, type ResolvedAssetMeta, type ResolvedMedia, type ResolvedTheme, SUPPORTED_API_VERSION, type SeoApi, type SiteSettingsApi, type StorageOutput, type StreamS3Options, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, _resetStreamS3Cache, createAmpless, createPluginHead, createPluginSettings, escapeJsonLdInlineBody, htmlToMarkdown, loadPackageManifest, markdownToHtml, renderBody, renderThemeCss, streamS3Object, streamS3ObjectWithRunner, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
package/dist/index.js CHANGED
@@ -230,6 +230,7 @@ import {
230
230
  Fragment,
231
231
  createElement
232
232
  } from "react";
233
+ import sanitizeHtml from "sanitize-html";
233
234
  import {
234
235
  isValidPluginKey,
235
236
  resolvePluginSettings
@@ -604,6 +605,81 @@ function collectFor(plugins, site, snapshot, surface, renderOne) {
604
605
  const keyed = dedupeAndKey(entries);
605
606
  return createElement(Fragment, null, ...keyed);
606
607
  }
608
+ var SANITIZE_OPTIONS = {
609
+ allowedTags: ["p", "span", "strong", "em", "a", "code", "br", "ul", "ol", "li"],
610
+ allowedAttributes: {
611
+ "*": ["class", "data-words", "data-minutes", "data-ampless-*"],
612
+ a: ["href", "rel", "target"]
613
+ },
614
+ allowedSchemes: ["http", "https"],
615
+ allowedSchemesAppliedToAttributes: ["href"],
616
+ allowProtocolRelative: false,
617
+ transformTags: {
618
+ a: (tagName, attribs) => {
619
+ const out = { ...attribs };
620
+ if (out["target"] === "_blank") {
621
+ const parts = (out["rel"] ?? "").split(/\s+/).filter(Boolean);
622
+ if (!parts.includes("noopener")) parts.push("noopener");
623
+ if (!parts.includes("noreferrer")) parts.push("noreferrer");
624
+ out["rel"] = parts.join(" ");
625
+ }
626
+ return { tagName, attribs: out };
627
+ }
628
+ }
629
+ };
630
+ function validateHtmlDescriptor(descriptor, pluginLabel, index) {
631
+ if (descriptor === null || typeof descriptor !== "object") {
632
+ warn(
633
+ `${pluginLabel}: publicHtmlForPost descriptor #${index} dropped \u2014 must be an object.`
634
+ );
635
+ return false;
636
+ }
637
+ const d = descriptor;
638
+ if (d.type !== "html") {
639
+ warn(
640
+ `${pluginLabel}: publicHtmlForPost descriptor #${index} dropped \u2014 "type" must be "html" (got ${typeof d.type === "string" ? `"${d.type}"` : typeof d.type}).`
641
+ );
642
+ return false;
643
+ }
644
+ if (typeof d.id !== "string") {
645
+ warn(
646
+ `${pluginLabel}: publicHtmlForPost descriptor #${index} dropped \u2014 "id" must be a string.`
647
+ );
648
+ return false;
649
+ }
650
+ if (typeof d.body !== "string") {
651
+ warn(
652
+ `${pluginLabel}: publicHtmlForPost descriptor "${d.id}" dropped \u2014 "body" must be a string.`
653
+ );
654
+ return false;
655
+ }
656
+ if (d.position !== "beforeContent" && d.position !== "afterContent") {
657
+ warn(
658
+ `${pluginLabel}: publicHtmlForPost descriptor "${d.id}" dropped \u2014 "position" must be "beforeContent" or "afterContent" (got ${typeof d.position === "string" ? `"${d.position}"` : typeof d.position}).`
659
+ );
660
+ return false;
661
+ }
662
+ return true;
663
+ }
664
+ function validateHtmlId(id, pluginLabel) {
665
+ if (id.length === 0) {
666
+ warn(`${pluginLabel}: publicHtmlForPost descriptor dropped \u2014 "id" must not be empty.`);
667
+ return false;
668
+ }
669
+ if (/[\x00-\x1f]/.test(id)) {
670
+ warn(
671
+ `${pluginLabel}: publicHtmlForPost descriptor "${id}" dropped \u2014 "id" contains control characters.`
672
+ );
673
+ return false;
674
+ }
675
+ if (id.length > 64) {
676
+ warn(
677
+ `${pluginLabel}: publicHtmlForPost descriptor "${id.slice(0, 32)}\u2026" dropped \u2014 "id" exceeds 64 characters.`
678
+ );
679
+ return false;
680
+ }
681
+ return true;
682
+ }
607
683
  function collectForPost(plugins, site, snapshot, post) {
608
684
  const entries = [];
609
685
  for (const plugin of plugins) {
@@ -629,6 +705,63 @@ function collectForPost(plugins, site, snapshot, post) {
629
705
  const keyed = dedupeAndKey(entries);
630
706
  return createElement(Fragment, null, ...keyed);
631
707
  }
708
+ function collectHtmlForPost(plugins, site, snapshot, post) {
709
+ const before = [];
710
+ const after = [];
711
+ const seenBefore = /* @__PURE__ */ new Set();
712
+ const seenAfter = /* @__PURE__ */ new Set();
713
+ for (const plugin of plugins) {
714
+ const factory = plugin.publicHtmlForPost;
715
+ if (!factory) continue;
716
+ const namespace = plugin.instanceId ?? plugin.name;
717
+ const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
718
+ const ctx = makeCtx(plugin, site, snapshot);
719
+ let descriptors;
720
+ try {
721
+ descriptors = factory.call(plugin, post, ctx) ?? [];
722
+ } catch (err) {
723
+ warn(
724
+ `${label}: threw inside publicHtmlForPost callback: ${err instanceof Error ? err.message : String(err)}`
725
+ );
726
+ continue;
727
+ }
728
+ for (let i = 0; i < descriptors.length; i++) {
729
+ const raw = descriptors[i];
730
+ if (!validateHtmlDescriptor(raw, label, i)) continue;
731
+ const descriptor = raw;
732
+ if (!validateHtmlId(descriptor.id, label)) continue;
733
+ const dedupeKey = `${namespace}:${descriptor.id}`;
734
+ const position = descriptor.position;
735
+ const seenSet = position === "beforeContent" ? seenBefore : seenAfter;
736
+ const bucket = position === "beforeContent" ? before : after;
737
+ if (seenSet.has(dedupeKey)) {
738
+ warn(
739
+ `${label}: publicHtmlForPost descriptor "${descriptor.id}" at position "${position}" is a duplicate \u2014 keeping the first occurrence.`
740
+ );
741
+ continue;
742
+ }
743
+ seenSet.add(dedupeKey);
744
+ const cleanHtml = sanitizeHtml(descriptor.body, SANITIZE_OPTIONS);
745
+ bucket.push({ key: dedupeKey, cleanHtml, namespace });
746
+ }
747
+ }
748
+ function toReactNode(entries, position) {
749
+ if (entries.length === 0) return null;
750
+ const elements = entries.map(
751
+ ({ key, cleanHtml, namespace }) => createElement("div", {
752
+ key,
753
+ "data-ampless-plugin": namespace,
754
+ "data-ampless-position": position,
755
+ dangerouslySetInnerHTML: { __html: cleanHtml }
756
+ })
757
+ );
758
+ return createElement(Fragment, null, ...elements);
759
+ }
760
+ return {
761
+ beforeContent: toReactNode(before, "beforeContent"),
762
+ afterContent: toReactNode(after, "afterContent")
763
+ };
764
+ }
632
765
  function createPluginHead(cmsConfig, pluginSettings) {
633
766
  const plugins = (cmsConfig.plugins ?? []).filter(isPlugin2);
634
767
  const validPlugins = [];
@@ -693,6 +826,16 @@ function createPluginHead(cmsConfig, pluginSettings) {
693
826
  `${label}: implements \`publicBodyForPost\` but "schema" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
694
827
  );
695
828
  }
829
+ if (caps.includes("publicHtmlForPost") && !plugin.publicHtmlForPost) {
830
+ warn(
831
+ `${label}: declares capability "publicHtmlForPost" but no \`publicHtmlForPost\` implementation. Drop the capability or add the function.`
832
+ );
833
+ }
834
+ if (plugin.publicHtmlForPost && !caps.includes("publicHtmlForPost")) {
835
+ warn(
836
+ `${label}: implements \`publicHtmlForPost\` but "publicHtmlForPost" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
837
+ );
838
+ }
696
839
  }
697
840
  }
698
841
  return {
@@ -719,6 +862,10 @@ function createPluginHead(cmsConfig, pluginSettings) {
719
862
  async renderBodyForPost(post) {
720
863
  const snapshot = await pluginSettings.loadAll();
721
864
  return collectForPost(validPlugins, cmsConfig.site, snapshot, post);
865
+ },
866
+ async renderHtmlForPost(post) {
867
+ const snapshot = await pluginSettings.loadAll();
868
+ return collectHtmlForPost(validPlugins, cmsConfig.site, snapshot, post);
722
869
  }
723
870
  };
724
871
  }
@@ -1211,6 +1358,7 @@ function createAmpless(opts) {
1211
1358
  publicHead: () => pluginHead.renderHead(),
1212
1359
  publicBodyEnd: () => pluginHead.renderBodyEnd(),
1213
1360
  publicBodyForPost: (post) => pluginHead.renderBodyForPost(post),
1361
+ publicHtmlForPost: (post) => pluginHead.renderHtmlForPost(post),
1214
1362
  renderBody: (post) => renderBody(post),
1215
1363
  renderThemeCss: (cssVars) => renderThemeCss(cssVars),
1216
1364
  publicAssetUrl: (key) => storage.publicAssetUrl(key),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "1.0.0-alpha.35",
3
+ "version": "1.0.0-alpha.37",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,19 +48,21 @@
48
48
  "clsx": "^2.1.1",
49
49
  "lucide-react": "^1.16.0",
50
50
  "marked": "^18.0.4",
51
+ "sanitize-html": "^2.17.4",
51
52
  "tailwind-merge": "^3.6.0",
52
- "ampless": "1.0.0-alpha.27",
53
- "@ampless/plugin-og-image": "0.2.0-alpha.27"
53
+ "@ampless/plugin-og-image": "0.2.0-alpha.29",
54
+ "ampless": "1.0.0-alpha.29"
54
55
  },
55
56
  "peerDependencies": {
57
+ "@aws-amplify/adapter-nextjs": "^1",
58
+ "aws-amplify": "^6",
56
59
  "next": "^15 || ^16",
57
60
  "react": "^18 || ^19",
58
- "react-dom": "^18 || ^19",
59
- "aws-amplify": "^6",
60
- "@aws-amplify/adapter-nextjs": "^1"
61
+ "react-dom": "^18 || ^19"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@types/react": "^19.2.15",
65
+ "@types/sanitize-html": "^2.16.1",
64
66
  "next": "^16.2.6",
65
67
  "react": "^19.2.6"
66
68
  },