@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 +20 -2
- package/dist/index.js +280 -1
- package/dist/routes/index.d.ts +1 -0
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Post, MediaMetadata, ThemeModule, ThemeManifest
|
|
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,
|
package/dist/routes/index.d.ts
CHANGED
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.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.
|
|
53
|
-
"@ampless/plugin-og-image": "0.2.0-alpha.
|
|
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",
|