@supersuit/artifacts 0.1.0 → 0.3.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 +76 -0
- package/README.md +157 -1
- package/lib/artifacts/front-matter.d.ts +4 -0
- package/lib/artifacts/front-matter.js +21 -1
- package/lib/artifacts/index.d.ts +4 -0
- package/lib/artifacts/index.js +4 -0
- package/lib/artifacts/notes-place.d.ts +27 -0
- package/lib/artifacts/notes-place.js +25 -0
- package/lib/artifacts/render.d.ts +7 -1
- package/lib/artifacts/render.js +41 -3
- package/lib/artifacts/state-store.d.ts +62 -0
- package/lib/artifacts/state-store.js +153 -0
- package/lib/artifacts/state-view.d.ts +38 -0
- package/lib/artifacts/state-view.js +66 -0
- package/lib/artifacts/state.d.ts +35 -0
- package/lib/artifacts/state.js +83 -0
- package/lib/artifacts/store.d.ts +2 -0
- package/lib/artifacts/store.js +4 -1
- package/lib/artifacts/widgets.d.ts +38 -0
- package/lib/artifacts/widgets.js +141 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/routes/artifacts.d.ts +25 -2
- package/lib/routes/artifacts.js +51 -8
- package/lib/routes/ids.d.ts +1 -0
- package/lib/routes/ids.js +3 -0
- package/lib/routes/state-routes.d.ts +35 -0
- package/lib/routes/state-routes.js +216 -0
- package/lib/widgets/notes.d.ts +21 -0
- package/lib/widgets/notes.js +134 -0
- package/package.json +39 -10
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Where readers' answers live. Beside the pages, per tenant:
|
|
2
|
+
//
|
|
3
|
+
// <base>State/<page>__<slot>__<readerKey> shape one, one document per reader per slot
|
|
4
|
+
// <base>State/<auto> shape many, one document per entry
|
|
5
|
+
// <base>StateRate/<page>__<ipHash>__<min> anonymous write counter, one per minute, with an
|
|
6
|
+
// `expireAt` a day ahead for a Firestore TTL policy
|
|
7
|
+
//
|
|
8
|
+
// One document per answer keeps every page far from Firestore's 1 MiB document cap, which the
|
|
9
|
+
// lightpaper hit on 2026-09-24 when history lived on the page document. Values are stored as a
|
|
10
|
+
// JSON STRING (`json`), because Firestore refuses nested arrays and a form answer can hold one.
|
|
11
|
+
//
|
|
12
|
+
// The Firestore implementation has no emulator test here; it is proven on the live host after
|
|
13
|
+
// each release that changes it. The memory implementation carries the contract tests.
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { FieldValue, Timestamp } from 'firebase-admin/firestore';
|
|
16
|
+
import { MAX_MANY_PER_READER } from './state.js';
|
|
17
|
+
export const readerKeyFor = {
|
|
18
|
+
signedIn: (uid) => `u:${uid}`,
|
|
19
|
+
anonymous: (id) => `a:${id}`,
|
|
20
|
+
};
|
|
21
|
+
const writerOf = (w) => ({
|
|
22
|
+
...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
|
|
23
|
+
});
|
|
24
|
+
const oneId = (artifactId, slot, readerKey) => `${artifactId}__${slot}__${readerKey}`;
|
|
25
|
+
const toEntry = (id, d) => {
|
|
26
|
+
const { json, ...rest } = d;
|
|
27
|
+
return { id, ...rest, value: JSON.parse(json) };
|
|
28
|
+
};
|
|
29
|
+
const toStored = (e) => {
|
|
30
|
+
const { id: _id, value, ...rest } = e;
|
|
31
|
+
return { ...rest, json: JSON.stringify(value) };
|
|
32
|
+
};
|
|
33
|
+
export function createStateStore(db, base) {
|
|
34
|
+
const col = () => db.collection(`${base}State`);
|
|
35
|
+
const rate = () => db.collection(`${base}StateRate`);
|
|
36
|
+
const load = async (q) => (await q.get()).docs.map((d) => toEntry(d.id, d.data()));
|
|
37
|
+
const store = {
|
|
38
|
+
entries: (artifactId) => load(col().where('artifactId', '==', artifactId)),
|
|
39
|
+
async set({ artifactId, slot, writer, value }) {
|
|
40
|
+
const e = { id: oneId(artifactId, slot, writer.key), artifactId, slot, shape: 'one', readerKey: writer.key, writer: writerOf(writer), value, at: new Date().toISOString() };
|
|
41
|
+
await col().doc(e.id).set(toStored(e));
|
|
42
|
+
return e;
|
|
43
|
+
},
|
|
44
|
+
async append({ artifactId, slot, writer, value }) {
|
|
45
|
+
const n = (await col().where('artifactId', '==', artifactId).where('slot', '==', slot).where('readerKey', '==', writer.key).count().get()).data().count;
|
|
46
|
+
if (n >= MAX_MANY_PER_READER)
|
|
47
|
+
return { full: true };
|
|
48
|
+
const ref = col().doc();
|
|
49
|
+
const e = { id: ref.id, artifactId, slot, shape: 'many', readerKey: writer.key, writer: writerOf(writer), value, at: new Date().toISOString() };
|
|
50
|
+
await ref.set(toStored(e));
|
|
51
|
+
return e;
|
|
52
|
+
},
|
|
53
|
+
async remove({ artifactId, slot, readerKey, entryId }) {
|
|
54
|
+
const mine = (await load(col().where('artifactId', '==', artifactId).where('readerKey', '==', readerKey)))
|
|
55
|
+
.filter((e) => e.slot === slot && (!entryId || e.id === entryId));
|
|
56
|
+
await Promise.all(mine.map((e) => col().doc(e.id).delete()));
|
|
57
|
+
return mine.length;
|
|
58
|
+
},
|
|
59
|
+
async removeReader(artifactId, readerKey) {
|
|
60
|
+
const mine = await load(col().where('artifactId', '==', artifactId).where('readerKey', '==', readerKey));
|
|
61
|
+
await Promise.all(mine.map((e) => col().doc(e.id).delete()));
|
|
62
|
+
return mine.length;
|
|
63
|
+
},
|
|
64
|
+
async moveReader(fromKey, to) {
|
|
65
|
+
const from = await load(col().where('readerKey', '==', fromKey));
|
|
66
|
+
let moved = 0;
|
|
67
|
+
for (const e of from) {
|
|
68
|
+
if (e.shape === 'one') {
|
|
69
|
+
const target = col().doc(oneId(e.artifactId, e.slot, to.key));
|
|
70
|
+
// The signed-in answer wins over one given anonymously on this device.
|
|
71
|
+
if (!(await target.get()).exists) {
|
|
72
|
+
await target.set(toStored({ ...e, id: target.id, readerKey: to.key, writer: writerOf(to) }));
|
|
73
|
+
moved++;
|
|
74
|
+
}
|
|
75
|
+
await col().doc(e.id).delete();
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
await col().doc(e.id).update({ readerKey: to.key, writer: writerOf(to) });
|
|
79
|
+
moved++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return moved;
|
|
83
|
+
},
|
|
84
|
+
async countAnonWrite(artifactId, ipHash, minute) {
|
|
85
|
+
const ref = rate().doc(`${artifactId}__${ipHash}__${minute}`);
|
|
86
|
+
await ref.set({ count: FieldValue.increment(1), at: new Date().toISOString(), expireAt: Timestamp.fromMillis(Date.now() + 86_400_000) }, { merge: true });
|
|
87
|
+
return (await ref.get()).data()?.count ?? 1;
|
|
88
|
+
},
|
|
89
|
+
async countSlot(artifactId, slot) {
|
|
90
|
+
return (await col().where('artifactId', '==', artifactId).where('slot', '==', slot).count().get()).data().count;
|
|
91
|
+
},
|
|
92
|
+
hasOne: async (artifactId, slot, readerKey) => (await col().doc(oneId(artifactId, slot, readerKey)).get()).exists,
|
|
93
|
+
};
|
|
94
|
+
return store;
|
|
95
|
+
}
|
|
96
|
+
/** The same contract in memory: for tests, and for hosts' own tests. */
|
|
97
|
+
export function createMemoryStateStore() {
|
|
98
|
+
const docs = new Map();
|
|
99
|
+
const counts = new Map();
|
|
100
|
+
const of = (artifactId, readerKey) => [...docs.values()].filter((e) => e.artifactId === artifactId && (!readerKey || e.readerKey === readerKey));
|
|
101
|
+
return {
|
|
102
|
+
entries: async (artifactId) => of(artifactId).map((e) => structuredClone(e)),
|
|
103
|
+
async set({ artifactId, slot, writer, value }) {
|
|
104
|
+
const e = { id: oneId(artifactId, slot, writer.key), artifactId, slot, shape: 'one', readerKey: writer.key, writer: writerOf(writer), value: structuredClone(value), at: new Date().toISOString() };
|
|
105
|
+
docs.set(e.id, e);
|
|
106
|
+
return structuredClone(e);
|
|
107
|
+
},
|
|
108
|
+
async append({ artifactId, slot, writer, value }) {
|
|
109
|
+
if (of(artifactId, writer.key).filter((e) => e.slot === slot).length >= MAX_MANY_PER_READER)
|
|
110
|
+
return { full: true };
|
|
111
|
+
const e = { id: randomUUID(), artifactId, slot, shape: 'many', readerKey: writer.key, writer: writerOf(writer), value: structuredClone(value), at: new Date().toISOString() };
|
|
112
|
+
docs.set(e.id, e);
|
|
113
|
+
return structuredClone(e);
|
|
114
|
+
},
|
|
115
|
+
async remove({ artifactId, slot, readerKey, entryId }) {
|
|
116
|
+
const mine = of(artifactId, readerKey).filter((e) => e.slot === slot && (!entryId || e.id === entryId));
|
|
117
|
+
for (const e of mine)
|
|
118
|
+
docs.delete(e.id);
|
|
119
|
+
return mine.length;
|
|
120
|
+
},
|
|
121
|
+
async removeReader(artifactId, readerKey) {
|
|
122
|
+
const mine = of(artifactId, readerKey);
|
|
123
|
+
for (const e of mine)
|
|
124
|
+
docs.delete(e.id);
|
|
125
|
+
return mine.length;
|
|
126
|
+
},
|
|
127
|
+
async moveReader(fromKey, to) {
|
|
128
|
+
let moved = 0;
|
|
129
|
+
for (const e of [...docs.values()].filter((e) => e.readerKey === fromKey)) {
|
|
130
|
+
docs.delete(e.id);
|
|
131
|
+
if (e.shape === 'one') {
|
|
132
|
+
const id = oneId(e.artifactId, e.slot, to.key);
|
|
133
|
+
if (docs.has(id))
|
|
134
|
+
continue;
|
|
135
|
+
docs.set(id, { ...e, id, readerKey: to.key, writer: writerOf(to) });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
docs.set(e.id, { ...e, readerKey: to.key, writer: writerOf(to) });
|
|
139
|
+
}
|
|
140
|
+
moved++;
|
|
141
|
+
}
|
|
142
|
+
return moved;
|
|
143
|
+
},
|
|
144
|
+
async countAnonWrite(artifactId, ipHash, minute) {
|
|
145
|
+
const k = `${artifactId}__${ipHash}__${minute}`;
|
|
146
|
+
const n = (counts.get(k) ?? 0) + 1;
|
|
147
|
+
counts.set(k, n);
|
|
148
|
+
return n;
|
|
149
|
+
},
|
|
150
|
+
countSlot: async (artifactId, slot) => of(artifactId).filter((e) => e.slot === slot).length,
|
|
151
|
+
hasOne: async (artifactId, slot, readerKey) => docs.has(oneId(artifactId, slot, readerKey)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Shape, type StateConfig, type Visibility } from './state.js';
|
|
2
|
+
import type { StateEntry } from './state-store.js';
|
|
3
|
+
export type Tally = {
|
|
4
|
+
signedIn: Record<string, number>;
|
|
5
|
+
anonymous: Record<string, number>;
|
|
6
|
+
};
|
|
7
|
+
export type SharedEntry = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
value: unknown;
|
|
11
|
+
at: string;
|
|
12
|
+
mine: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type SlotView = {
|
|
15
|
+
shape: Shape;
|
|
16
|
+
visibility: Visibility;
|
|
17
|
+
mine: unknown | {
|
|
18
|
+
id: string;
|
|
19
|
+
value: unknown;
|
|
20
|
+
at: string;
|
|
21
|
+
}[] | null;
|
|
22
|
+
tally?: Tally;
|
|
23
|
+
shared?: SharedEntry[];
|
|
24
|
+
};
|
|
25
|
+
export declare function stateView(state: StateConfig, entries: StateEntry[], readerKey: string | null): Record<string, SlotView>;
|
|
26
|
+
/** `reader` is the key `DELETE /responses?reader=` takes. Publisher-only: it is a credential. */
|
|
27
|
+
export type Response = {
|
|
28
|
+
slot: string;
|
|
29
|
+
id: string;
|
|
30
|
+
value: unknown;
|
|
31
|
+
at: string;
|
|
32
|
+
email: string | null;
|
|
33
|
+
name: string | null;
|
|
34
|
+
anonymous: boolean;
|
|
35
|
+
reader: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function responsesOf(entries: StateEntry[]): Response[];
|
|
38
|
+
export declare function responsesCsv(rows: Response[]): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// What each side is shown. A reader gets their own answers, plus tallies or shared entries where
|
|
2
|
+
// the page allows, and NEVER an email. The publisher (publish key) gets everything.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { SHARED_LIMIT, slotVisibility } from './state.js';
|
|
5
|
+
const byAt = (a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id);
|
|
6
|
+
const firstWord = (name) => (name?.trim() ? name.trim().split(/\s+/)[0] : 'a reader');
|
|
7
|
+
// A `one` entry's raw id is `<page>__<slot>__<readerKey>`, and the reader key inside it (`u:<uid>`
|
|
8
|
+
// or `a:<anonId>`) is a credential: whoever reads it can write as that reader by minting the same
|
|
9
|
+
// anonymous cookie. A `shared` slot handed that id straight to every reader, so it is hashed to
|
|
10
|
+
// an opaque, stable value instead. `many` entries keep their random id; it names nothing.
|
|
11
|
+
const opaqueOneId = (id) => createHash('sha256').update(id).digest('hex').slice(0, 16);
|
|
12
|
+
/** Counts per option. A list value (multiple choice) counts each item; objects are not tallied.
|
|
13
|
+
* Anonymous answers are counted apart, because anyone can answer again by clearing a cookie. */
|
|
14
|
+
function tallyOf(entries) {
|
|
15
|
+
const t = { signedIn: {}, anonymous: {} };
|
|
16
|
+
for (const e of entries) {
|
|
17
|
+
const bucket = e.writer.anonymous ? t.anonymous : t.signedIn;
|
|
18
|
+
const items = Array.isArray(e.value) ? e.value : [e.value];
|
|
19
|
+
for (const v of items) {
|
|
20
|
+
if (v === null || typeof v === 'object')
|
|
21
|
+
continue;
|
|
22
|
+
const k = String(v);
|
|
23
|
+
bucket[k] = (bucket[k] ?? 0) + 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return t;
|
|
27
|
+
}
|
|
28
|
+
export function stateView(state, entries, readerKey) {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const [slot, def] of Object.entries(state.slots)) {
|
|
31
|
+
const here = entries.filter((e) => e.slot === slot).sort(byAt);
|
|
32
|
+
const mine = readerKey ? here.filter((e) => e.readerKey === readerKey) : [];
|
|
33
|
+
const visibility = slotVisibility(state, slot);
|
|
34
|
+
const view = {
|
|
35
|
+
shape: def.shape,
|
|
36
|
+
visibility,
|
|
37
|
+
mine: def.shape === 'one' ? (mine[0]?.value ?? null) : mine.map((e) => ({ id: e.id, value: e.value, at: e.at })),
|
|
38
|
+
};
|
|
39
|
+
if (visibility === 'tally')
|
|
40
|
+
view.tally = tallyOf(here);
|
|
41
|
+
if (visibility === 'shared')
|
|
42
|
+
view.shared = here.slice(-SHARED_LIMIT).map((e) => ({
|
|
43
|
+
id: def.shape === 'one' ? opaqueOneId(e.id) : e.id,
|
|
44
|
+
name: firstWord(e.writer.name), value: e.value, at: e.at, mine: e.readerKey === readerKey,
|
|
45
|
+
}));
|
|
46
|
+
out[slot] = view;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export function responsesOf(entries) {
|
|
51
|
+
return entries.slice().sort(byAt).map((e) => ({
|
|
52
|
+
slot: e.slot, id: e.id, value: e.value, at: e.at, email: e.writer.email ?? null, name: e.writer.name, anonymous: e.writer.anonymous, reader: e.readerKey,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
// A cell a spreadsheet reads as a formula (leading =, +, -, @, tab or CR) gets an escaping
|
|
56
|
+
// leading quote first: a reader's answer becomes text a formula, not a command a publisher's
|
|
57
|
+
// spreadsheet app runs the moment the CSV is opened.
|
|
58
|
+
const FORMULA_LEAD = /^[=+\-@\t\r]/;
|
|
59
|
+
const cell = (v) => {
|
|
60
|
+
const safe = FORMULA_LEAD.test(v) ? `'${v}` : v;
|
|
61
|
+
return /[",\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
62
|
+
};
|
|
63
|
+
export function responsesCsv(rows) {
|
|
64
|
+
const head = 'slot,id,at,email,name,anonymous,reader,value';
|
|
65
|
+
return [head, ...rows.map((r) => [r.slot, r.id, r.at, r.email ?? '', r.name ?? '', String(r.anonymous), r.reader, JSON.stringify(r.value)].map(cell).join(','))].join('\n');
|
|
66
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type Shape = 'one' | 'many';
|
|
2
|
+
export type Visibility = 'private' | 'tally' | 'shared';
|
|
3
|
+
export type Writers = 'signed-in' | 'anyone';
|
|
4
|
+
export type SlotDef = {
|
|
5
|
+
shape: Shape;
|
|
6
|
+
visibility?: Visibility;
|
|
7
|
+
};
|
|
8
|
+
export type StateConfig = {
|
|
9
|
+
writers: Writers;
|
|
10
|
+
visibility: Visibility;
|
|
11
|
+
slots: Record<string, SlotDef>;
|
|
12
|
+
};
|
|
13
|
+
export declare const SLOT_NAME: RegExp;
|
|
14
|
+
export declare const MAX_VALUE_BYTES: number;
|
|
15
|
+
export declare const MAX_MANY_PER_READER = 200;
|
|
16
|
+
export declare const ANON_WRITES_PER_MINUTE = 30;
|
|
17
|
+
/** Every reader's answers together, per page per slot. A new answer past it is refused; a reader
|
|
18
|
+
* replacing their own `one` answer is not, because that adds nothing. */
|
|
19
|
+
export declare const MAX_ENTRIES_PER_SLOT = 2000;
|
|
20
|
+
/** A `shared` slot shows readers only its newest entries, so one busy page cannot make every read
|
|
21
|
+
* ship its whole history. Tallies still count everything. */
|
|
22
|
+
export declare const SHARED_LIMIT = 100;
|
|
23
|
+
export declare function parseStateConfig(raw: unknown): {
|
|
24
|
+
ok: true;
|
|
25
|
+
state: StateConfig;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
error: string;
|
|
29
|
+
};
|
|
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;
|
|
32
|
+
export declare function slotVisibility(state: StateConfig, slot: string): Visibility;
|
|
33
|
+
export declare function checkValue(value: unknown): string | null;
|
|
34
|
+
/** Answers are kept across a republish, so a slot may never change what shape its data is. */
|
|
35
|
+
export declare function shapeChanges(prev: StateConfig | undefined, next: StateConfig | undefined): string[];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const SLOT_NAME = /^[a-z][a-z0-9-]{0,39}$/;
|
|
2
|
+
export const MAX_VALUE_BYTES = 8 * 1024;
|
|
3
|
+
export const MAX_MANY_PER_READER = 200;
|
|
4
|
+
export const ANON_WRITES_PER_MINUTE = 30;
|
|
5
|
+
/** Every reader's answers together, per page per slot. A new answer past it is refused; a reader
|
|
6
|
+
* replacing their own `one` answer is not, because that adds nothing. */
|
|
7
|
+
export const MAX_ENTRIES_PER_SLOT = 2000;
|
|
8
|
+
/** A `shared` slot shows readers only its newest entries, so one busy page cannot make every read
|
|
9
|
+
* ship its whole history. Tallies still count everything. */
|
|
10
|
+
export const SHARED_LIMIT = 100;
|
|
11
|
+
const VISIBILITIES = ['private', 'tally', 'shared'];
|
|
12
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
13
|
+
export function parseStateConfig(raw) {
|
|
14
|
+
if (!isMap(raw))
|
|
15
|
+
return { ok: false, error: 'state must be a map' };
|
|
16
|
+
for (const k of Object.keys(raw))
|
|
17
|
+
if (!['writers', 'visibility', 'slots'].includes(k))
|
|
18
|
+
return { ok: false, error: `state has an unknown key: ${k}` };
|
|
19
|
+
const writers = raw.writers ?? 'signed-in';
|
|
20
|
+
if (writers !== 'signed-in' && writers !== 'anyone')
|
|
21
|
+
return { ok: false, error: 'state.writers must be signed-in or anyone' };
|
|
22
|
+
const visibility = raw.visibility ?? 'private';
|
|
23
|
+
if (!VISIBILITIES.includes(visibility))
|
|
24
|
+
return { ok: false, error: `state.visibility must be one of: ${VISIBILITIES.join(', ')}` };
|
|
25
|
+
const slots = {};
|
|
26
|
+
const rawSlots = raw.slots ?? {};
|
|
27
|
+
if (!isMap(rawSlots))
|
|
28
|
+
return { ok: false, error: 'state.slots must be a map' };
|
|
29
|
+
for (const [name, def] of Object.entries(rawSlots)) {
|
|
30
|
+
if (!SLOT_NAME.test(name))
|
|
31
|
+
return { ok: false, error: `slot name "${name}" must be lowercase letters, digits and dashes, starting with a letter` };
|
|
32
|
+
if (!isMap(def))
|
|
33
|
+
return { ok: false, error: `slot "${name}" needs shape one or many` };
|
|
34
|
+
for (const k of Object.keys(def))
|
|
35
|
+
if (k !== 'shape' && k !== 'visibility')
|
|
36
|
+
return { ok: false, error: `slot "${name}" has an unknown key: ${k}` };
|
|
37
|
+
if (def.shape !== 'one' && def.shape !== 'many')
|
|
38
|
+
return { ok: false, error: `slot "${name}" needs shape one or many` };
|
|
39
|
+
if (def.visibility !== undefined && !VISIBILITIES.includes(def.visibility))
|
|
40
|
+
return { ok: false, error: `slot "${name}" visibility must be one of: ${VISIBILITIES.join(', ')}` };
|
|
41
|
+
slots[name] = { shape: def.shape, ...(def.visibility ? { visibility: def.visibility } : {}) };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, state: { writers, visibility: visibility, slots } };
|
|
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;
|
|
48
|
+
}
|
|
49
|
+
export function slotVisibility(state, slot) {
|
|
50
|
+
return state.slots[slot]?.visibility ?? state.visibility;
|
|
51
|
+
}
|
|
52
|
+
function isJson(v) {
|
|
53
|
+
if (v === null || typeof v === 'string' || typeof v === 'boolean')
|
|
54
|
+
return true;
|
|
55
|
+
if (typeof v === 'number')
|
|
56
|
+
return Number.isFinite(v);
|
|
57
|
+
if (Array.isArray(v))
|
|
58
|
+
return v.every(isJson);
|
|
59
|
+
if (isMap(v))
|
|
60
|
+
return Object.values(v).every(isJson);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
export function checkValue(value) {
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return 'value is required';
|
|
66
|
+
if (!isJson(value))
|
|
67
|
+
return 'value must be JSON';
|
|
68
|
+
if (Buffer.byteLength(JSON.stringify(value), 'utf8') > MAX_VALUE_BYTES)
|
|
69
|
+
return 'value is over 8 KB';
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** Answers are kept across a republish, so a slot may never change what shape its data is. */
|
|
73
|
+
export function shapeChanges(prev, next) {
|
|
74
|
+
if (!prev || !next)
|
|
75
|
+
return [];
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const [name, def] of Object.entries(next.slots)) {
|
|
78
|
+
const was = prev.slots[name];
|
|
79
|
+
if (was && was.shape !== def.shape)
|
|
80
|
+
out.push(`slot "${name}" changed shape from ${was.shape} to ${def.shape}; rename the slot instead`);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Firestore } from 'firebase-admin/firestore';
|
|
2
2
|
import type { ArtifactMeta } from './front-matter.js';
|
|
3
3
|
import type { Access } from './reader.js';
|
|
4
|
+
import type { StateConfig } from './state.js';
|
|
4
5
|
export type ArtifactRecord = {
|
|
5
6
|
id: string;
|
|
6
7
|
title: string;
|
|
@@ -15,6 +16,7 @@ export type ArtifactRecord = {
|
|
|
15
16
|
narrationHash?: string;
|
|
16
17
|
password?: string;
|
|
17
18
|
access?: Access;
|
|
19
|
+
state?: StateConfig;
|
|
18
20
|
markdown: string;
|
|
19
21
|
createdAt: string;
|
|
20
22
|
updatedAt: string;
|
package/lib/artifacts/store.js
CHANGED
|
@@ -56,6 +56,7 @@ async function saveArtifact(col, input) {
|
|
|
56
56
|
...(input.meta.narrationHash ? { narrationHash: input.meta.narrationHash } : {}),
|
|
57
57
|
...(input.meta.password ? { password: input.meta.password } : {}),
|
|
58
58
|
...(input.meta.access && input.meta.access !== 'public' ? { access: input.meta.access } : {}),
|
|
59
|
+
...(input.meta.state ? { state: input.meta.state } : {}),
|
|
59
60
|
};
|
|
60
61
|
if (input.id) {
|
|
61
62
|
const existing = await getArtifact(col, input.id);
|
|
@@ -79,8 +80,10 @@ async function saveArtifact(col, input) {
|
|
|
79
80
|
// Access is the opposite of password on purpose: absent leaves it alone, and only an explicit
|
|
80
81
|
// `access: public` opens the page. Reopening a confidential page must never be a side effect.
|
|
81
82
|
const access = input.meta.access === 'public' ? { access: FieldValue.delete() } : {};
|
|
83
|
+
// Content, like the body: a republish that no longer declares state removes the slots.
|
|
84
|
+
const state = input.meta.state ? {} : { state: FieldValue.delete() };
|
|
82
85
|
await ref.update({
|
|
83
|
-
...fields, ...password, ...subtitle, ...access, markdown: input.markdown, updatedAt: now, version: next,
|
|
86
|
+
...fields, ...password, ...subtitle, ...access, ...state, markdown: input.markdown, updatedAt: now, version: next,
|
|
84
87
|
...(existing.versions ? { versions: FieldValue.delete() } : {}),
|
|
85
88
|
});
|
|
86
89
|
return { id: input.id, version: next, created: false };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { StateConfig, Visibility } from './state.js';
|
|
2
|
+
import { type Heading } from './notes-place.js';
|
|
3
|
+
export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes, type Heading, type NoteValue, type PlacedNote } from './notes-place.js';
|
|
4
|
+
export type NotesWidget = {
|
|
5
|
+
line: number;
|
|
6
|
+
visibility?: Exclude<Visibility, 'tally'>;
|
|
7
|
+
};
|
|
8
|
+
/** GitHub's rule: lowercase, drop everything but letters, digits, spaces, dashes and
|
|
9
|
+
* underscores, spaces to dashes. A heading of only punctuation is `section`. */
|
|
10
|
+
export declare function slugify(text: string): string;
|
|
11
|
+
/** Every heading in source order, with the line it starts on and a slug unique on the page
|
|
12
|
+
* (a repeated heading gets `-1`, `-2`). The renderer finds a heading here by its line, so the
|
|
13
|
+
* slug a note stores is exactly the slug the page draws. */
|
|
14
|
+
export declare function headingsOf(markdown: string): Heading[];
|
|
15
|
+
/** Finds the widget fences in a body. `offset` is how many lines of the file sit above the body
|
|
16
|
+
* (the front matter), so every error names the line the author sees in their editor. */
|
|
17
|
+
export declare function scanWidgets(body: string, offset: number): {
|
|
18
|
+
ok: true;
|
|
19
|
+
notes: NotesWidget | null;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
error: string;
|
|
23
|
+
};
|
|
24
|
+
/** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
|
|
25
|
+
* `state:` (creating `state:` with its defaults when the page had none), so the state API
|
|
26
|
+
* takes notes with no change and a republish's shape checks cover the slot. */
|
|
27
|
+
export declare function mergeWidgetState(state: StateConfig | undefined, notes: NotesWidget | null): {
|
|
28
|
+
ok: true;
|
|
29
|
+
state: StateConfig | undefined;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
error: string;
|
|
33
|
+
};
|
|
34
|
+
/** Does this page's body carry a notes block? The state route asks, to check a note's shape. */
|
|
35
|
+
export declare function hasNotesWidget(markdown: string): boolean;
|
|
36
|
+
/** The server's check on a note, on a page with a notes block. The browser checks too; this is
|
|
37
|
+
* the one that counts. */
|
|
38
|
+
export declare function checkNoteValue(v: unknown): string | null;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Widgets: fenced blocks in a document's markdown that give readers a place to answer, drawn by
|
|
2
|
+
// the shell in the page's brand and written through the state API (state.ts, state-routes.ts).
|
|
3
|
+
//
|
|
4
|
+
// This version draws ONE widget, `notes`: a small note control beside every heading, kept in the
|
|
5
|
+
// `many` slot `notes`. A note stores the heading it was left under, both its slug and its text,
|
|
6
|
+
// so a republish that renames or removes the heading keeps the note and shows it under "notes
|
|
7
|
+
// on earlier versions" instead of losing it or attaching it to the wrong section.
|
|
8
|
+
//
|
|
9
|
+
// Everything here is pure and server-safe: parsing, publish-time validation, the heading slugs
|
|
10
|
+
// the renderer and the stored notes agree on, and where each note is placed. The drawing lives
|
|
11
|
+
// in ../widgets/notes.tsx.
|
|
12
|
+
import { unified } from 'unified';
|
|
13
|
+
import remarkParse from 'remark-parse';
|
|
14
|
+
import remarkGfm from 'remark-gfm';
|
|
15
|
+
import { MAX_NOTE_CHARS, NOTES_SLOT } from './notes-place.js';
|
|
16
|
+
export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from './notes-place.js';
|
|
17
|
+
/** Widgets the design names and this version does not draw yet. A fence using one is refused at
|
|
18
|
+
* publish, so a page never ships a code block that turns into a live widget on a later update. */
|
|
19
|
+
const NOT_YET = ['poll', 'form', 'checklist'];
|
|
20
|
+
const MAX_HEADING_CHARS = 300;
|
|
21
|
+
const parse = (markdown) => unified().use(remarkParse).use(remarkGfm).parse(markdown);
|
|
22
|
+
function inline(nodes) {
|
|
23
|
+
let out = '';
|
|
24
|
+
for (const n of nodes) {
|
|
25
|
+
if (n.type === 'text' || n.type === 'inlineCode')
|
|
26
|
+
out += n.value;
|
|
27
|
+
else if (n.type === 'break')
|
|
28
|
+
out += ' ';
|
|
29
|
+
else if ('children' in n)
|
|
30
|
+
out += inline(n.children);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
function walk(nodes, visit) {
|
|
35
|
+
for (const n of nodes) {
|
|
36
|
+
visit(n);
|
|
37
|
+
if ('children' in n && n.type !== 'heading' && n.type !== 'paragraph')
|
|
38
|
+
walk(n.children, visit);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** GitHub's rule: lowercase, drop everything but letters, digits, spaces, dashes and
|
|
42
|
+
* underscores, spaces to dashes. A heading of only punctuation is `section`. */
|
|
43
|
+
export function slugify(text) {
|
|
44
|
+
const s = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-');
|
|
45
|
+
return s || 'section';
|
|
46
|
+
}
|
|
47
|
+
/** Every heading in source order, with the line it starts on and a slug unique on the page
|
|
48
|
+
* (a repeated heading gets `-1`, `-2`). The renderer finds a heading here by its line, so the
|
|
49
|
+
* slug a note stores is exactly the slug the page draws. */
|
|
50
|
+
export function headingsOf(markdown) {
|
|
51
|
+
const out = [];
|
|
52
|
+
const seen = new Map();
|
|
53
|
+
walk(parse(markdown).children, (n) => {
|
|
54
|
+
if (n.type !== 'heading')
|
|
55
|
+
return;
|
|
56
|
+
const text = inline(n.children).replace(/\s+/g, ' ').trim();
|
|
57
|
+
const base = slugify(text);
|
|
58
|
+
const k = seen.get(base) ?? 0;
|
|
59
|
+
seen.set(base, k + 1);
|
|
60
|
+
out.push({ depth: n.depth, line: n.position?.start.line ?? 0, text, slug: k ? `${base}-${k}` : base });
|
|
61
|
+
});
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Finds the widget fences in a body. `offset` is how many lines of the file sit above the body
|
|
65
|
+
* (the front matter), so every error names the line the author sees in their editor. */
|
|
66
|
+
export function scanWidgets(body, offset) {
|
|
67
|
+
const fences = [];
|
|
68
|
+
walk(parse(body).children, (n) => { if (n.type === 'code')
|
|
69
|
+
fences.push(n); });
|
|
70
|
+
let notes = null;
|
|
71
|
+
for (const f of fences) {
|
|
72
|
+
const line = (f.position?.start.line ?? 1) + offset;
|
|
73
|
+
if (f.lang && NOT_YET.includes(f.lang))
|
|
74
|
+
return { ok: false, error: `line ${line}: the ${f.lang} widget is not available in this version of the artifacts package` };
|
|
75
|
+
if (f.lang !== NOTES_SLOT)
|
|
76
|
+
continue;
|
|
77
|
+
if (notes)
|
|
78
|
+
return { ok: false, error: `line ${line}: a page takes at most one notes block` };
|
|
79
|
+
if (f.meta?.trim())
|
|
80
|
+
return { ok: false, error: `line ${line}: the notes block takes no name; it always writes to slot "notes"` };
|
|
81
|
+
const w = { line };
|
|
82
|
+
const rows = f.value ? f.value.split('\n') : [];
|
|
83
|
+
for (let i = 0; i < rows.length; i++) {
|
|
84
|
+
const raw = rows[i].replace(/#.*$/, '').trim();
|
|
85
|
+
if (!raw)
|
|
86
|
+
continue;
|
|
87
|
+
const at = line + 1 + i;
|
|
88
|
+
const m = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(raw);
|
|
89
|
+
if (!m)
|
|
90
|
+
return { ok: false, error: `line ${at}: the notes block takes lines like "visibility: shared"` };
|
|
91
|
+
if (m[1] !== 'visibility')
|
|
92
|
+
return { ok: false, error: `line ${at}: the notes block has an unknown key: ${m[1]}` };
|
|
93
|
+
if (m[2] !== 'private' && m[2] !== 'shared')
|
|
94
|
+
return { ok: false, error: `line ${at}: notes visibility must be private or shared` };
|
|
95
|
+
w.visibility = m[2];
|
|
96
|
+
}
|
|
97
|
+
notes = w;
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, notes };
|
|
100
|
+
}
|
|
101
|
+
/** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
|
|
102
|
+
* `state:` (creating `state:` with its defaults when the page had none), so the state API
|
|
103
|
+
* takes notes with no change and a republish's shape checks cover the slot. */
|
|
104
|
+
export function mergeWidgetState(state, notes) {
|
|
105
|
+
if (!notes)
|
|
106
|
+
return { ok: true, state };
|
|
107
|
+
const had = state?.slots[NOTES_SLOT];
|
|
108
|
+
if (had && had.shape !== 'many')
|
|
109
|
+
return { ok: false, error: `line ${notes.line}: the notes block writes to slot "notes" as shape many, and state: declares it shape ${had.shape}; rename that slot` };
|
|
110
|
+
if (had?.visibility && notes.visibility && had.visibility !== notes.visibility)
|
|
111
|
+
return { ok: false, error: `line ${notes.line}: the notes block says visibility ${notes.visibility} and state: says ${had.visibility} for slot "notes"; say it once` };
|
|
112
|
+
const slot = had
|
|
113
|
+
? (notes.visibility && !had.visibility ? { ...had, visibility: notes.visibility } : had)
|
|
114
|
+
: { shape: 'many', ...(notes.visibility ? { visibility: notes.visibility } : {}) };
|
|
115
|
+
const base = state ?? { writers: 'signed-in', visibility: 'private', slots: {} };
|
|
116
|
+
if (slot === had)
|
|
117
|
+
return { ok: true, state: base };
|
|
118
|
+
return { ok: true, state: { ...base, slots: { ...base.slots, [NOTES_SLOT]: slot } } };
|
|
119
|
+
}
|
|
120
|
+
/** Does this page's body carry a notes block? The state route asks, to check a note's shape. */
|
|
121
|
+
export function hasNotesWidget(markdown) {
|
|
122
|
+
const r = scanWidgets(markdown, 0);
|
|
123
|
+
return r.ok && r.notes !== null;
|
|
124
|
+
}
|
|
125
|
+
const SLUG = /^[\p{Ll}\p{Lo}\p{Lm}\p{N}_-]{1,120}$/u;
|
|
126
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
127
|
+
/** The server's check on a note, on a page with a notes block. The browser checks too; this is
|
|
128
|
+
* the one that counts. */
|
|
129
|
+
export function checkNoteValue(v) {
|
|
130
|
+
if (!isMap(v) || Object.keys(v).some((k) => !['slug', 'heading', 'note'].includes(k)))
|
|
131
|
+
return 'a note is { slug, heading, note }';
|
|
132
|
+
if (typeof v.slug !== 'string' || !SLUG.test(v.slug))
|
|
133
|
+
return 'a note needs the slug of the heading it is under';
|
|
134
|
+
if (typeof v.heading !== 'string' || v.heading.length > MAX_HEADING_CHARS)
|
|
135
|
+
return 'a note needs the heading it is under';
|
|
136
|
+
if (typeof v.note !== 'string' || !v.note.trim())
|
|
137
|
+
return 'a note needs some text';
|
|
138
|
+
if (v.note.length > MAX_NOTE_CHARS)
|
|
139
|
+
return `a note is at most ${MAX_NOTE_CHARS} characters`;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED