@supersuit/hypercontext-core 0.0.0-stage → 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ The first version: what every hypercontext record package shares, in one copy.
6
+
7
+ - `readHeader`, `writeHeader`, `placeKey`: the YAML header codec, the union of the copies group-md, piece-md, superobjective and hypercontext each carried (group-md's was the only one with `placeKey`). Never throws on a header it does not understand; a write changes only the keys it was asked to change, every other byte kept, CRLF included. Their codec tests came with it.
8
+ - `readFlatHeader`: the forgiving reader self-md reads self.md with (booleans, folded values joined), kept under its own name because it is not the same reader.
9
+ - `STANDARD_BASE`, `bareStandard`, `standardUrl`, `versionOf`, `classifyStandard`: the versioned-standard rule. `stampStandard`: the byte-preserving stamp that adds `standard:` after `kind:` or upgrades a kind's bare URL in place, and never gives a headerless file a header.
10
+ - `validateManifest`, `assertManifest`: the shape of a standard's manifest (STANDARDS.md section 2), with every problem said in words. Header-key constraints are JSON Schema's: `const`, `enum`, `pattern`, `type`, `items`, `minLength`, `maxLength`, `minItems`, `maxItems`.
11
+ - No dependencies.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SupersuitUp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # @supersuit/hypercontext-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ *This package lives in the [hypercontext repository](https://github.com/SupersuitUp/hypercontext/tree/main/packages/core), with every record standard that uses it.*
4
+
5
+ What every hypercontext record package shares, in one copy: the YAML header codec, the rule that a record names the version of the standard it follows, and the validator for a standard's manifest. friend-md, group-md, self-md, superprojects, transcript-md, piece-md, superobjective and hypercontext are built on it, and you only need it directly if you are writing a record package of your own.
6
+
7
+ ```sh
8
+ npm install @supersuit/hypercontext-core
9
+ ```
10
+
11
+ ## The header codec
12
+
13
+ ```js
14
+ import { readHeader, writeHeader, placeKey } from '@supersuit/hypercontext-core';
15
+
16
+ const { data, body, raw } = readHeader(text); // every scalar reads as a string
17
+ const next = writeHeader(text, { status: 'done' }); // only `status` changes, every other byte kept
18
+ const stamped = placeKey(text, 'standard', url, 'kind'); // a new key, directly after `kind:`
19
+ ```
20
+
21
+ The subset is what records use: scalars, quoted strings, flow lists, block lists, one-line flow maps and block lists of them. Two promises: it never throws on a header it does not understand (an unknown shape reads as a string and is carried through a write untouched), and a write changes only the keys it was asked to change. `null` removes a key.
22
+
23
+ `readFlatHeader(text)` is the forgiving reader self-md uses: it returns the data object directly, turns `true`/`false` into booleans, and joins folded values.
24
+
25
+ ## Versioned standards
26
+
27
+ ```js
28
+ import { standardUrl, versionOf, classifyStandard, stampStandard, bareStandard } from '@supersuit/hypercontext-core';
29
+
30
+ standardUrl('friend', 1); // https://hypercontext.wiki/standards/friend/v1
31
+ versionOf('friend', record.standard); // 1, 0 for the bare URL, null for anything else
32
+ classifyStandard('friend', value, [1]); // {state: missing|bare|current|implemented|unimplemented|other, version}
33
+ stampStandard(text, standardUrl('friend', 1), bareStandard('friend')); // {text, changed, reason?}
34
+ ```
35
+
36
+ `stampStandard` adds `standard:` after `kind:` (first when there is none), fills an empty key in place, and upgrades the kind's bare URL. It leaves another kind's URL or another version for a person, and never gives a file with no header one.
37
+
38
+ ## Manifests
39
+
40
+ ```js
41
+ import { validateManifest } from '@supersuit/hypercontext-core';
42
+
43
+ const { ok, problems } = validateManifest(JSON.parse(readFileSync('standards/friend/v1.json', 'utf8')));
44
+ ```
45
+
46
+ Checks the shape in [STANDARDS.md section 2](https://github.com/SupersuitUp/hypercontext/blob/main/packages/hypercontext/docs/STANDARDS.md): `manifest_version`, a `standard` that agrees with `kind` and `version`, `latest`, `title`, `status` and `superseded_by`, `record`, `header.required` and `header.keys` (constraints a reader can check without a package, with JSON Schema's meaning), `validator` or, when it is null, `checked_by`, every code with its `level`, `means` and `fix`, and `umbrella`.
47
+
48
+ ## License
49
+
50
+ MIT
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@supersuit/hypercontext-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "What every hypercontext record package shares, in one copy: the YAML header codec, the versioned-standard rule and its byte-preserving stamp, and the validator for a standard's manifest.",
5
+ "keywords": ["hypercontext", "records", "markdown", "frontmatter", "yaml", "manifest", "self-documenting", "supersuit"],
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.mjs",
9
+ "./header": "./src/header.mjs",
10
+ "./flat-header": "./src/flat-header.mjs",
11
+ "./selfdoc": "./src/selfdoc.mjs",
12
+ "./manifest": "./src/manifest.mjs",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": ["src/", "README.md", "CHANGELOG.md", "LICENSE"],
16
+ "scripts": {
17
+ "test": "node --test test/*.test.mjs",
18
+ "mutation-check": "node scripts/mutation-check.mjs"
19
+ },
20
+ "engines": { "node": ">=20" },
21
+ "license": "MIT",
22
+ "publishConfig": { "access": "public" },
23
+ "repository": { "type": "git", "url": "git+https://github.com/SupersuitUp/hypercontext.git", "directory": "packages/core" },
24
+ "dependencies": {}
25
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * readFlatHeader: a Markdown file's frontmatter as a plain object, the FORGIVING reader. Never throws.
3
+ *
4
+ * This is the second header reader the record packages carried, and it is NOT the same as
5
+ * header.mjs's readHeader, so it keeps its own name: it returns the data object directly rather
6
+ * than {data, body, raw}, turns "true"/"false" into booleans, and falls back to a line reader that
7
+ * joins indented continuation lines into the key above. self-md reads self.md with it.
8
+ *
9
+ * Copied from Freedom's people library (freedom-people.mjs readHeader) together with exactly the
10
+ * flat-YAML helpers it reaches (freedom-frontmatter split -> agentic-projects parseFlat), with
11
+ * behavior unchanged. The strict reader accepts the flat subset: scalars, quoted strings, inline
12
+ * lists and block lists. Anything it refuses (a folded value, an indented continuation) falls back
13
+ * to a forgiving line reader, so a hand-edited header still yields what it can.
14
+ */
15
+ class FrontmatterSyntaxError extends Error {
16
+ constructor(message, line) { super(`line ${line}: ${message}`); this.name = "FrontmatterSyntaxError"; this.line = line; }
17
+ }
18
+
19
+ const KEY = /^([a-z][a-z0-9_]*):(?:\s+(.*))?$/;
20
+ const LIST_ITEM = /^\s+-\s+(.*)$/;
21
+ const INDENTED = /^\s+\S/;
22
+
23
+ const unescape = (inner) => {
24
+ try { return JSON.parse(`"${inner}"`); }
25
+ catch { return inner; }
26
+ };
27
+
28
+ const unquote = (s) => {
29
+ const t = s.trim();
30
+ if (t.length >= 2 && t[0] === '"' && t.at(-1) === '"') return unescape(t.slice(1, -1));
31
+ if (t.length >= 2 && t[0] === "'" && t.at(-1) === "'") return t.slice(1, -1);
32
+ return t;
33
+ };
34
+
35
+ /** Strip a comment only when it follows a CLOSED quote or inline list. */
36
+ const stripTrailingComment = (v) => {
37
+ const t = v.trim();
38
+ const close = t[0] === '"' || t[0] === "'" ? t[0] : t[0] === "[" ? "]" : null;
39
+ if (!close) return t;
40
+ let q = null;
41
+ for (let i = 1; i < t.length; i++) {
42
+ const ch = t[i];
43
+ if (q) { if (ch === "\\" && q === '"') { i++; continue; } if (ch === q) q = null; continue; }
44
+ if (close === "]" && (ch === '"' || ch === "'")) { q = ch; continue; }
45
+ if (close !== "]" && ch === "\\" && close === '"') { i++; continue; }
46
+ if (ch === close) {
47
+ const rest = t.slice(i + 1);
48
+ return /^\s+#/.test(rest) ? t.slice(0, i + 1) : t;
49
+ }
50
+ }
51
+ return t;
52
+ };
53
+
54
+ const splitTopLevel = (inner) => {
55
+ const out = []; let cur = ""; let q = null;
56
+ for (let i = 0; i < inner.length; i++) {
57
+ const ch = inner[i];
58
+ if (q) {
59
+ cur += ch;
60
+ if (ch === "\\" && q === '"') { cur += inner[++i] ?? ""; continue; }
61
+ if (ch === q) q = null;
62
+ continue;
63
+ }
64
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
65
+ if (ch === ",") { out.push(cur); cur = ""; continue; }
66
+ cur += ch;
67
+ }
68
+ if (cur.trim()) out.push(cur);
69
+ return out;
70
+ };
71
+
72
+ const splitInline = (inner) => splitTopLevel(inner).map(unquote).filter((s) => s !== "");
73
+
74
+ /** The strict flat subset. Throws FrontmatterSyntaxError on anything outside it. */
75
+ function parseFlat(text) {
76
+ const out = {};
77
+ const lines = String(text ?? "").split("\n");
78
+ let listKey = null;
79
+ for (let i = 0; i < lines.length; i++) {
80
+ const line = lines[i], n = i + 1;
81
+ if (!line.trim() || line.trim().startsWith("#")) continue;
82
+ const item = line.match(LIST_ITEM);
83
+ if (item) {
84
+ if (!listKey) throw new FrontmatterSyntaxError("a list item with no list key above it", n);
85
+ out[listKey].push(unquote(stripTrailingComment(item[1])));
86
+ continue;
87
+ }
88
+ if (INDENTED.test(line)) {
89
+ throw new FrontmatterSyntaxError(/^\s+\S+:/.test(line)
90
+ ? "a nested map; frontmatter here is flat (facts, never paragraphs)"
91
+ : "an indented continuation; a value is one line", n);
92
+ }
93
+ const kv = line.match(KEY);
94
+ if (!kv) {
95
+ const bad = line.match(/^([^:]+):/);
96
+ throw new FrontmatterSyntaxError(bad ? `key "${bad[1]}" is not snake_case` : "not a key: value line", n);
97
+ }
98
+ const [, key, rawVal] = kv;
99
+ if (key in out) throw new FrontmatterSyntaxError(`duplicate key "${key}"`, n);
100
+ const val = stripTrailingComment(rawVal ?? "");
101
+ if (val === "") {
102
+ const next = lines[i + 1] ?? "";
103
+ if (LIST_ITEM.test(next)) { out[key] = []; listKey = key; continue; }
104
+ out[key] = ""; listKey = null; continue;
105
+ }
106
+ listKey = null;
107
+ if (val.startsWith("[")) {
108
+ if (!val.endsWith("]")) throw new FrontmatterSyntaxError("an inline list that does not close on its line", n);
109
+ out[key] = splitInline(val.slice(1, -1));
110
+ continue;
111
+ }
112
+ out[key] = unquote(val);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** `---\n<header>\n---\n<body>` -> { data }. data is null when there is no opening ---. */
118
+ function split(text) {
119
+ const src = String(text ?? "");
120
+ if (!/^---\n/.test(src)) return { data: null, body: src, raw: null };
121
+ const end = src.indexOf("\n---\n", 3);
122
+ const endEof = end < 0 && src.endsWith("\n---") ? src.length - 4 : -1;
123
+ if (end < 0 && endEof < 0) throw new FrontmatterSyntaxError("unterminated frontmatter block: no closing ---", 1);
124
+ const raw = src.slice(4, end >= 0 ? end : endEof);
125
+ const body = end >= 0 ? src.slice(end + 5) : "";
126
+ return { data: parseFlat(raw), body, raw };
127
+ }
128
+
129
+ const coerce = (v) => (v === "true" ? true : v === "false" ? false : v);
130
+
131
+ /** CRLF to LF, so every reader below sees one line ending. */
132
+ const lf = (t) => String(t ?? "").replace(/\r\n?/g, "\n");
133
+
134
+ /** Split a record into its raw header lines and its body. `header` is null when there is none. */
135
+ function parts(text) {
136
+ const src = lf(text);
137
+ if (!src.startsWith("---\n")) return { header: null, body: src };
138
+ const end = src.indexOf("\n---", 4);
139
+ if (end < 0) return { header: src.slice(4), body: "" };
140
+ const nl = src.indexOf("\n", end + 4);
141
+ return { header: src.slice(4, end), body: nl < 0 ? "" : src.slice(nl + 1) };
142
+ }
143
+
144
+ /** Drop a trailing ` # comment` from an UNQUOTED `key: value` line. */
145
+ const stripComment = (line) => {
146
+ const m = line.match(/^([A-Za-z_][\w-]*:[ \t]*)(.*)$/);
147
+ if (!m || /^["']/.test(m[2])) return line;
148
+ return m[1] + m[2].replace(/\s+#.*$/, "").replace(/^#.*$/, "");
149
+ };
150
+
151
+ /**
152
+ * The header as an object. Never throws: a folded or indented value that the strict parser
153
+ * refuses falls back to a line reader that joins continuation lines into the key above and
154
+ * collects `- item` block lists into arrays. "true"/"false" become booleans.
155
+ *
156
+ * @param {string} text The whole file.
157
+ * @returns {Record<string, string|boolean|string[]>}
158
+ */
159
+ export function readFlatHeader(text) {
160
+ const { header } = parts(text);
161
+ if (header === null) return {};
162
+ const raw = header.split("\n").map(stripComment).join("\n");
163
+ try {
164
+ const { data } = split(`---\n${raw}\n---\n`);
165
+ if (data) return Object.fromEntries(Object.entries(data).map(([k, v]) => [k, coerce(v)]));
166
+ } catch { /* fall through to the line reader */ }
167
+ const out = {};
168
+ let last = null;
169
+ for (const line of raw.split("\n")) {
170
+ const m = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
171
+ if (m) {
172
+ last = m[1];
173
+ let v = m[2].trim();
174
+ if (v === ">-" || v === ">" || v === "|" || v === "|-") v = "";
175
+ if (/^\[.*\]$/.test(v)) v = v.slice(1, -1).split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
176
+ else v = coerce(v.replace(/^["']|["']$/g, ""));
177
+ out[last] = v;
178
+ } else if (last && /^\s+-\s+\S/.test(line) && (out[last] === "" || Array.isArray(out[last]))) {
179
+ if (!Array.isArray(out[last])) out[last] = [];
180
+ out[last].push(line.replace(/^\s+-\s+/, "").trim().replace(/^["']|["']$/g, ""));
181
+ } else if (last && /^\s+\S/.test(line) && typeof out[last] === "string") {
182
+ out[last] = (out[last] ? out[last] + " " : "") + line.trim();
183
+ }
184
+ }
185
+ return out;
186
+ }
package/src/header.mjs ADDED
@@ -0,0 +1,274 @@
1
+ // The YAML header of a hypercontext record, in both directions, and only the subset a record uses:
2
+ // scalars, quoted strings, flow lists, block lists, one-line flow maps, and block lists of
3
+ // one-line flow maps.
4
+ //
5
+ // ONE COPY. This codec was copied into group-md, piece-md, superobjective and hypercontext, and the
6
+ // copies had already diverged (only group-md's had placeKey) when they were folded into this
7
+ // package on 2026-10-20. Every package that reads this subset imports it from here, so a codec
8
+ // fix lands once and reaches every kind.
9
+ //
10
+ // TWO PROMISES, AND THEY ARE WHY THIS IS HAND-ROLLED.
11
+ //
12
+ // 1. It never throws on a header it does not understand. A shape outside the subset (a folded
13
+ // scalar, a nested block map) is read as a string and its lines are carried through a write
14
+ // exactly as they were.
15
+ // 2. A write changes only the keys it was asked to change. Every other line of the header and
16
+ // the whole body come back byte for byte, including spacing and comments, because existing
17
+ // records carry keys this standard does not define and other tools read them.
18
+ //
19
+ // Every scalar reads as a string. `member_count: 4` is "4" and `tracked: false` is "false";
20
+ // the package that owns a kind decides what a value means.
21
+
22
+ const KEY = /^([A-Za-z_][\w.-]*):(?:[ \t]+(.*?))?[ \t]*$/;
23
+ const ITEM = /^[ \t]*-(?:[ \t]+(.*?))?[ \t]*$/;
24
+ const FENCE = /^---[ \t]*$/;
25
+ const clean = (line) => line.replace(/\r$/, '');
26
+
27
+ // ---------------------------------------------------------------- reading values
28
+
29
+ function readDouble(s, i) {
30
+ let j = i + 1;
31
+ while (j < s.length && s[j] !== '"') j += s[j] === '\\' ? 2 : 1;
32
+ if (j >= s.length) return null;
33
+ const inner = s.slice(i + 1, j);
34
+ let value;
35
+ // The inverse of JSON.stringify on the way out. A hand-typed value whose backslashes are
36
+ // not valid escapes is kept as written instead of thrown away.
37
+ try { value = JSON.parse(`"${inner}"`); } catch { value = inner; }
38
+ return { value, next: j + 1 };
39
+ }
40
+
41
+ function readSingle(s, i) {
42
+ let j = i + 1, value = '';
43
+ while (j < s.length) {
44
+ if (s[j] === "'") {
45
+ if (s[j + 1] === "'") { value += "'"; j += 2; continue; }
46
+ return { value, next: j + 1 };
47
+ }
48
+ value += s[j++];
49
+ }
50
+ return null;
51
+ }
52
+
53
+ const skipSpace = (s, i) => { while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i++; return i; };
54
+
55
+ // One value inside a flow list or a flow map. `stops` are the characters that end a plain scalar.
56
+ function readFlow(s, i, stops) {
57
+ i = skipSpace(s, i);
58
+ const ch = s[i];
59
+ if (ch === '"') return readDouble(s, i);
60
+ if (ch === "'") return readSingle(s, i);
61
+ if (ch === '[') return readFlowList(s, i);
62
+ if (ch === '{') return readFlowMap(s, i);
63
+ let j = i;
64
+ while (j < s.length && !stops.includes(s[j])) j++;
65
+ return { value: s.slice(i, j).trim(), next: j };
66
+ }
67
+
68
+ function readFlowList(s, i) {
69
+ const out = [];
70
+ i = skipSpace(s, i + 1);
71
+ if (s[i] === ']') return { value: out, next: i + 1 };
72
+ while (i < s.length) {
73
+ const item = readFlow(s, i, ',]');
74
+ if (!item) return null;
75
+ i = skipSpace(s, item.next);
76
+ if (item.value !== '' || s[i] !== ']') out.push(item.value);
77
+ if (s[i] === ',') { i = skipSpace(s, i + 1); if (s[i] === ']') return { value: out, next: i + 1 }; continue; }
78
+ if (s[i] === ']') return { value: out, next: i + 1 };
79
+ return null;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ function readFlowMap(s, i) {
85
+ const out = {};
86
+ i = skipSpace(s, i + 1);
87
+ if (s[i] === '}') return { value: out, next: i + 1 };
88
+ while (i < s.length) {
89
+ const key = readFlow(s, i, ':,}');
90
+ if (!key || typeof key.value !== 'string') return null;
91
+ i = skipSpace(s, key.next);
92
+ if (s[i] !== ':') return null;
93
+ const val = readFlow(s, i + 1, ',}');
94
+ if (!val) return null;
95
+ out[key.value] = val.value;
96
+ i = skipSpace(s, val.next);
97
+ if (s[i] === ',') { i = skipSpace(s, i + 1); if (s[i] === '}') return { value: out, next: i + 1 }; continue; }
98
+ if (s[i] === '}') return { value: out, next: i + 1 };
99
+ return null;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ // A plain scalar ends at a ` #` comment. `C#` keeps its `#` because nothing precedes it with a space.
105
+ const stripComment = (s) => s.replace(/(^|[ \t])#.*$/, '').trim();
106
+
107
+ // The value on a `key: value` line or after a `- `.
108
+ function readValue(rawValue) {
109
+ const s = (rawValue ?? '').trim();
110
+ if (s === '') return '';
111
+ if ('"\'[{'.includes(s[0])) {
112
+ const got = readFlow(s, 0, '');
113
+ if (got) {
114
+ const rest = s.slice(got.next);
115
+ if (/^[ \t]*(#.*)?$/.test(rest) && (rest === '' || /^[ \t]/.test(rest))) return got.value;
116
+ }
117
+ return s; // not a shape this codec models: keep the text
118
+ }
119
+ return stripComment(s);
120
+ }
121
+
122
+ // ---------------------------------------------------------------- the header as lines
123
+
124
+ // Splits text into the opening fence, the header lines and everything from the closing fence on.
125
+ function fences(text) {
126
+ const lines = String(text ?? '').split('\n');
127
+ if (!FENCE.test(clean(lines[0] ?? '').replace(/^/, ''))) return null;
128
+ for (let i = 1; i < lines.length; i++) {
129
+ if (FENCE.test(clean(lines[i]))) return { lines, close: i };
130
+ }
131
+ return null;
132
+ }
133
+
134
+ // Header lines grouped into segments: a key with the lines that belong to it, or a loose line
135
+ // (blank, comment, anything unreadable) that no write ever touches.
136
+ function segments(headerLines) {
137
+ const out = [];
138
+ let open = null;
139
+ for (const line of headerLines) {
140
+ const text = clean(line);
141
+ const key = text.match(KEY);
142
+ if (key) {
143
+ open = { key: key[1], value: key[2] ?? '', lines: [line] };
144
+ out.push(open);
145
+ continue;
146
+ }
147
+ const indented = /^[ \t]+\S/.test(text);
148
+ const item = ITEM.test(text);
149
+ if (open && (indented || (item && open.value === ''))) { open.lines.push(line); continue; }
150
+ open = null;
151
+ out.push({ key: null, lines: [line] });
152
+ }
153
+ return out;
154
+ }
155
+
156
+ function segmentValue(seg) {
157
+ if (seg.value !== '') return readValue(seg.value);
158
+ const rest = seg.lines.slice(1).map(clean);
159
+ if (!rest.length || !ITEM.test(rest[0])) return '';
160
+ const items = [];
161
+ for (const line of rest) {
162
+ const m = line.match(ITEM);
163
+ if (m) items.push(readValue(m[1] ?? ''));
164
+ }
165
+ return items;
166
+ }
167
+
168
+ /**
169
+ * Read a record's header.
170
+ * @returns {{data: Record<string, any>, body: string, raw: string}} `data` holds strings, lists
171
+ * and one-level maps; `body` is everything after the closing fence; `raw` is the header text.
172
+ * Text with no header reads as `{data: {}, body: text, raw: ''}`.
173
+ */
174
+ export function readHeader(text) {
175
+ const f = fences(text);
176
+ if (!f) return { data: {}, body: String(text ?? ''), raw: '' };
177
+ const headerLines = f.lines.slice(1, f.close);
178
+ const data = {};
179
+ for (const seg of segments(headerLines)) {
180
+ if (seg.key && !(seg.key in data)) data[seg.key] = segmentValue(seg);
181
+ }
182
+ return {
183
+ data,
184
+ body: f.lines.slice(f.close + 1).join('\n'),
185
+ raw: headerLines.length ? headerLines.join('\n') + '\n' : '',
186
+ };
187
+ }
188
+
189
+ // ---------------------------------------------------------------- writing values
190
+
191
+ const RESERVED = /^(true|false|null|yes|no|on|off|~)$/i;
192
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
193
+
194
+ // One rule everywhere: a date or a single plain token starting with a letter goes bare, and
195
+ // anything else is quoted, so no value can be read back as something it is not.
196
+ const quote = (s) => (DATE.test(s) || (/^[A-Za-z][A-Za-z0-9_-]*$/.test(s) && !RESERVED.test(s))) ? s : JSON.stringify(s);
197
+
198
+ const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
199
+
200
+ function flowValue(v) {
201
+ if (Array.isArray(v)) return `[${v.map(flowValue).join(', ')}]`;
202
+ if (isMap(v)) return flowMap(v);
203
+ return typeof v === 'string' ? quote(v) : String(v);
204
+ }
205
+
206
+ function flowMap(obj) {
207
+ const parts = Object.entries(obj).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => `${k}: ${flowValue(v)}`);
208
+ return `{${parts.join(', ')}}`;
209
+ }
210
+
211
+ function renderKey(key, value) {
212
+ if (Array.isArray(value)) {
213
+ if (!value.length) return [`${key}: []`];
214
+ if (value.some(isMap)) return [`${key}:`, ...value.map((v) => ` - ${isMap(v) ? flowMap(v) : flowValue(v)}`)];
215
+ return [`${key}: [${value.map((v) => JSON.stringify(String(v))).join(', ')}]`];
216
+ }
217
+ if (isMap(value)) return [`${key}: ${flowMap(value)}`];
218
+ return [`${key}: ${typeof value === 'string' ? quote(value) : String(value)}`];
219
+ }
220
+
221
+ // What a value will read back as, so a patch that changes nothing can be recognized.
222
+ const canon = (v) => JSON.stringify(v, (_, x) => (x === null || x === undefined ? undefined : typeof x === 'object' ? x : String(x)));
223
+
224
+ /**
225
+ * Set header keys, leaving every other line of the text byte-identical.
226
+ * A key already holding the value is not rewritten. `null` removes a key. A new key is appended
227
+ * before the closing fence. Text with no header gains one.
228
+ * @param {string} text the whole file
229
+ * @param {Record<string, any>} patch strings, numbers, booleans, lists, one-level maps, or null
230
+ */
231
+ export function writeHeader(text, patch) {
232
+ const src = String(text ?? '');
233
+ const entries = Object.entries(patch ?? {}).filter(([, v]) => v !== undefined);
234
+ const f = fences(src);
235
+ if (!f) {
236
+ const lines = entries.filter(([, v]) => v !== null).flatMap(([k, v]) => renderKey(k, v));
237
+ if (!lines.length) return src;
238
+ return `---\n${lines.join('\n')}\n---\n${src}`;
239
+ }
240
+ const eol = /\r$/.test(f.lines[0]) ? '\r' : '';
241
+ const segs = segments(f.lines.slice(1, f.close));
242
+ for (const [key, value] of entries) {
243
+ const at = segs.findIndex((s) => s.key === key);
244
+ if (value === null) { if (at >= 0) segs.splice(at, 1); continue; }
245
+ if (at >= 0 && canon(segmentValue(segs[at])) === canon(value)) continue;
246
+ const seg = { key, value: '', lines: renderKey(key, value).map((l) => l + eol) };
247
+ seg.value = clean(seg.lines[0]).match(KEY)?.[2] ?? '';
248
+ if (at >= 0) segs[at] = seg; else segs.push(seg);
249
+ }
250
+ return [f.lines[0], ...segs.flatMap((s) => s.lines), ...f.lines.slice(f.close)].join('\n');
251
+ }
252
+
253
+ /**
254
+ * Set one header key, and when the header does not have it yet, place it directly after the
255
+ * segment of `afterKey` (first in the header when that key is absent) instead of at the end.
256
+ * Every other line stays byte-identical, and the line ending follows the opening fence.
257
+ * A key already holding the value is a no-op; one holding another value is replaced in place.
258
+ * @param {string} text the whole file
259
+ * @param {string} key
260
+ * @param {any} value
261
+ * @param {string} afterKey
262
+ */
263
+ export function placeKey(text, key, value, afterKey) {
264
+ const src = String(text ?? '');
265
+ const f = fences(src);
266
+ if (!f) return writeHeader(src, { [key]: value });
267
+ const segs = segments(f.lines.slice(1, f.close));
268
+ if (segs.some((s) => s.key === key)) return writeHeader(src, { [key]: value });
269
+ const eol = /\r$/.test(f.lines[0]) ? '\r' : '';
270
+ const at = segs.findIndex((s) => s.key === afterKey);
271
+ const lines = renderKey(key, value).map((l) => l + eol);
272
+ segs.splice(at + 1, 0, { key, value: '', lines });
273
+ return [f.lines[0], ...segs.flatMap((s) => s.lines), ...f.lines.slice(f.close)].join('\n');
274
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ // @supersuit/hypercontext-core: what every hypercontext record package shares, in one copy.
2
+ export { readHeader, writeHeader, placeKey } from './header.mjs';
3
+ export { readFlatHeader } from './flat-header.mjs';
4
+ export { STANDARD_BASE, bareStandard, standardUrl, versionOf, classifyStandard, stampStandard } from './selfdoc.mjs';
5
+ export { validateManifest, assertManifest, MANIFEST_VERSION, STATUSES, FORMATS, LEVELS, CONSTRAINTS, TYPES } from './manifest.mjs';
@@ -0,0 +1,125 @@
1
+ // The shape of a standard's manifest: the machine-readable twin of one version of one standard
2
+ // (docs/STANDARDS.md in @supersuit/hypercontext, section 2), checked as data.
3
+ //
4
+ // Every record package ships its manifests at standards/<kind>/v<N>.json, the umbrella reads them
5
+ // to know which versions exist, and the wiki publishes them unchanged. One validator for all of
6
+ // them, so a manifest that a reader with no package could not use is refused in the package that
7
+ // ships it rather than discovered by the reader.
8
+ import { STANDARD_BASE, bareStandard, standardUrl } from './selfdoc.mjs';
9
+
10
+ export const MANIFEST_VERSION = 1;
11
+ export const STATUSES = Object.freeze(['current', 'superseded']);
12
+ export const FORMATS = Object.freeze(['md', 'json']);
13
+ /** Finding levels a manifest's codes may carry. transcript-md says `warning` where others say `warn`. */
14
+ export const LEVELS = Object.freeze(['error', 'warn', 'warning', 'info']);
15
+ /**
16
+ * The constraints a header key may carry: the ones a reader can check without installing anything.
17
+ * Their meaning is JSON Schema's, so a reader may hand them to any JSON Schema validator.
18
+ */
19
+ export const CONSTRAINTS = Object.freeze(['const', 'enum', 'pattern', 'type', 'items', 'minLength', 'maxLength', 'minItems', 'maxItems']);
20
+ export const TYPES = Object.freeze(['string', 'number', 'integer', 'boolean', 'array', 'object', 'null']);
21
+
22
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
23
+ const isStr = (v) => typeof v === 'string' && v.trim() !== '';
24
+ const KIND = /^[a-z][a-z0-9-]*$/;
25
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
26
+
27
+ function checkConstraint(where, c, problems) {
28
+ if (!isObj(c)) { problems.push(`${where} is not an object of constraints`); return; }
29
+ for (const name of Object.keys(c)) if (!CONSTRAINTS.includes(name)) problems.push(`${where} has constraint "${name}", which is not one of ${CONSTRAINTS.join(', ')}`);
30
+ if ('enum' in c && (!Array.isArray(c.enum) || c.enum.length === 0)) problems.push(`${where}.enum is not a non-empty list`);
31
+ if ('pattern' in c) {
32
+ if (typeof c.pattern !== 'string') problems.push(`${where}.pattern is not a string`);
33
+ else { try { new RegExp(c.pattern); } catch { problems.push(`${where}.pattern is not a valid regular expression`); } }
34
+ }
35
+ if ('type' in c) {
36
+ const types = Array.isArray(c.type) ? c.type : [c.type];
37
+ for (const t of types) if (!TYPES.includes(t)) problems.push(`${where}.type "${t}" is not one of ${TYPES.join(', ')}`);
38
+ }
39
+ for (const n of ['minLength', 'maxLength', 'minItems', 'maxItems']) if (n in c && !(Number.isInteger(c[n]) && c[n] >= 0)) problems.push(`${where}.${n} is not a whole number`);
40
+ if ('items' in c) checkConstraint(`${where}.items`, c.items, problems);
41
+ }
42
+
43
+ /**
44
+ * Check one manifest against the shape STANDARDS.md section 2 defines.
45
+ * @param {unknown} m the parsed manifest
46
+ * @returns {{ok: boolean, problems: string[]}} every problem found, in plain words; empty when ok
47
+ */
48
+ export function validateManifest(m) {
49
+ const problems = [];
50
+ if (!isObj(m)) return { ok: false, problems: ['the manifest is not a JSON object'] };
51
+ if (m.manifest_version !== MANIFEST_VERSION) problems.push(`manifest_version is ${JSON.stringify(m.manifest_version)}, not ${MANIFEST_VERSION}`);
52
+
53
+ const kindOk = typeof m.kind === 'string' && KIND.test(m.kind);
54
+ if (!kindOk) problems.push(`kind ${JSON.stringify(m.kind)} is not a lowercase kind name`);
55
+ const versionOk = Number.isInteger(m.version) && m.version >= 1;
56
+ if (!versionOk) problems.push(`version ${JSON.stringify(m.version)} is not a whole number from 1`);
57
+ if (typeof m.standard !== 'string' || !m.standard.startsWith(STANDARD_BASE)) problems.push(`standard ${JSON.stringify(m.standard)} is not a ${STANDARD_BASE}<kind>/v<N> URL`);
58
+ else if (kindOk && versionOk && m.standard !== standardUrl(m.kind, m.version)) problems.push(`standard is ${m.standard}, but kind and version say ${standardUrl(m.kind, m.version)}`);
59
+ if (kindOk && m.latest !== bareStandard(m.kind)) problems.push(`latest is ${JSON.stringify(m.latest)}, not ${bareStandard(m.kind)}`);
60
+
61
+ if (!isStr(m.title)) problems.push('title is missing or empty');
62
+ if (!STATUSES.includes(m.status)) problems.push(`status ${JSON.stringify(m.status)} is not one of ${STATUSES.join(', ')}`);
63
+ if ('superseded_by' in m && m.superseded_by !== null && typeof m.superseded_by !== 'string') problems.push('superseded_by is neither null nor a URL');
64
+ if (m.status === 'superseded' && !isStr(m.superseded_by)) problems.push('status is superseded, and superseded_by names no URL');
65
+ if (m.status === 'current' && isStr(m.superseded_by)) problems.push('status is current, and superseded_by names a URL');
66
+
67
+ if (!isObj(m.record)) problems.push('record is missing');
68
+ else {
69
+ if (!isStr(m.record.path)) problems.push('record.path is missing or empty');
70
+ if (!FORMATS.includes(m.record.format)) problems.push(`record.format ${JSON.stringify(m.record.format)} is not one of ${FORMATS.join(', ')}`);
71
+ }
72
+
73
+ if (!isObj(m.header)) problems.push('header is missing');
74
+ else {
75
+ if (!Array.isArray(m.header.required) || !m.header.required.every(isStr)) problems.push('header.required is not a list of key names');
76
+ else if (new Set(m.header.required).size !== m.header.required.length) problems.push('header.required names a key twice');
77
+ if (!isObj(m.header.keys)) problems.push('header.keys is not an object');
78
+ else {
79
+ for (const [key, c] of Object.entries(m.header.keys)) checkConstraint(`header.keys.${key}`, c, problems);
80
+ const std = m.header.keys.standard;
81
+ if (isObj(std) && 'const' in std && std.const !== m.standard) problems.push(`header.keys.standard.const is ${JSON.stringify(std.const)}, not this manifest's standard`);
82
+ const kind = m.header.keys.kind;
83
+ if (isObj(kind) && 'const' in kind && kind.const !== m.kind) problems.push(`header.keys.kind.const is ${JSON.stringify(kind.const)}, not this manifest's kind`);
84
+ }
85
+ }
86
+
87
+ if (m.validator === null) {
88
+ if (!isObj(m.checked_by)) problems.push('validator is null and checked_by is missing: nothing says what checks this kind');
89
+ else {
90
+ if (!isStr(m.checked_by.check)) problems.push('checked_by.check is missing or empty');
91
+ if (!isStr(m.checked_by.note)) problems.push('checked_by.note is missing: it must say what is not checked');
92
+ }
93
+ } else if (!isObj(m.validator)) problems.push('validator is neither an object nor null');
94
+ else {
95
+ if (!isStr(m.validator.package)) problems.push('validator.package is missing or empty');
96
+ if (typeof m.validator.implements_since !== 'string' || !SEMVER.test(m.validator.implements_since)) problems.push(`validator.implements_since ${JSON.stringify(m.validator.implements_since)} is not a version`);
97
+ if (!isStr(m.validator.check)) problems.push('validator.check is missing or empty');
98
+ if ('backfill' in m.validator && m.validator.backfill !== null && !isStr(m.validator.backfill)) problems.push('validator.backfill is neither null nor a command');
99
+ }
100
+
101
+ if (!Array.isArray(m.codes) || m.codes.length === 0) problems.push('codes is not a non-empty list');
102
+ else {
103
+ const seen = new Set();
104
+ m.codes.forEach((c, i) => {
105
+ const at = isObj(c) && isStr(c.code) ? `codes "${c.code}"` : `codes[${i}]`;
106
+ if (!isObj(c)) { problems.push(`${at} is not an object`); return; }
107
+ if (!isStr(c.code)) problems.push(`${at} has no code`);
108
+ else if (seen.has(c.code)) problems.push(`${at} is listed twice`);
109
+ else seen.add(c.code);
110
+ if (!LEVELS.includes(c.level)) problems.push(`${at} has level ${JSON.stringify(c.level)}, not one of ${LEVELS.join(', ')}`);
111
+ if (!isStr(c.means)) problems.push(`${at} does not say what it means`);
112
+ if (!isStr(c.fix)) problems.push(`${at} does not say how to fix it`);
113
+ });
114
+ }
115
+
116
+ if (!isStr(m.umbrella)) problems.push('umbrella is missing or empty');
117
+ return { ok: problems.length === 0, problems };
118
+ }
119
+
120
+ /** validateManifest, throwing one error that lists every problem. For a package's own tests. */
121
+ export function assertManifest(m, label = 'manifest') {
122
+ const { ok, problems } = validateManifest(m);
123
+ if (!ok) throw new Error(`${label} is not a valid manifest:\n - ${problems.join('\n - ')}`);
124
+ return m;
125
+ }
@@ -0,0 +1,90 @@
1
+ // The one property every hypercontext record shares: it names the version of the standard it
2
+ // follows, so an agent that has never seen its package can open the file cold and find its rules
3
+ // (docs/STANDARDS.md in @supersuit/hypercontext, section 1).
4
+ //
5
+ // What lives here is the part every package agrees on: the URL shapes, how a URL is read back as
6
+ // a version of a kind, and the byte-preserving stamp that writes `standard:` into a header. What a
7
+ // package does with an unimplemented version (refuse, throw, leave it) is that package's policy and
8
+ // stays in that package.
9
+ import { readHeader, writeHeader } from './header.mjs';
10
+
11
+ export const STANDARD_BASE = 'https://hypercontext.wiki/standards/';
12
+
13
+ /** The unversioned page of a kind: always its latest version. A record never names it. */
14
+ export const bareStandard = (kind) => `${STANDARD_BASE}${kind}`;
15
+
16
+ /** The URL of one version of a kind's standard, the value a record carries under `standard:`. */
17
+ export const standardUrl = (kind, version) => `${bareStandard(kind)}/v${version}`;
18
+
19
+ /**
20
+ * The version a URL names for a kind: a number, 0 for the bare URL (written before versions
21
+ * existed), null for anything else (another kind's standard, a typo, a URL that only looks like a
22
+ * version).
23
+ * @param {string} kind
24
+ * @param {unknown} standard
25
+ * @returns {number|null}
26
+ */
27
+ export function versionOf(kind, standard) {
28
+ const s = String(standard ?? '');
29
+ if (s === bareStandard(kind)) return 0;
30
+ const m = s.startsWith(`${bareStandard(kind)}/v`) ? s.slice(bareStandard(kind).length + 2).match(/^(\d+)$/) : null;
31
+ return m ? Number(m[1]) : null;
32
+ }
33
+
34
+ /**
35
+ * What a record's `standard:` value says, relative to the versions a validator implements:
36
+ * - `missing`: absent or empty;
37
+ * - `bare`: the kind's unversioned URL, written before versions; a stamp upgrades it;
38
+ * - `current`: the newest implemented version, the one writers stamp;
39
+ * - `implemented`: an older version the validator still reads;
40
+ * - `unimplemented`: a version of this kind the validator does not implement;
41
+ * - `other`: another kind's standard or any other string.
42
+ * @param {string} kind
43
+ * @param {unknown} standard
44
+ * @param {number[]} [implemented=[1]]
45
+ * @returns {{state: 'missing'|'bare'|'current'|'implemented'|'unimplemented'|'other', version: number|null}}
46
+ */
47
+ export function classifyStandard(kind, standard, implemented = [1]) {
48
+ const s = typeof standard === 'string' ? standard.trim() : '';
49
+ if (!s) return { state: 'missing', version: null };
50
+ const v = versionOf(kind, s);
51
+ if (v === 0) return { state: 'bare', version: null };
52
+ if (v === null) return { state: 'other', version: null };
53
+ if (!implemented.includes(v)) return { state: 'unimplemented', version: v };
54
+ return { state: v === Math.max(...implemented) ? 'current' : 'implemented', version: v };
55
+ }
56
+
57
+ /**
58
+ * Add `standard:` to a record that names none, or upgrade the bare URL of its own kind (written
59
+ * before versions) to the given version, changing nothing else. A new line goes directly after
60
+ * `kind:` when the header has one, otherwise first. A record naming another kind's standard, or a
61
+ * version of its own, comes back unchanged: that is a judgment for a person. A file with no header
62
+ * is never given one. CRLF line endings are kept.
63
+ * @param {string} text
64
+ * @param {string} url the versioned standard to name
65
+ * @param {string} [bare] the kind's unversioned URL, upgraded in place when found
66
+ * @returns {{text: string, changed: boolean, reason?: string}}
67
+ */
68
+ export function stampStandard(text, url, bare) {
69
+ const src = String(text ?? '');
70
+ const h = readHeader(src);
71
+ if (h.raw === '' && !/^---[ \t]*\r?\n/.test(src)) return { text: src, changed: false, reason: 'no header' };
72
+ const lines = src.split('\n');
73
+ let close = -1;
74
+ for (let i = 1; i < lines.length; i++) if (/^---[ \t]*\r?$/.test(lines[i])) { close = i; break; }
75
+ if (close < 0) return { text: src, changed: false, reason: 'no header' };
76
+ const eol = /\r$/.test(lines[0]) ? '\r' : '';
77
+ const has = typeof h.data.standard === 'string' && h.data.standard !== '';
78
+ if (has && bare && h.data.standard === bare) {
79
+ for (let i = 1; i < close; i++) {
80
+ if (/^standard:/.test(lines[i])) { lines[i] = `standard: ${JSON.stringify(url)}${eol}`; return { text: lines.join('\n'), changed: true }; }
81
+ }
82
+ }
83
+ if (has) return { text: src, changed: false, reason: 'already names a standard' };
84
+ // An empty `standard:` key is filled in place rather than duplicated.
85
+ if ('standard' in h.data) return { text: writeHeader(src, { standard: url }), changed: true };
86
+ let at = 1;
87
+ for (let i = 1; i < close; i++) if (/^kind:/.test(lines[i])) { at = i + 1; break; }
88
+ lines.splice(at, 0, `standard: ${JSON.stringify(url)}${eol}`);
89
+ return { text: lines.join('\n'), changed: true };
90
+ }