@supersuit/artifacts 0.15.0 → 0.16.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 +20 -0
- package/README.md +24 -0
- package/lib/artifacts/front-matter.d.ts +6 -0
- package/lib/artifacts/front-matter.js +8 -1
- package/lib/artifacts/render.d.ts +13 -1
- package/lib/artifacts/render.js +97 -11
- package/lib/artifacts/store.d.ts +2 -0
- package/lib/artifacts/store.js +2 -0
- package/lib/brand/index.d.ts +1 -1
- package/lib/brand/pack.d.ts +70 -0
- package/lib/routes/artifacts.d.ts +7 -1
- package/lib/routes/artifacts.js +35 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
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.16.0 (2026-09-29)
|
|
7
|
+
|
|
8
|
+
**A brand pack can draw a page's structure, and one page can wear a pack of its own.** Four new
|
|
9
|
+
optional `BrandPack` fields: `Cover` (the title block), `Article` (what holds the cover and body),
|
|
10
|
+
`Section` (each top-level section, with its index, count and the outline, for a break before it
|
|
11
|
+
or a wrapper around it) and `figure(src, alt)` (a component in place of an image or embed). New
|
|
12
|
+
routes config `packs` (by name) and `packFor(artifact)`, and front matter `pack: <name>`.
|
|
13
|
+
|
|
14
|
+
- DETECTOR: none needed. A pack that uses none of the new fields renders exactly as on 0.15.0,
|
|
15
|
+
pinned by a golden rendered from 0.15.0 itself (`src/routes/__golden__`).
|
|
16
|
+
- REMEDY: nothing, unless you want a page drawn differently: set the fields on a pack of your
|
|
17
|
+
own and register it with `packs` or choose it with `packFor`.
|
|
18
|
+
- Tests: the golden (default and wrapped packs, current page and a past version); each extension
|
|
19
|
+
point; a claimed image replacing its paragraph and an unclaimed one untouched; `pack:` by name,
|
|
20
|
+
an unknown name, `packFor` ahead of front matter, other pages untouched, the share card following
|
|
21
|
+
the page's pack; `pack:` parse and refusals and the store round trip; and the read-along: a page
|
|
22
|
+
rendered through all four has exactly the narrated words. Each was broken on purpose once and
|
|
23
|
+
failed (figure left speakable, sections not split, cover and article ignored, `packFor` and
|
|
24
|
+
`pack:` ignored, `pack` not cleared on republish).
|
|
25
|
+
|
|
6
26
|
## 0.15.0 (2026-09-29)
|
|
7
27
|
|
|
8
28
|
**A reader signed in with the wrong Google account can change it.** Before, "Sign out" only
|
package/README.md
CHANGED
|
@@ -157,6 +157,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
157
157
|
| `password` | Shuts the body behind a door (below) |
|
|
158
158
|
| `access` | `freedom`, `invite` or `public`: a confidential page (below) |
|
|
159
159
|
| `theme` | `light`, `dark` or `system` (follow the reader's device). Overrides the brand pack's mode for this page |
|
|
160
|
+
| `pack` | A brand pack the host registered by name (`packs` in the routes config). An unknown name renders in the host's default pack; a republish without the line goes back to it |
|
|
160
161
|
| `toc` | The table of contents. Absent, it appears once the page has four `##` sections; `false` never; `true` from one |
|
|
161
162
|
| `definitions` | Terms defined inline (below). A republish without it removes them |
|
|
162
163
|
| `change` | One line saying what this version changed (see Version history) |
|
|
@@ -546,6 +547,29 @@ motion. `colors` are `r,g,b` triplets (default `GLOW_PASTELS`); `veil` is 0 to 1
|
|
|
546
547
|
const pack: BrandPack = { ...freedomDefault, mode: 'light', backdrop: { kind: 'bubbles' } }
|
|
547
548
|
```
|
|
548
549
|
|
|
550
|
+
**Drawing more than colours.** Four optional fields let a pack draw a page's structure. A pack
|
|
551
|
+
that sets none of them renders every page byte for byte as before (a test holds that).
|
|
552
|
+
|
|
553
|
+
- `Cover` draws the title block instead of the centred header. It is handed `title`, `subtitle`
|
|
554
|
+
and `summary` to render (they are spoken, in that order), the shell's `kicker` and `meta`
|
|
555
|
+
(version line, signed-in line) to place, and the page's `markdown` to quote from.
|
|
556
|
+
- `Article` holds the cover, the inline contents and the body, instead of the centred column.
|
|
557
|
+
- `Section` receives each top-level section (split at the shallowest heading level the page
|
|
558
|
+
uses): its `index`, `count`, the whole `outline`, the heading's `text` and `slug`, the default
|
|
559
|
+
`heading`, or its `headingContent` and `headingAttrs` (id and comment anchor) for a pack that
|
|
560
|
+
draws the heading itself, and the section's body as `children`.
|
|
561
|
+
- `figure(src, alt)` returns a component to draw an image or embed in its place, or undefined to
|
|
562
|
+
keep the default. An image alone in its paragraph replaces the paragraph.
|
|
563
|
+
|
|
564
|
+
Everything a pack adds beyond the text it is handed must sit inside `data-nospeak`, which the
|
|
565
|
+
read-along skips; the shell puts figures there itself. A test renders a page through all four
|
|
566
|
+
and checks the words on the page are exactly the words narrated.
|
|
567
|
+
|
|
568
|
+
**One page in a different pack.** Register packs by name with `packs: { name: pack }` and a page
|
|
569
|
+
picks one with `pack: name` in its front matter. `packFor(artifact)` lets the host choose instead,
|
|
570
|
+
ahead of the front matter, for a page whose file cannot carry the line. The share card follows
|
|
571
|
+
the page's pack.
|
|
572
|
+
|
|
549
573
|
## Environment
|
|
550
574
|
|
|
551
575
|
| Variable | Used for |
|
|
@@ -36,6 +36,10 @@ export type ArtifactMeta = {
|
|
|
36
36
|
state?: StateConfig;
|
|
37
37
|
/** Light, dark, or the reader's device, overriding the brand pack's mode for this page. */
|
|
38
38
|
theme?: ThemeMode;
|
|
39
|
+
/** The brand pack this page renders in, by the name the host registered it under (`packs` in
|
|
40
|
+
* the routes config). A name the host does not know renders in its default pack. Content, like
|
|
41
|
+
* `theme:`: a republish without the line goes back to the default. */
|
|
42
|
+
pack?: string;
|
|
39
43
|
/** The table of contents: absent draws it once the page has four sections, false never. */
|
|
40
44
|
toc?: boolean;
|
|
41
45
|
/** Terms this page defines inline: the first occurrence of each is underlined and opens its
|
|
@@ -51,6 +55,8 @@ export type ArtifactMeta = {
|
|
|
51
55
|
/** Who sees shared comments: `owner` (the default) or every reader (`readers`). */
|
|
52
56
|
commentsVisible?: CommentsVisible;
|
|
53
57
|
};
|
|
58
|
+
/** A pack name, as a host registers one. */
|
|
59
|
+
export declare const PACK_NAME: RegExp;
|
|
54
60
|
/** The longest `to:` a page may carry: a name or two, never a sentence. */
|
|
55
61
|
export declare const MAX_TO_CHARS = 60;
|
|
56
62
|
export declare function parseArtifactSource(text: string): {
|
|
@@ -7,9 +7,11 @@ import { mergeWidgetState, scanWidgets } from './widgets.js';
|
|
|
7
7
|
import { COMMENTS_MODES, mergeCommentsState } from './comments.js';
|
|
8
8
|
import { THEME_MODES } from '../brand/theme.js';
|
|
9
9
|
import { parseDefinitions } from './definitions.js';
|
|
10
|
+
/** A pack name, as a host registers one. */
|
|
11
|
+
export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
10
12
|
/** The longest `to:` a page may carry: a name or two, never a sentence. */
|
|
11
13
|
export const MAX_TO_CHARS = 60;
|
|
12
|
-
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc', 'definitions', 'change', 'comments', 'comments_visible']);
|
|
14
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible']);
|
|
13
15
|
export function parseArtifactSource(text) {
|
|
14
16
|
const { data, content } = matter(text);
|
|
15
17
|
const d = data;
|
|
@@ -57,6 +59,11 @@ export function parseArtifactSource(text) {
|
|
|
57
59
|
return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
|
|
58
60
|
meta.theme = d.theme;
|
|
59
61
|
}
|
|
62
|
+
if (d.pack !== undefined && d.pack !== null) {
|
|
63
|
+
if (typeof d.pack !== 'string' || !PACK_NAME.test(d.pack))
|
|
64
|
+
return { ok: false, error: 'pack must be a pack name: lowercase letters, digits and dashes, up to 40 characters' };
|
|
65
|
+
meta.pack = d.pack;
|
|
66
|
+
}
|
|
60
67
|
if (d.toc !== undefined) {
|
|
61
68
|
if (typeof d.toc !== 'boolean')
|
|
62
69
|
return { ok: false, error: 'toc must be true or false' };
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
import { type ComponentType } from 'react';
|
|
2
|
+
import type { FigureProps, SectionOutline, SectionProps } from '../brand/pack.js';
|
|
3
|
+
import { type Heading } from './widgets.js';
|
|
1
4
|
import { type Definition } from './definitions.js';
|
|
2
5
|
export declare function isReachableHref(href: unknown): href is string;
|
|
6
|
+
/** A pack's figure resolver (BrandPack.figure). */
|
|
7
|
+
export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps> | undefined;
|
|
8
|
+
/** The page's top-level sections: every heading among the body's top-level blocks at the
|
|
9
|
+
* shallowest depth any of them uses. Headings inside lists or quotes never start a section. */
|
|
10
|
+
export declare function sectionOutline(markdown: string, headings?: Heading[]): SectionOutline[];
|
|
3
11
|
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
4
12
|
* page carries a ```notes block. */
|
|
5
|
-
export declare function ArtifactMarkdown({ markdown, notes, definitions, }: {
|
|
13
|
+
export declare function ArtifactMarkdown({ markdown, notes, definitions, Section, figure, }: {
|
|
6
14
|
markdown: string;
|
|
15
|
+
/** The pack's section component (BrandPack.Section): each top-level section is drawn through it. */
|
|
16
|
+
Section?: ComponentType<SectionProps>;
|
|
17
|
+
/** The pack's figure resolver (BrandPack.figure). */
|
|
18
|
+
figure?: FigureFor;
|
|
7
19
|
notes?: {
|
|
8
20
|
artifactId: string;
|
|
9
21
|
accent?: string;
|
package/lib/artifacts/render.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Markdown to the house look, for /a/<id>. Server-safe: no hooks, no client
|
|
3
|
+
// directive. Raw HTML is escaped by react-markdown's default; never add
|
|
4
|
+
// rehype-raw here, this renders strangers' links on a public route.
|
|
5
|
+
// Two extensions past GFM, both small on purpose:
|
|
6
|
+
// ```links one "url | note" per line, rendered as link cards
|
|
7
|
+
// > [!note] / > [!warning] a callout instead of a blockquote
|
|
8
|
+
import { Children, isValidElement } from 'react';
|
|
2
9
|
import ReactMarkdown from 'react-markdown';
|
|
3
10
|
import remarkGfm from 'remark-gfm';
|
|
11
|
+
import remarkParse from 'remark-parse';
|
|
12
|
+
import { unified } from 'unified';
|
|
4
13
|
import { blocksOf, hasNotesWidget, headingsOf } from './widgets.js';
|
|
5
14
|
import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
|
|
6
15
|
import { VideoAutoplay } from '../reader/video-autoplay.js';
|
|
@@ -70,18 +79,41 @@ function InlineAudio({ src, label }) {
|
|
|
70
79
|
const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio controls preload="none" class="block w-full"></audio>`;
|
|
71
80
|
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
81
|
}
|
|
73
|
-
|
|
82
|
+
/** The one image a paragraph holds and nothing else (whitespace aside), or null. */
|
|
83
|
+
function soleImage(node) {
|
|
84
|
+
const kids = (node?.children ?? []).filter((c) => !(c.type === 'text' && !c.value?.trim()));
|
|
85
|
+
if (kids.length !== 1 || kids[0].type !== 'element' || kids[0].tagName !== 'img')
|
|
86
|
+
return null;
|
|
87
|
+
const src = kids[0].properties?.src;
|
|
88
|
+
return typeof src === 'string' ? { src, alt: String(kids[0].properties?.alt ?? '') } : null;
|
|
89
|
+
}
|
|
90
|
+
function baseComponents(block, figure) {
|
|
74
91
|
return {
|
|
75
|
-
p: ({ node, children }) =>
|
|
92
|
+
p: ({ node, children }) => {
|
|
93
|
+
// An image alone in its paragraph that the pack draws itself replaces the paragraph, so the
|
|
94
|
+
// pack's figure may hold block content (a <p> cannot) and still carry the comment anchor.
|
|
95
|
+
const img = figure ? soleImage(node) : null;
|
|
96
|
+
const F = img ? figure(img.src, img.alt) : undefined;
|
|
97
|
+
if (img && F) {
|
|
98
|
+
return (_jsx("div", { ...block(node), "data-nospeak": true, "data-artifact-figure": true, children: _jsx(F, { src: img.src, alt: img.alt }) }));
|
|
99
|
+
}
|
|
100
|
+
return _jsx("p", { ...block(node), className: "my-4 leading-relaxed", children: children });
|
|
101
|
+
},
|
|
76
102
|
ul: ({ node, children }) => _jsx("ul", { ...block(node), className: "my-4 list-disc space-y-1 pl-6", children: children }),
|
|
77
103
|
ol: ({ node, children }) => _jsx("ol", { ...block(node), className: "my-4 list-decimal space-y-1 pl-6", children: children }),
|
|
78
104
|
hr: () => _jsx("hr", { className: "my-10 border-[color:var(--a-line)]" }),
|
|
79
105
|
table: ({ node, children }) => (_jsx("div", { ...block(node), className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
|
|
80
106
|
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 })),
|
|
81
107
|
td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
|
|
82
|
-
img: ({ src, alt }) =>
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
img: ({ src, alt }) => {
|
|
109
|
+
const F = typeof src === 'string' ? figure?.(src, alt ?? '') : undefined;
|
|
110
|
+
if (F) {
|
|
111
|
+
return (_jsx("span", { "data-nospeak": true, "data-artifact-figure": true, className: "block", children: _jsx(F, { src: src, alt: alt ?? '' }) }));
|
|
112
|
+
}
|
|
113
|
+
return typeof src === 'string' && VIDEO.test(src) ? (_jsx(InlineVideo, { src: src, label: alt ?? '' })) : typeof src === 'string' && AUDIO.test(src) ? (_jsx(InlineAudio, { src: src, label: alt ?? '' })) : (
|
|
114
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
115
|
+
_jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" }));
|
|
116
|
+
},
|
|
85
117
|
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] })),
|
|
86
118
|
code: ({ className, children }) => {
|
|
87
119
|
const lang = /language-(\w+)/.exec(className ?? '')?.[1];
|
|
@@ -127,20 +159,20 @@ const HEADING_CLASS = {
|
|
|
127
159
|
};
|
|
128
160
|
/** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
|
|
129
161
|
* so a contents link and a note both land on it. */
|
|
130
|
-
function pageComponents(headings, block) {
|
|
162
|
+
function pageComponents(headings, block, figure) {
|
|
131
163
|
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
132
164
|
const heading = (depth) => function Heading({ node, children }) {
|
|
133
165
|
const Tag = `h${depth}`;
|
|
134
166
|
const h = byLine.get(node?.position?.start.line ?? -1);
|
|
135
167
|
return _jsx(Tag, { id: h?.slug, ...block(node), className: HEADING_CLASS[depth], children: children });
|
|
136
168
|
};
|
|
137
|
-
return { ...baseComponents(block), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
|
|
169
|
+
return { ...baseComponents(block, figure), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
|
|
138
170
|
}
|
|
139
171
|
/** Components for a page with a notes block: every heading gets its slug as an id, a note
|
|
140
172
|
* control, and its notes after it. The heading is found by its source line, so the slug is the
|
|
141
173
|
* one headingsOf derived, the same one a note stores. */
|
|
142
|
-
function notesComponents(headings, block) {
|
|
143
|
-
const components = baseComponents(block);
|
|
174
|
+
function notesComponents(headings, block, figure) {
|
|
175
|
+
const components = baseComponents(block, figure);
|
|
144
176
|
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
145
177
|
const heading = (depth) => function NotedHeading({ node, children }) {
|
|
146
178
|
const Tag = `h${depth}`;
|
|
@@ -156,6 +188,55 @@ function notesComponents(headings, block) {
|
|
|
156
188
|
code: (props) => (/language-notes/.test(props.className ?? '') ? _jsx(NotesEarlier, {}) : _jsx(Code, { ...props })),
|
|
157
189
|
};
|
|
158
190
|
}
|
|
191
|
+
/** The page's top-level sections: every heading among the body's top-level blocks at the
|
|
192
|
+
* shallowest depth any of them uses. Headings inside lists or quotes never start a section. */
|
|
193
|
+
export function sectionOutline(markdown, headings = headingsOf(markdown)) {
|
|
194
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(markdown);
|
|
195
|
+
const tops = tree.children.filter((n) => n.type === 'heading');
|
|
196
|
+
if (!tops.length)
|
|
197
|
+
return [];
|
|
198
|
+
const depth = Math.min(...tops.map((h) => h.depth));
|
|
199
|
+
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
200
|
+
return tops
|
|
201
|
+
.filter((h) => h.depth === depth)
|
|
202
|
+
.map((h, index) => {
|
|
203
|
+
const known = byLine.get(h.position?.start.line ?? -1);
|
|
204
|
+
return { index, depth, text: known?.text ?? '', slug: known?.slug ?? '' };
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Groups the root's blocks into one node per top-level section (a heading at `depth` and what
|
|
208
|
+
* follows it up to the next), so the renderer can hand each to the pack's Section. The blocks
|
|
209
|
+
* keep their positions, so comment anchors and heading slugs are exactly what they were. */
|
|
210
|
+
function remarkSections(depth) {
|
|
211
|
+
return () => (tree) => {
|
|
212
|
+
const out = [];
|
|
213
|
+
let body = null;
|
|
214
|
+
let i = 0;
|
|
215
|
+
for (const n of tree.children) {
|
|
216
|
+
if (n.type === 'heading' && n.depth === depth) {
|
|
217
|
+
body = { children: [] };
|
|
218
|
+
const b = { type: 'artifactSectionBody', data: { hName: 'div', hProperties: { dataArtifactSectionBody: '' } }, children: body.children };
|
|
219
|
+
out.push({ type: 'artifactSection', data: { hName: 'section', hProperties: { dataArtifactSection: String(i++) } }, children: [n, b] });
|
|
220
|
+
}
|
|
221
|
+
else if (body)
|
|
222
|
+
body.children.push(n);
|
|
223
|
+
else
|
|
224
|
+
out.push(n);
|
|
225
|
+
}
|
|
226
|
+
tree.children = out;
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function sectionComponent(Section, outline, block) {
|
|
230
|
+
return function ArtifactSection({ node, children }) {
|
|
231
|
+
const i = Number(node?.properties?.dataArtifactSection);
|
|
232
|
+
const o = outline[i];
|
|
233
|
+
// Not one of ours (a raw <section> never reaches here: raw HTML is escaped).
|
|
234
|
+
if (!o)
|
|
235
|
+
return _jsx("section", { children: children });
|
|
236
|
+
const [heading, body] = Children.toArray(children).filter(isValidElement);
|
|
237
|
+
return (_jsx(Section, { index: i, count: outline.length, outline: outline, text: o.text, slug: o.slug, heading: heading, headingContent: heading?.props.children, headingAttrs: { id: o.slug, ...block(heading?.props.node) }, children: body?.props.children }));
|
|
238
|
+
};
|
|
239
|
+
}
|
|
159
240
|
// The term is text with a dotted underline; the description copy is visually hidden and out of
|
|
160
241
|
// flow (absolute), so it takes no room in the line. Neither ever changes size: the definition
|
|
161
242
|
// that opens is drawn by DefinitionLayer on document.body.
|
|
@@ -170,7 +251,7 @@ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;
|
|
|
170
251
|
'clip:rect(0,0,0,0);white-space:nowrap;border:0}';
|
|
171
252
|
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
172
253
|
* page carries a ```notes block. */
|
|
173
|
-
export function ArtifactMarkdown({ markdown, notes, definitions, }) {
|
|
254
|
+
export function ArtifactMarkdown({ markdown, notes, definitions, Section, figure, }) {
|
|
174
255
|
const on = !!notes && hasNotesWidget(markdown);
|
|
175
256
|
const headings = headingsOf(markdown);
|
|
176
257
|
const blocks = blocksOf(markdown);
|
|
@@ -179,7 +260,12 @@ export function ArtifactMarkdown({ markdown, notes, definitions, }) {
|
|
|
179
260
|
return id ? { 'data-block': id } : {};
|
|
180
261
|
};
|
|
181
262
|
const defined = definitions?.length ? definitions : null;
|
|
182
|
-
const
|
|
263
|
+
const outline = Section ? sectionOutline(markdown, headings) : [];
|
|
264
|
+
const plugins = [remarkGfm, ...(defined ? [remarkDefinitions(defined)] : []), ...(outline.length ? [remarkSections(outline[0].depth)] : [])];
|
|
265
|
+
const components = on ? notesComponents(headings, block, figure) : pageComponents(headings, block, figure);
|
|
266
|
+
if (outline.length)
|
|
267
|
+
components.section = sectionComponent(Section, outline, block);
|
|
268
|
+
const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, children: markdown }));
|
|
183
269
|
return (
|
|
184
270
|
// break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
|
|
185
271
|
_jsxs("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: [on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body, VIDEO_IN_MD.test(markdown) ? _jsx(VideoAutoplay, {}) : null, defined ? (_jsxs(_Fragment, { children: [_jsx("style", { children: DEFINED_TERM_CSS }), _jsx(DefinitionLayer, {})] })) : null] }));
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type ArtifactRecord = {
|
|
|
23
23
|
access?: Access;
|
|
24
24
|
state?: StateConfig;
|
|
25
25
|
theme?: ThemeMode;
|
|
26
|
+
/** The brand pack the page asked for by name; see ArtifactMeta.pack. */
|
|
27
|
+
pack?: string;
|
|
26
28
|
toc?: boolean;
|
|
27
29
|
definitions?: Definition[];
|
|
28
30
|
/** Who may leave shared comments; absent is off. Personal notes need nothing. */
|
package/lib/artifacts/store.js
CHANGED
|
@@ -134,6 +134,7 @@ async function saveArtifact(col, input) {
|
|
|
134
134
|
...(input.meta.access && input.meta.access !== 'public' ? { access: input.meta.access } : {}),
|
|
135
135
|
...(input.meta.state ? { state: input.meta.state } : {}),
|
|
136
136
|
...(input.meta.theme ? { theme: input.meta.theme } : {}),
|
|
137
|
+
...(input.meta.pack ? { pack: input.meta.pack } : {}),
|
|
137
138
|
...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
|
|
138
139
|
...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
|
|
139
140
|
...(input.meta.comments ? { comments: input.meta.comments } : {}),
|
|
@@ -181,6 +182,7 @@ async function saveArtifact(col, input) {
|
|
|
181
182
|
// How the page looks is content too: a line the file no longer carries goes back to the default.
|
|
182
183
|
const look = {
|
|
183
184
|
...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
|
|
185
|
+
...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
|
|
184
186
|
...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
|
|
185
187
|
...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
|
|
186
188
|
// Comments are not an access control, so a line the file no longer carries goes back to
|
package/lib/brand/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { freedomDefault, type BrandPack, type ShareCardAssets, type Palette, type Backdrop } from './pack.js';
|
|
1
|
+
export { freedomDefault, type BrandPack, type ShareCardAssets, type Palette, type Backdrop, type CoverProps, type ArticleProps, type SectionProps, type SectionOutline, type FigureProps } from './pack.js';
|
|
2
2
|
export { BrandGround, BrandMark } from './wrapper.js';
|
|
3
3
|
export { renderShareCard } from './share-card.js';
|
|
4
4
|
export { THEME_MODES, type ThemeMode, themeCss, themedPack, resolveMode } from './theme.js';
|
package/lib/brand/pack.d.ts
CHANGED
|
@@ -52,6 +52,76 @@ export type BrandPack = {
|
|
|
52
52
|
* previews as the page rather than as the brand. Absent, the instance's static default
|
|
53
53
|
* share image is used. Both loaders run once per instance. */
|
|
54
54
|
share?: ShareCardAssets;
|
|
55
|
+
/** Optional: draws the page's title block instead of the default centred header. Receives the
|
|
56
|
+
* title, subtitle and summary to render (they are SPOKEN, in that order, so render all three),
|
|
57
|
+
* plus the shell's unspoken kicker and version line to place. Anything else it draws must be
|
|
58
|
+
* inside `data-nospeak`, or the read-along highlight loses its place. */
|
|
59
|
+
Cover?: ComponentType<CoverProps>;
|
|
60
|
+
/** Optional: the element that holds the title block and the body, instead of the default
|
|
61
|
+
* centred column. Receives the cover (the pack's Cover or the default header), the inline
|
|
62
|
+
* table of contents when the page shows one, and the body. */
|
|
63
|
+
Article?: ComponentType<ArticleProps>;
|
|
64
|
+
/** Optional: each top-level section of the body (split at the shallowest heading level the page
|
|
65
|
+
* uses) is rendered through this, so a pack can draw a break before it or wrap it. Content
|
|
66
|
+
* before the first such heading is left as it is. Decoration must be `data-nospeak`. */
|
|
67
|
+
Section?: ComponentType<SectionProps>;
|
|
68
|
+
/** Optional: replaces an image (or `.mp4`/`.mp3` embed) with a component, by its src. Return
|
|
69
|
+
* undefined to keep the default. The shell wraps the result in `data-nospeak`, and an image
|
|
70
|
+
* alone in its paragraph replaces the paragraph, so the component may draw block content. */
|
|
71
|
+
figure?: (src: string, alt: string) => ComponentType<FigureProps> | undefined;
|
|
72
|
+
};
|
|
73
|
+
export type CoverProps = {
|
|
74
|
+
title: string;
|
|
75
|
+
subtitle?: string;
|
|
76
|
+
summary: string;
|
|
77
|
+
/** Who the page is for, when it says. Already inside `kicker`. */
|
|
78
|
+
to?: string;
|
|
79
|
+
/** The body, for a cover that quotes from it (anything quoted must be `data-nospeak`). */
|
|
80
|
+
markdown: string;
|
|
81
|
+
/** The shell's kicker line (the pack's kicker, then who it is for), unspoken. */
|
|
82
|
+
kicker: ReactNode;
|
|
83
|
+
/** The version line and, for a signed-in reader, who they are signed in as. Unspoken. */
|
|
84
|
+
meta: ReactNode;
|
|
85
|
+
version: number;
|
|
86
|
+
updatedAt: string;
|
|
87
|
+
createdAt: string;
|
|
88
|
+
};
|
|
89
|
+
export type ArticleProps = {
|
|
90
|
+
cover: ReactNode;
|
|
91
|
+
toc: ReactNode;
|
|
92
|
+
children: ReactNode;
|
|
93
|
+
};
|
|
94
|
+
/** One top-level heading of the page, in order. */
|
|
95
|
+
export type SectionOutline = {
|
|
96
|
+
index: number;
|
|
97
|
+
depth: number;
|
|
98
|
+
text: string;
|
|
99
|
+
slug: string;
|
|
100
|
+
};
|
|
101
|
+
export type SectionProps = {
|
|
102
|
+
/** This section's place among the page's top-level sections, from 0, and how many there are. */
|
|
103
|
+
index: number;
|
|
104
|
+
count: number;
|
|
105
|
+
/** Every top-level section, so a pack can tell what came before and what comes next. */
|
|
106
|
+
outline: SectionOutline[];
|
|
107
|
+
/** The heading's plain text and slug (its id, which the table of contents links to). */
|
|
108
|
+
text: string;
|
|
109
|
+
slug: string;
|
|
110
|
+
/** The heading as the shell draws it by default. */
|
|
111
|
+
heading: ReactNode;
|
|
112
|
+
/** The heading's inner content and the attributes it carries (its id and its comment anchor),
|
|
113
|
+
* for a pack that draws the heading element itself. Put the attributes on whatever holds it. */
|
|
114
|
+
headingContent: ReactNode;
|
|
115
|
+
headingAttrs: {
|
|
116
|
+
id?: string;
|
|
117
|
+
'data-block'?: string;
|
|
118
|
+
};
|
|
119
|
+
/** Everything under the heading, up to the next top-level heading. */
|
|
120
|
+
children: ReactNode;
|
|
121
|
+
};
|
|
122
|
+
export type FigureProps = {
|
|
123
|
+
src: string;
|
|
124
|
+
alt: string;
|
|
55
125
|
};
|
|
56
126
|
export type ShareCardAssets = {
|
|
57
127
|
/** The display font, as bytes the renderer can embed. Satori needs a real file: a CSS
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
-
import { type ArtifactStore, type VersionEntry } from '../artifacts/store.js';
|
|
3
|
+
import { type ArtifactRecord, type ArtifactStore, type VersionEntry } from '../artifacts/store.js';
|
|
4
4
|
import type { StateStore } from '../artifacts/state-store.js';
|
|
5
5
|
import { type ArtifactAssets } from '../artifacts/assets.js';
|
|
6
6
|
import type { BrandPack } from '../brand/pack.js';
|
|
@@ -18,6 +18,12 @@ export type ArtifactRoutesConfig = {
|
|
|
18
18
|
/** Where uploaded files go. Optional; without it PUT_ASSET answers 501. */
|
|
19
19
|
assets?: ArtifactAssets;
|
|
20
20
|
brand: BrandPack;
|
|
21
|
+
/** Other packs a page may choose by name with `pack: <name>` in its front matter. A name this
|
|
22
|
+
* host does not know falls back to `brand`, so a page never fails to render over its look. */
|
|
23
|
+
packs?: Record<string, BrandPack>;
|
|
24
|
+
/** Choose a page's pack on the host, ahead of its front matter: for a page whose file cannot
|
|
25
|
+
* carry the line, or a look the host decides. Undefined means no opinion. */
|
|
26
|
+
packFor?: (artifact: ArtifactRecord) => BrandPack | undefined;
|
|
21
27
|
/** The host that serves a 200, e.g. https://artifacts.example.com. No trailing slash. */
|
|
22
28
|
siteUrl: string;
|
|
23
29
|
/** Read at request time, so a rotated key needs no rebuild. */
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -43,6 +43,15 @@ export function createArtifactRoutes(config) {
|
|
|
43
43
|
// page follows its light, dark or system mode (brand/theme.ts).
|
|
44
44
|
const pack = config.brand;
|
|
45
45
|
const brand = themedPack(pack);
|
|
46
|
+
/** The pack one page renders in: the host's choice, then the page's `pack:`, then the default. */
|
|
47
|
+
const looks = new Map();
|
|
48
|
+
function lookOf(a) {
|
|
49
|
+
const p = config.packFor?.(a) ?? (a.pack ? config.packs?.[a.pack] : undefined) ?? pack;
|
|
50
|
+
let l = looks.get(p);
|
|
51
|
+
if (!l)
|
|
52
|
+
looks.set(p, (l = { pack: p, brand: themedPack(p) }));
|
|
53
|
+
return l;
|
|
54
|
+
}
|
|
46
55
|
const prefix = config.pagePrefix ?? '/';
|
|
47
56
|
const pageUrl = (id) => `${siteUrl}${prefix}${id}`;
|
|
48
57
|
const pagePath = (id) => `${prefix}${id}`;
|
|
@@ -71,7 +80,7 @@ export function createArtifactRoutes(config) {
|
|
|
71
80
|
});
|
|
72
81
|
/** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
|
|
73
82
|
* whole paragraph is data-nospeak, and narrationText has no `to` to read. */
|
|
74
|
-
function Kicker({ to }) {
|
|
83
|
+
function Kicker({ to, brand }) {
|
|
75
84
|
return (_jsxs("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: [brand.kicker, to ? _jsx("span", { "data-nospeak": true, "data-to": true, children: ` · For ${to}` }) : null] }));
|
|
76
85
|
}
|
|
77
86
|
async function generateMetadata({ params }) {
|
|
@@ -80,6 +89,7 @@ export function createArtifactRoutes(config) {
|
|
|
80
89
|
if (!a)
|
|
81
90
|
return { title: 'Not found', robots: { index: false, follow: false } };
|
|
82
91
|
const url = pageUrl(a.id);
|
|
92
|
+
const { brand } = lookOf(a);
|
|
83
93
|
const image = absolute(a.cover) ?? (brand.share ? shareCardUrl(a.id, a.updatedAt) : absolute(config.defaultShareImage));
|
|
84
94
|
// Who it is for leads the unfurl, so a thread shows it before the reader opens anything.
|
|
85
95
|
const title = a.to ? `For ${a.to}: ${a.title}` : a.title;
|
|
@@ -129,6 +139,7 @@ export function createArtifactRoutes(config) {
|
|
|
129
139
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
130
140
|
if (!a)
|
|
131
141
|
notFound();
|
|
142
|
+
const { pack, brand } = lookOf(a);
|
|
132
143
|
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top }));
|
|
133
144
|
if (a.access)
|
|
134
145
|
return GatedPage(a, id, render, n);
|
|
@@ -155,7 +166,7 @@ export function createArtifactRoutes(config) {
|
|
|
155
166
|
];
|
|
156
167
|
const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
|
|
157
168
|
if (!open) {
|
|
158
|
-
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
|
|
169
|
+
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
|
|
159
170
|
}
|
|
160
171
|
// Who is reading an open page, when they happen to be signed in: their comments follow them.
|
|
161
172
|
// Only a host that signs readers in can know one; the read never fails the page.
|
|
@@ -168,13 +179,14 @@ export function createArtifactRoutes(config) {
|
|
|
168
179
|
/** A page with `access:`. The body is rendered only after the reader is known and allowed;
|
|
169
180
|
* everyone else gets the title, the summary, and a door. */
|
|
170
181
|
async function GatedPage(a, id, render, n) {
|
|
182
|
+
const { pack, brand } = lookOf(a);
|
|
171
183
|
const here = n === null ? pageUrl(id) : `${pageUrl(id)}/v/${n}`;
|
|
172
184
|
const readCookie = config.readCookie ?? defaultReadCookie;
|
|
173
185
|
// Only a host that signs readers in can know one; the read never fails the page.
|
|
174
186
|
const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
|
|
175
187
|
const allow = config.readers && reader ? await config.readers.allowList(id) : [];
|
|
176
188
|
const d = config.readers ? decide(a.access, reader, allow) : { open: false, why: 'signed-out' };
|
|
177
|
-
const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }));
|
|
189
|
+
const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }));
|
|
178
190
|
if (!d.open) {
|
|
179
191
|
if (d.why === 'not-allowed' && config.readers)
|
|
180
192
|
void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
|
|
@@ -203,8 +215,16 @@ export function createArtifactRoutes(config) {
|
|
|
203
215
|
function VersionLine({ a, n, at, history }) {
|
|
204
216
|
return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
|
|
205
217
|
}
|
|
218
|
+
/** The title block and the body, in the pack's Article when it has one. Without one this is
|
|
219
|
+
* exactly the markup every page had before packs could frame it. */
|
|
220
|
+
function Framed({ pack, cover, toc, children }) {
|
|
221
|
+
if (pack.Article)
|
|
222
|
+
return _jsx(pack.Article, { cover: cover, toc: toc, children: children });
|
|
223
|
+
return (_jsxs(_Fragment, { children: [cover, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc, children] })] }));
|
|
224
|
+
}
|
|
206
225
|
/** A past version: its own body and title, read-only (no narration, no answers), under a banner. */
|
|
207
226
|
async function VersionBody(a, n, { top }) {
|
|
227
|
+
const { pack, brand } = lookOf(a);
|
|
208
228
|
const v = store.version ? await store.version(a.id, n) : null;
|
|
209
229
|
if (!v)
|
|
210
230
|
notFound();
|
|
@@ -215,10 +235,11 @@ export function createArtifactRoutes(config) {
|
|
|
215
235
|
const title = v.title ?? a.title;
|
|
216
236
|
const summary = v.summary ?? a.summary;
|
|
217
237
|
const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
|
|
218
|
-
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history })] }),
|
|
238
|
+
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, Section: pack.Section, figure: pack.figure }) })] }));
|
|
219
239
|
}
|
|
220
240
|
/** The page itself, shared by open and gated pages. */
|
|
221
241
|
async function Body(a, { top, reader }) {
|
|
242
|
+
const { pack, brand } = lookOf(a);
|
|
222
243
|
let words = [];
|
|
223
244
|
if (a.narration && a.timings) {
|
|
224
245
|
try {
|
|
@@ -233,7 +254,12 @@ export function createArtifactRoutes(config) {
|
|
|
233
254
|
const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
|
|
234
255
|
// Shared comments need the page to take them AND a host that keeps answers; personal notes need neither.
|
|
235
256
|
const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
|
|
236
|
-
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null,
|
|
257
|
+
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
|
|
258
|
+
const n = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
259
|
+
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id) }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
|
|
260
|
+
const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
|
|
261
|
+
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined, Section: pack.Section, figure: pack.figure }) }));
|
|
262
|
+
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : (
|
|
237
263
|
// No recorded narration, or its timings would not load: the browser reads the page.
|
|
238
264
|
_jsx(BrowserReader, { rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground }))] }));
|
|
239
265
|
}
|
|
@@ -505,12 +531,13 @@ export function createArtifactRoutes(config) {
|
|
|
505
531
|
* tenant on the static default never serves a half-styled card. */
|
|
506
532
|
async function SHARE_IMAGE(_request, { params }) {
|
|
507
533
|
const { id } = await params;
|
|
508
|
-
if (!brand.share)
|
|
509
|
-
return new NextResponse('no share card for this host', { status: 404 });
|
|
510
534
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
535
|
+
const look = a ? lookOf(a) : { pack, brand };
|
|
536
|
+
if (!look.brand.share)
|
|
537
|
+
return new NextResponse('no share card for this host', { status: 404 });
|
|
511
538
|
if (!a)
|
|
512
539
|
return new NextResponse(`no artifact with id ${id}`, { status: 404 });
|
|
513
|
-
return renderShareCard(pack, a.title, { to: a.to });
|
|
540
|
+
return renderShareCard(look.pack, a.title, { to: a.to });
|
|
514
541
|
}
|
|
515
542
|
/** GET|POST /api/artifacts/<id>/versions, publish key: the history, and a note written onto one
|
|
516
543
|
* version after the fact. POST body: { version: number, note: string | null }. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|