@ampless/runtime 1.0.0-alpha.25 → 1.0.0-alpha.27

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
@@ -153,7 +153,37 @@ interface PluginHeadApi {
153
153
  renderHead(): Promise<ReactNode>;
154
154
  /** React children safe to drop just before `</body>`. */
155
155
  renderBodyEnd(): Promise<ReactNode>;
156
+ /**
157
+ * Per-post body descriptors (Phase 4). Themes call this from their
158
+ * post template to render plugin-supplied `<script
159
+ * type="application/ld+json">` descriptors keyed off the specific
160
+ * post being viewed. Limited to `inlineScript` with
161
+ * `scriptType: 'application/ld+json'` so the per-post surface stays
162
+ * scoped to structured data — see `PublicPostBodyDescriptor`.
163
+ */
164
+ renderBodyForPost(post: Post): Promise<ReactNode>;
156
165
  }
166
+ /**
167
+ * Escape characters that would let a value break out of an inline
168
+ * `<script type="application/ld+json">` body. Applied automatically by
169
+ * the runtime to ANY inlineScript descriptor whose `scriptType` is
170
+ * `'application/ld+json'`, regardless of which surface
171
+ * (`publicHead` / `publicBodyEnd` / `publicBodyForPost`) emitted it.
172
+ * Plugin authors do not need to call this themselves; it's exported so
173
+ * other hand-rolled inline-JSON-LD code paths can reuse it.
174
+ *
175
+ * Each character is replaced with its `\uXXXX` form — a JSON-legal way
176
+ * to encode the same character inside a JSON string, so the JSON
177
+ * payload still parses but the HTML parser can no longer see a closing
178
+ * `</script>` sequence:
179
+ *
180
+ * '<' → '\u003c'
181
+ * '>' → '\u003e'
182
+ * '&' → '\u0026'
183
+ * U+2028 → '\u2028'
184
+ * U+2029 → '\u2029'
185
+ */
186
+ declare function escapeJsonLdInlineBody(value: string): string;
157
187
  /**
158
188
  * Create the head/body renderer for a `Config`. The constructor-time
159
189
  * pass logs a single dev warning when two plugins share an
@@ -384,6 +414,16 @@ interface Ampless {
384
414
  siteMetadata(): Promise<Metadata>;
385
415
  publicHead(): Promise<ReactNode>;
386
416
  publicBodyEnd(): Promise<ReactNode>;
417
+ /**
418
+ * Per-post body descriptors (Phase 4 `schema` capability). Theme
419
+ * post templates render the result so plugins like
420
+ * `@ampless/plugin-schema-jsonld` can emit
421
+ * `<script type="application/ld+json">` Article schema keyed off
422
+ * the specific post being viewed. Limited to inline-script
423
+ * descriptors with `scriptType: 'application/ld+json'` — the
424
+ * runtime auto-escapes `<`, `>`, `&`, U+2028, U+2029 in the body.
425
+ */
426
+ publicBodyForPost(post: Post): Promise<ReactNode>;
387
427
  renderBody(post: Post): string;
388
428
  renderThemeCss(cssVars: Record<string, string>): string;
389
429
  publicAssetUrl(key: string): string;
@@ -410,4 +450,4 @@ interface Ampless {
410
450
  */
411
451
  declare function createAmpless(opts: CreateAmplessOpts): Ampless;
412
452
 
413
- 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, type SeoApi, type SiteSettingsApi, type StorageOutput, type StreamS3Options, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, _resetStreamS3Cache, createAmpless, createPluginHead, createPluginSettings, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, streamS3Object, streamS3ObjectWithRunner, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
453
+ 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, type SeoApi, type SiteSettingsApi, type StorageOutput, type StreamS3Options, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, _resetStreamS3Cache, createAmpless, createPluginHead, createPluginSettings, escapeJsonLdInlineBody, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, streamS3Object, streamS3ObjectWithRunner, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
package/dist/index.js CHANGED
@@ -289,6 +289,42 @@ function applyAttrs(target, attrs, ownerLabel) {
289
289
  target[k] = v;
290
290
  }
291
291
  }
292
+ function escapeJsonLdInlineBody(value) {
293
+ return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
294
+ }
295
+ function inlineScriptTypeAllowed(surface, scriptType) {
296
+ if (scriptType === "application/ld+json") return true;
297
+ if (scriptType === void 0) {
298
+ return surface === "head" || surface === "body-end";
299
+ }
300
+ return false;
301
+ }
302
+ function renderInlineScript(descriptor, pluginLabel, index, surface) {
303
+ if (!descriptor.id) {
304
+ warn(
305
+ `${pluginLabel}: inlineScript descriptor #${index} dropped \u2014 missing required "id".`
306
+ );
307
+ return null;
308
+ }
309
+ if (!inlineScriptTypeAllowed(surface, descriptor.scriptType)) {
310
+ const got = descriptor.scriptType === void 0 ? "undefined" : `"${descriptor.scriptType}"`;
311
+ 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"`;
312
+ warn(
313
+ `${pluginLabel}: inlineScript "${descriptor.id}" dropped \u2014 scriptType ${got} not allowed on ${surface}. Allowed: ${allowed}.`
314
+ );
315
+ return null;
316
+ }
317
+ const body = descriptor.scriptType === "application/ld+json" ? escapeJsonLdInlineBody(descriptor.body) : descriptor.body;
318
+ const props = {
319
+ id: descriptor.id,
320
+ dangerouslySetInnerHTML: { __html: body }
321
+ };
322
+ if (descriptor.scriptType) props.type = descriptor.scriptType;
323
+ return {
324
+ id: descriptor.id,
325
+ element: createElement("script", props)
326
+ };
327
+ }
292
328
  function renderHeadDescriptor(descriptor, pluginLabel, index) {
293
329
  switch (descriptor.type) {
294
330
  case "script": {
@@ -319,22 +355,8 @@ function renderHeadDescriptor(descriptor, pluginLabel, index) {
319
355
  element: createElement("script", props)
320
356
  };
321
357
  }
322
- case "inlineScript": {
323
- if (!descriptor.id) {
324
- warn(
325
- `${pluginLabel}: inlineScript descriptor #${index} dropped \u2014 missing required "id".`
326
- );
327
- return null;
328
- }
329
- const props = {
330
- id: descriptor.id,
331
- dangerouslySetInnerHTML: { __html: descriptor.body }
332
- };
333
- return {
334
- id: descriptor.id,
335
- element: createElement("script", props)
336
- };
337
- }
358
+ case "inlineScript":
359
+ return renderInlineScript(descriptor, pluginLabel, index, "head");
338
360
  case "meta": {
339
361
  const props = { content: descriptor.content };
340
362
  if (descriptor.name) props.name = descriptor.name;
@@ -401,8 +423,20 @@ function renderBodyDescriptor(descriptor, pluginLabel, index) {
401
423
  element: createElement("iframe", props)
402
424
  };
403
425
  }
426
+ if (descriptor.type === "inlineScript") {
427
+ return renderInlineScript(descriptor, pluginLabel, index, "body-end");
428
+ }
404
429
  return renderHeadDescriptor(descriptor, pluginLabel, index);
405
430
  }
431
+ function renderPostBodyDescriptor(descriptor, pluginLabel, index) {
432
+ if (descriptor.type !== "inlineScript") {
433
+ warn(
434
+ `${pluginLabel}: publicBodyForPost descriptor #${index} dropped \u2014 only inlineScript with scriptType "application/ld+json" is allowed on this surface.`
435
+ );
436
+ return null;
437
+ }
438
+ return renderInlineScript(descriptor, pluginLabel, index, "body-for-post");
439
+ }
406
440
  function dedupeAndKey(entries) {
407
441
  const lastIndexById = /* @__PURE__ */ new Map();
408
442
  for (let i = 0; i < entries.length; i++) {
@@ -461,6 +495,31 @@ function collectFor(plugins, site, snapshot, surface, renderOne) {
461
495
  const keyed = dedupeAndKey(entries);
462
496
  return createElement(Fragment, null, ...keyed);
463
497
  }
498
+ function collectForPost(plugins, site, snapshot, post) {
499
+ const entries = [];
500
+ for (const plugin of plugins) {
501
+ const factory = plugin.publicBodyForPost;
502
+ if (!factory) continue;
503
+ const ctx = makeCtx(plugin, site, snapshot);
504
+ let descriptors;
505
+ try {
506
+ descriptors = factory.call(plugin, post, ctx) ?? [];
507
+ } catch (err) {
508
+ warn(
509
+ `plugin "${plugin.instanceId ?? plugin.name}" threw inside publicBodyForPost callback: ${err instanceof Error ? err.message : String(err)}`
510
+ );
511
+ continue;
512
+ }
513
+ const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
514
+ for (let i = 0; i < descriptors.length; i++) {
515
+ const entry = renderPostBodyDescriptor(descriptors[i], label, i);
516
+ if (entry) entries.push(entry);
517
+ }
518
+ }
519
+ if (entries.length === 0) return null;
520
+ const keyed = dedupeAndKey(entries);
521
+ return createElement(Fragment, null, ...keyed);
522
+ }
464
523
  function createPluginHead(cmsConfig, pluginSettings) {
465
524
  const plugins = (cmsConfig.plugins ?? []).filter(isPlugin2);
466
525
  const validPlugins = [];
@@ -512,6 +571,16 @@ function createPluginHead(cmsConfig, pluginSettings) {
512
571
  `${label}: implements \`publicBodyEnd\` but "publicBody" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
513
572
  );
514
573
  }
574
+ if (caps.includes("schema") && !plugin.publicBodyForPost) {
575
+ warn(
576
+ `${label}: declares capability "schema" but no \`publicBodyForPost\` implementation. Drop the capability or add the function.`
577
+ );
578
+ }
579
+ if (plugin.publicBodyForPost && !caps.includes("schema")) {
580
+ warn(
581
+ `${label}: implements \`publicBodyForPost\` but "schema" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
582
+ );
583
+ }
515
584
  }
516
585
  }
517
586
  return {
@@ -534,6 +603,10 @@ function createPluginHead(cmsConfig, pluginSettings) {
534
603
  (p) => p.publicBodyEnd,
535
604
  renderBodyDescriptor
536
605
  );
606
+ },
607
+ async renderBodyForPost(post) {
608
+ const snapshot = await pluginSettings.loadAll();
609
+ return collectForPost(validPlugins, cmsConfig.site, snapshot, post);
537
610
  }
538
611
  };
539
612
  }
@@ -1025,6 +1098,7 @@ function createAmpless(opts) {
1025
1098
  siteMetadata: () => seo.siteMetadata(),
1026
1099
  publicHead: () => pluginHead.renderHead(),
1027
1100
  publicBodyEnd: () => pluginHead.renderBodyEnd(),
1101
+ publicBodyForPost: (post) => pluginHead.renderBodyForPost(post),
1028
1102
  renderBody: (post) => renderBody(post),
1029
1103
  renderThemeCss: (cssVars) => renderThemeCss(cssVars),
1030
1104
  publicAssetUrl: (key) => storage.publicAssetUrl(key),
@@ -1050,6 +1124,7 @@ export {
1050
1124
  createAmpless,
1051
1125
  createPluginHead,
1052
1126
  createPluginSettings,
1127
+ escapeJsonLdInlineBody,
1053
1128
  htmlToMarkdown,
1054
1129
  markdownToHtml,
1055
1130
  renderBody,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "1.0.0-alpha.25",
3
+ "version": "1.0.0-alpha.27",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,8 +49,8 @@
49
49
  "lucide-react": "^1.16.0",
50
50
  "marked": "^18.0.4",
51
51
  "tailwind-merge": "^3.6.0",
52
- "ampless": "1.0.0-alpha.19",
53
- "@ampless/plugin-og-image": "0.2.0-alpha.19"
52
+ "ampless": "1.0.0-alpha.21",
53
+ "@ampless/plugin-og-image": "0.2.0-alpha.21"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "next": "^15 || ^16",