@supersuit/artifacts 0.10.0 → 0.11.1

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 (40) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +62 -0
  3. package/lib/artifacts/anchor.d.ts +33 -0
  4. package/lib/artifacts/anchor.js +93 -0
  5. package/lib/artifacts/comments.d.ts +38 -0
  6. package/lib/artifacts/comments.js +57 -0
  7. package/lib/artifacts/front-matter.d.ts +7 -0
  8. package/lib/artifacts/front-matter.js +19 -3
  9. package/lib/artifacts/index.d.ts +4 -1
  10. package/lib/artifacts/index.js +4 -1
  11. package/lib/artifacts/personal-store.d.ts +29 -0
  12. package/lib/artifacts/personal-store.js +100 -0
  13. package/lib/artifacts/render.js +60 -52
  14. package/lib/artifacts/state-store.d.ts +10 -0
  15. package/lib/artifacts/state-store.js +19 -0
  16. package/lib/artifacts/state.d.ts +7 -2
  17. package/lib/artifacts/state.js +7 -3
  18. package/lib/artifacts/store.d.ts +4 -0
  19. package/lib/artifacts/store.js +6 -0
  20. package/lib/artifacts/widgets.d.ts +4 -0
  21. package/lib/artifacts/widgets.js +70 -0
  22. package/lib/reader/comment-card.d.ts +18 -0
  23. package/lib/reader/comment-card.js +36 -0
  24. package/lib/reader/comment-layer.d.ts +17 -0
  25. package/lib/reader/comment-layer.js +394 -0
  26. package/lib/reader/comment-place.d.ts +45 -0
  27. package/lib/reader/comment-place.js +117 -0
  28. package/lib/reader/device-notes.d.ts +6 -0
  29. package/lib/reader/device-notes.js +35 -0
  30. package/lib/reader/index.d.ts +4 -0
  31. package/lib/reader/index.js +4 -0
  32. package/lib/reader/theme-vars.d.ts +3 -0
  33. package/lib/reader/theme-vars.js +33 -0
  34. package/lib/routes/artifacts.d.ts +23 -0
  35. package/lib/routes/artifacts.js +23 -11
  36. package/lib/routes/personal-routes.d.ts +24 -0
  37. package/lib/routes/personal-routes.js +100 -0
  38. package/lib/routes/state-routes.d.ts +3 -0
  39. package/lib/routes/state-routes.js +71 -12
  40. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import ReactMarkdown from 'react-markdown';
3
3
  import remarkGfm from 'remark-gfm';
4
- import { hasNotesWidget, headingsOf } from './widgets.js';
4
+ import { blocksOf, hasNotesWidget, headingsOf } from './widgets.js';
5
5
  import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
6
6
  import { VideoAutoplay } from '../reader/video-autoplay.js';
7
7
  import { DefinitionLayer } from '../reader/definition-layer.js';
@@ -70,50 +70,52 @@ function InlineAudio({ src, label }) {
70
70
  const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio controls preload="none" class="block w-full"></audio>`;
71
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
72
  }
73
- const components = {
74
- p: ({ children }) => _jsx("p", { className: "my-4 leading-relaxed", children: children }),
75
- ul: ({ children }) => _jsx("ul", { className: "my-4 list-disc space-y-1 pl-6", children: children }),
76
- ol: ({ children }) => _jsx("ol", { className: "my-4 list-decimal space-y-1 pl-6", children: children }),
77
- hr: () => _jsx("hr", { className: "my-10 border-[color:var(--a-line)]" }),
78
- table: ({ children }) => (_jsx("div", { className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
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 })),
80
- td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
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 ?? '' })) : (
82
- // eslint-disable-next-line @next/next/no-img-element
83
- _jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" })),
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] })),
85
- code: ({ className, children }) => {
86
- const lang = /language-(\w+)/.exec(className ?? '')?.[1];
87
- if (lang === 'links')
88
- return _jsx(LinkCards, { source: textOf(children) });
89
- // A notes fence is the widget's place on the page. Without notes enabled (the host keeps no
90
- // answers) it draws nothing: its settings are not prose and never shown as code.
91
- if (lang === 'notes')
92
- return null;
93
- if (!className) {
94
- return _jsx("code", { className: "rounded bg-[color:var(--a-surface-strong)] px-1.5 py-0.5 text-[0.9em] text-[color:var(--a-strong)]", children: children });
95
- }
96
- return _jsx("code", { className: className, children: children });
97
- },
98
- pre: ({ children }) => {
99
- // A links fence renders its own block; do not wrap it in <pre>.
100
- const inner = Array.isArray(children) ? children[0] : children;
101
- const cls = inner?.props?.className ?? '';
102
- if (/language-(links|notes)/.test(cls))
103
- return _jsx(_Fragment, { children: children });
104
- return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-code)] p-4 text-sm text-[color:var(--a-strong)]", children: children }));
105
- },
106
- blockquote: ({ children }) => {
107
- const t = textOf(children).trim();
108
- const m = CALLOUT.exec(t);
109
- if (!m) {
110
- return (_jsx("blockquote", { className: "my-6 border-l-2 pl-4 italic text-[color:var(--a-body)]", style: { borderColor: GOLD }, children: children }));
111
- }
112
- const kind = m[1].toLowerCase();
113
- const body = t.replace(CALLOUT, '');
114
- return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: 'var(--a-surface)' }, children: [_jsx("span", { "data-nospeak": true, className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: kind }), _jsx("p", { className: "mt-1 text-[color:var(--a-strong)]", children: body })] }));
115
- },
116
- };
73
+ function baseComponents(block) {
74
+ return {
75
+ p: ({ node, children }) => _jsx("p", { ...block(node), className: "my-4 leading-relaxed", children: children }),
76
+ ul: ({ node, children }) => _jsx("ul", { ...block(node), className: "my-4 list-disc space-y-1 pl-6", children: children }),
77
+ ol: ({ node, children }) => _jsx("ol", { ...block(node), className: "my-4 list-decimal space-y-1 pl-6", children: children }),
78
+ hr: () => _jsx("hr", { className: "my-10 border-[color:var(--a-line)]" }),
79
+ 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
+ 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
+ td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
82
+ 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 ?? '' })) : (
83
+ // eslint-disable-next-line @next/next/no-img-element
84
+ _jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" })),
85
+ 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
+ code: ({ className, children }) => {
87
+ const lang = /language-(\w+)/.exec(className ?? '')?.[1];
88
+ if (lang === 'links')
89
+ return _jsx(LinkCards, { source: textOf(children) });
90
+ // A notes fence is the widget's place on the page. Without notes enabled (the host keeps no
91
+ // answers) it draws nothing: its settings are not prose and never shown as code.
92
+ if (lang === 'notes')
93
+ return null;
94
+ if (!className) {
95
+ return _jsx("code", { className: "rounded bg-[color:var(--a-surface-strong)] px-1.5 py-0.5 text-[0.9em] text-[color:var(--a-strong)]", children: children });
96
+ }
97
+ return _jsx("code", { className: className, children: children });
98
+ },
99
+ pre: ({ node, children }) => {
100
+ // A links fence renders its own block; do not wrap it in <pre>.
101
+ const inner = Array.isArray(children) ? children[0] : children;
102
+ const cls = inner?.props?.className ?? '';
103
+ if (/language-(links|notes)/.test(cls))
104
+ return _jsx(_Fragment, { children: children });
105
+ return (_jsx("pre", { ...block(node), "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-code)] p-4 text-sm text-[color:var(--a-strong)]", children: children }));
106
+ },
107
+ blockquote: ({ node, children }) => {
108
+ const t = textOf(children).trim();
109
+ const m = CALLOUT.exec(t);
110
+ if (!m) {
111
+ return (_jsx("blockquote", { ...block(node), className: "my-6 border-l-2 pl-4 italic text-[color:var(--a-body)]", style: { borderColor: GOLD }, children: children }));
112
+ }
113
+ const kind = m[1].toLowerCase();
114
+ const body = t.replace(CALLOUT, '');
115
+ return (_jsxs("aside", { ...block(node), "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: 'var(--a-surface)' }, children: [_jsx("span", { "data-nospeak": true, className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: kind }), _jsx("p", { className: "mt-1 text-[color:var(--a-strong)]", children: body })] }));
116
+ },
117
+ };
118
+ }
117
119
  // scroll-mt keeps a heading clear of the top of the screen when a contents link jumps to it.
118
120
  const HEADING_CLASS = {
119
121
  1: 'mt-10 mb-4 scroll-mt-24 font-serif text-3xl text-[color:var(--a-strong)]',
@@ -125,26 +127,27 @@ const HEADING_CLASS = {
125
127
  };
126
128
  /** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
127
129
  * so a contents link and a note both land on it. */
128
- function pageComponents(headings) {
130
+ function pageComponents(headings, block) {
129
131
  const byLine = new Map(headings.map((h) => [h.line, h]));
130
132
  const heading = (depth) => function Heading({ node, children }) {
131
133
  const Tag = `h${depth}`;
132
134
  const h = byLine.get(node?.position?.start.line ?? -1);
133
- return _jsx(Tag, { id: h?.slug, className: HEADING_CLASS[depth], children: children });
135
+ return _jsx(Tag, { id: h?.slug, ...block(node), className: HEADING_CLASS[depth], children: children });
134
136
  };
135
- return { ...components, h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
137
+ return { ...baseComponents(block), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
136
138
  }
137
139
  /** Components for a page with a notes block: every heading gets its slug as an id, a note
138
140
  * control, and its notes after it. The heading is found by its source line, so the slug is the
139
141
  * one headingsOf derived, the same one a note stores. */
140
- function notesComponents(headings) {
142
+ function notesComponents(headings, block) {
143
+ const components = baseComponents(block);
141
144
  const byLine = new Map(headings.map((h) => [h.line, h]));
142
145
  const heading = (depth) => function NotedHeading({ node, children }) {
143
146
  const Tag = `h${depth}`;
144
147
  const h = byLine.get(node?.position?.start.line ?? -1);
145
148
  if (!h)
146
- return _jsx(Tag, { className: HEADING_CLASS[depth], children: children });
147
- return (_jsxs(_Fragment, { children: [_jsxs(Tag, { id: h.slug, className: HEADING_CLASS[depth], children: [children, _jsx(NoteToggle, { slug: h.slug })] }), _jsx(HeadingNotes, { slug: h.slug, text: h.text })] }));
149
+ return _jsx(Tag, { ...block(node), className: HEADING_CLASS[depth], children: children });
150
+ return (_jsxs(_Fragment, { children: [_jsxs(Tag, { id: h.slug, ...block(node), className: HEADING_CLASS[depth], children: [children, _jsx(NoteToggle, { slug: h.slug })] }), _jsx(HeadingNotes, { slug: h.slug, text: h.text })] }));
148
151
  };
149
152
  const Code = components.code;
150
153
  return {
@@ -170,8 +173,13 @@ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;
170
173
  export function ArtifactMarkdown({ markdown, notes, definitions, }) {
171
174
  const on = !!notes && hasNotesWidget(markdown);
172
175
  const headings = headingsOf(markdown);
176
+ const blocks = blocksOf(markdown);
177
+ const block = (node) => {
178
+ const id = blocks.get(node?.position?.start.offset ?? -1);
179
+ return id ? { 'data-block': id } : {};
180
+ };
173
181
  const defined = definitions?.length ? definitions : null;
174
- const body = (_jsx(ReactMarkdown, { remarkPlugins: defined ? [remarkGfm, remarkDefinitions(defined)] : [remarkGfm], components: on ? notesComponents(headings) : pageComponents(headings), children: markdown }));
182
+ const body = (_jsx(ReactMarkdown, { remarkPlugins: defined ? [remarkGfm, remarkDefinitions(defined)] : [remarkGfm], components: on ? notesComponents(headings, block) : pageComponents(headings, block), children: markdown }));
175
183
  return (
176
184
  // break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
177
185
  _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] }));
@@ -52,6 +52,16 @@ export interface StateStore {
52
52
  /** Does this reader already hold a `one` answer in this slot? One document read. Optional so a
53
53
  * host's own store keeps working; without it the route falls back to loading the page. */
54
54
  hasOne?(artifactId: string, slot: string, readerKey: string): Promise<boolean>;
55
+ /** Replace the value of one of THIS reader's own `many` entries (editing a comment), keeping its
56
+ * id and time. Null when no such entry is theirs. Optional so a host's own store keeps working;
57
+ * without it an edit answers 501. */
58
+ replace?(input: {
59
+ artifactId: string;
60
+ slot: string;
61
+ readerKey: string;
62
+ entryId: string;
63
+ value: unknown;
64
+ }): Promise<StateEntry | null>;
55
65
  }
56
66
  export declare const readerKeyFor: {
57
67
  signedIn: (uid: string) => string;
@@ -90,6 +90,17 @@ export function createStateStore(db, base) {
90
90
  return (await col().where('artifactId', '==', artifactId).where('slot', '==', slot).count().get()).data().count;
91
91
  },
92
92
  hasOne: async (artifactId, slot, readerKey) => (await col().doc(oneId(artifactId, slot, readerKey)).get()).exists,
93
+ async replace({ artifactId, slot, readerKey, entryId, value }) {
94
+ const ref = col().doc(entryId);
95
+ const snap = await ref.get();
96
+ if (!snap.exists)
97
+ return null;
98
+ const e = toEntry(snap.id, snap.data());
99
+ if (e.artifactId !== artifactId || e.slot !== slot || e.readerKey !== readerKey || e.shape !== 'many')
100
+ return null;
101
+ await ref.update({ json: JSON.stringify(value) });
102
+ return { ...e, value };
103
+ },
93
104
  };
94
105
  return store;
95
106
  }
@@ -149,5 +160,13 @@ export function createMemoryStateStore() {
149
160
  },
150
161
  countSlot: async (artifactId, slot) => of(artifactId).filter((e) => e.slot === slot).length,
151
162
  hasOne: async (artifactId, slot, readerKey) => docs.has(oneId(artifactId, slot, readerKey)),
163
+ async replace({ artifactId, slot, readerKey, entryId, value }) {
164
+ const e = docs.get(entryId);
165
+ if (!e || e.artifactId !== artifactId || e.slot !== slot || e.readerKey !== readerKey || e.shape !== 'many')
166
+ return null;
167
+ const next = { ...e, value: structuredClone(value) };
168
+ docs.set(entryId, next);
169
+ return structuredClone(next);
170
+ },
152
171
  };
153
172
  }
@@ -1,9 +1,13 @@
1
1
  export type Shape = 'one' | 'many';
2
2
  export type Visibility = 'private' | 'tally' | 'shared';
3
3
  export type Writers = 'signed-in' | 'anyone';
4
+ /** `writers` on a slot is never written by an author (parseStateConfig refuses it); a widget that
5
+ * declares its own slot sets it, as `comments:` does, so one page can take comments from anyone
6
+ * and votes only from signed-in readers. Absent means the page's `writers`. */
4
7
  export type SlotDef = {
5
8
  shape: Shape;
6
9
  visibility?: Visibility;
10
+ writers?: Writers;
7
11
  };
8
12
  export type StateConfig = {
9
13
  writers: Writers;
@@ -27,8 +31,9 @@ export declare function parseStateConfig(raw: unknown): {
27
31
  ok: false;
28
32
  error: string;
29
33
  };
30
- /** A gated page's readers are always signed in, so its state is too, whatever the file says. */
31
- export declare function effectiveWriters(state: StateConfig, access?: string): Writers;
34
+ /** A gated page's readers are always signed in, so its state is too, whatever the file says.
35
+ * With a slot named, that slot's own writers (when it declares any) stand in for the page's. */
36
+ export declare function effectiveWriters(state: StateConfig, access?: string, slot?: string): Writers;
32
37
  export declare function slotVisibility(state: StateConfig, slot: string): Visibility;
33
38
  export declare function checkValue(value: unknown): string | null;
34
39
  /** Answers are kept across a republish, so a slot may never change what shape its data is. */
@@ -42,9 +42,13 @@ export function parseStateConfig(raw) {
42
42
  }
43
43
  return { ok: true, state: { writers, visibility: visibility, slots } };
44
44
  }
45
- /** A gated page's readers are always signed in, so its state is too, whatever the file says. */
46
- export function effectiveWriters(state, access) {
47
- return access ? 'signed-in' : state.writers;
45
+ /** A gated page's readers are always signed in, so its state is too, whatever the file says.
46
+ * With a slot named, that slot's own writers (when it declares any) stand in for the page's. */
47
+ export function effectiveWriters(state, access, slot) {
48
+ if (access)
49
+ return 'signed-in';
50
+ const def = slot !== undefined && Object.hasOwn(state.slots, slot) ? state.slots[slot] : undefined;
51
+ return def?.writers ?? state.writers;
48
52
  }
49
53
  export function slotVisibility(state, slot) {
50
54
  return state.slots[slot]?.visibility ?? state.visibility;
@@ -4,6 +4,7 @@ import type { Access } from './reader.js';
4
4
  import type { StateConfig } from './state.js';
5
5
  import type { ThemeMode } from '../brand/theme.js';
6
6
  import type { Definition } from './definitions.js';
7
+ import type { CommentsMode, CommentsVisible } from './comments.js';
7
8
  export type ArtifactRecord = {
8
9
  id: string;
9
10
  title: string;
@@ -22,6 +23,9 @@ export type ArtifactRecord = {
22
23
  theme?: ThemeMode;
23
24
  toc?: boolean;
24
25
  definitions?: Definition[];
26
+ /** Who may leave shared comments; absent is off. Personal notes need nothing. */
27
+ comments?: CommentsMode;
28
+ commentsVisible?: CommentsVisible;
25
29
  markdown: string;
26
30
  createdAt: string;
27
31
  updatedAt: string;
@@ -135,6 +135,8 @@ async function saveArtifact(col, input) {
135
135
  ...(input.meta.theme ? { theme: input.meta.theme } : {}),
136
136
  ...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
137
137
  ...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
138
+ ...(input.meta.comments ? { comments: input.meta.comments } : {}),
139
+ ...(input.meta.commentsVisible ? { commentsVisible: input.meta.commentsVisible } : {}),
138
140
  };
139
141
  if (input.id) {
140
142
  const existing = await getArtifact(col, input.id);
@@ -178,6 +180,10 @@ async function saveArtifact(col, input) {
178
180
  ...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
179
181
  ...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
180
182
  ...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
183
+ // Comments are not an access control, so a line the file no longer carries goes back to
184
+ // off / owner. The comments already left are kept, like every answer.
185
+ ...(input.meta.comments ? {} : { comments: FieldValue.delete() }),
186
+ ...(input.meta.commentsVisible ? {} : { commentsVisible: FieldValue.delete() }),
181
187
  };
182
188
  await ref.update({
183
189
  ...fields, ...password, ...subtitle, ...access, ...state, ...look, markdown: input.markdown, version: next,
@@ -36,3 +36,7 @@ export declare function hasNotesWidget(markdown: string): boolean;
36
36
  /** The server's check on a note, on a page with a notes block. The browser checks too; this is
37
37
  * the one that counts. */
38
38
  export declare function checkNoteValue(v: unknown): string | null;
39
+ /** The stable id of every top-level block, by the offset it starts at in the markdown. The
40
+ * renderer puts it on the block as `data-block`, and a region comment is pinned to it. Blocks
41
+ * that draw no prose (a notes or links fence, a rule, raw html) get none. */
42
+ export declare function blocksOf(markdown: string): Map<number, string>;
@@ -12,6 +12,7 @@
12
12
  import { unified } from 'unified';
13
13
  import remarkParse from 'remark-parse';
14
14
  import remarkGfm from 'remark-gfm';
15
+ import { blockId } from './anchor.js';
15
16
  import { MAX_NOTE_CHARS, NOTES_SLOT } from './notes-place.js';
16
17
  export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from './notes-place.js';
17
18
  /** Widgets the design names and this version does not draw yet. A fence using one is refused at
@@ -139,3 +140,72 @@ export function checkNoteValue(v) {
139
140
  return `a note is at most ${MAX_NOTE_CHARS} characters`;
140
141
  return null;
141
142
  }
143
+ function plain(n) {
144
+ if ('value' in n && typeof n.value === 'string')
145
+ return n.value;
146
+ if (n.type === 'image')
147
+ return n.alt ?? '';
148
+ if ('children' in n)
149
+ return n.children.map(plain).join(n.type === 'list' || n.type === 'table' || n.type === 'tableRow' ? '\n' : '');
150
+ return '';
151
+ }
152
+ /** An image's asset name without the host's content digest (`chart.0a1b2c3d.png` is `chart.png`),
153
+ * so replacing the file under the same name keeps the comments drawn on it. */
154
+ function assetName(url) {
155
+ const base = url.split(/[?#]/)[0].split('/').pop() ?? url;
156
+ return base.replace(/\.[0-9a-f]{8}(\.[^.]+)$/, '$1');
157
+ }
158
+ /** The stable id of every top-level block, by the offset it starts at in the markdown. The
159
+ * renderer puts it on the block as `data-block`, and a region comment is pinned to it. Blocks
160
+ * that draw no prose (a notes or links fence, a rule, raw html) get none. */
161
+ export function blocksOf(markdown) {
162
+ const out = new Map();
163
+ const seen = new Map();
164
+ for (const n of parse(markdown).children) {
165
+ const at = n.position?.start.offset;
166
+ if (at === undefined)
167
+ continue;
168
+ let kind;
169
+ let content;
170
+ if (n.type === 'paragraph') {
171
+ const only = n.children.filter((c) => !(c.type === 'text' && !c.value.trim()));
172
+ if (only.length === 1 && only[0].type === 'image') {
173
+ kind = 'img';
174
+ content = assetName(only[0].url);
175
+ }
176
+ else {
177
+ kind = 'p';
178
+ content = plain(n);
179
+ }
180
+ }
181
+ else if (n.type === 'heading') {
182
+ kind = 'h';
183
+ content = plain(n);
184
+ }
185
+ else if (n.type === 'list') {
186
+ kind = n.ordered ? 'ol' : 'ul';
187
+ content = plain(n);
188
+ }
189
+ else if (n.type === 'table') {
190
+ kind = 'table';
191
+ content = plain(n);
192
+ }
193
+ else if (n.type === 'blockquote') {
194
+ kind = 'quote';
195
+ content = plain(n);
196
+ }
197
+ else if (n.type === 'code') {
198
+ if (n.lang === 'links' || n.lang === NOTES_SLOT)
199
+ continue;
200
+ kind = 'code';
201
+ content = n.value;
202
+ }
203
+ else
204
+ continue;
205
+ const k = `${kind}\n${content}`;
206
+ const i = seen.get(k) ?? 0;
207
+ seen.set(k, i + 1);
208
+ out.set(at, blockId(kind, i, content));
209
+ }
210
+ return out;
211
+ }
@@ -0,0 +1,18 @@
1
+ import { type CommentsMode } from '../artifacts/comments.js';
2
+ /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
3
+ export declare function warningLine(ownerName: string, mode: CommentsMode): string;
4
+ export declare const DEVICE_LINE = "Saved on this device. Sign in to keep it everywhere.";
5
+ export declare function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial, accent, signIn, onSave, onCancel, }: {
6
+ ownerName: string;
7
+ mode: CommentsMode;
8
+ canShare: boolean;
9
+ signedIn: boolean;
10
+ /** The quoted text the comment is pinned to, shown above the box. */
11
+ quote?: string;
12
+ initial?: string;
13
+ accent?: string;
14
+ signIn?: string | null;
15
+ /** Resolves to an error to show, or null when saved. */
16
+ onSave: (body: string, share: boolean) => Promise<string | null>;
17
+ onCancel: () => void;
18
+ }): import("react").JSX.Element;
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The card a comment is written in. It says, before anything is typed, who will read it: when the
4
+ // reader cannot share, the first line in the card is the warning that this is a personal note the
5
+ // owner will never see, shown every time, because the moment it matters is the moment someone is
6
+ // about to write something they meant for the owner. When they can share, a switch picks Share
7
+ // (the default) or Just for me.
8
+ import { useState } from 'react';
9
+ import { MAX_COMMENT_CHARS } from '../artifacts/comments.js';
10
+ /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
11
+ export function warningLine(ownerName, mode) {
12
+ const why = mode === 'off'
13
+ ? `${ownerName} has not opened this page to comments`
14
+ : `${ownerName} takes comments on this page from signed-in readers`;
15
+ return `Only you will see this. ${why}, so this is a personal note. They won't see it.`;
16
+ }
17
+ export const DEVICE_LINE = 'Saved on this device. Sign in to keep it everywhere.';
18
+ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial = '', accent = 'currentColor', signIn, onSave, onCancel, }) {
19
+ const [text, setText] = useState(initial);
20
+ const [share, setShare] = useState(canShare);
21
+ const [busy, setBusy] = useState(false);
22
+ const [error, setError] = useState(null);
23
+ const personal = !canShare || !share;
24
+ const submit = async (e) => {
25
+ e?.preventDefault();
26
+ const body = text.trim();
27
+ if (!body || busy)
28
+ return;
29
+ setBusy(true);
30
+ const err = await onSave(body, canShare && share);
31
+ setBusy(false);
32
+ setError(err);
33
+ };
34
+ return (_jsxs("form", { "data-comment-card": true, "data-comment-ui": true, "data-nospeak": true, onSubmit: submit, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-left font-sans text-sm text-[color:var(--a-strong)] shadow-xl", children: [!canShare ? (_jsx("p", { "data-comment-warning": true, role: "note", className: "mb-2 rounded-lg border px-3 py-2 text-[13px] leading-snug", style: { borderColor: accent }, children: warningLine(ownerName, mode) })) : null, quote ? _jsx("p", { className: "mb-2 line-clamp-3 border-l-2 pl-2 text-[13px] italic opacity-75", style: { borderColor: accent }, children: quote }) : null, _jsx("textarea", { value: text, autoFocus: true, onChange: (e) => setText(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey))
35
+ void submit(); }, maxLength: MAX_COMMENT_CHARS, rows: 3, "aria-label": personal ? 'A personal note' : `A comment for ${ownerName}`, placeholder: personal ? 'A note for yourself' : `A comment for ${ownerName}`, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] p-2 text-[15px]" }), canShare ? (_jsxs("fieldset", { className: "mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[13px]", children: [_jsx("legend", { className: "sr-only", children: "Who sees this" }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "share", checked: share, onChange: () => setShare(true) }), `Share with ${ownerName}`] }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "mine", checked: !share, onChange: () => setShare(false) }), "Just for me"] })] })) : null, personal && !signedIn ? (_jsx("p", { "data-comment-device": true, className: "mt-2 text-xs opacity-70", children: signIn ? _jsxs(_Fragment, { children: ["Saved on this device. ", _jsx("a", { href: signIn, className: "underline", children: "Sign in to keep it everywhere." })] }) : DEVICE_LINE })) : null, error ? _jsx("p", { role: "alert", className: "mt-2 text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx("button", { type: "submit", "data-comment-save": true, disabled: busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: personal ? 'Save note' : 'Comment' }), _jsx("button", { type: "button", onClick: onCancel, className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
36
+ }
@@ -0,0 +1,17 @@
1
+ import { type CommentsMode } from '../artifacts/comments.js';
2
+ export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, }: {
3
+ artifactId: string;
4
+ /** The element holding the page's prose, the same root the read-aloud lights. */
5
+ rootId: string;
6
+ ownerName: string;
7
+ comments: CommentsMode;
8
+ /** This reader may leave shared comments here (comments: is on, and they are signed in where it asks). */
9
+ canShare: boolean;
10
+ signedIn: boolean;
11
+ isOwner: boolean;
12
+ /** The version being read, stored on every comment left on it. */
13
+ version: number;
14
+ accent: string;
15
+ ground: string;
16
+ signIn?: string | null;
17
+ }): import("react").JSX.Element;