@jini-ai/cms 0.5.5 → 0.5.7

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.
Files changed (138) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/consent/express/index.d.ts +53 -0
  3. package/dist/consent/express/index.js +159 -0
  4. package/dist/consent/express/response.d.ts +21 -0
  5. package/dist/consent/express/response.js +89 -0
  6. package/dist/consent/html/client.d.ts +6 -0
  7. package/dist/consent/html/client.js +135 -0
  8. package/dist/consent/html/gate.d.ts +29 -0
  9. package/dist/consent/html/gate.js +104 -0
  10. package/dist/consent/html/index.d.ts +4 -0
  11. package/dist/consent/html/index.js +3 -0
  12. package/dist/consent/html/render.d.ts +27 -0
  13. package/dist/consent/html/render.js +62 -0
  14. package/dist/consent/html/static.d.ts +7 -0
  15. package/dist/consent/html/static.js +25 -0
  16. package/dist/consent/html/styles.d.ts +3 -0
  17. package/dist/consent/html/styles.js +22 -0
  18. package/dist/consent/index.d.ts +3 -0
  19. package/dist/consent/index.js +3 -0
  20. package/dist/consent/node/index.d.ts +47 -0
  21. package/dist/consent/node/index.js +169 -0
  22. package/dist/consent/policy.d.ts +47 -0
  23. package/dist/consent/policy.js +62 -0
  24. package/dist/consent/types.d.ts +40 -0
  25. package/dist/consent/types.js +5 -0
  26. package/dist/content-types/agent-tools.js +1 -1
  27. package/dist/content-types/tool-registrations.d.ts +2 -0
  28. package/dist/content-types/tool-registrations.js +1 -0
  29. package/dist/content-types/write-service.d.ts +2 -0
  30. package/dist/content-types/write-service.js +3 -0
  31. package/dist/forms/descriptor-rules.d.ts +9 -0
  32. package/dist/forms/descriptor-rules.js +112 -0
  33. package/dist/forms/email-format.d.ts +7 -0
  34. package/dist/forms/email-format.js +11 -0
  35. package/dist/forms/express/handler.js +4 -8
  36. package/dist/forms/field-registry.d.ts +193 -0
  37. package/dist/forms/field-registry.js +231 -0
  38. package/dist/forms/forms.d.ts +8 -5
  39. package/dist/forms/forms.js +29 -46
  40. package/dist/forms/html/html-authoring.js +63 -13
  41. package/dist/forms/html/html-submission-adapter.d.ts +5 -5
  42. package/dist/forms/html/html-submission-adapter.js +6 -13
  43. package/dist/forms/html-render.d.ts +10 -3
  44. package/dist/forms/html-render.js +116 -14
  45. package/dist/forms/index.d.ts +1 -0
  46. package/dist/forms/index.js +1 -0
  47. package/dist/forms/repo.memory.js +4 -4
  48. package/dist/forms/submission-attempts.d.ts +1 -1
  49. package/dist/forms/submission-attempts.js +13 -2
  50. package/dist/forms/submission-email-validation.js +2 -4
  51. package/dist/forms/submit-service.d.ts +3 -0
  52. package/dist/forms/submit-service.js +38 -5
  53. package/dist/forms/types.d.ts +50 -2
  54. package/dist/listing/batch.d.ts +53 -0
  55. package/dist/listing/batch.js +57 -0
  56. package/dist/listing/html.d.ts +72 -0
  57. package/dist/listing/html.js +109 -0
  58. package/dist/listing/index.d.ts +5 -0
  59. package/dist/listing/index.js +5 -0
  60. package/dist/listing/paths.d.ts +32 -0
  61. package/dist/listing/paths.js +37 -0
  62. package/dist/listing/see-more-script.d.ts +16 -0
  63. package/dist/listing/see-more-script.js +62 -0
  64. package/dist/media/content-type-sniffer.d.ts +1 -1
  65. package/dist/media/content-type-sniffer.js +15 -0
  66. package/dist/media/image-transformer.sharp.js +4 -1
  67. package/dist/media/media-service.d.ts +5 -3
  68. package/dist/media/media-service.js +10 -26
  69. package/dist/media/node.d.ts +1 -1
  70. package/dist/media/node.js +1 -1
  71. package/dist/media/slug.d.ts +8 -0
  72. package/dist/media/slug.js +32 -0
  73. package/dist/media/transform-registry.js +1 -1
  74. package/dist/media/transform-types.d.ts +2 -0
  75. package/dist/media/transform-types.js +3 -0
  76. package/dist/media/video-frames.ffmpeg.d.ts +15 -2
  77. package/dist/media/video-frames.ffmpeg.js +28 -13
  78. package/dist/navigation/dropdown.d.ts +41 -0
  79. package/dist/navigation/dropdown.js +85 -0
  80. package/dist/navigation/index.d.ts +2 -0
  81. package/dist/navigation/index.js +2 -0
  82. package/dist/redirects/capture.d.ts +2 -0
  83. package/dist/redirects/capture.js +4 -0
  84. package/dist/redirects/redirects.d.ts +8 -0
  85. package/dist/redirects/redirects.js +14 -0
  86. package/dist/routing/index.d.ts +5 -0
  87. package/dist/routing/index.js +2 -0
  88. package/dist/routing/ports.d.ts +51 -0
  89. package/dist/routing/ports.js +2 -0
  90. package/dist/routing/routing.d.ts +138 -0
  91. package/dist/routing/routing.js +443 -0
  92. package/dist/routing/types.d.ts +164 -0
  93. package/dist/routing/types.js +2 -0
  94. package/dist/seo/media.d.ts +23 -0
  95. package/dist/seo/media.js +30 -5
  96. package/dist/seo/page-head.js +8 -1
  97. package/dist/seo/seo.d.ts +2 -0
  98. package/dist/seo/seo.js +11 -6
  99. package/dist/seo/types.d.ts +8 -0
  100. package/dist/seo/xml-escape.d.ts +1 -3
  101. package/dist/seo/xml-escape.js +1 -6
  102. package/dist/widgets/concurrency.d.ts +27 -1
  103. package/dist/widgets/concurrency.js +12 -23
  104. package/dist/widgets/config-validation.js +6 -0
  105. package/dist/widgets/gallery-resolver.d.ts +23 -0
  106. package/dist/widgets/gallery-resolver.js +80 -0
  107. package/dist/widgets/html/gallery-render.d.ts +24 -0
  108. package/dist/widgets/html/gallery-render.js +65 -0
  109. package/dist/widgets/html/html-embeds.d.ts +4 -0
  110. package/dist/widgets/html/html-embeds.js +7 -1
  111. package/dist/widgets/html/index.d.ts +3 -0
  112. package/dist/widgets/html/index.js +3 -0
  113. package/dist/widgets/html/server-fragment.d.ts +9 -0
  114. package/dist/widgets/html/server-fragment.js +38 -0
  115. package/dist/widgets/markers/gallery.d.ts +50 -0
  116. package/dist/widgets/markers/gallery.js +164 -0
  117. package/dist/widgets/markers/html-attributes.d.ts +6 -1
  118. package/dist/widgets/markers/html-attributes.js +24 -9
  119. package/dist/widgets/markers/index.d.ts +2 -0
  120. package/dist/widgets/markers/index.js +2 -0
  121. package/dist/widgets/markers/marker.d.ts +4 -0
  122. package/dist/widgets/markers/marker.js +6 -0
  123. package/dist/widgets/markers/media-reference.d.ts +11 -0
  124. package/dist/widgets/markers/media-reference.js +11 -0
  125. package/dist/widgets/page-embed-types.d.ts +1 -1
  126. package/dist/widgets/page-embed-types.js +1 -1
  127. package/dist/widgets/registry.d.ts +144 -10
  128. package/dist/widgets/registry.js +40 -0
  129. package/dist/widgets/resolver-service.d.ts +10 -0
  130. package/dist/widgets/resolver-service.js +73 -29
  131. package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
  132. package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
  133. package/dist/widgets/resolvers/gallery.d.ts +12 -0
  134. package/dist/widgets/resolvers/gallery.js +33 -0
  135. package/dist/widgets/resolvers/index.d.ts +1 -0
  136. package/dist/widgets/resolvers/index.js +1 -0
  137. package/dist/widgets/types.d.ts +3 -1
  138. package/package.json +100 -7
@@ -0,0 +1,65 @@
1
+ import { escapeHtml } from '@jini-ai/core/text';
2
+ import { parseEmbedHtmlAttributes } from '../markers/html-attributes.js';
3
+ import { parseGalleryConfig } from '../markers/gallery.js';
4
+ /** Layout-only baseline; host themes override jini classes/custom properties without palette coupling. */
5
+ export const GALLERY_STYLE = `<style data-jini-gallery-style>
6
+ .jini-gallery{gap:var(--jini-gallery-gap,1rem)}
7
+ .jini-gallery-item{margin:0;min-width:0;break-inside:avoid}
8
+ .jini-gallery audio{display:block;width:100%}
9
+ .jini-gallery img,.jini-gallery video{display:block;width:100%;height:auto;aspect-ratio:var(--jini-gallery-ratio,auto);object-fit:cover}
10
+ .jini-gallery-single{display:grid;grid-template-columns:1fr}
11
+ .jini-gallery-grid{display:grid;grid-template-columns:repeat(var(--jini-gallery-columns,3),minmax(0,1fr))}
12
+ .jini-gallery-masonry{columns:var(--jini-gallery-columns,3);column-gap:var(--jini-gallery-gap,1rem)}
13
+ .jini-gallery-masonry .jini-gallery-item{margin-block-end:var(--jini-gallery-gap,1rem)}
14
+ .jini-gallery-carousel{display:flex;overflow-x:auto;scroll-snap-type:x mandatory}
15
+ .jini-gallery-carousel .jini-gallery-item{flex:0 0 min(85%,36rem);scroll-snap-align:start}
16
+ @media(max-width:48rem){.jini-gallery-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.jini-gallery-masonry{columns:2}}
17
+ @media(max-width:30rem){.jini-gallery-grid{grid-template-columns:1fr}.jini-gallery-masonry{columns:1}}
18
+ </style>`;
19
+ /** Render one ordered figure using the host's established image renderer and alt fallback.
20
+ * Missing alt is flagged in markup as well as validation; an explicit empty alt stays decorative.
21
+ */
22
+ function renderFigure({ image, media, renderImage }) {
23
+ if ((image.slug !== undefined || image.mediaId !== undefined) && !media)
24
+ return '';
25
+ const storedAlt = typeof media?.alt === 'string' && media.alt.length > 0 ? media.alt : undefined;
26
+ const alt = image.alt ?? storedAlt;
27
+ const missingAlt = alt === undefined ? ' data-jini-alt-missing="true"' : '';
28
+ const markup = renderImage({ image: { ...image, alt: alt ?? '' }, media });
29
+ const caption = image.caption === undefined ? '' : `<figcaption>${escapeHtml({ text: image.caption })}</figcaption>`;
30
+ return `<figure class="jini-gallery-item"${missingAlt}>${markup}${caption}</figure>`;
31
+ }
32
+ /** Server-render accessible gallery markup. Image URLs, metadata and srcset belong to the host
33
+ * image pipeline supplied as a port, never templated here. Invalid config produces no output.
34
+ * @complexity O(n) time and output space for at most 50 images, without I/O.
35
+ */
36
+ export function renderGallery({ config, media, renderImage, renderRawHtml }, _optional = {}) {
37
+ const parsed = parseGalleryConfig({ config }, { allowEmpty: true });
38
+ if (!parsed.config)
39
+ return '';
40
+ const gallery = parsed.config;
41
+ const records = typeof media === 'object' && media !== null && !Array.isArray(media) ? media : {};
42
+ if (gallery.mode === 'html')
43
+ return renderRawHtml?.({ html: gallery.html ?? '', media: records }) ?? '';
44
+ // Media-mode configs come from the Media widget editor; naming the Gallery editor there misleads.
45
+ if (gallery.images.length === 0)
46
+ return `<div class="jini-gallery jini-gallery-empty" role="note">${gallery.mode === 'media' ? 'Choose media in the Media widget editor.' : 'Choose images in the Gallery widget editor.'}</div>`;
47
+ const figures = gallery.images.map(image => renderFigure({ image, media: (image.slug ?? image.mediaId) !== undefined && Object.hasOwn(records, (image.slug ?? image.mediaId)) ? records[(image.slug ?? image.mediaId)] : undefined, renderImage })).join('');
48
+ const ratio = gallery.aspectRatio === undefined ? '' : `;--jini-gallery-ratio:${gallery.aspectRatio}`;
49
+ // TODO: bind data-jini-lightbox through a host theme's lightbox script slot when one exists.
50
+ // Until then the gallery stays readable and keyboard-scrollable without any client script.
51
+ const attributes = {
52
+ role: 'region', 'aria-label': gallery.mode === 'media' ? 'Media' : 'Image gallery',
53
+ ...(gallery.layout === 'carousel' ? { tabindex: '0', 'aria-roledescription': 'carousel' } : {}),
54
+ ...(gallery.lightbox === true ? { 'data-jini-lightbox': 'true' } : {}),
55
+ ...parseEmbedHtmlAttributes({ text: gallery.htmlAttributes ?? '' }).attributes,
56
+ };
57
+ const extraClass = [gallery.cssClass, attributes.class].filter(Boolean).join(' ');
58
+ delete attributes.class;
59
+ // Layout styles remain the base; authored safe style declarations override them in order.
60
+ const authoredStyle = attributes.style;
61
+ delete attributes.style;
62
+ const extra = Object.entries(attributes).map(([name, value]) => ` ${name}="${escapeHtml({ text: value })}"`).join('');
63
+ return GALLERY_STYLE + `<div class="jini-gallery jini-gallery-${gallery.layout}${extraClass ? ` ${escapeHtml({ text: extraClass })}` : ''}"${extra} style="--jini-gallery-columns:${gallery.columns ?? 3}${ratio}${authoredStyle ? `;${escapeHtml({ text: authoredStyle })}` : ''}">${figures}</div>`;
64
+ }
65
+ //# sourceMappingURL=gallery-render.js.map
@@ -107,6 +107,8 @@ import { type MarkerAttribute } from "../markers/marker.js";
107
107
  * — this interface is the compatibility seam, not a ceiling.
108
108
  */
109
109
  export interface PageHtmlEmbedRef {
110
+ /** Occurrence identity for placement overrides sharing one saved widget. */
111
+ readonly placementKey?: string;
110
112
  readonly type: string;
111
113
  readonly id: string | null;
112
114
  /** See this file's header for why this is NOT the same mechanism as `name` below, despite the
@@ -129,6 +131,8 @@ export interface PageHtmlEmbedRef {
129
131
  */
130
132
  readonly header: boolean;
131
133
  readonly mode?: "html";
134
+ /** Full inline configuration for compound embeds; existing scalar projections remain stable. */
135
+ readonly config?: Readonly<Record<string, unknown>>;
132
136
  }
133
137
  /**
134
138
  * Resource bound (Programmer workflow step 5a4) mirroring `embed-service.ts`'s
@@ -156,12 +156,18 @@ function configBooleanDefaultTrue(config, key) {
156
156
  function toEmbedRef(marker) {
157
157
  return {
158
158
  type: marker.type.toLowerCase(),
159
- id: normalizeEmbedId(configString(marker.config, "id")),
159
+ // Only saved widget overlays use placement keys. Inline Media content already has its own
160
+ // occurrence id below; a second key would make rendering miss the resolver's media:<n> result.
161
+ ...(marker.type.toLowerCase() === "widget" && marker.config.content !== undefined ? { placementKey: `placement:${marker.occurrence}` } : {}),
162
+ // Inline galleries have no target entity. Key by occurrence so repeated assets with different
163
+ // layouts/captions cannot overwrite each other's resolved IR, even with an authored id.
164
+ id: (marker.type.toLowerCase() === "gallery" || (marker.type.toLowerCase() === "media" && (marker.config.content !== undefined || marker.config.items !== undefined || marker.config.html !== undefined))) ? `${marker.type.toLowerCase()}:${marker.occurrence}` : normalizeEmbedId(configString(marker.config, "id")),
160
165
  slug: normalizeEmbedSlug(configString(marker.config, "slug")),
161
166
  name: configString(marker.config, "name"),
162
167
  variant: configString(marker.config, "variant"),
163
168
  header: configBooleanDefaultTrue(marker.config, "header"),
164
169
  ...(marker.config.mode === "html" ? { mode: "html" } : {}),
170
+ config: marker.config,
165
171
  };
166
172
  }
167
173
  /**
@@ -1,5 +1,8 @@
1
+ export { resolveGalleryWidgetMedia } from "../resolver-service.js";
1
2
  export * from "./html-embeds.js";
2
3
  export * from "./legacy-widget-markers.js";
4
+ export * from "./gallery-render.js";
3
5
  export * from "./resolve-html-page-embeds.js";
4
6
  export type { ResolveHtmlPageEmbedsDeps } from "../resolver-service.js";
7
+ export * from './server-fragment.js';
5
8
  //# sourceMappingURL=index.d.ts.map
@@ -1,4 +1,7 @@
1
+ export { resolveGalleryWidgetMedia } from "../resolver-service.js";
1
2
  export * from "./html-embeds.js";
2
3
  export * from "./legacy-widget-markers.js";
4
+ export * from "./gallery-render.js";
3
5
  export * from "./resolve-html-page-embeds.js";
6
+ export * from './server-fragment.js';
4
7
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,9 @@
1
+ /** Reject executable/unsupported markup rather than quietly preserving a plugin's unsafe output.
2
+ * URLs are local or HTTP(S); form submission remains native. No inline style, events, SVG,
3
+ * iframe, script, srcdoc, data URLs, or enhancement assets are admitted. */
4
+ export declare function validateServerWidgetFragment(required: {
5
+ html: string;
6
+ }, optional?: {
7
+ maxBytes?: number;
8
+ }): string;
9
+ //# sourceMappingURL=server-fragment.d.ts.map
@@ -0,0 +1,38 @@
1
+ /** Public plugin fragments are HTML, never same-origin script (ADR-025). Use the existing
2
+ * HTML parser, not regex sanitization: decoded attributes and foreign namespaces matter. */
3
+ import { parseFragment, serialize } from 'parse5';
4
+ const ELEMENTS = new Set(['div', 'span', 'p', 'br', 'hr', 'strong', 'em', 'b', 'i', 'small', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code', 'a', 'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'section', 'article', 'aside', 'header', 'footer', 'nav', 'form', 'label', 'input', 'textarea', 'select', 'option', 'button']);
5
+ const ATTRIBUTES = new Set(['id', 'class', 'title', 'role', 'href', 'src', 'alt', 'width', 'height', 'action', 'method', 'name', 'value', 'type', 'required', 'disabled', 'checked', 'selected', 'multiple', 'placeholder', 'for', 'rows', 'cols', 'min', 'max', 'step', 'colspan', 'rowspan']);
6
+ /** Reject executable/unsupported markup rather than quietly preserving a plugin's unsafe output.
7
+ * URLs are local or HTTP(S); form submission remains native. No inline style, events, SVG,
8
+ * iframe, script, srcdoc, data URLs, or enhancement assets are admitted. */
9
+ export function validateServerWidgetFragment(required, optional = {}) {
10
+ if (new TextEncoder().encode(required.html).byteLength > (optional.maxBytes ?? 65536))
11
+ throw new Error('widget HTML exceeds its size cap');
12
+ const root = parseFragment(required.html);
13
+ const visit = (node) => {
14
+ if ('tagName' in node) {
15
+ if (node.namespaceURI !== 'http://www.w3.org/1999/xhtml' || !ELEMENTS.has(node.tagName))
16
+ throw new Error('executable or unsupported widget element');
17
+ for (const attr of node.attrs) {
18
+ if (attr.namespace || (!ATTRIBUTES.has(attr.name) && !/^aria-[a-z-]+$/.test(attr.name)))
19
+ throw new Error('executable or unsupported widget attribute');
20
+ if (['href', 'src', 'action'].includes(attr.name)) {
21
+ // URL parsing catches entity-decoded schemes and embedded tab/newline obfuscation.
22
+ const value = attr.value;
23
+ if (/[\u0000-\u0020\\]/.test(value) || value.startsWith('//'))
24
+ throw new Error('unsafe widget URL');
25
+ const url = new URL(value, 'https://widget.invalid/');
26
+ if (!['http:', 'https:'].includes(url.protocol))
27
+ throw new Error('unsafe widget URL');
28
+ }
29
+ }
30
+ for (const child of node.childNodes)
31
+ visit(child);
32
+ }
33
+ };
34
+ for (const node of root.childNodes)
35
+ visit(node);
36
+ return serialize(root);
37
+ }
38
+ //# sourceMappingURL=server-fragment.js.map
@@ -0,0 +1,50 @@
1
+ /** Inline gallery configuration; media resolution remains owned by the media embed pipeline. */
2
+ export interface GalleryImage {
3
+ readonly mediaId?: string;
4
+ readonly slug?: string;
5
+ readonly src?: string;
6
+ readonly alt?: string;
7
+ readonly caption?: string;
8
+ }
9
+ export interface GalleryConfig {
10
+ readonly images: readonly GalleryImage[];
11
+ readonly layout: 'single' | 'grid' | 'carousel' | 'masonry';
12
+ readonly mode?: 'media' | 'html';
13
+ readonly html?: string;
14
+ readonly htmlAttributes?: string;
15
+ readonly cssClass?: string;
16
+ readonly columns?: number;
17
+ readonly aspectRatio?: string;
18
+ readonly lightbox?: boolean;
19
+ }
20
+ export declare const MAX_GALLERY_IMAGES = 50;
21
+ /** Pure validation result, with missing-alt warnings separate from fatal configuration errors.
22
+ * Explicit alt="" denotes decorative content. Unknown keys remain available to host authoring.
23
+ * @complexity O(n) time and space for at most 50 images; oversized arrays are rejected before walking.
24
+ */
25
+ export declare function parseGalleryConfig({ config }: {
26
+ config: unknown;
27
+ }, { allowEmpty }?: {
28
+ allowEmpty?: boolean;
29
+ }): {
30
+ config?: GalleryConfig;
31
+ errors: string[];
32
+ warnings: string[];
33
+ };
34
+ /** References describe authored intent even when another config field is invalid. Do not gate
35
+ * safe-delete or export on successful rendering: that would hide the broken page needing protection.
36
+ * Images beyond the renderable image cap cannot render and do not expand the reference index.
37
+ * @complexity O(min(n,50)) time and space over the authored image list; no I/O or resolution.
38
+ */
39
+ export declare function galleryMediaReferences({ config }: {
40
+ config: unknown;
41
+ }, _optional?: Record<string, never>): readonly {
42
+ mediaId: string;
43
+ index: number;
44
+ }[];
45
+ /** Authored slug references, independent of render validity. Tokens and quoted data attributes
46
+ * share the media reference key rules. Marker values are already entity-decoded by the scanner. */
47
+ export declare function mediaHtmlSlugs({ html }: {
48
+ html: string;
49
+ }, _optional?: Record<string, never>): readonly string[];
50
+ //# sourceMappingURL=gallery.d.ts.map
@@ -0,0 +1,164 @@
1
+ import { decodeMarkerHtmlAttribute } from './marker.js';
2
+ import { isPlausibleMediaRefId } from './media-reference.js';
3
+ export const MAX_GALLERY_IMAGES = 50;
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ /** Same bounded, single-path-segment contract the media resolver accepts. No I/O. */
8
+ function validMediaId(value) {
9
+ return isPlausibleMediaRefId({ value });
10
+ }
11
+ /** Native URL parsing validates browser-loaded sources; hosts may enforce stricter URL policy. */
12
+ function validSource(value) {
13
+ if (typeof value !== 'string' || value.length === 0 || value.length > 2048)
14
+ return false;
15
+ try {
16
+ const url = new URL(value);
17
+ return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** Numeric CSS ratio only: prevents configuration text becoming arbitrary CSS declarations. */
24
+ function validRatio(value) {
25
+ if (typeof value !== 'string' || !/^\d+(?:\.\d+)?(?:\s*\/\s*\d+(?:\.\d+)?)?$/.test(value))
26
+ return false;
27
+ return value.split('/').every(part => Number.isFinite(Number(part)) && Number(part) > 0);
28
+ }
29
+ /** Validate one image independently so a bad sibling never hides another image's diagnostics. */
30
+ function imageErrors(value, index) {
31
+ const path = `images[${index}]`;
32
+ if (!isRecord(value))
33
+ return [`${path} must be an object`];
34
+ const errors = [];
35
+ if ((value.mediaId !== undefined) === (value.src !== undefined))
36
+ errors.push(`${path} requires exactly one of mediaId or src`);
37
+ if (value.mediaId !== undefined && !validMediaId(value.mediaId))
38
+ errors.push(`${path}.mediaId must be a nonempty media id of at most 200 characters, without slashes or whitespace`);
39
+ if (value.src !== undefined && !validSource(value.src))
40
+ errors.push(`${path}.src must be an HTTP(S) image URL without credentials, at most 2048 characters`);
41
+ for (const key of ['alt', 'caption']) {
42
+ if (value[key] !== undefined && typeof value[key] !== 'string')
43
+ errors.push(`${path}.${key} must be a string`);
44
+ }
45
+ return errors;
46
+ }
47
+ /** Pure validation result, with missing-alt warnings separate from fatal configuration errors.
48
+ * Explicit alt="" denotes decorative content. Unknown keys remain available to host authoring.
49
+ * @complexity O(n) time and space for at most 50 images; oversized arrays are rejected before walking.
50
+ */
51
+ export function parseGalleryConfig({ config }, { allowEmpty = false } = {}) {
52
+ if (!isRecord(config))
53
+ return { errors: ['gallery config must be an object'], warnings: [] };
54
+ if (config.mode !== undefined || config.items !== undefined || config.html !== undefined)
55
+ return parseMediaContent({ config });
56
+ if (!Array.isArray(config.images) || (!allowEmpty && config.images.length === 0) || config.images.length > MAX_GALLERY_IMAGES) {
57
+ return { errors: [`images must contain between 1 and ${MAX_GALLERY_IMAGES} images`], warnings: [] };
58
+ }
59
+ const errors = config.images.flatMap(imageErrors);
60
+ const warnings = config.images.flatMap((image, index) => isRecord(image) && image.alt === undefined
61
+ ? [`images[${index}].alt is missing; supply alt text or an explicit empty string for a decorative image`] : []);
62
+ if (typeof config.layout !== 'string' || !['grid', 'carousel', 'masonry'].includes(config.layout))
63
+ errors.push('layout must be grid, carousel or masonry');
64
+ if (config.columns !== undefined && (!Number.isInteger(config.columns) || Number(config.columns) < 2 || Number(config.columns) > 6))
65
+ errors.push('columns must be an integer between 2 and 6');
66
+ if (config.aspectRatio !== undefined && !validRatio(config.aspectRatio))
67
+ errors.push('aspectRatio must be a positive number or ratio such as 16/9');
68
+ if (config.lightbox !== undefined && typeof config.lightbox !== 'boolean')
69
+ errors.push('lightbox must be a boolean');
70
+ if (errors.length > 0)
71
+ return { errors, warnings };
72
+ return { config: config, errors, warnings };
73
+ }
74
+ /** References describe authored intent even when another config field is invalid. Do not gate
75
+ * safe-delete or export on successful rendering: that would hide the broken page needing protection.
76
+ * Images beyond the renderable image cap cannot render and do not expand the reference index.
77
+ * @complexity O(min(n,50)) time and space over the authored image list; no I/O or resolution.
78
+ */
79
+ export function galleryMediaReferences({ config }, _optional = {}) {
80
+ if (!isRecord(config))
81
+ return [];
82
+ const refs = [];
83
+ const items = Array.isArray(config.items) ? config.items : Array.isArray(config.images) ? config.images : [];
84
+ for (const [index, item] of items.slice(0, MAX_GALLERY_IMAGES).entries()) {
85
+ if (!isRecord(item))
86
+ continue;
87
+ const key = item.slug ?? item.mediaId;
88
+ if (validMediaId(key))
89
+ refs.push({ mediaId: key, index });
90
+ }
91
+ if (typeof config.html === 'string') {
92
+ for (const key of mediaHtmlSlugs({ html: config.html }))
93
+ refs.push({ mediaId: key, index: refs.length });
94
+ }
95
+ if (isRecord(config.content))
96
+ refs.push(...galleryMediaReferences({ config: config.content }));
97
+ return refs;
98
+ }
99
+ /** Normalize the new slug-first content into the existing gallery render contract. Legacy images
100
+ * remain read-only compatibility input; authored media items never accept raw URLs. O(n), n<=50. */
101
+ function parseMediaContent({ config }) {
102
+ const errors = [];
103
+ const mode = config.mode ?? (config.html !== undefined ? 'html' : 'media');
104
+ if (mode !== 'media' && mode !== 'html')
105
+ errors.push('mode must be media or html');
106
+ for (const key of ['htmlAttributes', 'cssClass'])
107
+ if (config[key] !== undefined && typeof config[key] !== 'string')
108
+ errors.push(`${key} must be a string`);
109
+ if (mode === 'html') {
110
+ if (typeof config.html !== 'string' || config.html.length > 65536)
111
+ errors.push('html must be a string of at most 65536 characters');
112
+ return errors.length ? { errors, warnings: [] } : { config: { ...config, mode, images: [], layout: 'single' }, errors, warnings: [] };
113
+ }
114
+ if (!Array.isArray(config.items) || config.items.length > MAX_GALLERY_IMAGES)
115
+ return { errors: ['items must be an array of at most 50 slug items'], warnings: [] };
116
+ const images = [];
117
+ config.items.forEach((item, index) => {
118
+ if (!isRecord(item)) {
119
+ errors.push(`items[${index}] must be an object`);
120
+ return;
121
+ }
122
+ if (!validMediaId(item.slug))
123
+ errors.push(`items[${index}].slug must be a nonempty slug of at most 200 characters without slashes or whitespace`);
124
+ if (item.src !== undefined || item.mediaId !== undefined)
125
+ errors.push(`items[${index}] uses slug, never src or mediaId`);
126
+ for (const key of ['alt', 'caption'])
127
+ if (item[key] !== undefined && typeof item[key] !== 'string')
128
+ errors.push(`items[${index}].${key} must be a string`);
129
+ images.push(item);
130
+ });
131
+ const requestedLayout = config.layout ?? 'single';
132
+ let layout = 'single';
133
+ if (requestedLayout === 'single' || requestedLayout === 'grid' || requestedLayout === 'carousel' || requestedLayout === 'masonry') {
134
+ layout = requestedLayout;
135
+ }
136
+ else {
137
+ errors.push('layout must be single, grid, carousel or masonry');
138
+ }
139
+ if (config.columns !== undefined && (!Number.isInteger(config.columns) || Number(config.columns) < 1 || Number(config.columns) > 6))
140
+ errors.push('columns must be an integer between 1 and 6');
141
+ if (config.aspectRatio !== undefined && !validRatio(config.aspectRatio))
142
+ errors.push('aspectRatio must be a positive number or ratio such as 16/9');
143
+ if (errors.length)
144
+ return { errors, warnings: [] };
145
+ const normalized = { ...config, mode: 'media', images, layout };
146
+ return { config: normalized, errors, warnings: [] };
147
+ }
148
+ /** Authored slug references, independent of render validity. Tokens and quoted data attributes
149
+ * share the media reference key rules. Marker values are already entity-decoded by the scanner. */
150
+ export function mediaHtmlSlugs({ html }, _optional = {}) {
151
+ const keys = [];
152
+ for (const match of html.matchAll(/\{\{media:([^{}]+)\}\}/g)) {
153
+ const key = decodeMarkerHtmlAttribute({ value: match[1] });
154
+ if (validMediaId(key))
155
+ keys.push(key);
156
+ }
157
+ for (const match of html.matchAll(/\bdata-media-slug\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi)) {
158
+ const key = decodeMarkerHtmlAttribute({ value: match[1] ?? match[2] ?? match[3] ?? '' });
159
+ if (validMediaId(key))
160
+ keys.push(key);
161
+ }
162
+ return keys;
163
+ }
164
+ //# sourceMappingURL=gallery.js.map
@@ -89,10 +89,15 @@ export declare function isAllowedEmbedHtmlAttributeName({ name }: {
89
89
  * therefore the ONLY path to acceptance for a name; nothing here tries to sanitize an otherwise-
90
90
  * disallowed name or value into something safe.
91
91
  *
92
+ * @param options.styleContext Use `stylesheet` only for a style element's CSS text; allows
93
+ * non-ASCII scalar hex escapes for glyphs while preserving the inline escape restriction.
94
+ *
92
95
  * @complexity Time O(n) in `text`'s length (one regex pass over it), space O(k) for k accepted
93
96
  * attributes plus O(e) for e rejected tokens.
94
97
  */
95
98
  export declare function parseEmbedHtmlAttributes({ text }: {
96
99
  text: string;
97
- }, _optional?: Record<string, never>): ParsedEmbedHtmlAttributes;
100
+ }, options?: {
101
+ styleContext?: "stylesheet";
102
+ }): ParsedEmbedHtmlAttributes;
98
103
  //# sourceMappingURL=html-attributes.d.ts.map
@@ -116,16 +116,28 @@ function isUnsafeUrlValue(value) {
116
116
  * Whether a `style` value carries a known CSS injection or CSS-escape-bypass vector:
117
117
  * `expression(...)` (legacy IE script-in-CSS), a `javascript:`/`vbscript:` value inside a CSS
118
118
  * `url(...)`, `-moz-binding`/`behavior:` (XBL/HTC script bindings), `@import` (pulls in a second,
119
- * unvetted stylesheet), or ANY backslash — a CSS escape sequence (`\65` is `e`) can respell any of
120
- * the above past a plain substring check, so a backslash anywhere in the value is rejected outright
119
+ * unvetted stylesheet), or ANY backslash in an inline attribute — a CSS escape sequence (`\65`
120
+ * is `e`) can respell any of the above past a plain substring check, so a backslash anywhere in
121
+ * an inline value is rejected outright
121
122
  * rather than trying to first decode every possible CSS escape. Plain `url(...)` values (background
122
123
  * images) are otherwise allowed — authors here are the owner/admins, not anonymous input.
124
+ * Stylesheets additionally allow non-ASCII scalar hex escapes (e.g. `\2713` for a checkmark).
125
+ * They cannot respell the ASCII injection vocabulary. Only the validation projection changes;
126
+ * the accepted value remains byte-for-byte authored CSS. ASCII/invalid/other escapes still fail.
123
127
  *
124
- * @complexity O(n) in the value's length (one regex test).
128
+ * @complexity O(n) time/space in the value's length; escape matches consume at most six digits.
125
129
  */
126
130
  const UNSAFE_STYLE_PATTERN = /expression\(|javascript:|vbscript:|-moz-binding|behavior:|@import|\\/i;
127
- function isUnsafeStyleValue(value) {
128
- return UNSAFE_STYLE_PATTERN.test(value);
131
+ function isUnsafeStyleValue(value, { styleContext } = {}) {
132
+ const inspected = styleContext === "stylesheet"
133
+ ? value.replace(/\\([0-9a-f]{1,6})(?:\r\n|[ \t\n\r\f])?/gi, (escape, hex) => {
134
+ const codePoint = Number.parseInt(hex, 16);
135
+ if (codePoint < 0xa0 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff))
136
+ return escape;
137
+ return String.fromCodePoint(codePoint);
138
+ })
139
+ : value;
140
+ return UNSAFE_STYLE_PATTERN.test(inspected);
129
141
  }
130
142
  /**
131
143
  * Classifies one already-tokenized `name`/`value` pair into a rejection reason, or `null` when it is
@@ -144,14 +156,14 @@ function isUnsafeStyleValue(value) {
144
156
  * @complexity O(1) — four fixed checks against one already-extracted token; each check's own O(n) in
145
157
  * the token's length is linear, not nested.
146
158
  */
147
- function classifyEmbedHtmlAttributeToken(name, value) {
159
+ function classifyEmbedHtmlAttributeToken(name, value, options = {}) {
148
160
  if (name.startsWith("on"))
149
161
  return { reason: "event-handler", attribute: name };
150
162
  if (isUnsafeUrlValue(value))
151
163
  return { reason: "unsafe-url", attribute: name };
152
164
  if (!isAllowedEmbedHtmlAttributeName({ name: name }))
153
165
  return { reason: "disallowed-name", attribute: name };
154
- if (name === "style" && isUnsafeStyleValue(value))
166
+ if (name === "style" && isUnsafeStyleValue(value, options))
155
167
  return { reason: "unsafe-style", attribute: name };
156
168
  return null;
157
169
  }
@@ -172,10 +184,13 @@ const HTML_ATTRIBUTE_TOKEN = /([^\s="']+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"']+)))
172
184
  * therefore the ONLY path to acceptance for a name; nothing here tries to sanitize an otherwise-
173
185
  * disallowed name or value into something safe.
174
186
  *
187
+ * @param options.styleContext Use `stylesheet` only for a style element's CSS text; allows
188
+ * non-ASCII scalar hex escapes for glyphs while preserving the inline escape restriction.
189
+ *
175
190
  * @complexity Time O(n) in `text`'s length (one regex pass over it), space O(k) for k accepted
176
191
  * attributes plus O(e) for e rejected tokens.
177
192
  */
178
- export function parseEmbedHtmlAttributes({ text }, _optional = {}) {
193
+ export function parseEmbedHtmlAttributes({ text }, options = {}) {
179
194
  const trimmed = text.trim();
180
195
  if (trimmed === "")
181
196
  return { attributes: {}, errors: [], error: null };
@@ -198,7 +213,7 @@ export function parseEmbedHtmlAttributes({ text }, _optional = {}) {
198
213
  consumed = match.index + match[0].length;
199
214
  const name = (match[1] ?? "").toLowerCase();
200
215
  const value = match[2] ?? match[3] ?? match[4] ?? "";
201
- const rejection = classifyEmbedHtmlAttributeToken(name, value);
216
+ const rejection = classifyEmbedHtmlAttributeToken(name, value, options);
202
217
  if (rejection) {
203
218
  errors.push(rejection);
204
219
  continue;
@@ -1,3 +1,5 @@
1
1
  export * from "./marker.js";
2
2
  export * from "./html-attributes.js";
3
+ export * from "./gallery.js";
4
+ export * from "./media-reference.js";
3
5
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,5 @@
1
1
  export * from "./marker.js";
2
2
  export * from "./html-attributes.js";
3
+ export * from "./gallery.js";
4
+ export * from "./media-reference.js";
3
5
  //# sourceMappingURL=index.js.map
@@ -99,6 +99,10 @@ export interface ScanEmbedMarkersResult {
99
99
  */
100
100
  readonly rejected: readonly EmbedMarkerRejection[];
101
101
  }
102
+ /** Shared decoding seam for authored media references; parsing and indexing use one entity policy. */
103
+ export declare function decodeMarkerHtmlAttribute({ value }: {
104
+ value: string;
105
+ }, _optional?: Record<string, never>): string;
102
106
  /**
103
107
  * Produce a same-length copy of `html` with every HTML comment, and the raw-text content of every
104
108
  * `<script>`/`<style>` element, replaced by space filler. {@link scanEmbedMarkers} runs
@@ -89,6 +89,10 @@ function decodeAttributeValue(value) {
89
89
  return codePoint > 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : whole;
90
90
  });
91
91
  }
92
+ /** Shared decoding seam for authored media references; parsing and indexing use one entity policy. */
93
+ export function decodeMarkerHtmlAttribute({ value }, _optional = {}) {
94
+ return decodeAttributeValue(value);
95
+ }
92
96
  /** The inverse of {@link decodeAttributeValue} for a value written back double-quoted. */
93
97
  function encodeDoubleQuotedValue(value) {
94
98
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
@@ -512,6 +516,8 @@ export const EMBED_MARKER_TARGET_KEYS = {
512
516
  form: ["id", "slug"],
513
517
  taxonomy: ["id"],
514
518
  media: ["id", "slug"],
519
+ // Inline ordered images, not a persisted gallery entity; each mediaId is its own reference.
520
+ gallery: [],
515
521
  post: ["id", "slug"],
516
522
  content: ["id", "slug"],
517
523
  [COLLECTION_MARKER_TYPE]: ["id", "typeKey"],
@@ -0,0 +1,11 @@
1
+ /** Mirrors the public renderer's `MAX_MEDIA_REF_ID_LENGTH`/`PLAUSIBLE_MEDIA_REF_ID_PATTERN`
2
+ * shape check for an assetId/transformName that will become a `/m/` URL path segment. Widgets
3
+ * must not depend on the host renderer above it in the layering: this check guards repository
4
+ * lookup keys, and the renderer re-checks immediately before URL templating as defense in depth.
5
+ * Shared by media and gallery config so their media-reference contracts cannot drift.
6
+ * @complexity O(n) in the reference length, with a 200-character bound; no I/O.
7
+ */
8
+ export declare function isPlausibleMediaRefId({ value }: {
9
+ value: unknown;
10
+ }, _optional?: Record<string, never>): boolean;
11
+ //# sourceMappingURL=media-reference.d.ts.map
@@ -0,0 +1,11 @@
1
+ /** Mirrors the public renderer's `MAX_MEDIA_REF_ID_LENGTH`/`PLAUSIBLE_MEDIA_REF_ID_PATTERN`
2
+ * shape check for an assetId/transformName that will become a `/m/` URL path segment. Widgets
3
+ * must not depend on the host renderer above it in the layering: this check guards repository
4
+ * lookup keys, and the renderer re-checks immediately before URL templating as defense in depth.
5
+ * Shared by media and gallery config so their media-reference contracts cannot drift.
6
+ * @complexity O(n) in the reference length, with a 200-character bound; no I/O.
7
+ */
8
+ export function isPlausibleMediaRefId({ value }, _optional = {}) {
9
+ return typeof value === 'string' && value.length > 0 && value.length <= 200 && /^[^\s/]+$/.test(value);
10
+ }
11
+ //# sourceMappingURL=media-reference.js.map
@@ -10,7 +10,7 @@
10
10
  * ~990 modules and seconds of cold start per render (see `__tests__/worker-import-graph.test.ts`
11
11
  * under `server/inbound/public-http/http/site/`).
12
12
  */
13
- export declare const PAGE_EMBED_TYPES: readonly ["widget", "form", "taxonomy", "media", "post", "content"];
13
+ export declare const PAGE_EMBED_TYPES: readonly ["widget", "form", "taxonomy", "media", "gallery", "post", "content"];
14
14
  export type PageEmbedType = (typeof PAGE_EMBED_TYPES)[number];
15
15
  /**
16
16
  * Does the page-embed stage OWN this marker type — i.e. is a REQ-28 placeholder the honest answer
@@ -10,7 +10,7 @@
10
10
  * ~990 modules and seconds of cold start per render (see `__tests__/worker-import-graph.test.ts`
11
11
  * under `server/inbound/public-http/http/site/`).
12
12
  */
13
- export const PAGE_EMBED_TYPES = ["widget", "form", "taxonomy", "media", "post", "content"];
13
+ export const PAGE_EMBED_TYPES = ["widget", "form", "taxonomy", "media", "gallery", "post", "content"];
14
14
  const PAGE_EMBED_TYPE_SET = new Set(PAGE_EMBED_TYPES);
15
15
  /**
16
16
  * Does the page-embed stage OWN this marker type — i.e. is a REQ-28 placeholder the honest answer