@ampless/runtime 1.0.0-alpha.22 → 1.0.0-alpha.23

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,6 +1,7 @@
1
- import { Post, MediaMetadata, ThemeModule, ThemeManifest, Config } from 'ampless';
1
+ import { Post, MediaMetadata, Config, ThemeModule, ThemeManifest } from 'ampless';
2
2
  export { Config, Post, ThemeManifest } from 'ampless';
3
3
  import { Metadata } from 'next';
4
+ import { ReactNode } from 'react';
4
5
  import { getProperties } from 'aws-amplify/storage/server';
5
6
  import { cookies } from 'next/headers';
6
7
 
@@ -122,6 +123,20 @@ interface SeoApi {
122
123
  siteMetadata(): Promise<Metadata>;
123
124
  }
124
125
 
126
+ interface PluginHeadApi {
127
+ /** React children safe to drop into `<head>`. */
128
+ renderHead(): ReactNode;
129
+ /** React children safe to drop just before `</body>`. */
130
+ renderBodyEnd(): ReactNode;
131
+ }
132
+ /**
133
+ * Create the head/body renderer for a `Config`. The constructor-time
134
+ * pass logs a single dev warning when two plugins share an
135
+ * `instanceId ?? name`; everything else happens at render time so
136
+ * descriptors reflect per-request site config.
137
+ */
138
+ declare function createPluginHead(cmsConfig: Config): PluginHeadApi;
139
+
125
140
  interface ThemesRegistry {
126
141
  /** Map of theme name → loaded theme module. */
127
142
  themes: Record<string, ThemeModule>;
@@ -336,6 +351,8 @@ interface Ampless {
336
351
  loadThemeConfig(): Promise<EffectiveThemeConfig>;
337
352
  postMetadata(post: Post): Promise<Metadata>;
338
353
  siteMetadata(): Promise<Metadata>;
354
+ publicHead(): ReactNode;
355
+ publicBodyEnd(): ReactNode;
339
356
  renderBody(post: Post): string;
340
357
  renderThemeCss(cssVars: Record<string, string>): string;
341
358
  publicAssetUrl(key: string): string;
@@ -350,6 +367,7 @@ interface Ampless {
350
367
  readonly themeActive: ThemeActiveApi;
351
368
  readonly themeConfig: ThemeConfigApi;
352
369
  readonly storageApi: StorageApi;
370
+ readonly pluginHead: PluginHeadApi;
353
371
  }
354
372
  /**
355
373
  * Wire up the ampless runtime from user-supplied config blobs. The
@@ -360,4 +378,4 @@ interface Ampless {
360
378
  */
361
379
  declare function createAmpless(opts: CreateAmplessOpts): Ampless;
362
380
 
363
- 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 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, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, streamS3Object, streamS3ObjectWithRunner, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
381
+ 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 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, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, streamS3Object, streamS3ObjectWithRunner, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
package/dist/index.js CHANGED
@@ -225,6 +225,280 @@ function createSeo(cmsConfig, settingsApi) {
225
225
  };
226
226
  }
227
227
 
228
+ // src/plugin-head.ts
229
+ import {
230
+ Fragment,
231
+ createElement
232
+ } from "react";
233
+ function isPlugin2(p) {
234
+ return typeof p === "object" && p !== null && "apiVersion" in p;
235
+ }
236
+ var ALLOWED_ATTRS = /* @__PURE__ */ new Set([
237
+ "crossorigin",
238
+ "referrerpolicy",
239
+ "integrity",
240
+ "fetchpriority",
241
+ // `nonce` is intentionally NOT in the allowlist for Phase 1. CSP
242
+ // nonce propagation is scoped out of Phase 1 (see spec §7); attrs
243
+ // shouldn't let plugins smuggle nonces past the design decision.
244
+ // The CSP nonce RFP will reintroduce it through `cspNonce` on
245
+ // PluginPublicRenderContext + `inlineScript.nonce: 'auto'`, not via
246
+ // the `attrs` bag.
247
+ "loading",
248
+ // iframe lazy-loading
249
+ "sandbox",
250
+ // iframe sandbox attribute
251
+ "allow",
252
+ // iframe permissions policy
253
+ "allowfullscreen"
254
+ // iframe fullscreen
255
+ ]);
256
+ function isAllowedAttr(name) {
257
+ if (name.startsWith("data-")) return true;
258
+ return ALLOWED_ATTRS.has(name.toLowerCase());
259
+ }
260
+ function isSafeUrl(value) {
261
+ const trimmed = value.trim();
262
+ if (trimmed.length === 0) return false;
263
+ const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
264
+ if (!schemeMatch) return true;
265
+ const scheme = schemeMatch[1].toLowerCase();
266
+ return scheme === "http" || scheme === "https";
267
+ }
268
+ function isDev() {
269
+ const env = typeof process !== "undefined" && process.env ? process.env.NODE_ENV : void 0;
270
+ return env !== "production";
271
+ }
272
+ function warn(message) {
273
+ if (!isDev()) return;
274
+ console.warn(`[ampless plugin-head] ${message}`);
275
+ }
276
+ function applyAttrs(target, attrs, ownerLabel) {
277
+ if (!attrs) return;
278
+ for (const [k, v] of Object.entries(attrs)) {
279
+ if (!isAllowedAttr(k)) {
280
+ warn(
281
+ `${ownerLabel}: attr "${k}" not in allowlist (data-* / crossorigin / referrerpolicy / integrity / fetchpriority / loading / sandbox / allow / allowfullscreen). skipping.`
282
+ );
283
+ continue;
284
+ }
285
+ target[k] = v;
286
+ }
287
+ }
288
+ function renderHeadDescriptor(descriptor, pluginLabel, index) {
289
+ switch (descriptor.type) {
290
+ case "script": {
291
+ if (!isSafeUrl(descriptor.src)) {
292
+ warn(
293
+ `${pluginLabel}: script descriptor #${index} dropped \u2014 unsafe src "${descriptor.src}".`
294
+ );
295
+ return null;
296
+ }
297
+ const props = {
298
+ src: descriptor.src
299
+ };
300
+ if (descriptor.id) props.id = descriptor.id;
301
+ const hasAsync = typeof descriptor.async === "boolean";
302
+ const hasDefer = typeof descriptor.defer === "boolean";
303
+ if (hasAsync) props.async = descriptor.async;
304
+ if (hasDefer) props.defer = descriptor.defer;
305
+ if (!hasAsync && !hasDefer) {
306
+ if (descriptor.strategy === "lazyOnload") {
307
+ props.defer = true;
308
+ } else {
309
+ props.async = true;
310
+ }
311
+ }
312
+ applyAttrs(props, descriptor.attrs, `${pluginLabel} script#${descriptor.id ?? index}`);
313
+ return {
314
+ id: descriptor.id ?? null,
315
+ element: createElement("script", props)
316
+ };
317
+ }
318
+ case "inlineScript": {
319
+ if (!descriptor.id) {
320
+ warn(
321
+ `${pluginLabel}: inlineScript descriptor #${index} dropped \u2014 missing required "id".`
322
+ );
323
+ return null;
324
+ }
325
+ const props = {
326
+ id: descriptor.id,
327
+ dangerouslySetInnerHTML: { __html: descriptor.body }
328
+ };
329
+ return {
330
+ id: descriptor.id,
331
+ element: createElement("script", props)
332
+ };
333
+ }
334
+ case "meta": {
335
+ const props = { content: descriptor.content };
336
+ if (descriptor.name) props.name = descriptor.name;
337
+ if (descriptor.property) props.property = descriptor.property;
338
+ return {
339
+ // meta has no id channel in the descriptor. Don't derive a
340
+ // dedup id from name/property — multiple `<meta name=...>`
341
+ // entries with the same name are legitimate (e.g. theme-color
342
+ // media variants, and two plugins emitting overlapping names
343
+ // is a real case the runtime shouldn't silently collapse).
344
+ // Position-based React keys handle the stable-key requirement.
345
+ id: null,
346
+ element: createElement("meta", props)
347
+ };
348
+ }
349
+ case "link": {
350
+ if (!isSafeUrl(descriptor.href)) {
351
+ warn(
352
+ `${pluginLabel}: link descriptor #${index} dropped \u2014 unsafe href "${descriptor.href}".`
353
+ );
354
+ return null;
355
+ }
356
+ const props = {
357
+ rel: descriptor.rel,
358
+ href: descriptor.href
359
+ };
360
+ if (descriptor.as) props.as = descriptor.as;
361
+ if (descriptor.typeAttr) props.type = descriptor.typeAttr;
362
+ return {
363
+ id: null,
364
+ element: createElement("link", props)
365
+ };
366
+ }
367
+ case "noscript": {
368
+ const props = {
369
+ dangerouslySetInnerHTML: { __html: descriptor.html }
370
+ };
371
+ if (descriptor.id) props.id = descriptor.id;
372
+ return {
373
+ id: descriptor.id ?? null,
374
+ element: createElement("noscript", props)
375
+ };
376
+ }
377
+ }
378
+ }
379
+ function renderBodyDescriptor(descriptor, pluginLabel, index) {
380
+ if (descriptor.type === "iframe") {
381
+ if (!isSafeUrl(descriptor.src)) {
382
+ warn(
383
+ `${pluginLabel}: iframe descriptor #${index} dropped \u2014 unsafe src "${descriptor.src}".`
384
+ );
385
+ return null;
386
+ }
387
+ const props = {
388
+ src: descriptor.src
389
+ };
390
+ if (descriptor.id) props.id = descriptor.id;
391
+ if (descriptor.title) props.title = descriptor.title;
392
+ if (typeof descriptor.width === "number") props.width = descriptor.width;
393
+ if (typeof descriptor.height === "number") props.height = descriptor.height;
394
+ applyAttrs(props, descriptor.attrs, `${pluginLabel} iframe#${descriptor.id ?? index}`);
395
+ return {
396
+ id: descriptor.id ?? null,
397
+ element: createElement("iframe", props)
398
+ };
399
+ }
400
+ return renderHeadDescriptor(descriptor, pluginLabel, index);
401
+ }
402
+ function dedupeAndKey(entries) {
403
+ const lastIndexById = /* @__PURE__ */ new Map();
404
+ for (let i = 0; i < entries.length; i++) {
405
+ const id = entries[i].id;
406
+ if (id === null) continue;
407
+ if (lastIndexById.has(id)) {
408
+ warn(`duplicate descriptor id "${id}" \u2014 keeping the last occurrence.`);
409
+ }
410
+ lastIndexById.set(id, i);
411
+ }
412
+ const kept = [];
413
+ for (let i = 0; i < entries.length; i++) {
414
+ const { id, element } = entries[i];
415
+ if (id !== null && lastIndexById.get(id) !== i) continue;
416
+ const key = id ?? `__pos-${i}`;
417
+ const existingProps = element.props;
418
+ kept.push(createElement(element.type, { ...existingProps, key }));
419
+ }
420
+ return kept;
421
+ }
422
+ function collectFor(plugins, ctx, surface, renderOne) {
423
+ const entries = [];
424
+ for (const plugin of plugins) {
425
+ const factory = surface(plugin);
426
+ if (!factory) continue;
427
+ let descriptors;
428
+ try {
429
+ descriptors = factory.call(plugin, ctx) ?? [];
430
+ } catch (err) {
431
+ warn(
432
+ `plugin "${plugin.instanceId ?? plugin.name}" threw inside descriptor callback: ${err instanceof Error ? err.message : String(err)}`
433
+ );
434
+ continue;
435
+ }
436
+ const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
437
+ for (let i = 0; i < descriptors.length; i++) {
438
+ const entry = renderOne(descriptors[i], label, i);
439
+ if (entry) entries.push(entry);
440
+ }
441
+ }
442
+ if (entries.length === 0) return null;
443
+ const keyed = dedupeAndKey(entries);
444
+ return createElement(Fragment, null, ...keyed);
445
+ }
446
+ function createPluginHead(cmsConfig) {
447
+ const plugins = (cmsConfig.plugins ?? []).filter(isPlugin2);
448
+ const seenNamespaces = /* @__PURE__ */ new Set();
449
+ for (const plugin of plugins) {
450
+ const ns = plugin.instanceId ?? plugin.name;
451
+ const label = plugin.instanceId ? `${plugin.name}#${plugin.instanceId}` : plugin.name;
452
+ if (seenNamespaces.has(ns)) {
453
+ warn(
454
+ `duplicate plugin namespace "${ns}" detected in cms.config.plugins. Set distinct \`instanceId\` on each instance to disambiguate.`
455
+ );
456
+ }
457
+ seenNamespaces.add(ns);
458
+ const caps = plugin.capabilities;
459
+ if (caps) {
460
+ if (caps.includes("publicHead") && !plugin.publicHead) {
461
+ warn(
462
+ `${label}: declares capability "publicHead" but no \`publicHead\` implementation. Drop the capability or add the function.`
463
+ );
464
+ }
465
+ if (caps.includes("publicBody") && !plugin.publicBodyEnd) {
466
+ warn(
467
+ `${label}: declares capability "publicBody" but no \`publicBodyEnd\` implementation. Drop the capability or add the function.`
468
+ );
469
+ }
470
+ if (plugin.publicHead && !caps.includes("publicHead")) {
471
+ warn(
472
+ `${label}: implements \`publicHead\` but "publicHead" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
473
+ );
474
+ }
475
+ if (plugin.publicBodyEnd && !caps.includes("publicBody")) {
476
+ warn(
477
+ `${label}: implements \`publicBodyEnd\` but "publicBody" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
478
+ );
479
+ }
480
+ }
481
+ }
482
+ return {
483
+ renderHead() {
484
+ return collectFor(
485
+ plugins,
486
+ { site: cmsConfig.site },
487
+ (p) => p.publicHead,
488
+ renderHeadDescriptor
489
+ );
490
+ },
491
+ renderBodyEnd() {
492
+ return collectFor(
493
+ plugins,
494
+ { site: cmsConfig.site },
495
+ (p) => p.publicBodyEnd,
496
+ renderBodyDescriptor
497
+ );
498
+ }
499
+ };
500
+ }
501
+
228
502
  // src/theme-active.ts
229
503
  import { headers } from "next/headers";
230
504
  function createThemeActive(registry, storage) {
@@ -657,6 +931,7 @@ function createAmpless(opts) {
657
931
  const media = createMediaApi(outputs);
658
932
  const settings = createSiteSettings(cmsConfig, storage);
659
933
  const seo = createSeo(cmsConfig, settings);
934
+ const pluginHead = createPluginHead(cmsConfig);
660
935
  const themeActive = createThemeActive(themes, storage);
661
936
  const themeConfig = createThemeConfig(themeActive, storage);
662
937
  return {
@@ -670,6 +945,8 @@ function createAmpless(opts) {
670
945
  loadThemeConfig: () => themeConfig.loadThemeConfig(),
671
946
  postMetadata: (post) => seo.postMetadata(post),
672
947
  siteMetadata: () => seo.siteMetadata(),
948
+ publicHead: () => pluginHead.renderHead(),
949
+ publicBodyEnd: () => pluginHead.renderBodyEnd(),
673
950
  renderBody: (post) => renderBody(post),
674
951
  renderThemeCss: (cssVars) => renderThemeCss(cssVars),
675
952
  publicAssetUrl: (key) => storage.publicAssetUrl(key),
@@ -683,7 +960,8 @@ function createAmpless(opts) {
683
960
  seo,
684
961
  themeActive,
685
962
  themeConfig,
686
- storageApi: storage
963
+ storageApi: storage,
964
+ pluginHead
687
965
  };
688
966
  }
689
967
  export {
@@ -691,6 +969,7 @@ export {
691
969
  DEFAULT_COLOR_SCHEME,
692
970
  _resetStreamS3Cache,
693
971
  createAmpless,
972
+ createPluginHead,
694
973
  htmlToMarkdown,
695
974
  markdownToHtml,
696
975
  renderBody,
@@ -1,6 +1,7 @@
1
1
  import { Ampless } from '../index.js';
2
2
  import 'ampless';
3
3
  import 'next';
4
+ import 'react';
4
5
  import 'aws-amplify/storage/server';
5
6
  import 'next/headers';
6
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "1.0.0-alpha.22",
3
+ "version": "1.0.0-alpha.23",
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.16",
53
- "@ampless/plugin-og-image": "0.2.0-alpha.16"
52
+ "ampless": "1.0.0-alpha.17",
53
+ "@ampless/plugin-og-image": "0.2.0-alpha.17"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "next": "^15 || ^16",