@supersuit/artifacts 0.10.0 → 0.11.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 +32 -0
- package/README.md +62 -0
- package/lib/artifacts/anchor.d.ts +33 -0
- package/lib/artifacts/anchor.js +93 -0
- package/lib/artifacts/comments.d.ts +38 -0
- package/lib/artifacts/comments.js +57 -0
- package/lib/artifacts/front-matter.d.ts +7 -0
- package/lib/artifacts/front-matter.js +19 -3
- package/lib/artifacts/index.d.ts +4 -1
- package/lib/artifacts/index.js +4 -1
- package/lib/artifacts/personal-store.d.ts +29 -0
- package/lib/artifacts/personal-store.js +100 -0
- package/lib/artifacts/render.js +60 -52
- package/lib/artifacts/state-store.d.ts +10 -0
- package/lib/artifacts/state-store.js +19 -0
- package/lib/artifacts/state.d.ts +7 -2
- package/lib/artifacts/state.js +7 -3
- package/lib/artifacts/store.d.ts +4 -0
- package/lib/artifacts/store.js +6 -0
- package/lib/artifacts/widgets.d.ts +4 -0
- package/lib/artifacts/widgets.js +70 -0
- package/lib/reader/comment-card.d.ts +18 -0
- package/lib/reader/comment-card.js +36 -0
- package/lib/reader/comment-layer.d.ts +17 -0
- package/lib/reader/comment-layer.js +384 -0
- package/lib/reader/comment-place.d.ts +45 -0
- package/lib/reader/comment-place.js +117 -0
- package/lib/reader/device-notes.d.ts +6 -0
- package/lib/reader/device-notes.js +35 -0
- package/lib/reader/index.d.ts +4 -0
- package/lib/reader/index.js +4 -0
- package/lib/routes/artifacts.d.ts +23 -0
- package/lib/routes/artifacts.js +23 -11
- package/lib/routes/personal-routes.d.ts +24 -0
- package/lib/routes/personal-routes.js +100 -0
- package/lib/routes/state-routes.d.ts +3 -0
- package/lib/routes/state-routes.js +71 -12
- package/package.json +1 -1
package/lib/artifacts/render.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 { ...
|
|
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
|
}
|
package/lib/artifacts/state.d.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/lib/artifacts/state.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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;
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -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;
|
package/lib/artifacts/store.js
CHANGED
|
@@ -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>;
|
package/lib/artifacts/widgets.js
CHANGED
|
@@ -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;
|