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

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,51 @@ interface SeoApi {
122
123
  siteMetadata(): Promise<Metadata>;
123
124
  }
124
125
 
126
+ /**
127
+ * Per-instance flat map of stored field values, keyed by the field's
128
+ * `key` (not the full `plugins.<instanceId>.<key>` SK). The keys
129
+ * here mirror the field manifest so callers can hand the map
130
+ * directly to `resolvePluginSettings`.
131
+ */
132
+ type PluginSettingsSnapshot = Map<string, Record<string, unknown>>;
133
+ interface PluginSettingsApi {
134
+ /**
135
+ * Fetch all `plugins.*` settings from the public cache and bucket
136
+ * them by `instanceId`. Returns an empty Map on any failure mode
137
+ * (storage unconfigured, 404, JSON parse error) — callers fall
138
+ * back to manifest defaults so the layout never crashes when the
139
+ * cache is missing.
140
+ */
141
+ loadAll(): Promise<PluginSettingsSnapshot>;
142
+ }
143
+ declare function createPluginSettings(storage: StorageApi): PluginSettingsApi;
144
+
145
+ interface PluginHeadApi {
146
+ /**
147
+ * React children safe to drop into `<head>`. Async because admin-
148
+ * managed settings are read from S3 on the first call per request.
149
+ * Within a single request both `renderHead` and `renderBodyEnd` share
150
+ * the same fetched snapshot via Next.js fetch dedup on the
151
+ * `site-settings` cache tag.
152
+ */
153
+ renderHead(): Promise<ReactNode>;
154
+ /** React children safe to drop just before `</body>`. */
155
+ renderBodyEnd(): Promise<ReactNode>;
156
+ }
157
+ /**
158
+ * Create the head/body renderer for a `Config`. The constructor-time
159
+ * pass logs a single dev warning when two plugins share an
160
+ * `instanceId ?? name`; everything else happens at render time so
161
+ * descriptors reflect per-request site config.
162
+ *
163
+ * `pluginSettings` (Phase 2) is the runtime accessor that pulls
164
+ * admin-managed `settings.public` values from the S3 site-settings
165
+ * cache. Within a single request we fetch once via `loadAll()` and
166
+ * bind a per-plugin `ctx.setting(key)` accessor before invoking
167
+ * either `publicHead` or `publicBodyEnd`.
168
+ */
169
+ declare function createPluginHead(cmsConfig: Config, pluginSettings: PluginSettingsApi): PluginHeadApi;
170
+
125
171
  interface ThemesRegistry {
126
172
  /** Map of theme name → loaded theme module. */
127
173
  themes: Record<string, ThemeModule>;
@@ -336,6 +382,8 @@ interface Ampless {
336
382
  loadThemeConfig(): Promise<EffectiveThemeConfig>;
337
383
  postMetadata(post: Post): Promise<Metadata>;
338
384
  siteMetadata(): Promise<Metadata>;
385
+ publicHead(): Promise<ReactNode>;
386
+ publicBodyEnd(): Promise<ReactNode>;
339
387
  renderBody(post: Post): string;
340
388
  renderThemeCss(cssVars: Record<string, string>): string;
341
389
  publicAssetUrl(key: string): string;
@@ -350,6 +398,8 @@ interface Ampless {
350
398
  readonly themeActive: ThemeActiveApi;
351
399
  readonly themeConfig: ThemeConfigApi;
352
400
  readonly storageApi: StorageApi;
401
+ readonly pluginHead: PluginHeadApi;
402
+ readonly pluginSettings: PluginSettingsApi;
353
403
  }
354
404
  /**
355
405
  * Wire up the ampless runtime from user-supplied config blobs. The
@@ -360,4 +410,4 @@ interface Ampless {
360
410
  */
361
411
  declare function createAmpless(opts: CreateAmplessOpts): Ampless;
362
412
 
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 };
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 };
package/dist/index.js CHANGED
@@ -225,6 +225,357 @@ 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
+ import {
234
+ isValidPluginKey,
235
+ resolvePluginSettings
236
+ } from "ampless";
237
+ function isPlugin2(p) {
238
+ return typeof p === "object" && p !== null && "apiVersion" in p;
239
+ }
240
+ var ALLOWED_ATTRS = /* @__PURE__ */ new Set([
241
+ "crossorigin",
242
+ "referrerpolicy",
243
+ "integrity",
244
+ "fetchpriority",
245
+ // `nonce` is intentionally NOT in the allowlist for Phase 1. CSP
246
+ // nonce propagation is scoped out of Phase 1 (see spec §7); attrs
247
+ // shouldn't let plugins smuggle nonces past the design decision.
248
+ // The CSP nonce RFP will reintroduce it through `cspNonce` on
249
+ // PluginPublicRenderContext + `inlineScript.nonce: 'auto'`, not via
250
+ // the `attrs` bag.
251
+ "loading",
252
+ // iframe lazy-loading
253
+ "sandbox",
254
+ // iframe sandbox attribute
255
+ "allow",
256
+ // iframe permissions policy
257
+ "allowfullscreen"
258
+ // iframe fullscreen
259
+ ]);
260
+ function isAllowedAttr(name) {
261
+ if (name.startsWith("data-")) return true;
262
+ return ALLOWED_ATTRS.has(name.toLowerCase());
263
+ }
264
+ function isSafeUrl(value) {
265
+ const trimmed = value.trim();
266
+ if (trimmed.length === 0) return false;
267
+ const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
268
+ if (!schemeMatch) return true;
269
+ const scheme = schemeMatch[1].toLowerCase();
270
+ return scheme === "http" || scheme === "https";
271
+ }
272
+ function isDev() {
273
+ const env = typeof process !== "undefined" && process.env ? process.env.NODE_ENV : void 0;
274
+ return env !== "production";
275
+ }
276
+ function warn(message) {
277
+ if (!isDev()) return;
278
+ console.warn(`[ampless plugin-head] ${message}`);
279
+ }
280
+ function applyAttrs(target, attrs, ownerLabel) {
281
+ if (!attrs) return;
282
+ for (const [k, v] of Object.entries(attrs)) {
283
+ if (!isAllowedAttr(k)) {
284
+ warn(
285
+ `${ownerLabel}: attr "${k}" not in allowlist (data-* / crossorigin / referrerpolicy / integrity / fetchpriority / loading / sandbox / allow / allowfullscreen). skipping.`
286
+ );
287
+ continue;
288
+ }
289
+ target[k] = v;
290
+ }
291
+ }
292
+ function renderHeadDescriptor(descriptor, pluginLabel, index) {
293
+ switch (descriptor.type) {
294
+ case "script": {
295
+ if (!isSafeUrl(descriptor.src)) {
296
+ warn(
297
+ `${pluginLabel}: script descriptor #${index} dropped \u2014 unsafe src "${descriptor.src}".`
298
+ );
299
+ return null;
300
+ }
301
+ const props = {
302
+ src: descriptor.src
303
+ };
304
+ if (descriptor.id) props.id = descriptor.id;
305
+ const hasAsync = typeof descriptor.async === "boolean";
306
+ const hasDefer = typeof descriptor.defer === "boolean";
307
+ if (hasAsync) props.async = descriptor.async;
308
+ if (hasDefer) props.defer = descriptor.defer;
309
+ if (!hasAsync && !hasDefer) {
310
+ if (descriptor.strategy === "lazyOnload") {
311
+ props.defer = true;
312
+ } else {
313
+ props.async = true;
314
+ }
315
+ }
316
+ applyAttrs(props, descriptor.attrs, `${pluginLabel} script#${descriptor.id ?? index}`);
317
+ return {
318
+ id: descriptor.id ?? null,
319
+ element: createElement("script", props)
320
+ };
321
+ }
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
+ }
338
+ case "meta": {
339
+ const props = { content: descriptor.content };
340
+ if (descriptor.name) props.name = descriptor.name;
341
+ if (descriptor.property) props.property = descriptor.property;
342
+ return {
343
+ // meta has no id channel in the descriptor. Don't derive a
344
+ // dedup id from name/property — multiple `<meta name=...>`
345
+ // entries with the same name are legitimate (e.g. theme-color
346
+ // media variants, and two plugins emitting overlapping names
347
+ // is a real case the runtime shouldn't silently collapse).
348
+ // Position-based React keys handle the stable-key requirement.
349
+ id: null,
350
+ element: createElement("meta", props)
351
+ };
352
+ }
353
+ case "link": {
354
+ if (!isSafeUrl(descriptor.href)) {
355
+ warn(
356
+ `${pluginLabel}: link descriptor #${index} dropped \u2014 unsafe href "${descriptor.href}".`
357
+ );
358
+ return null;
359
+ }
360
+ const props = {
361
+ rel: descriptor.rel,
362
+ href: descriptor.href
363
+ };
364
+ if (descriptor.as) props.as = descriptor.as;
365
+ if (descriptor.typeAttr) props.type = descriptor.typeAttr;
366
+ return {
367
+ id: null,
368
+ element: createElement("link", props)
369
+ };
370
+ }
371
+ case "noscript": {
372
+ const props = {
373
+ dangerouslySetInnerHTML: { __html: descriptor.html }
374
+ };
375
+ if (descriptor.id) props.id = descriptor.id;
376
+ return {
377
+ id: descriptor.id ?? null,
378
+ element: createElement("noscript", props)
379
+ };
380
+ }
381
+ }
382
+ }
383
+ function renderBodyDescriptor(descriptor, pluginLabel, index) {
384
+ if (descriptor.type === "iframe") {
385
+ if (!isSafeUrl(descriptor.src)) {
386
+ warn(
387
+ `${pluginLabel}: iframe descriptor #${index} dropped \u2014 unsafe src "${descriptor.src}".`
388
+ );
389
+ return null;
390
+ }
391
+ const props = {
392
+ src: descriptor.src
393
+ };
394
+ if (descriptor.id) props.id = descriptor.id;
395
+ if (descriptor.title) props.title = descriptor.title;
396
+ if (typeof descriptor.width === "number") props.width = descriptor.width;
397
+ if (typeof descriptor.height === "number") props.height = descriptor.height;
398
+ applyAttrs(props, descriptor.attrs, `${pluginLabel} iframe#${descriptor.id ?? index}`);
399
+ return {
400
+ id: descriptor.id ?? null,
401
+ element: createElement("iframe", props)
402
+ };
403
+ }
404
+ return renderHeadDescriptor(descriptor, pluginLabel, index);
405
+ }
406
+ function dedupeAndKey(entries) {
407
+ const lastIndexById = /* @__PURE__ */ new Map();
408
+ for (let i = 0; i < entries.length; i++) {
409
+ const id = entries[i].id;
410
+ if (id === null) continue;
411
+ if (lastIndexById.has(id)) {
412
+ warn(`duplicate descriptor id "${id}" \u2014 keeping the last occurrence.`);
413
+ }
414
+ lastIndexById.set(id, i);
415
+ }
416
+ const kept = [];
417
+ for (let i = 0; i < entries.length; i++) {
418
+ const { id, element } = entries[i];
419
+ if (id !== null && lastIndexById.get(id) !== i) continue;
420
+ const key = id ?? `__pos-${i}`;
421
+ const existingProps = element.props;
422
+ kept.push(createElement(element.type, { ...existingProps, key }));
423
+ }
424
+ return kept;
425
+ }
426
+ function makeCtx(plugin, site, snapshot) {
427
+ const instanceId = plugin.instanceId ?? plugin.name;
428
+ const stored = snapshot.get(instanceId) ?? {};
429
+ const resolved = resolvePluginSettings(plugin.settings, stored);
430
+ return {
431
+ site,
432
+ setting(key) {
433
+ if (!isValidPluginKey(key)) return void 0;
434
+ const v = resolved[key];
435
+ return v === void 0 ? void 0 : v;
436
+ }
437
+ };
438
+ }
439
+ function collectFor(plugins, site, snapshot, surface, renderOne) {
440
+ const entries = [];
441
+ for (const plugin of plugins) {
442
+ const factory = surface(plugin);
443
+ if (!factory) continue;
444
+ const ctx = makeCtx(plugin, site, snapshot);
445
+ let descriptors;
446
+ try {
447
+ descriptors = factory.call(plugin, ctx) ?? [];
448
+ } catch (err) {
449
+ warn(
450
+ `plugin "${plugin.instanceId ?? plugin.name}" threw inside descriptor callback: ${err instanceof Error ? err.message : String(err)}`
451
+ );
452
+ continue;
453
+ }
454
+ const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
455
+ for (let i = 0; i < descriptors.length; i++) {
456
+ const entry = renderOne(descriptors[i], label, i);
457
+ if (entry) entries.push(entry);
458
+ }
459
+ }
460
+ if (entries.length === 0) return null;
461
+ const keyed = dedupeAndKey(entries);
462
+ return createElement(Fragment, null, ...keyed);
463
+ }
464
+ function createPluginHead(cmsConfig, pluginSettings) {
465
+ const plugins = (cmsConfig.plugins ?? []).filter(isPlugin2);
466
+ const validPlugins = [];
467
+ const seenNamespaces = /* @__PURE__ */ new Set();
468
+ for (const plugin of plugins) {
469
+ const ns = plugin.instanceId ?? plugin.name;
470
+ const label = plugin.instanceId ? `${plugin.name}#${plugin.instanceId}` : plugin.name;
471
+ if (!isValidPluginKey(ns)) {
472
+ warn(
473
+ `${label}: plugin namespace "${ns}" violates ${`/^[a-zA-Z0-9_-]+$/`}. Use a simple identifier (letters / digits / "-" / "_"). Skipping plugin.`
474
+ );
475
+ continue;
476
+ }
477
+ if (seenNamespaces.has(ns)) {
478
+ warn(
479
+ `duplicate plugin namespace "${ns}" detected in cms.config.plugins. Set distinct \`instanceId\` on each instance to disambiguate.`
480
+ );
481
+ }
482
+ seenNamespaces.add(ns);
483
+ if (plugin.settings?.public) {
484
+ for (const field of plugin.settings.public) {
485
+ if (!isValidPluginKey(field.key)) {
486
+ warn(
487
+ `${label}: settings.public field key "${field.key}" violates ${`/^[a-zA-Z0-9_-]+$/`}. The field will not be readable through ctx.setting(). Rename the field key.`
488
+ );
489
+ }
490
+ }
491
+ }
492
+ validPlugins.push(plugin);
493
+ const caps = plugin.capabilities;
494
+ if (caps) {
495
+ if (caps.includes("publicHead") && !plugin.publicHead) {
496
+ warn(
497
+ `${label}: declares capability "publicHead" but no \`publicHead\` implementation. Drop the capability or add the function.`
498
+ );
499
+ }
500
+ if (caps.includes("publicBody") && !plugin.publicBodyEnd) {
501
+ warn(
502
+ `${label}: declares capability "publicBody" but no \`publicBodyEnd\` implementation. Drop the capability or add the function.`
503
+ );
504
+ }
505
+ if (plugin.publicHead && !caps.includes("publicHead")) {
506
+ warn(
507
+ `${label}: implements \`publicHead\` but "publicHead" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
508
+ );
509
+ }
510
+ if (plugin.publicBodyEnd && !caps.includes("publicBody")) {
511
+ warn(
512
+ `${label}: implements \`publicBodyEnd\` but "publicBody" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
513
+ );
514
+ }
515
+ }
516
+ }
517
+ return {
518
+ async renderHead() {
519
+ const snapshot = await pluginSettings.loadAll();
520
+ return collectFor(
521
+ validPlugins,
522
+ cmsConfig.site,
523
+ snapshot,
524
+ (p) => p.publicHead,
525
+ renderHeadDescriptor
526
+ );
527
+ },
528
+ async renderBodyEnd() {
529
+ const snapshot = await pluginSettings.loadAll();
530
+ return collectFor(
531
+ validPlugins,
532
+ cmsConfig.site,
533
+ snapshot,
534
+ (p) => p.publicBodyEnd,
535
+ renderBodyDescriptor
536
+ );
537
+ }
538
+ };
539
+ }
540
+
541
+ // src/plugin-settings.ts
542
+ import { unflattenSettings as unflattenSettings2 } from "ampless";
543
+ function createPluginSettings(storage) {
544
+ return {
545
+ async loadAll() {
546
+ const out = /* @__PURE__ */ new Map();
547
+ if (!storage.isStorageConfigured()) return out;
548
+ let url;
549
+ try {
550
+ url = storage.publicAssetUrl("public/site-settings.json");
551
+ } catch {
552
+ return out;
553
+ }
554
+ let flat;
555
+ try {
556
+ const res = await fetch(url, {
557
+ // Same revalidate + tag as site-settings.ts so a single
558
+ // request to the public route hits the JSON once and both
559
+ // helpers share the cached body.
560
+ next: { revalidate: 60, tags: ["site-settings"] }
561
+ });
562
+ if (!res.ok) return out;
563
+ flat = await res.json();
564
+ } catch {
565
+ return out;
566
+ }
567
+ const nested = unflattenSettings2(flat);
568
+ const pluginsBlock = nested.plugins;
569
+ if (!pluginsBlock || typeof pluginsBlock !== "object") return out;
570
+ for (const [instanceId, block] of Object.entries(pluginsBlock)) {
571
+ if (!block || typeof block !== "object" || Array.isArray(block)) continue;
572
+ out.set(instanceId, { ...block });
573
+ }
574
+ return out;
575
+ }
576
+ };
577
+ }
578
+
228
579
  // src/theme-active.ts
229
580
  import { headers } from "next/headers";
230
581
  function createThemeActive(registry, storage) {
@@ -657,6 +1008,8 @@ function createAmpless(opts) {
657
1008
  const media = createMediaApi(outputs);
658
1009
  const settings = createSiteSettings(cmsConfig, storage);
659
1010
  const seo = createSeo(cmsConfig, settings);
1011
+ const pluginSettings = createPluginSettings(storage);
1012
+ const pluginHead = createPluginHead(cmsConfig, pluginSettings);
660
1013
  const themeActive = createThemeActive(themes, storage);
661
1014
  const themeConfig = createThemeConfig(themeActive, storage);
662
1015
  return {
@@ -670,6 +1023,8 @@ function createAmpless(opts) {
670
1023
  loadThemeConfig: () => themeConfig.loadThemeConfig(),
671
1024
  postMetadata: (post) => seo.postMetadata(post),
672
1025
  siteMetadata: () => seo.siteMetadata(),
1026
+ publicHead: () => pluginHead.renderHead(),
1027
+ publicBodyEnd: () => pluginHead.renderBodyEnd(),
673
1028
  renderBody: (post) => renderBody(post),
674
1029
  renderThemeCss: (cssVars) => renderThemeCss(cssVars),
675
1030
  publicAssetUrl: (key) => storage.publicAssetUrl(key),
@@ -683,7 +1038,9 @@ function createAmpless(opts) {
683
1038
  seo,
684
1039
  themeActive,
685
1040
  themeConfig,
686
- storageApi: storage
1041
+ storageApi: storage,
1042
+ pluginHead,
1043
+ pluginSettings
687
1044
  };
688
1045
  }
689
1046
  export {
@@ -691,6 +1048,8 @@ export {
691
1048
  DEFAULT_COLOR_SCHEME,
692
1049
  _resetStreamS3Cache,
693
1050
  createAmpless,
1051
+ createPluginHead,
1052
+ createPluginSettings,
694
1053
  htmlToMarkdown,
695
1054
  markdownToHtml,
696
1055
  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.24",
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.18",
53
+ "@ampless/plugin-og-image": "0.2.0-alpha.18"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "next": "^15 || ^16",