@supersuit/artifacts 0.9.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 +51 -0
- package/README.md +63 -1
- 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/artifact-reader.d.ts +5 -1
- package/lib/reader/artifact-reader.js +23 -17
- package/lib/reader/browser-reader.d.ts +8 -0
- package/lib/reader/browser-reader.js +171 -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 +8 -0
- package/lib/reader/index.js +10 -0
- package/lib/reader/player-bar.d.ts +18 -0
- package/lib/reader/player-bar.js +16 -0
- package/lib/reader/speech-plan.d.ts +20 -0
- package/lib/reader/speech-plan.js +67 -0
- package/lib/routes/artifacts.d.ts +23 -0
- package/lib/routes/artifacts.js +26 -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 +3 -3
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Anchor, type RegionAnchor } from '../artifacts/anchor.js';
|
|
2
|
+
export type TextIndex = {
|
|
3
|
+
text: string;
|
|
4
|
+
nodes: {
|
|
5
|
+
node: Text;
|
|
6
|
+
start: number;
|
|
7
|
+
}[];
|
|
8
|
+
};
|
|
9
|
+
/** Every text node under the root, in order, joined. Word spans the read-aloud wraps around the
|
|
10
|
+
* words keep the same text, so the string is the same before and after they are drawn. */
|
|
11
|
+
export declare function indexText(root: HTMLElement): TextIndex;
|
|
12
|
+
/** The offset in the index of a selection boundary (a text node and an offset in it, or an element
|
|
13
|
+
* and a child index). Null when the boundary is not under the indexed root. */
|
|
14
|
+
export declare function offsetAt(idx: TextIndex, container: Node, offset: number): number | null;
|
|
15
|
+
/** A Range over [start, end) of the index. */
|
|
16
|
+
export declare function rangeOf(idx: TextIndex, start: number, end: number): Range | null;
|
|
17
|
+
/** The slug of the last heading at or before an offset: the section a quote sat under. */
|
|
18
|
+
export declare function headingAt(root: HTMLElement, idx: TextIndex, offset: number): string | undefined;
|
|
19
|
+
export type Placement = {
|
|
20
|
+
kind: 'text';
|
|
21
|
+
range: Range;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'region';
|
|
24
|
+
block: HTMLElement;
|
|
25
|
+
anchor: RegionAnchor;
|
|
26
|
+
};
|
|
27
|
+
/** Where an anchor is on this page now, or null: the comment is then on an earlier version. */
|
|
28
|
+
export declare function placeAnchor(root: HTMLElement, idx: TextIndex, a: Anchor): Placement | null;
|
|
29
|
+
type Box = {
|
|
30
|
+
left: number;
|
|
31
|
+
top: number;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
};
|
|
35
|
+
/** Page coordinates (scroll included) of a placement: a text quote's first line, or the drawn box. */
|
|
36
|
+
export declare function boxOf(p: Placement): Box;
|
|
37
|
+
/** A box dragged over a block, as fractions of that block, clamped inside it. */
|
|
38
|
+
export declare function regionFrom(block: DOMRect, a: {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
}, b: {
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
}, id: string): RegionAnchor | null;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Between the page in the browser and a comment's anchor: the page's text as one string (so a
|
|
2
|
+
// selection becomes a text-quote anchor and an anchor becomes a Range again), and where on the
|
|
3
|
+
// screen each anchor sits. DOM only, no React, so the layer and its tests share it.
|
|
4
|
+
import { findText } from '../artifacts/anchor.js';
|
|
5
|
+
/** Text that is on the page but not the page's prose: the notes widget, hidden definition copy,
|
|
6
|
+
* and anything the comment layer itself draws. */
|
|
7
|
+
const SKIP = 'script, style, [data-heading-notes], [data-artifact-notes], .artifact-defined-note, [data-comment-ui]';
|
|
8
|
+
/** Every text node under the root, in order, joined. Word spans the read-aloud wraps around the
|
|
9
|
+
* words keep the same text, so the string is the same before and after they are drawn. */
|
|
10
|
+
export function indexText(root) {
|
|
11
|
+
const nodes = [];
|
|
12
|
+
let text = '';
|
|
13
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
14
|
+
acceptNode: (n) => (n.parentElement?.closest(SKIP) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
|
|
15
|
+
});
|
|
16
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
17
|
+
nodes.push({ node: n, start: text.length });
|
|
18
|
+
text += n.data;
|
|
19
|
+
}
|
|
20
|
+
return { text, nodes };
|
|
21
|
+
}
|
|
22
|
+
/** The offset in the index of a selection boundary (a text node and an offset in it, or an element
|
|
23
|
+
* and a child index). Null when the boundary is not under the indexed root. */
|
|
24
|
+
export function offsetAt(idx, container, offset) {
|
|
25
|
+
const direct = idx.nodes.find((n) => n.node === container);
|
|
26
|
+
if (direct)
|
|
27
|
+
return direct.start + Math.min(offset, direct.node.data.length);
|
|
28
|
+
const at = document.createRange();
|
|
29
|
+
try {
|
|
30
|
+
at.setStart(container, offset);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
at.collapse(true);
|
|
36
|
+
let pos = null;
|
|
37
|
+
for (const { node, start } of idx.nodes) {
|
|
38
|
+
// A text node wholly before the boundary counts in full; the first one after it stops the count.
|
|
39
|
+
if (at.comparePoint(node, node.data.length) <= 0)
|
|
40
|
+
pos = start + node.data.length;
|
|
41
|
+
else {
|
|
42
|
+
pos ??= start;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return pos;
|
|
47
|
+
}
|
|
48
|
+
/** A Range over [start, end) of the index. */
|
|
49
|
+
export function rangeOf(idx, start, end) {
|
|
50
|
+
const locate = (i, isEnd) => {
|
|
51
|
+
for (const { node, start: s } of idx.nodes) {
|
|
52
|
+
const e = s + node.data.length;
|
|
53
|
+
if (i < e || (isEnd && i === e))
|
|
54
|
+
return { node, off: i - s };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
};
|
|
58
|
+
const a = locate(start, false);
|
|
59
|
+
const b = locate(end, true);
|
|
60
|
+
if (!a || !b)
|
|
61
|
+
return null;
|
|
62
|
+
const r = document.createRange();
|
|
63
|
+
r.setStart(a.node, a.off);
|
|
64
|
+
r.setEnd(b.node, b.off);
|
|
65
|
+
return r;
|
|
66
|
+
}
|
|
67
|
+
/** The slug of the last heading at or before an offset: the section a quote sat under. */
|
|
68
|
+
export function headingAt(root, idx, offset) {
|
|
69
|
+
let slug;
|
|
70
|
+
for (const h of root.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]')) {
|
|
71
|
+
const at = offsetAt(idx, h, 0);
|
|
72
|
+
if (at !== null && at <= offset)
|
|
73
|
+
slug = h.id;
|
|
74
|
+
}
|
|
75
|
+
return slug;
|
|
76
|
+
}
|
|
77
|
+
/** Where an anchor is on this page now, or null: the comment is then on an earlier version. */
|
|
78
|
+
export function placeAnchor(root, idx, a) {
|
|
79
|
+
if (a.kind === 'text') {
|
|
80
|
+
const at = findText(idx.text, a);
|
|
81
|
+
const range = at ? rangeOf(idx, at.start, at.end) : null;
|
|
82
|
+
return range ? { kind: 'text', range } : null;
|
|
83
|
+
}
|
|
84
|
+
const block = root.querySelector(`[data-block="${a.block}"]`);
|
|
85
|
+
return block ? { kind: 'region', block, anchor: a } : null;
|
|
86
|
+
}
|
|
87
|
+
const pageBox = (r) => ({ left: r.left + window.scrollX, top: r.top + window.scrollY, width: r.width, height: r.height });
|
|
88
|
+
/** Page coordinates (scroll included) of a placement: a text quote's first line, or the drawn box. */
|
|
89
|
+
export function boxOf(p) {
|
|
90
|
+
if (p.kind === 'text') {
|
|
91
|
+
// jsdom has no Range geometry; a browser always does.
|
|
92
|
+
const rects = typeof p.range.getClientRects === 'function' ? Array.from(p.range.getClientRects()) : [];
|
|
93
|
+
const first = rects.find((r) => r.width || r.height) ?? p.range.startContainer.parentElement?.getBoundingClientRect();
|
|
94
|
+
return pageBox(first ?? { left: 0, top: 0, width: 0, height: 0 });
|
|
95
|
+
}
|
|
96
|
+
const b = p.block.getBoundingClientRect();
|
|
97
|
+
return pageBox({ left: b.left + p.anchor.x * b.width, top: b.top + p.anchor.y * b.height, width: p.anchor.w * b.width, height: p.anchor.h * b.height });
|
|
98
|
+
}
|
|
99
|
+
/** A box dragged over a block, as fractions of that block, clamped inside it. */
|
|
100
|
+
export function regionFrom(block, a, b, id) {
|
|
101
|
+
if (!block.width || !block.height)
|
|
102
|
+
return null;
|
|
103
|
+
const clamp = (v) => Math.min(1, Math.max(0, v));
|
|
104
|
+
const x1 = clamp((Math.min(a.x, b.x) - block.left) / block.width);
|
|
105
|
+
const x2 = clamp((Math.max(a.x, b.x) - block.left) / block.width);
|
|
106
|
+
const y1 = clamp((Math.min(a.y, b.y) - block.top) / block.height);
|
|
107
|
+
const y2 = clamp((Math.max(a.y, b.y) - block.top) / block.height);
|
|
108
|
+
if (x2 - x1 <= 0 || y2 - y1 <= 0)
|
|
109
|
+
return null;
|
|
110
|
+
// Rounded at the edges and measured between them, so x + w never rounds past the block's edge.
|
|
111
|
+
const r4 = (v) => Math.round(v * 10000) / 10000;
|
|
112
|
+
const w = r4(x2) - r4(x1);
|
|
113
|
+
const h = r4(y2) - r4(y1);
|
|
114
|
+
if (w <= 0 || h <= 0)
|
|
115
|
+
return null;
|
|
116
|
+
return { kind: 'region', block: id, x: r4(x1), y: r4(y1), w, h };
|
|
117
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PersonalNote, PersonalNoteValue } from '../artifacts/personal-store.js';
|
|
2
|
+
export declare const deviceNotesKey: (artifactId: string) => string;
|
|
3
|
+
export declare const deviceNotes: (artifactId: string) => PersonalNote[];
|
|
4
|
+
export declare function addDeviceNote(artifactId: string, value: PersonalNoteValue): PersonalNote;
|
|
5
|
+
export declare function replaceDeviceNote(artifactId: string, id: string, value: PersonalNoteValue): void;
|
|
6
|
+
export declare function removeDeviceNote(artifactId: string, id: string): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const deviceNotesKey = (artifactId) => `artifact-notes:${location.host}:${artifactId}`;
|
|
2
|
+
function read(artifactId) {
|
|
3
|
+
try {
|
|
4
|
+
const raw = JSON.parse(window.localStorage.getItem(deviceNotesKey(artifactId)) ?? '[]');
|
|
5
|
+
if (!Array.isArray(raw))
|
|
6
|
+
return [];
|
|
7
|
+
return raw.filter((n) => !!n && typeof n === 'object' && typeof n.id === 'string' && typeof n.at === 'string' && !!n.value && typeof n.value === 'object');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function write(artifactId, notes) {
|
|
14
|
+
try {
|
|
15
|
+
if (notes.length)
|
|
16
|
+
window.localStorage.setItem(deviceNotesKey(artifactId), JSON.stringify(notes));
|
|
17
|
+
else
|
|
18
|
+
window.localStorage.removeItem(deviceNotesKey(artifactId));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Storage full or blocked (a private window): the note is lost with the tab, and nothing breaks.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const deviceNotes = (artifactId) => read(artifactId);
|
|
25
|
+
export function addDeviceNote(artifactId, value) {
|
|
26
|
+
const n = { id: `d-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, value, at: new Date().toISOString() };
|
|
27
|
+
write(artifactId, [...read(artifactId), n]);
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
export function replaceDeviceNote(artifactId, id, value) {
|
|
31
|
+
write(artifactId, read(artifactId).map((n) => (n.id === id ? { ...n, value, updatedAt: new Date().toISOString() } : n)));
|
|
32
|
+
}
|
|
33
|
+
export function removeDeviceNote(artifactId, id) {
|
|
34
|
+
write(artifactId, read(artifactId).filter((n) => n.id !== id));
|
|
35
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming } from './artifact-reader.js';
|
|
2
|
+
export { BrowserReader } from './browser-reader.js';
|
|
3
|
+
export { PlayerBar } from './player-bar.js';
|
|
4
|
+
export { planSentences, wordAt, sentenceOf, fromWord, MAX_SENTENCE_WORDS, type Sentence } from './speech-plan.js';
|
|
5
|
+
export { CommentLayer } from './comment-layer.js';
|
|
6
|
+
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
7
|
+
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
|
8
|
+
export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom, type TextIndex, type Placement } from './comment-place.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The `./reader` entry: both readers and the bar they share. Each component module carries its
|
|
2
|
+
// own 'use client'; this barrel is plain re-exports so it can be imported from either side.
|
|
3
|
+
export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifact-reader.js';
|
|
4
|
+
export { BrowserReader } from './browser-reader.js';
|
|
5
|
+
export { PlayerBar } from './player-bar.js';
|
|
6
|
+
export { planSentences, wordAt, sentenceOf, fromWord, MAX_SENTENCE_WORDS } from './speech-plan.js';
|
|
7
|
+
export { CommentLayer } from './comment-layer.js';
|
|
8
|
+
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
9
|
+
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
|
10
|
+
export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom } from './comment-place.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function PlayerBar({ playing, onToggle, label, accent, ground, value, max, step, onSeek, progress, rate, onRate, }: {
|
|
2
|
+
playing: boolean;
|
|
3
|
+
onToggle: () => void;
|
|
4
|
+
/** The line under the play button. */
|
|
5
|
+
label: string;
|
|
6
|
+
accent: string;
|
|
7
|
+
ground: string;
|
|
8
|
+
value: number;
|
|
9
|
+
max: number;
|
|
10
|
+
step: number;
|
|
11
|
+
onSeek: (value: number) => void;
|
|
12
|
+
/** The text at the right of the bar: a time for recorded audio, a word count for the browser. */
|
|
13
|
+
progress: string;
|
|
14
|
+
rate: number;
|
|
15
|
+
onRate: () => void;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
17
|
+
/** The speeds the speed button steps through, in order, wrapping back to 1. */
|
|
18
|
+
export declare function nextRate(rate: number): number;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The bar at the bottom of a page that can be listened to, shared by the recorded-narration
|
|
4
|
+
// reader and the browser read-aloud, so a page looks the same whichever one is speaking. It is
|
|
5
|
+
// pure chrome: each reader owns its engine and hands the bar what to show and what to call.
|
|
6
|
+
export function PlayerBar({ playing, onToggle, label, accent, ground, value, max, step, onSeek, progress, rate, onRate, }) {
|
|
7
|
+
return (_jsxs(_Fragment, { children: [_jsx("style", { children: `
|
|
8
|
+
.artifact-word { border-radius: 3px; transition: background-color 120ms; }
|
|
9
|
+
.artifact-word:hover { background: var(--a-surface-strong, rgba(255,255,255,0.08)); cursor: pointer; }
|
|
10
|
+
.artifact-word-lit { background: color-mix(in srgb, ${accent} 35%, transparent); color: var(--a-strong, #fff); }
|
|
11
|
+
` }), _jsx("div", { "data-artifact-player": true, className: "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--a-line)] backdrop-blur", style: { background: `color-mix(in srgb, ${ground} 90%, transparent)` }, children: _jsxs("div", { className: "mx-auto flex max-w-2xl items-center gap-4 px-6 py-3", children: [_jsx("button", { type: "button", onClick: onToggle, "aria-label": playing ? 'Pause narration' : 'Play narration', className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-full", style: { background: accent, color: ground }, children: playing ? (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("rect", { x: "6", y: "5", width: "4", height: "14" }), _jsx("rect", { x: "14", y: "5", width: "4", height: "14" })] })) : (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M8 5v14l11-7z" }) })) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: label }), _jsx("input", { type: "range", min: 0, max: max || 0, step: step, value: Math.min(value, max || 0), onChange: (e) => onSeek(Number(e.target.value)), "aria-label": "Seek", className: "mt-1 w-full", style: { accentColor: accent } })] }), _jsx("div", { className: "w-16 shrink-0 text-right text-xs tabular-nums text-[color:var(--a-muted)]", children: progress }), _jsxs("button", { type: "button", onClick: onRate, className: "shrink-0 rounded border border-[color:var(--a-line-strong)] px-2 py-1 text-xs text-[color:var(--a-body)]", "aria-label": "Playback speed", children: [rate, "x"] })] }) }), _jsx("div", { className: "h-20", "aria-hidden": true })] }));
|
|
12
|
+
}
|
|
13
|
+
/** The speeds the speed button steps through, in order, wrapping back to 1. */
|
|
14
|
+
export function nextRate(rate) {
|
|
15
|
+
return rate >= 1.5 ? 1 : rate + 0.25;
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type Sentence = {
|
|
2
|
+
/** The words joined by single spaces: exactly what is handed to the speech engine. */
|
|
3
|
+
text: string;
|
|
4
|
+
/** Index of the sentence's first word in the page's word list. */
|
|
5
|
+
first: number;
|
|
6
|
+
/** offsets[i] is the char offset, inside `text`, of word `first + i`. */
|
|
7
|
+
offsets: number[];
|
|
8
|
+
};
|
|
9
|
+
export declare const MAX_SENTENCE_WORDS = 60;
|
|
10
|
+
/** Split a page's words into sentences. `starts` holds word indexes that begin a new block
|
|
11
|
+
* (a heading, a paragraph, a list item), which always begin a new sentence. */
|
|
12
|
+
export declare function planSentences(words: string[], starts?: ReadonlySet<number>): Sentence[];
|
|
13
|
+
/** The absolute word index a boundary event's `charIndex` falls in: the last word whose offset
|
|
14
|
+
* is at or before it. */
|
|
15
|
+
export declare function wordAt(s: Sentence, charIndex: number): number;
|
|
16
|
+
/** The index of the sentence holding `wordIndex`. */
|
|
17
|
+
export declare function sentenceOf(plan: Sentence[], wordIndex: number): number;
|
|
18
|
+
/** The sentence holding `wordIndex`, cut so it starts AT that word, with offsets rebased to the
|
|
19
|
+
* cut text. What is spoken when a reader clicks a word in the middle of a sentence. */
|
|
20
|
+
export declare function fromWord(plan: Sentence[], wordIndex: number): Sentence;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// How the browser read-aloud splits a page into utterances, and maps a speech engine's
|
|
2
|
+
// word-boundary events back to the page's word spans. Pure, so it is tested without a browser.
|
|
3
|
+
//
|
|
4
|
+
// One utterance per sentence is the drift bound: Safari's boundary charIndex wanders on long
|
|
5
|
+
// passages, and every new utterance restarts it at zero against a sentence whose word offsets
|
|
6
|
+
// are known. The cap keeps a page with no punctuation (a list, a table) from becoming one long
|
|
7
|
+
// utterance, which is also where Chrome is known to stop speaking part-way.
|
|
8
|
+
export const MAX_SENTENCE_WORDS = 60;
|
|
9
|
+
// A word ends a sentence when it ends in . ! ? or :, optionally followed by closing quotes or
|
|
10
|
+
// brackets. "10:30" and "example.com" do not, because the punctuation is not at the end.
|
|
11
|
+
const ENDS_SENTENCE = /[.!?:]["'”’)\]]*$/;
|
|
12
|
+
/** Split a page's words into sentences. `starts` holds word indexes that begin a new block
|
|
13
|
+
* (a heading, a paragraph, a list item), which always begin a new sentence. */
|
|
14
|
+
export function planSentences(words, starts) {
|
|
15
|
+
const plan = [];
|
|
16
|
+
let cur = null;
|
|
17
|
+
for (let i = 0; i < words.length; i++) {
|
|
18
|
+
if (cur && (cur.offsets.length >= MAX_SENTENCE_WORDS || starts?.has(i))) {
|
|
19
|
+
plan.push(cur);
|
|
20
|
+
cur = null;
|
|
21
|
+
}
|
|
22
|
+
if (!cur)
|
|
23
|
+
cur = { text: '', first: i, offsets: [] };
|
|
24
|
+
if (cur.offsets.length)
|
|
25
|
+
cur.text += ' ';
|
|
26
|
+
cur.offsets.push(cur.text.length);
|
|
27
|
+
cur.text += words[i];
|
|
28
|
+
if (ENDS_SENTENCE.test(words[i])) {
|
|
29
|
+
plan.push(cur);
|
|
30
|
+
cur = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (cur)
|
|
34
|
+
plan.push(cur);
|
|
35
|
+
return plan;
|
|
36
|
+
}
|
|
37
|
+
/** The absolute word index a boundary event's `charIndex` falls in: the last word whose offset
|
|
38
|
+
* is at or before it. */
|
|
39
|
+
export function wordAt(s, charIndex) {
|
|
40
|
+
let i = 0;
|
|
41
|
+
for (let k = 0; k < s.offsets.length; k++) {
|
|
42
|
+
if (s.offsets[k] <= charIndex)
|
|
43
|
+
i = k;
|
|
44
|
+
else
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
return s.first + i;
|
|
48
|
+
}
|
|
49
|
+
/** The index of the sentence holding `wordIndex`. */
|
|
50
|
+
export function sentenceOf(plan, wordIndex) {
|
|
51
|
+
let idx = 0;
|
|
52
|
+
for (let k = 0; k < plan.length; k++) {
|
|
53
|
+
if (plan[k].first <= wordIndex)
|
|
54
|
+
idx = k;
|
|
55
|
+
else
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
return idx;
|
|
59
|
+
}
|
|
60
|
+
/** The sentence holding `wordIndex`, cut so it starts AT that word, with offsets rebased to the
|
|
61
|
+
* cut text. What is spoken when a reader clicks a word in the middle of a sentence. */
|
|
62
|
+
export function fromWord(plan, wordIndex) {
|
|
63
|
+
const s = plan[sentenceOf(plan, wordIndex)];
|
|
64
|
+
const k = Math.max(0, Math.min(wordIndex - s.first, s.offsets.length - 1));
|
|
65
|
+
const base = s.offsets[k];
|
|
66
|
+
return { text: s.text.slice(base), first: s.first + k, offsets: s.offsets.slice(k).map((o) => o - base) };
|
|
67
|
+
}
|
|
@@ -6,10 +6,14 @@ import { type ArtifactAssets } from '../artifacts/assets.js';
|
|
|
6
6
|
import type { BrandPack } from '../brand/pack.js';
|
|
7
7
|
import { type Access } from '../artifacts/reader.js';
|
|
8
8
|
import { type ReadersStore } from '../artifacts/readers-store.js';
|
|
9
|
+
import type { PersonalStore } from '../artifacts/personal-store.js';
|
|
9
10
|
export type ArtifactRoutesConfig = {
|
|
10
11
|
store: ArtifactStore;
|
|
11
12
|
/** Where readers' answers live. Without it every state route answers 501. */
|
|
12
13
|
state?: StateStore;
|
|
14
|
+
/** Where signed-in readers' personal notes live. Read ONLY by the reader's own /personal
|
|
15
|
+
* handlers, never by any publisher route. Without it every reader's notes stay on their device. */
|
|
16
|
+
personal?: PersonalStore;
|
|
13
17
|
/** Where uploaded files go. Optional; without it PUT_ASSET answers 501. */
|
|
14
18
|
assets?: ArtifactAssets;
|
|
15
19
|
brand: BrandPack;
|
|
@@ -41,6 +45,10 @@ export type ArtifactRoutesConfig = {
|
|
|
41
45
|
* `x-forwarded-for`, which is correct on Vercel (it overwrites XFF with the real client IP)
|
|
42
46
|
* and wrong behind any other proxy that appends rather than replaces; pass this there. */
|
|
43
47
|
clientIp?: (req: NextRequest) => string;
|
|
48
|
+
/** The page owner's sign-in email. Signed in as them, a page shows every shared comment with
|
|
49
|
+
* its writer's name, whatever `comments_visible:` says. Without it nobody is the owner, and
|
|
50
|
+
* the publisher reads comments through /responses. */
|
|
51
|
+
ownerEmail?: string;
|
|
44
52
|
};
|
|
45
53
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
46
54
|
export declare const ARTIFACT_ID: RegExp;
|
|
@@ -68,6 +76,21 @@ type VersionProps = VersionParams & {
|
|
|
68
76
|
export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
69
77
|
dynamic: "force-dynamic";
|
|
70
78
|
maxDuration: number;
|
|
79
|
+
PERSONAL_GET: (req: NextRequest, { params }: {
|
|
80
|
+
params: Promise<{
|
|
81
|
+
id: string;
|
|
82
|
+
}>;
|
|
83
|
+
}) => Promise<NextResponse<unknown> | undefined>;
|
|
84
|
+
PERSONAL_POST: (req: NextRequest, { params }: {
|
|
85
|
+
params: Promise<{
|
|
86
|
+
id: string;
|
|
87
|
+
}>;
|
|
88
|
+
}) => Promise<NextResponse<unknown> | undefined>;
|
|
89
|
+
PERSONAL_DELETE: (req: NextRequest, { params }: {
|
|
90
|
+
params: Promise<{
|
|
91
|
+
id: string;
|
|
92
|
+
}>;
|
|
93
|
+
}) => Promise<NextResponse<unknown> | undefined>;
|
|
71
94
|
STATE_GET: (req: NextRequest, { params }: {
|
|
72
95
|
params: Promise<{
|
|
73
96
|
id: string;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -9,13 +9,16 @@ import { ArtifactMarkdown } from '../artifacts/render.js';
|
|
|
9
9
|
import { ArtifactDoor } from '../artifacts/door.js';
|
|
10
10
|
import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
|
|
11
11
|
import { cleanNote } from '../artifacts/store.js';
|
|
12
|
-
import { shapeChanges } from '../artifacts/state.js';
|
|
12
|
+
import { effectiveWriters, shapeChanges } from '../artifacts/state.js';
|
|
13
|
+
import { COMMENTS_SLOT } from '../artifacts/comments.js';
|
|
13
14
|
import { ASSET_DIGEST, ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
|
|
14
15
|
import { BrandGround } from '../brand/wrapper.js';
|
|
15
16
|
import { themedPack } from '../brand/theme.js';
|
|
16
17
|
import { showToc, tocOf, TocInline, TocRail } from '../artifacts/toc.js';
|
|
17
18
|
import { renderShareCard } from '../brand/share-card.js';
|
|
18
19
|
import { ArtifactReader } from '../reader/artifact-reader.js';
|
|
20
|
+
import { BrowserReader } from '../reader/browser-reader.js';
|
|
21
|
+
import { CommentLayer } from '../reader/comment-layer.js';
|
|
19
22
|
import { ReaderWatch } from '../reader/reader-watch.js';
|
|
20
23
|
import { UpdatedTime } from '../reader/updated-time.js';
|
|
21
24
|
import { VersionHistory } from '../reader/version-history.js';
|
|
@@ -24,6 +27,7 @@ import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
|
|
|
24
27
|
import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
|
|
25
28
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
26
29
|
import { createStateRoutes } from './state-routes.js';
|
|
30
|
+
import { createPersonalRoutes } from './personal-routes.js';
|
|
27
31
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
28
32
|
export const ARTIFACT_ID = ARTIFACT_ID_RE;
|
|
29
33
|
/** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
|
|
@@ -45,13 +49,15 @@ export function createArtifactRoutes(config) {
|
|
|
45
49
|
// The version in the URL is what makes a re-publish show up: every unfurler caches by URL.
|
|
46
50
|
const signInOrigin = config.signInOrigin;
|
|
47
51
|
const owner = config.owner ?? brand.name;
|
|
52
|
+
const ownerEmail = config.ownerEmail?.trim().toLowerCase();
|
|
48
53
|
const readerSecret = () => config.readerSecret?.();
|
|
49
54
|
const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
|
|
50
55
|
const shareCardUrl = (id, updatedAt) => `${pageUrl(id)}/share.png?v=${encodeURIComponent(updatedAt)}`;
|
|
51
56
|
const stateRoutes = createStateRoutes({
|
|
52
57
|
store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey, pageUrl, signInOrigin, siteUrl,
|
|
53
|
-
clientIp: config.clientIp,
|
|
58
|
+
clientIp: config.clientIp, ownerEmail: config.ownerEmail,
|
|
54
59
|
});
|
|
60
|
+
const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl });
|
|
55
61
|
async function generateMetadata({ params }) {
|
|
56
62
|
const { id } = await params;
|
|
57
63
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
@@ -105,7 +111,7 @@ export function createArtifactRoutes(config) {
|
|
|
105
111
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
106
112
|
if (!a)
|
|
107
113
|
notFound();
|
|
108
|
-
const render = (top) => (n === null ? Body(a, { top }) : VersionBody(a, n, { top }));
|
|
114
|
+
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top }));
|
|
109
115
|
if (a.access)
|
|
110
116
|
return GatedPage(a, id, render, n);
|
|
111
117
|
// A password shuts the body, never the title: the header stays so the reader knows which
|
|
@@ -120,19 +126,23 @@ export function createArtifactRoutes(config) {
|
|
|
120
126
|
// The same cookie again on the page's state API: a cookie scoped to the page path is never
|
|
121
127
|
// sent to /api/artifacts/<id>/state, so without it a password page could not take answers.
|
|
122
128
|
const unlockLine = (path) => `${unlockCookieName(id)}=${keyHash(id, a.password)}; Path=${path}; Max-Age=31536000; SameSite=Lax; Secure`;
|
|
123
|
-
// The API copy is set on EVERY open of a page with state
|
|
129
|
+
// The API copy is set on EVERY open of a page with state: (or on a host keeping personal notes,
|
|
130
|
+
// which every page takes), because the page cannot see it (a
|
|
124
131
|
// cookie scoped to /api/... never reaches the page path): a reader who unlocked the page
|
|
125
132
|
// before its API cookie existed would otherwise be refused every answer until they reopened
|
|
126
133
|
// the ?key= link.
|
|
127
134
|
const paths = !open || !a.password ? [] : [
|
|
128
135
|
...(key !== undefined && cookie !== keyHash(id, a.password) ? [pagePath(id)] : []),
|
|
129
|
-
...(a.state ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
|
|
136
|
+
...(a.state || config.personal ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
|
|
130
137
|
];
|
|
131
138
|
const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
|
|
132
139
|
if (!open) {
|
|
133
140
|
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("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 })] }));
|
|
134
141
|
}
|
|
135
|
-
|
|
142
|
+
// Who is reading an open page, when they happen to be signed in: their comments follow them.
|
|
143
|
+
// Only a host that signs readers in can know one; the read never fails the page.
|
|
144
|
+
const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
|
|
145
|
+
const page = await render(true, reader);
|
|
136
146
|
if (n === null)
|
|
137
147
|
void store.bumpViews(id);
|
|
138
148
|
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, page] }));
|
|
@@ -142,7 +152,8 @@ export function createArtifactRoutes(config) {
|
|
|
142
152
|
async function GatedPage(a, id, render, n) {
|
|
143
153
|
const here = n === null ? pageUrl(id) : `${pageUrl(id)}/v/${n}`;
|
|
144
154
|
const readCookie = config.readCookie ?? defaultReadCookie;
|
|
145
|
-
|
|
155
|
+
// Only a host that signs readers in can know one; the read never fails the page.
|
|
156
|
+
const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
|
|
146
157
|
const allow = config.readers && reader ? await config.readers.allowList(id) : [];
|
|
147
158
|
const d = config.readers ? decide(a.access, reader, allow) : { open: false, why: 'signed-out' };
|
|
148
159
|
const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 })] }));
|
|
@@ -158,7 +169,7 @@ export function createArtifactRoutes(config) {
|
|
|
158
169
|
if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
|
|
159
170
|
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: signOutUrl(id) })] }));
|
|
160
171
|
}
|
|
161
|
-
const page = await render(false);
|
|
172
|
+
const page = await render(false, r);
|
|
162
173
|
if (n === null)
|
|
163
174
|
void store.bumpViews(id);
|
|
164
175
|
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: signOutUrl(id) }) }), page, _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
|
|
@@ -189,7 +200,7 @@ export function createArtifactRoutes(config) {
|
|
|
189
200
|
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("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 })] }), _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions })] })] }));
|
|
190
201
|
}
|
|
191
202
|
/** The page itself, shared by open and gated pages. */
|
|
192
|
-
async function Body(a, { top }) {
|
|
203
|
+
async function Body(a, { top, reader }) {
|
|
193
204
|
let words = [];
|
|
194
205
|
if (a.narration && a.timings) {
|
|
195
206
|
try {
|
|
@@ -202,7 +213,11 @@ export function createArtifactRoutes(config) {
|
|
|
202
213
|
}
|
|
203
214
|
}
|
|
204
215
|
const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
|
|
205
|
-
|
|
216
|
+
// Shared comments need the page to take them AND a host that keeps answers; personal notes need neither.
|
|
217
|
+
const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
|
|
218
|
+
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) })] }), 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, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), _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 })) : (
|
|
219
|
+
// No recorded narration, or its timings would not load: the browser reads the page.
|
|
220
|
+
_jsx(BrowserReader, { rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground }))] }));
|
|
206
221
|
}
|
|
207
222
|
const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
208
223
|
/** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
|
|
@@ -493,5 +508,5 @@ export function createArtifactRoutes(config) {
|
|
|
493
508
|
revalidatePath(pagePath(id));
|
|
494
509
|
return NextResponse.json({ deleted: true });
|
|
495
510
|
}
|
|
496
|
-
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
511
|
+
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
497
512
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { type PersonalStore } from '../artifacts/personal-store.js';
|
|
3
|
+
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
|
+
import type { ReadersStore } from '../artifacts/readers-store.js';
|
|
5
|
+
export type PersonalRoutesContext = {
|
|
6
|
+
store: ArtifactStore;
|
|
7
|
+
personal?: PersonalStore;
|
|
8
|
+
readers?: ReadersStore;
|
|
9
|
+
readerSecret: () => string | undefined;
|
|
10
|
+
pageUrl: (id: string) => string;
|
|
11
|
+
signInOrigin?: string;
|
|
12
|
+
siteUrl: string;
|
|
13
|
+
};
|
|
14
|
+
type Params = {
|
|
15
|
+
params: Promise<{
|
|
16
|
+
id: string;
|
|
17
|
+
}>;
|
|
18
|
+
};
|
|
19
|
+
export declare function createPersonalRoutes(ctx: PersonalRoutesContext): {
|
|
20
|
+
PERSONAL_GET: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
|
|
21
|
+
PERSONAL_POST: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
|
|
22
|
+
PERSONAL_DELETE: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
|
|
23
|
+
};
|
|
24
|
+
export {};
|