@ampless/runtime 1.0.0-alpha.26 → 1.0.0-alpha.28

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
@@ -1,4 +1,4 @@
1
- import { Post, MediaMetadata, Config, ThemeModule, ThemeManifest } from 'ampless';
1
+ import { Post, MediaMetadata, Config, ThemeModule, ThemeManifest, PluginPackageManifest } from 'ampless';
2
2
  export { Config, Post, ThemeManifest } from 'ampless';
3
3
  import { Metadata } from 'next';
4
4
  import { ReactNode } from 'react';
@@ -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
@@ -239,6 +269,45 @@ interface ThemeConfigApi {
239
269
  */
240
270
  declare function renderThemeCss(cssVars: Record<string, string>): string;
241
271
 
272
+ /**
273
+ * Resolve `<packageName>/package.json`, read it, and return the
274
+ * `amplessPlugin` field as a typed `PluginPackageManifest`. Returns
275
+ * `null` for any failure — the caller skips cross-check rather than
276
+ * aborting plugin loading.
277
+ *
278
+ * Failure modes that resolve to `null`:
279
+ * - Package not installed at this resolution root
280
+ * (`ERR_MODULE_NOT_FOUND`)
281
+ * - `package.json` not in the package's `exports`
282
+ * (`ERR_PACKAGE_PATH_NOT_EXPORTED`) — see the spec under
283
+ * `docs/tmp/plugin-extension-phase5.md` §B
284
+ * - `readFileSync` throws (e.g. ENOENT, permissions)
285
+ * - JSON parse error
286
+ * - `amplessPlugin` field absent or not an object
287
+ * - `amplessPlugin` field present but structurally invalid (e.g.
288
+ * `apiVersion` not a number, `capabilities` not an array of
289
+ * strings, `trustLevel` not one of the three allowed values) —
290
+ * see `isValidManifest`
291
+ *
292
+ * The structural check matters: without it, a downstream consumer
293
+ * like `crossCheckStaticManifest`'s `for ... of` over the manifest's
294
+ * `capabilities` would crash on `capabilities: {}` or `capabilities: 42`,
295
+ * which is at odds with the "non-apiVersion mismatches warn rather
296
+ * than throw" policy.
297
+ *
298
+ * After this function returns a non-null value, the caller can trust
299
+ * every field it inspects has the declared type; mismatches against
300
+ * the factory return value are still surfaced as warnings (or, for
301
+ * `apiVersion`, throws) by the caller itself.
302
+ */
303
+ declare function loadPackageManifest(packageName: string): PluginPackageManifest | null;
304
+ /**
305
+ * Maximum `apiVersion` value this runtime can host. Plugins declaring
306
+ * a higher value are rejected at constructor time — the runtime can't
307
+ * safely call into surfaces it doesn't know about yet.
308
+ */
309
+ declare const SUPPORTED_API_VERSION: 1;
310
+
242
311
  declare function renderBody(post: Post): string;
243
312
  /**
244
313
  * Convert a tiptap doc to its HTML form. Same renderer the public
@@ -384,6 +453,16 @@ interface Ampless {
384
453
  siteMetadata(): Promise<Metadata>;
385
454
  publicHead(): Promise<ReactNode>;
386
455
  publicBodyEnd(): Promise<ReactNode>;
456
+ /**
457
+ * Per-post body descriptors (Phase 4 `schema` capability). Theme
458
+ * post templates render the result so plugins like
459
+ * `@ampless/plugin-schema-jsonld` can emit
460
+ * `<script type="application/ld+json">` Article schema keyed off
461
+ * the specific post being viewed. Limited to inline-script
462
+ * descriptors with `scriptType: 'application/ld+json'` — the
463
+ * runtime auto-escapes `<`, `>`, `&`, U+2028, U+2029 in the body.
464
+ */
465
+ publicBodyForPost(post: Post): Promise<ReactNode>;
387
466
  renderBody(post: Post): string;
388
467
  renderThemeCss(cssVars: Record<string, string>): string;
389
468
  publicAssetUrl(key: string): string;
@@ -410,4 +489,4 @@ interface Ampless {
410
489
  */
411
490
  declare function createAmpless(opts: CreateAmplessOpts): Ampless;
412
491
 
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 };
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 };
package/dist/index.js CHANGED
@@ -234,6 +234,53 @@ import {
234
234
  isValidPluginKey,
235
235
  resolvePluginSettings
236
236
  } from "ampless";
237
+
238
+ // src/plugin-package-manifest.ts
239
+ import { readFileSync } from "fs";
240
+ import { fileURLToPath } from "url";
241
+ var TRUST_LEVELS = ["untrusted", "trusted", "privileged"];
242
+ function isValidManifest(value) {
243
+ if (typeof value !== "object" || value === null) return false;
244
+ const v = value;
245
+ if (typeof v.apiVersion !== "number") return false;
246
+ if (typeof v.name !== "string") return false;
247
+ if (typeof v.trustLevel !== "string") return false;
248
+ if (!TRUST_LEVELS.includes(v.trustLevel)) return false;
249
+ if (!Array.isArray(v.capabilities)) return false;
250
+ for (const c of v.capabilities) {
251
+ if (typeof c !== "string") return false;
252
+ }
253
+ return true;
254
+ }
255
+ function loadPackageManifest(packageName) {
256
+ let resolvedUrl;
257
+ try {
258
+ resolvedUrl = import.meta.resolve(`${packageName}/package.json`);
259
+ } catch {
260
+ return null;
261
+ }
262
+ let raw;
263
+ try {
264
+ raw = readFileSync(fileURLToPath(resolvedUrl), "utf8");
265
+ } catch {
266
+ return null;
267
+ }
268
+ let pkg;
269
+ try {
270
+ pkg = JSON.parse(raw);
271
+ } catch {
272
+ return null;
273
+ }
274
+ if (typeof pkg !== "object" || pkg === null || !("amplessPlugin" in pkg)) {
275
+ return null;
276
+ }
277
+ const manifest = pkg.amplessPlugin;
278
+ if (!isValidManifest(manifest)) return null;
279
+ return manifest;
280
+ }
281
+ var SUPPORTED_API_VERSION = 1;
282
+
283
+ // src/plugin-head.ts
237
284
  function isPlugin2(p) {
238
285
  return typeof p === "object" && p !== null && "apiVersion" in p;
239
286
  }
@@ -277,6 +324,50 @@ function warn(message) {
277
324
  if (!isDev()) return;
278
325
  console.warn(`[ampless plugin-head] ${message}`);
279
326
  }
327
+ function crossCheckStaticManifest(plugin, label) {
328
+ const packageName = plugin.packageName;
329
+ const manifest = loadPackageManifest(packageName);
330
+ if (!manifest) return;
331
+ if (typeof manifest.apiVersion !== "number") {
332
+ throw new Error(
333
+ `${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.`
334
+ );
335
+ }
336
+ if (manifest.apiVersion > SUPPORTED_API_VERSION) {
337
+ throw new Error(
338
+ `${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.`
339
+ );
340
+ }
341
+ if (manifest.apiVersion !== plugin.apiVersion) {
342
+ throw new Error(
343
+ `${label}: apiVersion mismatch \u2014 package.json declares ${manifest.apiVersion}, factory declares ${plugin.apiVersion}. The two must agree.`
344
+ );
345
+ }
346
+ if (manifest.name !== plugin.name) {
347
+ warn(
348
+ `${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.`
349
+ );
350
+ }
351
+ if (manifest.trustLevel !== plugin.trust_level) {
352
+ warn(
353
+ `${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.`
354
+ );
355
+ }
356
+ const factoryCaps = Array.isArray(plugin.capabilities) ? plugin.capabilities : [];
357
+ const manifestCaps = manifest.capabilities;
358
+ if (!setsEqual(factoryCaps, manifestCaps)) {
359
+ warn(
360
+ `${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.`
361
+ );
362
+ }
363
+ }
364
+ function setsEqual(a, b) {
365
+ const sa = new Set(a);
366
+ const sb = new Set(b);
367
+ if (sa.size !== sb.size) return false;
368
+ for (const c of sb) if (!sa.has(c)) return false;
369
+ return true;
370
+ }
280
371
  function applyAttrs(target, attrs, ownerLabel) {
281
372
  if (!attrs) return;
282
373
  for (const [k, v] of Object.entries(attrs)) {
@@ -289,6 +380,42 @@ function applyAttrs(target, attrs, ownerLabel) {
289
380
  target[k] = v;
290
381
  }
291
382
  }
383
+ function escapeJsonLdInlineBody(value) {
384
+ return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
385
+ }
386
+ function inlineScriptTypeAllowed(surface, scriptType) {
387
+ if (scriptType === "application/ld+json") return true;
388
+ if (scriptType === void 0) {
389
+ return surface === "head" || surface === "body-end";
390
+ }
391
+ return false;
392
+ }
393
+ function renderInlineScript(descriptor, pluginLabel, index, surface) {
394
+ if (!descriptor.id) {
395
+ warn(
396
+ `${pluginLabel}: inlineScript descriptor #${index} dropped \u2014 missing required "id".`
397
+ );
398
+ return null;
399
+ }
400
+ if (!inlineScriptTypeAllowed(surface, descriptor.scriptType)) {
401
+ const got = descriptor.scriptType === void 0 ? "undefined" : `"${descriptor.scriptType}"`;
402
+ 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"`;
403
+ warn(
404
+ `${pluginLabel}: inlineScript "${descriptor.id}" dropped \u2014 scriptType ${got} not allowed on ${surface}. Allowed: ${allowed}.`
405
+ );
406
+ return null;
407
+ }
408
+ const body = descriptor.scriptType === "application/ld+json" ? escapeJsonLdInlineBody(descriptor.body) : descriptor.body;
409
+ const props = {
410
+ id: descriptor.id,
411
+ dangerouslySetInnerHTML: { __html: body }
412
+ };
413
+ if (descriptor.scriptType) props.type = descriptor.scriptType;
414
+ return {
415
+ id: descriptor.id,
416
+ element: createElement("script", props)
417
+ };
418
+ }
292
419
  function renderHeadDescriptor(descriptor, pluginLabel, index) {
293
420
  switch (descriptor.type) {
294
421
  case "script": {
@@ -319,22 +446,8 @@ function renderHeadDescriptor(descriptor, pluginLabel, index) {
319
446
  element: createElement("script", props)
320
447
  };
321
448
  }
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
- }
449
+ case "inlineScript":
450
+ return renderInlineScript(descriptor, pluginLabel, index, "head");
338
451
  case "meta": {
339
452
  const props = { content: descriptor.content };
340
453
  if (descriptor.name) props.name = descriptor.name;
@@ -401,8 +514,20 @@ function renderBodyDescriptor(descriptor, pluginLabel, index) {
401
514
  element: createElement("iframe", props)
402
515
  };
403
516
  }
517
+ if (descriptor.type === "inlineScript") {
518
+ return renderInlineScript(descriptor, pluginLabel, index, "body-end");
519
+ }
404
520
  return renderHeadDescriptor(descriptor, pluginLabel, index);
405
521
  }
522
+ function renderPostBodyDescriptor(descriptor, pluginLabel, index) {
523
+ if (descriptor.type !== "inlineScript") {
524
+ warn(
525
+ `${pluginLabel}: publicBodyForPost descriptor #${index} dropped \u2014 only inlineScript with scriptType "application/ld+json" is allowed on this surface.`
526
+ );
527
+ return null;
528
+ }
529
+ return renderInlineScript(descriptor, pluginLabel, index, "body-for-post");
530
+ }
406
531
  function dedupeAndKey(entries) {
407
532
  const lastIndexById = /* @__PURE__ */ new Map();
408
533
  for (let i = 0; i < entries.length; i++) {
@@ -461,6 +586,31 @@ function collectFor(plugins, site, snapshot, surface, renderOne) {
461
586
  const keyed = dedupeAndKey(entries);
462
587
  return createElement(Fragment, null, ...keyed);
463
588
  }
589
+ function collectForPost(plugins, site, snapshot, post) {
590
+ const entries = [];
591
+ for (const plugin of plugins) {
592
+ const factory = plugin.publicBodyForPost;
593
+ if (!factory) continue;
594
+ const ctx = makeCtx(plugin, site, snapshot);
595
+ let descriptors;
596
+ try {
597
+ descriptors = factory.call(plugin, post, ctx) ?? [];
598
+ } catch (err) {
599
+ warn(
600
+ `plugin "${plugin.instanceId ?? plugin.name}" threw inside publicBodyForPost callback: ${err instanceof Error ? err.message : String(err)}`
601
+ );
602
+ continue;
603
+ }
604
+ const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
605
+ for (let i = 0; i < descriptors.length; i++) {
606
+ const entry = renderPostBodyDescriptor(descriptors[i], label, i);
607
+ if (entry) entries.push(entry);
608
+ }
609
+ }
610
+ if (entries.length === 0) return null;
611
+ const keyed = dedupeAndKey(entries);
612
+ return createElement(Fragment, null, ...keyed);
613
+ }
464
614
  function createPluginHead(cmsConfig, pluginSettings) {
465
615
  const plugins = (cmsConfig.plugins ?? []).filter(isPlugin2);
466
616
  const validPlugins = [];
@@ -490,6 +640,9 @@ function createPluginHead(cmsConfig, pluginSettings) {
490
640
  }
491
641
  }
492
642
  validPlugins.push(plugin);
643
+ if (plugin.packageName) {
644
+ crossCheckStaticManifest(plugin, label);
645
+ }
493
646
  const caps = plugin.capabilities;
494
647
  if (caps) {
495
648
  if (caps.includes("publicHead") && !plugin.publicHead) {
@@ -512,6 +665,16 @@ function createPluginHead(cmsConfig, pluginSettings) {
512
665
  `${label}: implements \`publicBodyEnd\` but "publicBody" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
513
666
  );
514
667
  }
668
+ if (caps.includes("schema") && !plugin.publicBodyForPost) {
669
+ warn(
670
+ `${label}: declares capability "schema" but no \`publicBodyForPost\` implementation. Drop the capability or add the function.`
671
+ );
672
+ }
673
+ if (plugin.publicBodyForPost && !caps.includes("schema")) {
674
+ warn(
675
+ `${label}: implements \`publicBodyForPost\` but "schema" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
676
+ );
677
+ }
515
678
  }
516
679
  }
517
680
  return {
@@ -534,6 +697,10 @@ function createPluginHead(cmsConfig, pluginSettings) {
534
697
  (p) => p.publicBodyEnd,
535
698
  renderBodyDescriptor
536
699
  );
700
+ },
701
+ async renderBodyForPost(post) {
702
+ const snapshot = await pluginSettings.loadAll();
703
+ return collectForPost(validPlugins, cmsConfig.site, snapshot, post);
537
704
  }
538
705
  };
539
706
  }
@@ -1025,6 +1192,7 @@ function createAmpless(opts) {
1025
1192
  siteMetadata: () => seo.siteMetadata(),
1026
1193
  publicHead: () => pluginHead.renderHead(),
1027
1194
  publicBodyEnd: () => pluginHead.renderBodyEnd(),
1195
+ publicBodyForPost: (post) => pluginHead.renderBodyForPost(post),
1028
1196
  renderBody: (post) => renderBody(post),
1029
1197
  renderThemeCss: (cssVars) => renderThemeCss(cssVars),
1030
1198
  publicAssetUrl: (key) => storage.publicAssetUrl(key),
@@ -1046,11 +1214,14 @@ function createAmpless(opts) {
1046
1214
  export {
1047
1215
  COLOR_SCHEME_SETTING_KEY,
1048
1216
  DEFAULT_COLOR_SCHEME,
1217
+ SUPPORTED_API_VERSION,
1049
1218
  _resetStreamS3Cache,
1050
1219
  createAmpless,
1051
1220
  createPluginHead,
1052
1221
  createPluginSettings,
1222
+ escapeJsonLdInlineBody,
1053
1223
  htmlToMarkdown,
1224
+ loadPackageManifest,
1054
1225
  markdownToHtml,
1055
1226
  renderBody,
1056
1227
  renderThemeCss,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "1.0.0-alpha.26",
3
+ "version": "1.0.0-alpha.28",
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.20",
53
- "@ampless/plugin-og-image": "0.2.0-alpha.20"
52
+ "ampless": "1.0.0-alpha.22",
53
+ "@ampless/plugin-og-image": "0.2.0-alpha.22"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "next": "^15 || ^16",