@supersuit/artifacts 0.7.0 → 0.8.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,33 @@
3
3
  `@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
4
4
  host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
5
5
 
6
+ ## 0.8.0 (2026-09-27)
7
+
8
+ **Pages play audio clips.** `![caption](clip.mp3)` renders a captioned player with native
9
+ controls, the way `![alt](x.mp4)` already renders a video. An annotated conversation, a voice
10
+ note, or a quote meant to be heard can now sit in the prose it belongs to, instead of on a
11
+ second page or a download link. mp3 was already an accepted asset type; nothing rendered it.
12
+
13
+ - `preload="none"`, never `autoplay`: a page of twenty clips fetches nothing until one is pressed.
14
+ - The alt is the clip's caption, shown above the player and escaped as text.
15
+ - DETECTOR: a page with an `.mp3` in image syntax that renders a broken image.
16
+ - REMEDY: bump; republishing is not needed, the markdown is rendered on request.
17
+ - Tests: `render.test.tsx` pins the audio element, its attributes, the caption and the escaping.
18
+
19
+ ## 0.7.1 (2026-09-27)
20
+
21
+ **The narrator reads the subtitle.** `narrationText` built the spoken text from the title, the
22
+ summary and the body, while the page renders a `subtitle:` between the title and the summary with
23
+ no `data-nospeak`. So the audio skipped the page's first line under the title, and the highlighter
24
+ expected words the narration never said.
25
+
26
+ - `narrationText` takes an optional `subtitle` and reads it second; the narration-text route
27
+ passes it.
28
+ - DETECTOR: a page with a `subtitle:` whose audio goes straight from the title to the summary.
29
+ - REMEDY: bump, then republish the page so its narration is regenerated (the spoken text changed,
30
+ so the narration hash no longer matches and the publisher re-narrates).
31
+ - Tests: `narration.test.ts` pins title, subtitle, summary, body order.
32
+
6
33
  ## 0.7.0 (2026-09-27)
7
34
 
8
35
  **A real version history: every page names its version, lists every version with its change note,
@@ -1,6 +1,7 @@
1
1
  export declare function narrationTextFromMarkdown(markdown: string): string;
2
2
  export declare function narrationText(input: {
3
3
  title: string;
4
+ subtitle?: string;
4
5
  summary: string;
5
6
  markdown: string;
6
7
  }): string;
@@ -76,8 +76,12 @@ export function narrationTextFromMarkdown(markdown) {
76
76
  blocks(tree.children, out);
77
77
  return clean(out);
78
78
  }
79
+ // The subtitle renders between the title and the summary with no data-nospeak, so the
80
+ // highlighter expects it there; leaving it out made the narrator skip the page's first line.
79
81
  export function narrationText(input) {
80
- return [input.title.trim(), input.summary.trim(), narrationTextFromMarkdown(input.markdown)].filter(Boolean).join('\n');
82
+ return [input.title.trim(), (input.subtitle ?? '').trim(), input.summary.trim(), narrationTextFromMarkdown(input.markdown)]
83
+ .filter(Boolean)
84
+ .join('\n');
81
85
  }
82
86
  /** Tokens the highlighter and the timings agree on: whitespace-split, lowercased, letters and digits only. */
83
87
  export function normalizeWord(w) {
@@ -52,6 +52,10 @@ export function isReachableHref(href) {
52
52
  * is decoded frame by frame on the CPU and stuttered on an iPhone at 24fps (2026-09-27); video
53
53
  * is decoded in hardware. */
54
54
  const VIDEO = /\.mp4(?:[?#].*)?$/i;
55
+ /** `![caption](x.mp3)` is a clip the reader plays: an annotated conversation, a voice note, a
56
+ * quote heard rather than read. Native controls, and `preload="none"` so a page of clips fetches
57
+ * nothing until one is pressed. The alt is its caption, shown above the player. */
58
+ const AUDIO = /\.mp3(?:[?#].*)?$/i;
55
59
  const VIDEO_IN_MD = /\]\([^)\s]+\.mp4(?:[?#][^)]*)?\)/i;
56
60
  const escapeAttr = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
57
61
  // Written as markup, not JSX: React renders `muted` as a property and leaves it out of server
@@ -62,6 +66,10 @@ function InlineVideo({ src, label }) {
62
66
  const html = `<video src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-video muted loop playsinline preload="metadata" disablepictureinpicture class="block w-full"></video>`;
63
67
  return _jsx("span", { className: "my-6 block overflow-hidden rounded-lg border border-[color:var(--a-line)]", dangerouslySetInnerHTML: { __html: html } });
64
68
  }
69
+ function InlineAudio({ src, label }) {
70
+ const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio controls preload="none" class="block w-full"></audio>`;
71
+ return (_jsxs("span", { className: "my-6 block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3", children: [label ? _jsx("span", { className: "mb-2 block text-sm text-[color:var(--a-strong)]", children: label }) : null, _jsx("span", { className: "block", dangerouslySetInnerHTML: { __html: html } })] }));
72
+ }
65
73
  const components = {
66
74
  p: ({ children }) => _jsx("p", { className: "my-4 leading-relaxed", children: children }),
67
75
  ul: ({ children }) => _jsx("ul", { className: "my-4 list-disc space-y-1 pl-6", children: children }),
@@ -70,7 +78,7 @@ const components = {
70
78
  table: ({ children }) => (_jsx("div", { className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
71
79
  th: ({ children }) => (_jsx("th", { className: "border-b border-[color:var(--a-line-strong)] px-3 py-2 text-left font-semibold text-[color:var(--a-strong)]", children: children })),
72
80
  td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
73
- img: ({ src, alt }) => typeof src === 'string' && VIDEO.test(src) ? (_jsx(InlineVideo, { src: src, label: alt ?? '' })) : (
81
+ img: ({ src, alt }) => typeof src === 'string' && VIDEO.test(src) ? (_jsx(InlineVideo, { src: src, label: alt ?? '' })) : typeof src === 'string' && AUDIO.test(src) ? (_jsx(InlineAudio, { src: src, label: alt ?? '' })) : (
74
82
  // eslint-disable-next-line @next/next/no-img-element
75
83
  _jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" })),
76
84
  a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] })),
@@ -391,7 +391,7 @@ export function createArtifactRoutes(config) {
391
391
  const a = await store.get(id);
392
392
  if (!a)
393
393
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
394
- return NextResponse.json({ id, text: narrationText({ title: a.title, summary: a.summary, markdown: a.markdown }) });
394
+ return NextResponse.json({ id, text: narrationText({ title: a.title, subtitle: a.subtitle, summary: a.summary, markdown: a.markdown }) });
395
395
  }
396
396
  /** PUT /api/artifacts/<id>/assets/<name>: raw bytes in, public URL out. 16 MiB cap. */
397
397
  async function PUT_ASSET(request, { params }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
5
5
  "license": "MIT",
6
6
  "type": "module",