@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 +28 -1
- package/dist/index.js +148 -0
- package/package.json +8 -6
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.
|
|
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": "
|
|
53
|
-
"
|
|
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
|
},
|