@supersuit/artifacts 0.4.5 → 0.4.6

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,21 @@
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.4.6 (2026-09-27)
7
+
8
+ **Pages play video: `![alt](x.mp4)` is a silent, looping, autoplaying inline video.**
9
+
10
+ - Animated WebP is decoded frame by frame on the CPU and stuttered on an iPhone at 24fps; video
11
+ is decoded in hardware. Image syntax pointing at an `.mp4` now renders
12
+ `<video autoplay muted loop playsinline>` with the alt text as its `aria-label`, in the same
13
+ rounded frame an image gets. It is written as markup because React leaves `muted` out of
14
+ server HTML, and iOS will not autoplay a video whose markup is not muted.
15
+ - `mp4` (`video/mp4`) joins the allowed asset types for `PUT_ASSET` and `UPLOAD`.
16
+ - **DETECTOR:** a published `.mp4` renders as a broken image, or its upload answers 415.
17
+ - **REMEDY:** update the dependency; no host change.
18
+ - **Tests:** an `.mp4` renders a muted, looping, inline, autoplaying video labelled by its alt
19
+ text and a `.png` still renders an image; `d.mp4` maps to `video/mp4`.
20
+
6
21
  ## 0.4.5 (2026-09-27)
7
22
 
8
23
  **The bubble ground looks as it did in 0.4.2, and stays smooth.**
@@ -15,6 +15,7 @@ export const ASSET_TYPES = {
15
15
  jpeg: 'image/jpeg',
16
16
  gif: 'image/gif',
17
17
  mp3: 'audio/mpeg',
18
+ mp4: 'video/mp4',
18
19
  json: 'application/json',
19
20
  };
20
21
  export function contentTypeFor(name) {
@@ -45,6 +45,18 @@ const REACHABLE = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i;
45
45
  export function isReachableHref(href) {
46
46
  return typeof href === 'string' && REACHABLE.test(href);
47
47
  }
48
+ /** `![alt](x.mp4)` plays as video: an animation that must stay smooth on a phone. Animated WebP
49
+ * is decoded frame by frame on the CPU and stuttered on an iPhone at 24fps (2026-09-27); video
50
+ * is decoded in hardware. */
51
+ const VIDEO = /\.mp4(?:[?#].*)?$/i;
52
+ const escapeAttr = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
53
+ // Written as markup, not JSX: React renders `muted` as a property and leaves it out of server
54
+ // HTML, and iOS refuses to autoplay a video whose markup is not muted. `playsinline` keeps
55
+ // iPhone from going full screen.
56
+ function InlineVideo({ src, label }) {
57
+ const html = `<video src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" autoplay muted loop playsinline preload="auto" disablepictureinpicture class="block w-full"></video>`;
58
+ return _jsx("span", { className: "my-6 block overflow-hidden rounded-lg border border-[color:var(--a-line)]", dangerouslySetInnerHTML: { __html: html } });
59
+ }
48
60
  const components = {
49
61
  p: ({ children }) => _jsx("p", { className: "my-4 leading-relaxed", children: children }),
50
62
  ul: ({ children }) => _jsx("ul", { className: "my-4 list-disc space-y-1 pl-6", children: children }),
@@ -53,7 +65,7 @@ const components = {
53
65
  table: ({ children }) => (_jsx("div", { className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
54
66
  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 })),
55
67
  td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
56
- img: ({ src, alt }) => (
68
+ img: ({ src, alt }) => typeof src === 'string' && VIDEO.test(src) ? (_jsx(InlineVideo, { src: src, label: alt ?? '' })) : (
57
69
  // eslint-disable-next-line @next/next/no-img-element
58
70
  _jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" })),
59
71
  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] })),
@@ -349,7 +349,7 @@ export function createArtifactRoutes(config) {
349
349
  return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
350
350
  const type = contentTypeFor(name);
351
351
  if (!type)
352
- return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3 or json' }, { status: 415 });
352
+ return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3, mp4 or json' }, { status: 415 });
353
353
  const bytes = Buffer.from(await request.arrayBuffer());
354
354
  if (bytes.length === 0)
355
355
  return NextResponse.json({ error: 'empty body' }, { status: 400 });
@@ -379,7 +379,7 @@ export function createArtifactRoutes(config) {
379
379
  return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
380
380
  const type = contentTypeFor(name);
381
381
  if (!type)
382
- return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3 or json' }, { status: 415 });
382
+ return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3, mp4 or json' }, { status: 415 });
383
383
  const body = (await request.json().catch(() => ({})));
384
384
  const digest = typeof body.digest === 'string' ? body.digest : '';
385
385
  if (!ASSET_DIGEST.test(digest))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
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",