@karmicsoft/lc-serialize 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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ LightCode Platform License — Community, Educational, and Partnership Use
2
+ © KarmicSoft. All rights reserved.
3
+
4
+ This package (@karmicsoft/lc-serialize) is part of the LightCode platform
5
+ components developed by KarmicSoft (Erasmus+ project "Strengthening the Digital
6
+ Transformation of Higher Education Through Low-Code", No. 2022-1-FR01-KA220-HED-000086863).
7
+
8
+ Free access and use are granted for educational and non-commercial academic
9
+ purposes, and for LightCode partner and collaboration projects. Use outside this
10
+ scope — commercial activities, professional services, or proprietary integrations —
11
+ requires a separate commercial agreement.
12
+
13
+ Full terms: see licence.md in the lightcodepedia repository
14
+ (https://lightcodepedia.org). Contact KarmicSoft for a partnership or commercial
15
+ license.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @karmicsoft/lc-serialize
2
+
3
+ Faithful YAML round-trip for git-backed structured content — a **LightCode** brick.
4
+ © KarmicSoft (see `LICENSE`).
5
+
6
+ Reads and writes the **same YAML that lives in git**, losing nothing:
7
+
8
+ - **key order preserved** (no reordering → minimal git diffs);
9
+ - **unquoted dates kept as strings** (`1830-05-29` stays a string — no `js-yaml` timestamp→ISO coercion);
10
+ - **block-scalar chomping preserved** (`|` / `|-` / `|+`);
11
+ - **null preserved**, nested objects/lists, `[]` for empty arrays.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install @karmicsoft/lc-serialize
17
+ ```
18
+
19
+ ## API
20
+
21
+ ```js
22
+ import { load, dump, roundtrip, isByteIdentical, isLossless } from '@karmicsoft/lc-serialize';
23
+
24
+ const obj = load(yamlText); // parse (dates stay strings)
25
+ const yaml = dump(obj); // serialize (block style, key order preserved)
26
+ const yaml2 = roundtrip(yamlText); // load()→dump()
27
+ isByteIdentical(yamlText); // roundtrip === source ?
28
+ isLossless(yamlText); // parsed data survives round-trip (order-independent)
29
+ ```
30
+
31
+ `dump(obj, { order })` — pass an explicit key order if you want a canonical order
32
+ instead of the object's own order. Omit it to preserve the source order.
33
+
34
+ ## One-liner corpus check (CLI)
35
+
36
+ Ships a `bin` — point it at a folder (or a file); exits non-zero on any data loss:
37
+
38
+ ```sh
39
+ npx @karmicsoft/lc-serialize lc-serialize-check ./content
40
+ # → "N file(s) checked · 0 data-loss · K byte-drift (advisory)"
41
+ # LC_VERBOSE=1 lists every drifting file
42
+ ```
43
+
44
+ `isLossless` is the gate (must be 0). Byte-drift is advisory: a lossless file can
45
+ still reformat — **comments are dropped** and flow style (`{a: 1}` / `[1, 2]`)
46
+ becomes block. On a Sveltia-generated corpus drift ≈ 0; hand-annotated files drift
47
+ by exactly their comments.
48
+
49
+ ## CI recipe (round-trip over the whole corpus, in code)
50
+
51
+ ```js
52
+ import { isLossless, isByteIdentical } from '@karmicsoft/lc-serialize';
53
+ import { readFileSync } from 'node:fs';
54
+ import { globSync } from 'glob';
55
+
56
+ let loss = 0, drift = 0;
57
+ for (const f of globSync('content/**/*.yaml')) {
58
+ const src = readFileSync(f, 'utf8');
59
+ if (!isLossless(src)) { loss++; console.error('LOSS ', f); } // ← must be 0 (fail CI)
60
+ else if (!isByteIdentical(src)) { drift++; console.warn ('drift ', f); } // ← advisory
61
+ }
62
+ process.exit(loss ? 1 : 0);
63
+ ```
64
+
65
+ ### Scope of the guarantee
66
+
67
+ - **`isLossless` is the contract** — no data lost. Assert this in CI; it must be 0.
68
+ - **`isByteIdentical`** additionally holds when the source is already in **canonical
69
+ block style** (your migrated corpus largely is). It will differ — while staying
70
+ lossless — for files that carry **YAML comments** (`# …`, dropped on parse) or
71
+ **flow style** (`{a: 1}`, `[1, 2]`). Treat byte-drift as an **advisory / drift
72
+ detector**, not a failure.
73
+ - Round-trip is **idempotent**: after one `dump`, further round-trips are byte-stable.
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ /*!
3
+ * lc-serialize-check — "does any of my files lose data?" over a whole corpus.
4
+ * © KarmicSoft — LightCode.
5
+ *
6
+ * npx @karmicsoft/lc-serialize lc-serialize-check ./content
7
+ * node check-corpus.mjs <dir-or-file> (LC_VERBOSE=1 lists every drift)
8
+ *
9
+ * isLossless (the CONTRACT) must be 0 → exit 1 on any loss (use as a CI gate).
10
+ * isByteIdentical is advisory: a lossless file can still reformat (comments are
11
+ * dropped, flow style {a: 1} / [1, 2] becomes block style) — that is drift, not loss.
12
+ */
13
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { isLossless, isByteIdentical } from './index.js';
16
+
17
+ const root = process.argv[2] || '.';
18
+ const EXT = ['.yaml', '.yml'];
19
+
20
+ function walk(dir, out = []) {
21
+ for (const name of readdirSync(dir)) {
22
+ if (name.startsWith('.') || name === 'node_modules') continue;
23
+ const p = join(dir, name);
24
+ const s = statSync(p);
25
+ if (s.isDirectory()) walk(p, out);
26
+ else if (EXT.some((e) => name.toLowerCase().endsWith(e))) out.push(p);
27
+ }
28
+ return out;
29
+ }
30
+
31
+ let files;
32
+ try {
33
+ files = statSync(root).isDirectory() ? walk(root) : [root];
34
+ } catch {
35
+ console.error('path not found: ' + root);
36
+ process.exit(2);
37
+ }
38
+
39
+ let loss = 0, drift = 0, checked = 0;
40
+ for (const f of files) {
41
+ let src;
42
+ try { src = readFileSync(f, 'utf8'); } catch { continue; }
43
+ checked++;
44
+ try {
45
+ if (!isLossless(src)) { loss++; console.error('LOSS ' + f); }
46
+ else if (!isByteIdentical(src)) { drift++; if (process.env.LC_VERBOSE) console.warn('drift ' + f); }
47
+ } catch (e) { loss++; console.error('ERROR ' + f + ' — ' + e.message); }
48
+ }
49
+
50
+ console.log(`\n${checked} file(s) checked · ${loss} data-loss · ${drift} byte-drift (advisory)`);
51
+ console.log(loss
52
+ ? '❌ FAIL — data would be lost on write-back. Fix before deploying.'
53
+ : '✅ PASS — no data lost' + (drift ? ` (${drift} file(s) reformat only — normalization, not loss).` : '.'));
54
+ process.exit(loss ? 1 : 0);
package/example.yaml ADDED
@@ -0,0 +1,27 @@
1
+ # Example fiche — feed it to load()/dump() to see the round-trip.
2
+ id: louise-michel
3
+ slug: louise-michel
4
+ type: person
5
+ title: Louise Michel
6
+ gender: féminin
7
+ birth:
8
+ date: 1830-05-29
9
+ year: 1830
10
+ professions:
11
+ - institutrice
12
+ - anarchiste
13
+ addresses:
14
+ - id: 24-rue-houdon
15
+ role: résidence
16
+ period: 1871
17
+ - id: cimetiere-de-levallois
18
+ role: mémoire
19
+ image:
20
+ credit: BnF · domaine public — Gallica
21
+ body: |
22
+ Institutrice et militante anarchiste.
23
+
24
+ Surnommée « la Vierge rouge ».
25
+ workflow:
26
+ draft: false
27
+ aiText: null
package/index.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @karmicsoft/lc-serialize — faithful YAML round-trip for git-backed content.
3
+ * © KarmicSoft — LightCode.
4
+ */
5
+
6
+ /** Parse YAML with the CORE schema so unquoted dates stay strings (no timestamp coercion). */
7
+ export function load(text: string): any;
8
+
9
+ /**
10
+ * Serialize a value to block-style YAML, preserving the object's own key order
11
+ * (minimal git diffs). Pass `{ order }` for an explicit canonical key order.
12
+ */
13
+ export function dump(obj: any, opts?: { order?: string[] }): string;
14
+
15
+ /** load() → dump(): the round-trip a corpus CI uses to prove non-loss. */
16
+ export function roundtrip(text: string): string;
17
+
18
+ /** True when roundtrip(text) === text. Holds for canonical block-style YAML. */
19
+ export function isByteIdentical(text: string): boolean;
20
+
21
+ /** True when the parsed data survives a round-trip (order-independent). The contract. */
22
+ export function isLossless(text: string): boolean;
package/index.js ADDED
@@ -0,0 +1,107 @@
1
+ /*!
2
+ * @karmicsoft/lc-serialize — faithful YAML round-trip for git-backed content.
3
+ * © KarmicSoft — LightCode. See LICENSE.
4
+ *
5
+ * Reads/writes the SAME YAML that lives in git, losing nothing:
6
+ * - key order preserved (no reordering → minimal git diffs)
7
+ * - unquoted dates kept as strings (no timestamp coercion: 1830-05-29 stays a string)
8
+ * - block-scalar chomping preserved (| / |- / |+)
9
+ * - null preserved, nested objects/lists, [] for empty arrays
10
+ */
11
+ import yaml from 'js-yaml';
12
+
13
+ /** Parse YAML WITHOUT date/timestamp coercion (CORE schema). */
14
+ export function load(text) {
15
+ return yaml.load(text, { schema: yaml.CORE_SCHEMA });
16
+ }
17
+
18
+ /** Serialize a plain object to block-style YAML, preserving its key order. */
19
+ export function dump(obj, opts = {}) {
20
+ return emitMap(obj, 0, opts.order || null) + '\n';
21
+ }
22
+
23
+ /** load() → dump(): the round-trip your CI uses to prove non-loss. */
24
+ export function roundtrip(text) {
25
+ return dump(load(text));
26
+ }
27
+
28
+ /** Byte-identical check. Holds for canonical block-style YAML (see README § scope). */
29
+ export function isByteIdentical(text) {
30
+ return roundtrip(text) === text;
31
+ }
32
+
33
+ /** Semantic non-loss: the parsed data survives a round-trip (order-independent). */
34
+ export function isLossless(text) {
35
+ return deepEqual(load(text), load(roundtrip(text)));
36
+ }
37
+
38
+ // ── emitter ──────────────────────────────────────────────────────────────
39
+ const pad = (n) => ' '.repeat(n);
40
+ const isArr = Array.isArray;
41
+ const isObj = (v) => v && typeof v === 'object' && !isArr(v);
42
+
43
+ function quote(s) { return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; }
44
+ function scalarStr(s) {
45
+ if (s === '') return '""';
46
+ if (/^\s|\s$/.test(s)) return quote(s);
47
+ if (/:\s/.test(s) || /:$/.test(s)) return quote(s);
48
+ if (/\s#/.test(s)) return quote(s);
49
+ if (/^[>|&*!%@`"'\[\]{}?,]/.test(s)) return quote(s);
50
+ if (/^-(\s|$)/.test(s)) return quote(s);
51
+ if (/^(true|false|null|~|yes|no|on|off)$/i.test(s)) return quote(s);
52
+ if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return quote(s);
53
+ return s;
54
+ }
55
+ function scalarInline(v, p) {
56
+ if (v === null || v === undefined) return 'null';
57
+ const t = typeof v;
58
+ if (t === 'number' || t === 'boolean') return String(v);
59
+ const s = String(v);
60
+ if (s.indexOf('\n') !== -1) {
61
+ const trail = (/(\n*)$/.exec(s)[1] || '').length; // trailing newlines
62
+ const chomp = trail === 0 ? '-' : (trail === 1 ? '' : '+'); // faithful chomping
63
+ const lines = s.replace(/\n+$/, '').split('\n');
64
+ let out = '|' + chomp;
65
+ for (const ln of lines) out += '\n' + (ln === '' ? '' : p + ' ' + ln);
66
+ for (let i = 0; i < trail - 1; i++) out += '\n';
67
+ return out;
68
+ }
69
+ return scalarStr(s);
70
+ }
71
+ function orderedKeys(map, order) {
72
+ const keys = [], seen = new Set();
73
+ if (order) for (const k of order) if (Object.prototype.hasOwnProperty.call(map, k)) { keys.push(k); seen.add(k); }
74
+ for (const k of Object.keys(map)) if (!seen.has(k)) keys.push(k);
75
+ return keys;
76
+ }
77
+ function emitMap(map, indent, order) {
78
+ return orderedKeys(map, order).map((k) => emitKV(map, k, indent)).join('\n');
79
+ }
80
+ function emitKV(map, k, indent) {
81
+ const p = pad(indent), v = map[k];
82
+ if (v === null || v === undefined) return p + k + ': null';
83
+ if (isArr(v)) {
84
+ if (v.length === 0) return p + k + ': []';
85
+ let o = p + k + ':';
86
+ for (const it of v) o += '\n' + emitSeqItem(it, indent + 1);
87
+ return o;
88
+ }
89
+ if (isObj(v)) return p + k + ':\n' + emitMap(v, indent + 1, null);
90
+ return p + k + ': ' + scalarInline(v, p);
91
+ }
92
+ function emitSeqItem(item, indent) {
93
+ const p = pad(indent);
94
+ if (isObj(item)) {
95
+ const mt = emitMap(item, indent + 1, null), lead = pad(indent + 1), lines = mt.split('\n');
96
+ lines[0] = p + '- ' + lines[0].slice(lead.length);
97
+ return lines.join('\n');
98
+ }
99
+ if (isArr(item)) return p + '- []';
100
+ return p + '- ' + scalarInline(item, p);
101
+ }
102
+ function deepEqual(a, b) { return JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b)); }
103
+ function sortDeep(v) {
104
+ if (isArr(v)) return v.map(sortDeep);
105
+ if (isObj(v)) { const o = {}; for (const k of Object.keys(v).sort()) o[k] = sortDeep(v[k]); return o; }
106
+ return v;
107
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@karmicsoft/lc-serialize",
3
+ "version": "0.1.0",
4
+ "description": "Faithful YAML round-trip for git-backed structured content — a LightCode brick.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "lc-serialize-check": "./check-corpus.mjs"
16
+ },
17
+ "files": [
18
+ "index.js",
19
+ "index.d.ts",
20
+ "check-corpus.mjs",
21
+ "example.yaml",
22
+ "LICENSE",
23
+ "README.md"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "scripts": {
29
+ "test": "node test.mjs"
30
+ },
31
+ "license": "SEE LICENSE IN LICENSE",
32
+ "author": "KarmicSoft",
33
+ "homepage": "https://lightcodepedia.org",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/michelzam/lightcodepedia",
37
+ "directory": "packages/lc-serialize"
38
+ },
39
+ "dependencies": {
40
+ "js-yaml": "^4.3.0"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "keywords": [
46
+ "yaml",
47
+ "round-trip",
48
+ "git-cms",
49
+ "lightcode",
50
+ "karmicsoft"
51
+ ]
52
+ }