@notrealstudio/nr-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.
- package/LICENSE +52 -0
- package/README.md +136 -0
- package/dist/coerce.d.ts +30 -0
- package/dist/coerce.js +159 -0
- package/dist/includes.d.ts +26 -0
- package/dist/includes.js +218 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +23 -0
- package/dist/interpolation.d.ts +41 -0
- package/dist/interpolation.js +135 -0
- package/dist/json5-value.d.ts +30 -0
- package/dist/json5-value.js +79 -0
- package/dist/parser.d.ts +3 -0
- package/dist/parser.js +227 -0
- package/dist/schema.d.ts +80 -0
- package/dist/schema.js +853 -0
- package/dist/serialize.d.ts +83 -0
- package/dist/serialize.js +525 -0
- package/dist/typed-header.d.ts +56 -0
- package/dist/typed-header.js +229 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.js +4 -0
- package/dist/value.d.ts +33 -0
- package/dist/value.js +193 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Denis Vinogradsky
|
|
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.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The JSON5 reader in `src/json5.ts` is a port of JSON5 (https://github.com/json5/json5),
|
|
26
|
+
used under the following terms. The test fixtures in `test/json5-suite/` are a
|
|
27
|
+
verbatim copy of https://github.com/json5/json5-tests, under the same terms; see
|
|
28
|
+
`test/json5-suite/LICENSE.md`.
|
|
29
|
+
|
|
30
|
+
MIT License
|
|
31
|
+
|
|
32
|
+
Copyright (c) 2012-2018 Aseem Kishore, and [others].
|
|
33
|
+
|
|
34
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
35
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
36
|
+
in the Software without restriction, including without limitation the rights
|
|
37
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
38
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
39
|
+
furnished to do so, subject to the following conditions:
|
|
40
|
+
|
|
41
|
+
The above copyright notice and this permission notice shall be included in all
|
|
42
|
+
copies or substantial portions of the Software.
|
|
43
|
+
|
|
44
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
45
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
46
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
47
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
48
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
49
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
50
|
+
SOFTWARE.
|
|
51
|
+
|
|
52
|
+
[others]: https://github.com/json5/json5/contributors
|
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# nr-md
|
|
2
|
+
|
|
3
|
+
Markdown as structured data: headings become blocks, `$key: value` lines become attributes, and everything else stays the prose it always was.
|
|
4
|
+
|
|
5
|
+
```markdown
|
|
6
|
+
# $model gpt-4o
|
|
7
|
+
$temperature: 0.7
|
|
8
|
+
$stop: $["\n\n", "END"]
|
|
9
|
+
$limits: {input: 8000, output: 800}
|
|
10
|
+
|
|
11
|
+
You are a terse assistant.
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
import { parse, serialize } from '@notrealstudio/nr-md'
|
|
16
|
+
|
|
17
|
+
const doc = parse(text)
|
|
18
|
+
const model = doc.root.children[0]
|
|
19
|
+
|
|
20
|
+
model.id // 'gpt-4o'
|
|
21
|
+
model.attrs[0].value // 0.7 — coerced, not a string
|
|
22
|
+
model.attrs[1].value // ['\n\n', 'END']
|
|
23
|
+
model.attrs[2].value // { input: 8000, output: 800 }
|
|
24
|
+
model.body // 'You are a terse assistant.'
|
|
25
|
+
|
|
26
|
+
serialize(doc) // back to text, in canonical form
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`serialize` emits the canonical spelling of a document, so `parse(serialize(doc))`
|
|
30
|
+
gives back the same tree — values that need no quotes lose them, blank separator
|
|
31
|
+
lines are not preserved. Byte-identical output is guaranteed for text that is
|
|
32
|
+
already canonical, which is what `serialize` produces.
|
|
33
|
+
|
|
34
|
+
The point is the file, not the API. It is a Markdown document — it renders in Obsidian, previews on GitHub, diffs by line, and a person can edit it without knowing there is a parser. It is also a tree with typed values, so a program can read it as configuration, as a prompt library, as records. You do not keep two files, one for humans and one for machines.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npm install @notrealstudio/nr-md
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Zero configuration, one dependency (a JSON5 microcodec), no host APIs — it runs wherever a string is a string.
|
|
43
|
+
|
|
44
|
+
## The grammar
|
|
45
|
+
|
|
46
|
+
**Blocks.** A heading whose text starts with the sigil opens a block; the rest of the line is its name, and anything after a space is its id. Heading depth nests them.
|
|
47
|
+
|
|
48
|
+
```markdown
|
|
49
|
+
# $character Aria → block "character", id "Aria", level 1
|
|
50
|
+
## $voice → nested block "voice"
|
|
51
|
+
## $ → closing token: back to the top level
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Attributes.** A line starting with the sigil, up to the first `:`, is a key.
|
|
55
|
+
|
|
56
|
+
```markdown
|
|
57
|
+
$name: Aria → 'Aria' (string)
|
|
58
|
+
$age: 24 → 24 (number)
|
|
59
|
+
$active: true → true (boolean)
|
|
60
|
+
$id: 007 → '007' (leading zero — an id, not a number)
|
|
61
|
+
$phone: +79051234567 → string (a leading + is not a sign)
|
|
62
|
+
$title: "42" → '42' (quotes force a string)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Repeating a key is reassignment: the last one wins.
|
|
66
|
+
|
|
67
|
+
**Lists** are flow literals — sigil, brackets, commas:
|
|
68
|
+
|
|
69
|
+
```markdown
|
|
70
|
+
$tags: $[fantasy, "slice, of life", 3]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Objects** are JSON5, on one line:
|
|
74
|
+
|
|
75
|
+
```markdown
|
|
76
|
+
$sampler: {steps: 30, cfg: 7.5, sched: 'karras'}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Bodies.** Every line that is not a heading or an attribute belongs to the current block's body, verbatim — Markdown, code fences, tables, whatever. `serialize` escapes a body line that would re-parse as structure, so a round-trip through a document that talks *about* the format is still exact.
|
|
80
|
+
|
|
81
|
+
**Interpolation.** `${...}` is recognised but never evaluated. A value carrying one parses to `{ raw, placeholders }`: the original text, plus the spans where interpolations sit. What the expression inside means is your language, not ours — bring your own evaluator. `\${` opts out, and the marker survives parsing intact.
|
|
82
|
+
|
|
83
|
+
## Two sigils
|
|
84
|
+
|
|
85
|
+
The sigil is a parameter. `$` is the mdz profile, `@` is mdd — same grammar, different marker, so two conventions can live in one document tree without colliding.
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
parse(text) // '$' by default
|
|
89
|
+
parse(text, { sigil: '@' }) // '@name: value', '# @block'
|
|
90
|
+
serialize(doc, { sigil: '@' })
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Schema layer
|
|
94
|
+
|
|
95
|
+
A subpath, because it is a different job — laying a JSON Schema out as a document and reading it back:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
import { serializeWithSchema, parseWithSchema } from '@notrealstudio/nr-md/schema'
|
|
99
|
+
|
|
100
|
+
const text = serializeWithSchema(obj, schema) // x-storage decides attr/body/block/tbl
|
|
101
|
+
const back = parseWithSchema(text, schema) // deep-equals obj
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`x-storage` and `x-mdd` annotations say where each field lives: an attribute, the block body, a block of its own, a table, a flow list. Unknown fields round-trip losslessly instead of being dropped, so you can layer this over someone else's schema without owning it.
|
|
105
|
+
|
|
106
|
+
`validate` delegates to Ajv2020, an **optional** peer — install `ajv` if you want it, or pass your own validator. The root import never touches it.
|
|
107
|
+
|
|
108
|
+
Finding an installed `ajv` from ESM without importing a host module needs `process.getBuiltinModule` (Node 22.3+). Below that, and in the browser, auto-discovery is off and `validate` asks for `opts.validator` — everything else in the package is plain ES2022 and runs on Node 20 LTS and any current browser.
|
|
109
|
+
|
|
110
|
+
## API
|
|
111
|
+
|
|
112
|
+
| | |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `parse(text, opts?)` | text → `Document` |
|
|
115
|
+
| `serialize(doc, opts?)` | `Document` → text |
|
|
116
|
+
| `parseAttributeValue`, `parseBodyValue` | value-level parsing |
|
|
117
|
+
| `coerce`, `isFullyQuoted` | scalar coercion (§3) |
|
|
118
|
+
| `serializeValue` | one value → its source form |
|
|
119
|
+
| `parseTable`, `parseTableTyped`, `serializeTable`, `serializeTypedTable` | `tbl` bodies |
|
|
120
|
+
| `parseHeaderCell`, `emitHeaderCell`, `coerceTyped`, `tableSchema` | typed table headers |
|
|
121
|
+
| `isJson5Shaped`, `isJson5Object`, `parseJson5Object` | JSON5 attribute values |
|
|
122
|
+
| `resolveIncludes`, `resolveIncludesAsync`, `extractSection` | `$[[path#section]]` preprocessing |
|
|
123
|
+
|
|
124
|
+
Types: `Document`, `Block`, `Attribute`, `AttributeValue`, `BodyValue`, `Scalar`, `ListItem`, `InterpolatedValue`, `InterpolationSpan`, `Json5Object`, `Json5Value`, `Sigil`, `ParseOptions`, `SerializeOptions`, `Pos`.
|
|
125
|
+
|
|
126
|
+
`Block` and `Attribute` carry an optional `pos` slot. Nothing populates it yet; it is reserved so that adding position tracking later cannot break an exhaustive walk written against this tree today.
|
|
127
|
+
|
|
128
|
+
## What this is not
|
|
129
|
+
|
|
130
|
+
It does not evaluate anything. No expressions, no includes resolved from disk (you inject the reader), no inheritance, no templating. Those belong to whoever consumes the tree, and keeping them out is what makes the tree portable — the same document can feed a different engine, in a different language, with a different idea of what `${x}` means.
|
|
131
|
+
|
|
132
|
+
The normative grammar reference (format-spec, serialize-spec) is maintained with the engine that grew this format and ships alongside it.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
package/dist/coerce.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Scalar } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Is `s` one quoted scalar and nothing else — opening quote at the first
|
|
4
|
+
* character, closing quote at the last, no closing quote in between (escapes
|
|
5
|
+
* honoured). Only in that position does a quote carry the force of a scalar
|
|
6
|
+
* literal: `${}` inside is literal text rather than an interpolation, and the
|
|
7
|
+
* quotes themselves come off.
|
|
8
|
+
*
|
|
9
|
+
* Partially quoted text (`"yes" or "no"`, JSON `{"m":"${x}"}`) is ordinary
|
|
10
|
+
* text — the quotes belong to the content, not to the markup.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isFullyQuoted(s: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Coerce a scalar string to a typed value (§3).
|
|
15
|
+
*
|
|
16
|
+
* Rules:
|
|
17
|
+
* - `""` (empty) → empty string
|
|
18
|
+
* - `"..."` (fully quoted) → string with quotes removed (no further coercion)
|
|
19
|
+
* - `null` → null
|
|
20
|
+
* - `true` / `false` → boolean
|
|
21
|
+
* - integer → number (leading `+` → string; leading-zero multi-digit `007` → string)
|
|
22
|
+
* - decimal → number (note: `1.10` → `1.1`)
|
|
23
|
+
* - anything else → string (incl. partially quoted `"yes" or "no"` — verbatim)
|
|
24
|
+
*/
|
|
25
|
+
export declare function coerce(s: string): Scalar;
|
|
26
|
+
/**
|
|
27
|
+
* Unescape a double-quoted scalar literal. Inside quotes only the minimal
|
|
28
|
+
* set `\"`, `\\`, `\n`, `\t` is recognised; everything else stays literal.
|
|
29
|
+
*/
|
|
30
|
+
export declare function unescapeQuoted(s: string): string;
|
package/dist/coerce.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// YAML-like scalar coercion (§3).
|
|
2
|
+
// Portable: string primitives only, no regex dialects.
|
|
3
|
+
const CC_0 = 48;
|
|
4
|
+
const CC_9 = 57;
|
|
5
|
+
const CC_DOT = 46;
|
|
6
|
+
const CC_MINUS = 45;
|
|
7
|
+
function isDigit(c) {
|
|
8
|
+
return c >= CC_0 && c <= CC_9;
|
|
9
|
+
}
|
|
10
|
+
// Integer: optional leading `-`, then digits. NOT a number if:
|
|
11
|
+
// - leading `+` — a `+` is not a sign (`+7905...` is a phone, §3) → string
|
|
12
|
+
// - multi-digit with a leading zero (`007` is an id/code → string); bare `0`
|
|
13
|
+
// stays the number 0
|
|
14
|
+
function isInt(s) {
|
|
15
|
+
if (s.length === 0)
|
|
16
|
+
return false;
|
|
17
|
+
let i = 0;
|
|
18
|
+
if (s.charCodeAt(0) === CC_MINUS)
|
|
19
|
+
i++;
|
|
20
|
+
if (i === s.length)
|
|
21
|
+
return false;
|
|
22
|
+
if (s.charCodeAt(i) === CC_0 && s.length - i > 1)
|
|
23
|
+
return false;
|
|
24
|
+
for (; i < s.length; i++) {
|
|
25
|
+
if (!isDigit(s.charCodeAt(i)))
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
// Float: optional leading `-`, digits with exactly one dot. Same sign/zero
|
|
31
|
+
// exclusions as isInt (`+0.5` → string, `00.5` → string; lone `0.5` is fine).
|
|
32
|
+
function isFloat(s) {
|
|
33
|
+
if (s.length === 0)
|
|
34
|
+
return false;
|
|
35
|
+
let i = 0;
|
|
36
|
+
if (s.charCodeAt(0) === CC_MINUS)
|
|
37
|
+
i++;
|
|
38
|
+
if (s.charCodeAt(i) === CC_0 && i + 1 < s.length && isDigit(s.charCodeAt(i + 1)))
|
|
39
|
+
return false;
|
|
40
|
+
let hasDigit = false;
|
|
41
|
+
let hasDot = false;
|
|
42
|
+
for (; i < s.length; i++) {
|
|
43
|
+
const c = s.charCodeAt(i);
|
|
44
|
+
if (isDigit(c)) {
|
|
45
|
+
hasDigit = true;
|
|
46
|
+
}
|
|
47
|
+
else if (c === CC_DOT) {
|
|
48
|
+
if (hasDot)
|
|
49
|
+
return false;
|
|
50
|
+
hasDot = true;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return hasDigit && hasDot;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Is `s` one quoted scalar and nothing else — opening quote at the first
|
|
60
|
+
* character, closing quote at the last, no closing quote in between (escapes
|
|
61
|
+
* honoured). Only in that position does a quote carry the force of a scalar
|
|
62
|
+
* literal: `${}` inside is literal text rather than an interpolation, and the
|
|
63
|
+
* quotes themselves come off.
|
|
64
|
+
*
|
|
65
|
+
* Partially quoted text (`"yes" or "no"`, JSON `{"m":"${x}"}`) is ordinary
|
|
66
|
+
* text — the quotes belong to the content, not to the markup.
|
|
67
|
+
*/
|
|
68
|
+
export function isFullyQuoted(s) {
|
|
69
|
+
if (s.length < 2 || s.charCodeAt(0) !== 34 /* " */)
|
|
70
|
+
return false;
|
|
71
|
+
let i = 1;
|
|
72
|
+
while (i < s.length) {
|
|
73
|
+
if (s.charCodeAt(i) === 92 /* \ */ && i + 1 < s.length) {
|
|
74
|
+
i += 2;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (s.charCodeAt(i) === 34)
|
|
78
|
+
return i === s.length - 1;
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Coerce a scalar string to a typed value (§3).
|
|
85
|
+
*
|
|
86
|
+
* Rules:
|
|
87
|
+
* - `""` (empty) → empty string
|
|
88
|
+
* - `"..."` (fully quoted) → string with quotes removed (no further coercion)
|
|
89
|
+
* - `null` → null
|
|
90
|
+
* - `true` / `false` → boolean
|
|
91
|
+
* - integer → number (leading `+` → string; leading-zero multi-digit `007` → string)
|
|
92
|
+
* - decimal → number (note: `1.10` → `1.1`)
|
|
93
|
+
* - anything else → string (incl. partially quoted `"yes" or "no"` — verbatim)
|
|
94
|
+
*/
|
|
95
|
+
export function coerce(s) {
|
|
96
|
+
if (s.length === 0)
|
|
97
|
+
return '';
|
|
98
|
+
// Fully-quoted string — force string, drop quotes, do NOT re-coerce content.
|
|
99
|
+
if (isFullyQuoted(s)) {
|
|
100
|
+
return unescapeQuoted(s.slice(1, -1));
|
|
101
|
+
}
|
|
102
|
+
if (s === 'null')
|
|
103
|
+
return null;
|
|
104
|
+
if (s === 'true')
|
|
105
|
+
return true;
|
|
106
|
+
if (s === 'false')
|
|
107
|
+
return false;
|
|
108
|
+
if (isInt(s)) {
|
|
109
|
+
const n = Number(s);
|
|
110
|
+
if (Number.isFinite(n))
|
|
111
|
+
return n;
|
|
112
|
+
}
|
|
113
|
+
if (isFloat(s)) {
|
|
114
|
+
const n = Number(s);
|
|
115
|
+
if (Number.isFinite(n))
|
|
116
|
+
return n;
|
|
117
|
+
}
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Unescape a double-quoted scalar literal. Inside quotes only the minimal
|
|
122
|
+
* set `\"`, `\\`, `\n`, `\t` is recognised; everything else stays literal.
|
|
123
|
+
*/
|
|
124
|
+
export function unescapeQuoted(s) {
|
|
125
|
+
let out = '';
|
|
126
|
+
let i = 0;
|
|
127
|
+
while (i < s.length) {
|
|
128
|
+
const c = s.charCodeAt(i);
|
|
129
|
+
if (c === 92 /* \ */ && i + 1 < s.length) {
|
|
130
|
+
const n = s[i + 1];
|
|
131
|
+
if (n === '"') {
|
|
132
|
+
out += '"';
|
|
133
|
+
i += 2;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (n === '\\') {
|
|
137
|
+
out += '\\';
|
|
138
|
+
i += 2;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (n === 'n') {
|
|
142
|
+
out += '\n';
|
|
143
|
+
i += 2;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (n === 't') {
|
|
147
|
+
out += '\t';
|
|
148
|
+
i += 2;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
out += '\\' + n;
|
|
152
|
+
i += 2;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
out += s[i];
|
|
156
|
+
i++;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface IncludeOptions {
|
|
2
|
+
/** Maximum nesting depth (default: 10). */
|
|
3
|
+
maxDepth?: number;
|
|
4
|
+
/** Active sigil — only this sigil's includes are resolved. */
|
|
5
|
+
sigil?: '$' | '@';
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Resolve file includes in text. Replaces `$[[path]]` / `$[[path#section]]`
|
|
9
|
+
* with file/section content, recursively. Cycle detection by path.
|
|
10
|
+
*
|
|
11
|
+
* readFile: `(path: string) => string | undefined` — returns file content
|
|
12
|
+
* or undefined if not found (left as-is in output).
|
|
13
|
+
*
|
|
14
|
+
* Async version: resolveIncludesAsync.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveIncludes(text: string, readFile: (path: string) => string | undefined, opts?: IncludeOptions): string;
|
|
17
|
+
/**
|
|
18
|
+
* Async version — readFile returns Promise.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveIncludesAsync(text: string, readFile: (path: string) => Promise<string | undefined>, opts?: IncludeOptions): Promise<string>;
|
|
21
|
+
/**
|
|
22
|
+
* Extract a section from markdown by heading name. Case-insensitive.
|
|
23
|
+
* Returns content from that heading until next heading of same/higher level.
|
|
24
|
+
* Returns undefined if not found.
|
|
25
|
+
*/
|
|
26
|
+
export declare function extractSection(content: string, sectionName: string): string | undefined;
|
package/dist/includes.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// File includes: $[[path]] / @[[path]] preprocessor (format-spec §2.6).
|
|
2
|
+
// Text-level substitution BEFORE parsing. Recursive with cycle detection.
|
|
3
|
+
// Supports sections: $[[path#section]], $[[#section]] (from current text).
|
|
4
|
+
/**
|
|
5
|
+
* Resolve file includes in text. Replaces `$[[path]]` / `$[[path#section]]`
|
|
6
|
+
* with file/section content, recursively. Cycle detection by path.
|
|
7
|
+
*
|
|
8
|
+
* readFile: `(path: string) => string | undefined` — returns file content
|
|
9
|
+
* or undefined if not found (left as-is in output).
|
|
10
|
+
*
|
|
11
|
+
* Async version: resolveIncludesAsync.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveIncludes(text, readFile, opts) {
|
|
14
|
+
const sigil = opts?.sigil ?? '$';
|
|
15
|
+
const maxDepth = opts?.maxDepth ?? 10;
|
|
16
|
+
return expand(text, readFile, sigil, maxDepth, new Set(), 0, text);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Async version — readFile returns Promise.
|
|
20
|
+
*/
|
|
21
|
+
export async function resolveIncludesAsync(text, readFile, opts) {
|
|
22
|
+
const sigil = opts?.sigil ?? '$';
|
|
23
|
+
const maxDepth = opts?.maxDepth ?? 10;
|
|
24
|
+
return expandAsync(text, readFile, sigil, maxDepth, new Set(), 0, text);
|
|
25
|
+
}
|
|
26
|
+
// ---------- Extract section from markdown ----------
|
|
27
|
+
/**
|
|
28
|
+
* Extract a section from markdown by heading name. Case-insensitive.
|
|
29
|
+
* Returns content from that heading until next heading of same/higher level.
|
|
30
|
+
* Returns undefined if not found.
|
|
31
|
+
*/
|
|
32
|
+
export function extractSection(content, sectionName) {
|
|
33
|
+
const lines = content.split('\n');
|
|
34
|
+
const target = sectionName.trim().toLowerCase();
|
|
35
|
+
let startIdx = -1;
|
|
36
|
+
let startLevel = 0;
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
const lvl = headingLevel(lines[i]);
|
|
39
|
+
if (lvl > 0) {
|
|
40
|
+
const name = lines[i].slice(lvl).trim().toLowerCase();
|
|
41
|
+
if (name === target) {
|
|
42
|
+
startIdx = i;
|
|
43
|
+
startLevel = lvl;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (startIdx === -1)
|
|
49
|
+
return undefined;
|
|
50
|
+
let endIdx = lines.length;
|
|
51
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
52
|
+
const lvl = headingLevel(lines[i]);
|
|
53
|
+
if (lvl > 0 && lvl <= startLevel) {
|
|
54
|
+
endIdx = i;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Trim trailing empty lines
|
|
59
|
+
while (endIdx > startIdx && lines[endIdx - 1].trim() === '')
|
|
60
|
+
endIdx--;
|
|
61
|
+
return lines.slice(startIdx, endIdx).join('\n');
|
|
62
|
+
}
|
|
63
|
+
function headingLevel(line) {
|
|
64
|
+
let i = 0;
|
|
65
|
+
while (i < line.length && line[i] === '#')
|
|
66
|
+
i++;
|
|
67
|
+
if (i === 0 || i > 6 || line[i] !== ' ')
|
|
68
|
+
return 0;
|
|
69
|
+
return i;
|
|
70
|
+
}
|
|
71
|
+
// ---------- Sync expand ----------
|
|
72
|
+
function expand(text, readFile, sigil, maxDepth, visited, depth, currentText) {
|
|
73
|
+
if (depth > maxDepth)
|
|
74
|
+
return text;
|
|
75
|
+
let out = '';
|
|
76
|
+
let i = 0;
|
|
77
|
+
while (i < text.length) {
|
|
78
|
+
// Escaped include: \$[[ → literal $[[
|
|
79
|
+
if (text[i] === '\\' && i + 3 < text.length && text[i + 1] === sigil && text[i + 2] === '[' && text[i + 3] === '[') {
|
|
80
|
+
out += sigil + '[[';
|
|
81
|
+
i += 4;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// Include: $[[ or @[[
|
|
85
|
+
if (text[i] === sigil && i + 2 < text.length && text[i + 1] === '[' && text[i + 2] === '[') {
|
|
86
|
+
const start = i;
|
|
87
|
+
i += 3;
|
|
88
|
+
let raw = '';
|
|
89
|
+
let closed = false;
|
|
90
|
+
while (i < text.length) {
|
|
91
|
+
if (text[i] === ']' && i + 1 < text.length && text[i + 1] === ']') {
|
|
92
|
+
closed = true;
|
|
93
|
+
i += 2;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
raw += text[i];
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
if (!closed) {
|
|
100
|
+
out += text.slice(start, i);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
raw = raw.trim();
|
|
104
|
+
// Parse path#section
|
|
105
|
+
const hashIdx = raw.indexOf('#');
|
|
106
|
+
const filePath = hashIdx >= 0 ? raw.slice(0, hashIdx).trim() : raw;
|
|
107
|
+
const section = hashIdx >= 0 ? raw.slice(hashIdx + 1).trim() : undefined;
|
|
108
|
+
// $[[#section]] — section from current text
|
|
109
|
+
if (!filePath && section) {
|
|
110
|
+
const extracted = extractSection(currentText, section);
|
|
111
|
+
if (extracted !== undefined) {
|
|
112
|
+
out += extracted;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
out += text.slice(start, i); // not found — leave as-is
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// Cycle detection
|
|
120
|
+
if (visited.has(filePath)) {
|
|
121
|
+
out += text.slice(start, i);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const content = readFile(filePath);
|
|
125
|
+
if (content === undefined) {
|
|
126
|
+
out += text.slice(start, i);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// Extract section if requested
|
|
130
|
+
let result = content;
|
|
131
|
+
if (section) {
|
|
132
|
+
const extracted = extractSection(content, section);
|
|
133
|
+
if (extracted === undefined) {
|
|
134
|
+
out += text.slice(start, i); // section not found — leave as-is
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
result = extracted;
|
|
138
|
+
}
|
|
139
|
+
// Recurse
|
|
140
|
+
visited.add(filePath);
|
|
141
|
+
out += expand(result, readFile, sigil, maxDepth, visited, depth + 1, content);
|
|
142
|
+
visited.delete(filePath);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
out += text[i];
|
|
146
|
+
i++;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
// ---------- Async expand ----------
|
|
151
|
+
async function expandAsync(text, readFile, sigil, maxDepth, visited, depth, currentText) {
|
|
152
|
+
if (depth > maxDepth)
|
|
153
|
+
return text;
|
|
154
|
+
// Collect all include positions first, then resolve in order
|
|
155
|
+
// (sequential, not parallel — order matters for cycle detection)
|
|
156
|
+
let out = '';
|
|
157
|
+
let i = 0;
|
|
158
|
+
while (i < text.length) {
|
|
159
|
+
if (text[i] === '\\' && i + 3 < text.length && text[i + 1] === sigil && text[i + 2] === '[' && text[i + 3] === '[') {
|
|
160
|
+
out += sigil + '[[';
|
|
161
|
+
i += 4;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (text[i] === sigil && i + 2 < text.length && text[i + 1] === '[' && text[i + 2] === '[') {
|
|
165
|
+
const start = i;
|
|
166
|
+
i += 3;
|
|
167
|
+
let raw = '';
|
|
168
|
+
let closed = false;
|
|
169
|
+
while (i < text.length) {
|
|
170
|
+
if (text[i] === ']' && i + 1 < text.length && text[i + 1] === ']') {
|
|
171
|
+
closed = true;
|
|
172
|
+
i += 2;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
raw += text[i];
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
if (!closed) {
|
|
179
|
+
out += text.slice(start, i);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
raw = raw.trim();
|
|
183
|
+
const hashIdx = raw.indexOf('#');
|
|
184
|
+
const filePath = hashIdx >= 0 ? raw.slice(0, hashIdx).trim() : raw;
|
|
185
|
+
const section = hashIdx >= 0 ? raw.slice(hashIdx + 1).trim() : undefined;
|
|
186
|
+
if (!filePath && section) {
|
|
187
|
+
const extracted = extractSection(currentText, section);
|
|
188
|
+
out += extracted ?? text.slice(start, i);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (visited.has(filePath)) {
|
|
192
|
+
out += text.slice(start, i);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const content = await readFile(filePath);
|
|
196
|
+
if (content === undefined) {
|
|
197
|
+
out += text.slice(start, i);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
let result = content;
|
|
201
|
+
if (section) {
|
|
202
|
+
const extracted = extractSection(content, section);
|
|
203
|
+
if (extracted === undefined) {
|
|
204
|
+
out += text.slice(start, i);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
result = extracted;
|
|
208
|
+
}
|
|
209
|
+
visited.add(filePath);
|
|
210
|
+
out += await expandAsync(result, readFile, sigil, maxDepth, visited, depth + 1, content);
|
|
211
|
+
visited.delete(filePath);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
out += text[i];
|
|
215
|
+
i++;
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { parse } from './parser.js';
|
|
2
|
+
export { coerce, isFullyQuoted } from './coerce.js';
|
|
3
|
+
export { parseAttributeValue, parseBodyValue, unescape } from './value.js';
|
|
4
|
+
export { isJson5Shaped, isJson5Object, parseJson5Object, Json5ParseError } from './json5-value.js';
|
|
5
|
+
export { serialize, serializeValue, serializeTable, serializeTypedTable, parseTable, parseTableRows, parseTableTyped, } from './serialize.js';
|
|
6
|
+
export type { TableSerializeOptions, TableRecord } from './serialize.js';
|
|
7
|
+
export { parseHeaderCell, emitHeaderCell, coerceTyped, tableSchema, TableParseError } from './typed-header.js';
|
|
8
|
+
export type { TypedColumn, ColumnType, JSONSchema } from './typed-header.js';
|
|
9
|
+
export { resolveIncludes, resolveIncludesAsync, extractSection } from './includes.js';
|
|
10
|
+
export type { IncludeOptions } from './includes.js';
|
|
11
|
+
export type { Sigil, ParseOptions, SerializeOptions, Document, Block, Attribute, Pos, AttributeValue, BodyValue, Scalar, ListItem, Json5Object, Json5Value, InterpolatedValue, InterpolationSpan, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @notrealstudio/nr-md — Markdown as structured data.
|
|
2
|
+
//
|
|
3
|
+
// Text ↔ tree, both directions, nothing else: parse a Markdown document whose
|
|
4
|
+
// headings and `$key: value` lines carry structure, get a plain AST back;
|
|
5
|
+
// serialize an AST, get the document back. The sigil is a parameter — `$` (mdz)
|
|
6
|
+
// and `@` (mdd) are two profiles of one grammar.
|
|
7
|
+
//
|
|
8
|
+
// The boundary this package keeps: it emits FACTS (shapes of data), never
|
|
9
|
+
// interpretations. `${...}` is recognised as a span, not parsed as an
|
|
10
|
+
// expression; a `$nr-extends` line is an attribute like any other. What the
|
|
11
|
+
// facts mean is the caller's language, on top.
|
|
12
|
+
// ---------- Parser (format-spec §1–§7) ----------
|
|
13
|
+
export { parse } from './parser.js';
|
|
14
|
+
export { coerce, isFullyQuoted } from './coerce.js';
|
|
15
|
+
export { parseAttributeValue, parseBodyValue, unescape } from './value.js';
|
|
16
|
+
// ---------- JSON5 object as an attribute value (§3; json5-scalar-spec) ----------
|
|
17
|
+
export { isJson5Shaped, isJson5Object, parseJson5Object, Json5ParseError } from './json5-value.js';
|
|
18
|
+
// ---------- Serialization (serialize-spec §1, §2) ----------
|
|
19
|
+
export { serialize, serializeValue, serializeTable, serializeTypedTable, parseTable, parseTableRows, parseTableTyped, } from './serialize.js';
|
|
20
|
+
// ---------- Typed tbl header + tableSchema (serialize-spec §2.3) ----------
|
|
21
|
+
export { parseHeaderCell, emitHeaderCell, coerceTyped, tableSchema, TableParseError } from './typed-header.js';
|
|
22
|
+
// ---------- Includes (format-spec §2.6) ----------
|
|
23
|
+
export { resolveIncludes, resolveIncludesAsync, extractSection } from './includes.js';
|