@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 +15 -0
- package/README.md +73 -0
- package/check-corpus.mjs +54 -0
- package/example.yaml +27 -0
- package/index.d.ts +22 -0
- package/index.js +107 -0
- package/package.json +52 -0
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.
|
package/check-corpus.mjs
ADDED
|
@@ -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
|
+
}
|