@supersuit/transcript-md 0.1.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.
@@ -0,0 +1,248 @@
1
+ // .agents/lib/frontmatter/yaml.mjs
2
+ // The flat YAML subset this workspace writes, in both directions, and nothing else.
3
+ //
4
+ // WHY A SUBSET AND WHY HAND-ROLLED. The repo ships with no dependencies and already carries two
5
+ // flat frontmatter readers (freedom-wiki-canon.mjs, freedom-operator.mjs), each a different
6
+ // regex. The spec (docs/superpowers/specs/2026-09-13-frontmatter-is-the-truth-design.md) says
7
+ // frontmatter carries facts and never paragraphs: scalars, ISO dates, flat lists. A full YAML
8
+ // parser would accept anchors, multi-line folded scalars and nested maps, which is exactly the
9
+ // prose-in-braces the convention forbids, so the parser refuses them by construction rather than
10
+ // by policy. Every value is a string or an array of strings; a kind decides what it means.
11
+ //
12
+ // ONE NAMED EXCEPTION, AND IT IS OPT-IN PER CALL: `state:`. The artifacts host
13
+ // (@supersuit/artifacts 0.2.0+) accepts a `state:` block on a published page
14
+ // (writers/visibility/slots for a reader to answer), and that shape is genuinely a small map,
15
+ // not a paragraph wearing braces. This parser never decides on its own that a key may nest: the
16
+ // caller passes `{ nested: [...] }` naming exactly which keys are allowed to, for THIS read or
17
+ // write. A caller that passes nothing gets the original behaviour, every key refused. That
18
+ // scoping lives one layer up (`freedom-frontmatter.mjs` derives it from the document's `kind`,
19
+ // via `nestedKeysFor()` in `frontmatter/kinds.mjs`, for the one kind — `document` — whose schema
20
+ // declares `state: { type: "map" }`), so a caller with no kind in view (a map file, a project
21
+ // header) can never accidentally inherit nesting meant for a published artifact.
22
+
23
+ export class FrontmatterSyntaxError extends Error {
24
+ constructor(message, line) { super(`line ${line}: ${message}`); this.name = "FrontmatterSyntaxError"; this.line = line; }
25
+ }
26
+
27
+ const KEY = /^([a-z][a-z0-9_]*):(?:\s+(.*))?$/;
28
+ const LIST_ITEM = /^\s+-\s+(.*)$/;
29
+ const INDENTED = /^\s+\S/;
30
+
31
+ // The inverse of quote()'s JSON.stringify. Without it the two halves are asymmetric: the
32
+ // writer escapes on every emit and the reader never unescapes, so a value carrying a quotation
33
+ // gains a pair of backslashes on every read/write cycle and degrades to unreadable. A value
34
+ // whose backslashes are not valid escapes (a Windows path typed by hand) is left exactly as
35
+ // written rather than thrown away.
36
+ const unescape = (inner) => {
37
+ try { return JSON.parse(`"${inner}"`); }
38
+ catch { return inner; }
39
+ };
40
+
41
+ const unquote = (s) => {
42
+ const t = s.trim();
43
+ if (t.length >= 2 && t[0] === '"' && t.at(-1) === '"') return unescape(t.slice(1, -1));
44
+ // Single quotes carry no backslash escapes in YAML, so the slice IS the value.
45
+ if (t.length >= 2 && t[0] === "'" && t.at(-1) === "'") return t.slice(1, -1);
46
+ return t;
47
+ };
48
+
49
+ // `"example" # a note` is the value `example`. Only a comment that follows a CLOSED
50
+ // quote or a closed inline list is stripped: an unquoted value keeps any `#` it carries
51
+ // (`C# and F#`, `page#anchor`), because the writer quotes every value containing `#` and a
52
+ // hand-written unquoted one is safer kept whole than guessed at.
53
+ export const stripTrailingComment = (v) => {
54
+ const t = v.trim();
55
+ const close = t[0] === '"' || t[0] === "'" ? t[0] : t[0] === "[" ? "]" : null;
56
+ if (!close) return t;
57
+ let q = null;
58
+ for (let i = 1; i < t.length; i++) {
59
+ const ch = t[i];
60
+ if (q) { if (ch === "\\" && q === '"') { i++; continue; } if (ch === q) q = null; continue; }
61
+ if (close === "]" && (ch === '"' || ch === "'")) { q = ch; continue; }
62
+ if (close !== "]" && ch === "\\" && close === '"') { i++; continue; }
63
+ if (ch === close) {
64
+ const rest = t.slice(i + 1);
65
+ return /^\s+#/.test(rest) ? t.slice(0, i + 1) : t;
66
+ }
67
+ }
68
+ return t;
69
+ };
70
+
71
+ // `a, "b, c"`: split on commas outside quotes, leaving each piece unparsed.
72
+ const splitTopLevel = (inner) => {
73
+ const out = []; let cur = ""; let q = null;
74
+ for (let i = 0; i < inner.length; i++) {
75
+ const ch = inner[i];
76
+ if (q) {
77
+ cur += ch;
78
+ // Keep escaped pairs intact for unquote(). Paired backslashes leave a following
79
+ // quote free to close; single-quoted values have no backslash escapes.
80
+ if (ch === "\\" && q === '"') { cur += inner[++i] ?? ""; continue; }
81
+ if (ch === q) q = null;
82
+ continue;
83
+ }
84
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
85
+ if (ch === ",") { out.push(cur); cur = ""; continue; }
86
+ cur += ch;
87
+ }
88
+ if (cur.trim()) out.push(cur);
89
+ return out;
90
+ };
91
+
92
+ const splitInline = (inner) => splitTopLevel(inner).map(unquote).filter((s) => s !== "");
93
+
94
+ // `{ shape: one }` / `{ shape: many, visibility: shared }`: a flow map, one level, scalar values
95
+ // only. This is the leaf shape the artifacts host's `state.slots.*` entries use; it is not a
96
+ // general flow-YAML parser, only enough of one to round-trip that shape.
97
+ function parseFlowMap(s) {
98
+ const inner = s.slice(1, -1).trim();
99
+ const out = {};
100
+ if (!inner) return out;
101
+ for (const part of splitTopLevel(inner)) {
102
+ const m = part.trim().match(/^([a-z][a-z0-9_]*)\s*:\s*(.*)$/);
103
+ if (!m) throw new FrontmatterSyntaxError(`"${part.trim()}" is not a "key: value" entry in a flow map`, 0);
104
+ out[m[1]] = unquote(m[2].trim());
105
+ }
106
+ return out;
107
+ }
108
+
109
+ const indentOf = (line) => (line.match(/^(\s*)/) ?? ["", ""])[1].length;
110
+
111
+ /**
112
+ * A nested block starting at `lines[start]` (which sets the block's indent level): every
113
+ * `key: value` line at that indent, a value-less key followed by a MORE indented block recursing
114
+ * one level deeper, and a `{ ... }` value read as a flow map. Stops at the first line whose
115
+ * indent is less than the block's own. Returns `{ value, next }`, `next` being the index of the
116
+ * first line NOT consumed, so the caller's main loop can resume there.
117
+ */
118
+ function parseNestedBlock(lines, start) {
119
+ const blockIndent = indentOf(lines[start]);
120
+ const out = {};
121
+ let i = start;
122
+ while (i < lines.length) {
123
+ const line = lines[i];
124
+ if (!line.trim()) { i++; continue; }
125
+ const ind = indentOf(line);
126
+ if (ind < blockIndent) break;
127
+ if (ind > blockIndent) throw new FrontmatterSyntaxError("bad indentation in a nested map", i + 1);
128
+ const m = line.match(/^\s*([a-z][a-z0-9_]*):(?:\s+(.*))?$/);
129
+ if (!m) throw new FrontmatterSyntaxError("not a \"key: value\" line inside a nested map", i + 1);
130
+ const [, key, rawVal] = m;
131
+ if (key in out) throw new FrontmatterSyntaxError(`duplicate key "${key}"`, i + 1);
132
+ const val = stripTrailingComment(rawVal ?? "");
133
+ if (val === "") {
134
+ const nextLine = lines[i + 1];
135
+ if (nextLine !== undefined && nextLine.trim() && indentOf(nextLine) > blockIndent) {
136
+ const sub = parseNestedBlock(lines, i + 1);
137
+ out[key] = sub.value;
138
+ i = sub.next;
139
+ continue;
140
+ }
141
+ out[key] = "";
142
+ i++;
143
+ continue;
144
+ }
145
+ out[key] = val.startsWith("{") && val.endsWith("}") ? parseFlowMap(val) : unquote(val);
146
+ i++;
147
+ }
148
+ return { value: out, next: i };
149
+ }
150
+
151
+ /** `state:`'s value, serialized as an indented block (mirroring how it is authored by hand). A
152
+ * nested map one level deep is written as a flow map (`{ k: v }`), matching the shape the host
153
+ * itself accepts and what a hand-authored file already looks like. */
154
+ function stringifyNestedBlock(obj, indent) {
155
+ const pad = " ".repeat(indent);
156
+ let out = "";
157
+ for (const [k, v] of Object.entries(obj ?? {})) {
158
+ if (v === undefined) continue;
159
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
160
+ const allScalar = Object.values(v).every((vv) => vv === null || typeof vv !== "object");
161
+ if (allScalar) {
162
+ const inner = Object.entries(v).map(([kk, vv]) => `${kk}: ${quote(String(vv))}`).join(", ");
163
+ out += `${pad}${k}: { ${inner} }\n`;
164
+ } else {
165
+ out += `${pad}${k}:\n${stringifyNestedBlock(v, indent + 1)}`;
166
+ }
167
+ continue;
168
+ }
169
+ out += `${pad}${k}: ${quote(String(v))}\n`;
170
+ }
171
+ return out;
172
+ }
173
+
174
+ export function parseFlat(text, { nested = [] } = {}) {
175
+ const nestedKeys = nested instanceof Set ? nested : new Set(nested);
176
+ const out = {};
177
+ const lines = String(text ?? "").split("\n");
178
+ let listKey = null;
179
+ for (let i = 0; i < lines.length; i++) {
180
+ const line = lines[i], n = i + 1;
181
+ if (!line.trim() || line.trim().startsWith("#")) continue;
182
+ const item = line.match(LIST_ITEM);
183
+ if (item) {
184
+ if (!listKey) throw new FrontmatterSyntaxError("a list item with no list key above it", n);
185
+ out[listKey].push(unquote(stripTrailingComment(item[1])));
186
+ continue;
187
+ }
188
+ if (INDENTED.test(line)) {
189
+ throw new FrontmatterSyntaxError(/^\s+\S+:/.test(line)
190
+ ? "a nested map; frontmatter here is flat (facts, never paragraphs)"
191
+ : "an indented continuation; a value is one line", n);
192
+ }
193
+ const kv = line.match(KEY);
194
+ if (!kv) {
195
+ const bad = line.match(/^([^:]+):/);
196
+ throw new FrontmatterSyntaxError(bad ? `key "${bad[1]}" is not snake_case` : "not a key: value line", n);
197
+ }
198
+ const [, key, rawVal] = kv;
199
+ if (key in out) throw new FrontmatterSyntaxError(`duplicate key "${key}"`, n);
200
+ const val = stripTrailingComment(rawVal ?? "");
201
+ if (val === "") {
202
+ // Either an empty scalar, the head of a block list, or (for a key THIS CALL named nestable)
203
+ // the head of a nested map; the next line decides.
204
+ const next = lines[i + 1] ?? "";
205
+ if (LIST_ITEM.test(next)) { out[key] = []; listKey = key; continue; }
206
+ if (nestedKeys.has(key) && next.trim() && INDENTED.test(next)) {
207
+ const sub = parseNestedBlock(lines, i + 1);
208
+ out[key] = sub.value;
209
+ listKey = null;
210
+ i = sub.next - 1; // the for-loop's own i++ resumes at sub.next
211
+ continue;
212
+ }
213
+ out[key] = ""; listKey = null; continue;
214
+ }
215
+ listKey = null;
216
+ if (val.startsWith("[")) {
217
+ if (!val.endsWith("]")) throw new FrontmatterSyntaxError("an inline list that does not close on its line", n);
218
+ out[key] = splitInline(val.slice(1, -1));
219
+ continue;
220
+ }
221
+ out[key] = unquote(val);
222
+ }
223
+ return out;
224
+ }
225
+
226
+ const needsQuotes = (s) => s === "" || /[:#\[\]{}"'|>&*!%@`,]/.test(s) || /^\s|\s$/.test(s) || /^(true|false|null|yes|no|~)$/i.test(s) || /^[\d.+-]+$/.test(s) && !/^\d{4}-\d{2}-\d{2}$/.test(s);
227
+ const quote = (s) => needsQuotes(s) ? JSON.stringify(s) : s;
228
+
229
+ export function stringifyFlat(data, { nested = [] } = {}) {
230
+ const nestedKeys = nested instanceof Set ? nested : new Set(nested);
231
+ let out = "";
232
+ for (const [key, value] of Object.entries(data ?? {})) {
233
+ if (value === undefined) continue;
234
+ if (Array.isArray(value)) {
235
+ for (const v of value) if (typeof v !== "string") throw new Error(`"${key}": list items must be strings`);
236
+ out += value.length ? `${key}:\n${value.map((v) => ` - ${quote(v)}\n`).join("")}` : `${key}: []\n`;
237
+ continue;
238
+ }
239
+ if (value !== null && typeof value === "object") {
240
+ if (!nestedKeys.has(key)) throw new Error(`"${key}": nested maps are not frontmatter here; facts, never paragraphs`);
241
+ out += `${key}:\n${stringifyNestedBlock(value, 1)}`;
242
+ continue;
243
+ }
244
+ if (typeof value !== "string") throw new Error(`"${key}": values are strings (dates as YYYY-MM-DD)`);
245
+ out += `${key}: ${quote(value)}\n`;
246
+ }
247
+ return out;
248
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@supersuit/transcript-md",
3
+ "version": "0.1.0",
4
+ "description": "Local transcript.md records: lossless codec, validation, revisions and citations",
5
+ "type": "module",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "exports": {
11
+ ".": "./index.mjs",
12
+ "./codec": "./lib/frontmatter/conversation.mjs",
13
+ "./reader": "./lib/freedom-conversations.mjs",
14
+ "./writer": "./lib/freedom-conversation-write.mjs",
15
+ "./citations": "./lib/freedom-conversation-citations.mjs",
16
+ "./schema": "./lib/frontmatter/conversation.schema.json",
17
+ "./python": "./lib/freedom_conversations.py",
18
+ "./cli": "./lib/freedom-conversations-cli.mjs"
19
+ },
20
+ "bin": {
21
+ "transcript-md": "lib/freedom-conversations-cli.mjs"
22
+ },
23
+ "files": [
24
+ "index.mjs",
25
+ "lib/",
26
+ "LICENSE",
27
+ "NOTICE",
28
+ "README.md",
29
+ "FORMAT.md",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "scripts": {
33
+ "test": "node scripts/run-tests.mjs",
34
+ "test:installed": "node scripts/run-tests.mjs --installed",
35
+ "audit:payload": "node scripts/verify-payload.mjs"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/SupersuitUp/transcript-md.git"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }