@nilvn/core 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ // Screenplay contract, formatter half (see screenplay.ts for the contract
2
+ // overview). formatScreenplay pulls human-written deviations back to the strict
3
+ // grammar-v1 form — lenient parsing is format→parse, one parser code path.
4
+ //
5
+ // Normalization comes in two tiers:
6
+ // 1. Control characters (unconditional, STRUCTURAL positions only): speaker
7
+ // colon `:`→`:` + mood parens `()`→`()` (guarded by the ≤4-char
8
+ // no-punctuation prefix heuristic so prose with a full-width colon is left
9
+ // alone), `->`→`→` before link targets on `>`/`-` lines, and legacy
10
+ // pandoc `{#id}` heading anchors → trailing code spans.
11
+ // 2. Body punctuation style (per-project configurable): quote pairs and
12
+ // ellipses unified to ONE form so short-label matching, cross-paragraph
13
+ // references and translation catalogs never break on mixed styles.
14
+ // (Dash normalization is deliberately left out: `---` collides with
15
+ // markdown thematic breaks / frontmatter fences.)
16
+ //
17
+ // Invariants (each is a test): idempotent; meaning-preserving (glyphs change,
18
+ // the play does not); code spans, link targets and fenced blocks are never
19
+ // touched; lines are never merged or split.
20
+ // Speaker-colon disambiguation: a prefix of at most 4 letter/number characters
21
+ // (no punctuation, no spaces) followed by a colon reads as a speaker; anything
22
+ // longer or punctuated is prose. The rewrite only fires when a FULL-WIDTH
23
+ // structural char (:()) is present — an all-half-width line is either
24
+ // already strict or intentional prose (`12:30 kickoff` must not gain a space).
25
+ const SPEAKER_RE = /^([\p{L}\p{N}]{1,4})(?:([((])([^()()]{0,16})([))]))?([::])\s?(.*)$/u;
26
+ const INNER_SPEAKER_RE = /^(\*\()([\p{L}\p{N}]{1,4}):\s?(.*\)\*)$/u;
27
+ const LEGACY_ANCHOR_RE = /^(#{1,6} .*?)\s*\{#([^}]+)\}\s*$/;
28
+ const ARROW_BEFORE_LINK_RE = /\s*->\s*(?=\[)/g;
29
+ export function formatScreenplay(text, opts = {}) {
30
+ const quoteStyle = opts.quoteStyle ?? '"';
31
+ const ellipsis = opts.ellipsis ?? '……';
32
+ const newline = text.includes('\r\n') ? '\r\n' : '\n';
33
+ const lines = text.split(/\r\n?|\n/);
34
+ let inFence = false;
35
+ const out = lines.map((raw) => {
36
+ if (/^```/.test(raw.trimStart())) {
37
+ inFence = !inFence;
38
+ return raw;
39
+ }
40
+ if (inFence)
41
+ return raw;
42
+ let line = raw.trimEnd();
43
+ // Tier 1 — structural control characters.
44
+ line = line.replace(LEGACY_ANCHOR_RE, (_, head, id) => `${head} \`${id}\``);
45
+ if (line.startsWith('>') || line.startsWith('- ')) {
46
+ line = line.replace(ARROW_BEFORE_LINK_RE, ' → ');
47
+ }
48
+ const speaker = line.match(SPEAKER_RE);
49
+ if (speaker && (speaker[5] === ':' || speaker[2] === '(' || speaker[4] === ')')) {
50
+ line = `${speaker[1]}${speaker[3] ? `(${speaker[3]})` : ''}: ${speaker[6]}`;
51
+ }
52
+ else {
53
+ const inner = line.match(INNER_SPEAKER_RE);
54
+ if (inner)
55
+ line = `${inner[1]}${inner[2]}: ${inner[3]}`;
56
+ }
57
+ // Tier 2 — body punctuation, skipping code spans and link targets.
58
+ return mapUnprotected(line, (seg) => {
59
+ // ASCII `...` and doubled-up `…` runs unify; a lone `…` is a legitimate
60
+ // short pause and stays.
61
+ let s = ellipsis === '……' ? seg.replace(/\.{3,}/g, '……').replace(/…{2,}/g, '……') : seg.replace(/…+|\.{3,}/g, '...');
62
+ if (quoteStyle === '"') {
63
+ s = s.replace(/[“”「」]/g, '"');
64
+ }
65
+ else {
66
+ s = s.replace(/「/g, quoteStyle[0]).replace(/」/g, quoteStyle[1]);
67
+ s = s.replace(/“/g, quoteStyle[0]).replace(/”/g, quoteStyle[1]);
68
+ }
69
+ return s;
70
+ }, quoteStyle);
71
+ });
72
+ return out.join(newline);
73
+ }
74
+ // Split a line into protected (code spans, `](target)` link tails) and open
75
+ // segments, apply fn to the open ones. Straight-quote → directional conversion
76
+ // needs pairing state, carried across the open segments of one line (opening
77
+ // and closing quotes are assumed to sit in the same line — catalog values are
78
+ // single-line by construction, see catalog.ts).
79
+ function mapUnprotected(line, fn, quoteStyle) {
80
+ const parts = line.split(/(`[^`]*`|\]\([^)]*\))/);
81
+ let quoteOpen = false;
82
+ return parts
83
+ .map((part, i) => {
84
+ if (i % 2 === 1)
85
+ return part; // protected: code span or link target
86
+ let s = fn(part);
87
+ if (quoteStyle !== '"') {
88
+ s = s.replace(/"/g, () => {
89
+ quoteOpen = !quoteOpen;
90
+ return quoteOpen ? quoteStyle[0] : quoteStyle[1];
91
+ });
92
+ }
93
+ return s;
94
+ })
95
+ .join('');
96
+ }
@@ -0,0 +1,99 @@
1
+ /** A `[label](target)` markdown link as used by jumps and branch options.
2
+ * `target` splits into `file` (cross-file: `ch02.md#open`) and/or `anchor`
3
+ * (in-file: `#relief`); both stay undefined when the target has neither shape. */
4
+ export interface ScreenplayLink {
5
+ label: string;
6
+ /** `ch02.md` in `ch02.md#open`; undefined for in-file targets. */
7
+ file?: string;
8
+ /** `open` in `ch02.md#open` or `#open`; undefined when the link has no `#` part. */
9
+ anchor?: string;
10
+ /** The raw target text between the parentheses, verbatim. */
11
+ raw: string;
12
+ }
13
+ /** One `- **label** → [target](#id):consequence` list item. Everything past
14
+ * the bold label is optional: an option with no target imports as unwired. */
15
+ export interface ScreenplayOption {
16
+ label: string;
17
+ /** Natural-language condition from a `*(if …)*` prefix, parens content verbatim. */
18
+ condition?: string;
19
+ target?: ScreenplayLink;
20
+ /** Prose consequence after the (full- or half-width) colon following the target. */
21
+ consequence?: string;
22
+ line: number;
23
+ }
24
+ export type ScreenplayEvent =
25
+ /** `# Chapter 1 · Rooftop` — chapter heading (file-tree level concept downstream). */
26
+ {
27
+ kind: 'chapter';
28
+ title: string;
29
+ line: number;
30
+ }
31
+ /** `` ## Scene · Classroom `dusk` `` — the trailing code span is the anchor id. */
32
+ | {
33
+ kind: 'sceneHeading';
34
+ title: string;
35
+ id?: string;
36
+ line: number;
37
+ }
38
+ /** `name(mood): text` — half-width colon + space is the anchor; mood optional. */
39
+ | {
40
+ kind: 'say';
41
+ name: string;
42
+ mood?: string;
43
+ text: string;
44
+ line: number;
45
+ }
46
+ /** Bare prose line (the fallback for story text). Inline `**…**` / `` `…` ``
47
+ * markers are kept verbatim; strip with stripScreenplayMarkup at import. */
48
+ | {
49
+ kind: 'narrate';
50
+ text: string;
51
+ line: number;
52
+ }
53
+ /** Whole-line `*(……)*` first-person inner monologue; `*(name: ……)*` named. */
54
+ | {
55
+ kind: 'inner';
56
+ name?: string;
57
+ text: string;
58
+ line: number;
59
+ }
60
+ /** Whole-line `**……**` action/emotion beat. */
61
+ | {
62
+ kind: 'beat';
63
+ text: string;
64
+ line: number;
65
+ }
66
+ /** `>` prose block that is neither a jump nor a branch prompt — a director
67
+ * note. Consecutive `>` lines merge (joined with newlines). */
68
+ | {
69
+ kind: 'note';
70
+ text: string;
71
+ line: number;
72
+ }
73
+ /** A run of option list items, with the immediately preceding `>` prose block
74
+ * (if any) attached as the prompt ("player chooses here" marker — its absence
75
+ * means the branch is state-driven). */
76
+ | {
77
+ kind: 'branch';
78
+ prompt?: string;
79
+ options: ScreenplayOption[];
80
+ line: number;
81
+ }
82
+ /** `> → [label](#anchor)` — unconditional jump. */
83
+ | {
84
+ kind: 'jump';
85
+ target: ScreenplayLink;
86
+ line: number;
87
+ }
88
+ /** Anything the grammar does not define (tables, plain list items, deep
89
+ * headings, fenced code…). Kept verbatim so importers can count and report. */
90
+ | {
91
+ kind: 'unknown';
92
+ raw: string;
93
+ line: number;
94
+ };
95
+ export declare function parseScreenplayLink(label: string, rawTarget: string): ScreenplayLink;
96
+ export declare function parseScreenplay(text: string): ScreenplayEvent[];
97
+ /** Strip the inline markers grammar v1 allows inside prose — bold emphasis and
98
+ * machine-word code spans — keeping the text (what an import keeps). */
99
+ export declare function stripScreenplayMarkup(text: string): string;
@@ -0,0 +1,161 @@
1
+ // Screenplay contract, parser half. Grammar v1 ("render-first markdown") is
2
+ // specified by the grammar table the writing tools ship; the three artifacts
3
+ // (grammar table, this parser, screenplay-format.ts) must stay in lockstep, and
4
+ // the tools' sample screenplay doubles as the round-trip test corpus.
5
+ //
6
+ // parseScreenplay is STRICT and line-based: it only recognizes the canonical
7
+ // half-width forms and never guesses. Human-written
8
+ // deviations (full-width colons, `->` arrows, legacy `{#id}` anchors) are the
9
+ // formatter's job — lenient parsing is `parseScreenplay(formatScreenplay(x))`,
10
+ // a single parser code path.
11
+ //
12
+ // The output is a flat event stream, NOT IR: mapping events onto scenes/nodes
13
+ // (actor resolution, mood→face, wiring) is a downstream editorial decision
14
+ // (editor paste-import, nilvn-director, M6 validators).
15
+ // Speaker anchor: 1–12 chars with no whitespace/colon/paren, optional
16
+ // half-width (mood), then ": " (half-width colon + space). Kept deliberately
17
+ // permissive on the name charset — the ≤4-char heuristic belongs to the
18
+ // FORMATTER's full-width disambiguation, not to the strict form.
19
+ const SAY_RE = /^([^\s::()()]{1,12})(?:\(([^()]*)\))?: (.*)$/;
20
+ const HEADING_SCENE_RE = /^## (.*?)(?:\s+`([^`]+)`)?\s*$/;
21
+ const INNER_RE = /^\*\((.+)\)\*$/;
22
+ const BEAT_RE = /^\*\*(.+)\*\*$/;
23
+ const JUMP_RE = /^>\s*→\s*\[(.+?)\]\(([^)]*)\)\s*$/;
24
+ const OPTION_RE = /^- (?:\*\(([^)]*)\)\*\s+)?\*\*(.+?)\*\*(?:\s*→\s*\[(.+?)\]\(([^)]*)\))?\s*(?:[::]\s*(.*))?$/;
25
+ const LINK_TARGET_RE = /^(?:([^#\s)]+\.md))?(?:#([^\s)]+))?$/;
26
+ // Lines the grammar deliberately does not define: tables, non-option list
27
+ // items, thematic breaks, deep/malformed headings, raw HTML.
28
+ const UNDEFINED_LINE_RE = /^(\||[-*+] |\d+[.)] |#{3,}|#[^ ]|---\s*$|<)/;
29
+ export function parseScreenplayLink(label, rawTarget) {
30
+ const m = rawTarget.match(LINK_TARGET_RE);
31
+ const link = { label, raw: rawTarget };
32
+ if (m && (m[1] || m[2])) {
33
+ if (m[1])
34
+ link.file = m[1];
35
+ if (m[2])
36
+ link.anchor = m[2];
37
+ }
38
+ return link;
39
+ }
40
+ export function parseScreenplay(text) {
41
+ const events = [];
42
+ const lines = text.split(/\r\n?|\n/);
43
+ // A trailing `>` prose block waiting to become either a branch prompt (if an
44
+ // option list follows across at most blank lines) or a standalone note.
45
+ let pendingNote = null;
46
+ let branch = null;
47
+ let inFence = false;
48
+ const flushNote = () => {
49
+ if (!pendingNote)
50
+ return;
51
+ events.push({ kind: 'note', text: pendingNote.text.join('\n'), line: pendingNote.line });
52
+ pendingNote = null;
53
+ };
54
+ const flushBranch = () => {
55
+ if (!branch)
56
+ return;
57
+ events.push({ kind: 'branch', prompt: branch.prompt, options: branch.options, line: branch.line });
58
+ branch = null;
59
+ };
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const raw = lines[i];
62
+ const lineNo = i + 1;
63
+ const line = raw.trimEnd();
64
+ if (/^```/.test(line.trimStart())) {
65
+ flushNote();
66
+ flushBranch();
67
+ inFence = !inFence;
68
+ events.push({ kind: 'unknown', raw, line: lineNo });
69
+ continue;
70
+ }
71
+ if (inFence) {
72
+ events.push({ kind: 'unknown', raw, line: lineNo });
73
+ continue;
74
+ }
75
+ if (line === '')
76
+ continue; // blank lines keep pendingNote/branch open
77
+ const option = line.match(OPTION_RE);
78
+ if (option) {
79
+ if (!branch) {
80
+ branch = { options: [], line: pendingNote ? pendingNote.line : lineNo };
81
+ if (pendingNote) {
82
+ branch.prompt = pendingNote.text.join('\n');
83
+ pendingNote = null;
84
+ }
85
+ }
86
+ const opt = { label: option[2], line: lineNo };
87
+ if (option[1] !== undefined)
88
+ opt.condition = option[1];
89
+ if (option[3] !== undefined)
90
+ opt.target = parseScreenplayLink(option[3], option[4] ?? '');
91
+ if (option[5])
92
+ opt.consequence = option[5];
93
+ branch.options.push(opt);
94
+ continue;
95
+ }
96
+ // Any non-blank, non-option line closes an open branch.
97
+ flushBranch();
98
+ if (line.startsWith('>')) {
99
+ const jump = line.match(JUMP_RE);
100
+ if (jump) {
101
+ flushNote();
102
+ events.push({ kind: 'jump', target: parseScreenplayLink(jump[1], jump[2] ?? ''), line: lineNo });
103
+ continue;
104
+ }
105
+ const text = line.replace(/^>\s?/, '');
106
+ if (pendingNote)
107
+ pendingNote.text.push(text);
108
+ else
109
+ pendingNote = { text: [text], line: lineNo };
110
+ continue;
111
+ }
112
+ flushNote();
113
+ if (line.startsWith('# ')) {
114
+ events.push({ kind: 'chapter', title: line.slice(2).trim(), line: lineNo });
115
+ continue;
116
+ }
117
+ const scene = line.startsWith('## ') ? line.match(HEADING_SCENE_RE) : null;
118
+ if (scene) {
119
+ const ev = { kind: 'sceneHeading', title: scene[1].trim(), line: lineNo };
120
+ if (scene[2])
121
+ ev.id = scene[2];
122
+ events.push(ev);
123
+ continue;
124
+ }
125
+ if (UNDEFINED_LINE_RE.test(line)) {
126
+ events.push({ kind: 'unknown', raw, line: lineNo });
127
+ continue;
128
+ }
129
+ const inner = line.match(INNER_RE);
130
+ if (inner) {
131
+ const named = inner[1].match(/^([^\s::()()]{1,12}): (.*)$/);
132
+ if (named)
133
+ events.push({ kind: 'inner', name: named[1], text: named[2], line: lineNo });
134
+ else
135
+ events.push({ kind: 'inner', text: inner[1], line: lineNo });
136
+ continue;
137
+ }
138
+ const beat = line.match(BEAT_RE);
139
+ if (beat) {
140
+ events.push({ kind: 'beat', text: beat[1], line: lineNo });
141
+ continue;
142
+ }
143
+ const say = line.match(SAY_RE);
144
+ if (say) {
145
+ const ev = { kind: 'say', name: say[1], text: say[3], line: lineNo };
146
+ if (say[2])
147
+ ev.mood = say[2];
148
+ events.push(ev);
149
+ continue;
150
+ }
151
+ events.push({ kind: 'narrate', text: line, line: lineNo });
152
+ }
153
+ flushBranch();
154
+ flushNote();
155
+ return events;
156
+ }
157
+ /** Strip the inline markers grammar v1 allows inside prose — bold emphasis and
158
+ * machine-word code spans — keeping the text (what an import keeps). */
159
+ export function stripScreenplayMarkup(text) {
160
+ return text.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1');
161
+ }
@@ -0,0 +1,15 @@
1
+ export interface SemVer {
2
+ major: number;
3
+ minor: number;
4
+ patch: number;
5
+ pre?: string;
6
+ }
7
+ /** Parse `x.y.z[-pre]`; missing minor / patch read as 0. null when malformed. */
8
+ export declare function parseSemVer(v: string): SemVer | null;
9
+ /** Standard ordering: numeric triple, then a prerelease sorts BELOW its release. */
10
+ export declare function compareSemVer(a: SemVer, b: SemVer): number;
11
+ /** Is `range` well-formed in this subset? (Empty / `*` count as valid.) */
12
+ export declare function isValidRange(range: string | undefined): boolean;
13
+ /** Does `version` satisfy `range`? A malformed version never does; a malformed
14
+ * range never matches either (callers validate ranges up front and report). */
15
+ export declare function satisfiesRange(version: string, range: string | undefined): boolean;
package/dist/semver.js ADDED
@@ -0,0 +1,129 @@
1
+ // Minimal SemVer range matching, zero-dep — for plugin manifests' `engine` /
2
+ // `editor` compatibility ranges and `dependencies`. Deliberately a SUBSET of node-semver:
3
+ // `*` / '' / undefined any version
4
+ // `1.2.3` exact; a partial (`1.2`, `1`) is a wildcard tail
5
+ // `^1.2.3` / `~1.2.3` caret / tilde (partials allowed: `^0.15`)
6
+ // `>=1.2 <2` `>1` `<=2.0.0` comparators, space-separated = AND
7
+ // `a || b` alternatives = OR
8
+ // Prerelease tags order lexically below the bare triple (`1.0.0-beta < 1.0.0`).
9
+ // The engine mirrors this file verbatim (it holds no runtime dependency on
10
+ // core); the core↔engine contract test pins the two copies equal.
11
+ const VERSION_RE = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
12
+ /** Parse `x.y.z[-pre]`; missing minor / patch read as 0. null when malformed. */
13
+ export function parseSemVer(v) {
14
+ const m = VERSION_RE.exec(v.trim());
15
+ if (!m)
16
+ return null;
17
+ return { major: Number(m[1]), minor: Number(m[2] ?? 0), patch: Number(m[3] ?? 0), pre: m[4] };
18
+ }
19
+ /** Standard ordering: numeric triple, then a prerelease sorts BELOW its release. */
20
+ export function compareSemVer(a, b) {
21
+ if (a.major !== b.major)
22
+ return a.major - b.major;
23
+ if (a.minor !== b.minor)
24
+ return a.minor - b.minor;
25
+ if (a.patch !== b.patch)
26
+ return a.patch - b.patch;
27
+ if (a.pre === b.pre)
28
+ return 0;
29
+ if (a.pre === undefined)
30
+ return 1;
31
+ if (b.pre === undefined)
32
+ return -1;
33
+ return a.pre < b.pre ? -1 : 1;
34
+ }
35
+ /** How many parts a partial version spelled out (`1` → 1, `1.2` → 2, `1.2.3` → 3). */
36
+ function partsOf(s) {
37
+ const bare = s.replace(/^v/, '').split('-')[0];
38
+ return bare.split('.').length;
39
+ }
40
+ /** Expand one range token into comparators. null = malformed token. */
41
+ function tokenToComparators(tok) {
42
+ if (tok === '*' || tok === 'x' || tok === 'X')
43
+ return [];
44
+ const m = /^(>=|<=|>|<|=|\^|~)?(.+)$/.exec(tok);
45
+ if (!m)
46
+ return null;
47
+ const op = m[1] ?? '';
48
+ const raw = m[2];
49
+ const v = parseSemVer(raw);
50
+ if (!v)
51
+ return null;
52
+ const n = partsOf(raw);
53
+ const nextMajor = { major: v.major + 1, minor: 0, patch: 0 };
54
+ const nextMinor = { major: v.major, minor: v.minor + 1, patch: 0 };
55
+ const nextPatch = { major: v.major, minor: v.minor, patch: v.patch + 1 };
56
+ switch (op) {
57
+ case '>=':
58
+ case '>':
59
+ case '<=':
60
+ case '<':
61
+ return [{ op, v }];
62
+ case '^':
63
+ if (v.major > 0 || n === 1)
64
+ return [{ op: '>=', v }, { op: '<', v: nextMajor }];
65
+ if (v.minor > 0 || n === 2)
66
+ return [{ op: '>=', v }, { op: '<', v: nextMinor }];
67
+ return [{ op: '>=', v }, { op: '<', v: nextPatch }];
68
+ case '~':
69
+ if (n === 1)
70
+ return [{ op: '>=', v }, { op: '<', v: nextMajor }];
71
+ return [{ op: '>=', v }, { op: '<', v: nextMinor }];
72
+ case '=':
73
+ case '':
74
+ if (n === 1)
75
+ return [{ op: '>=', v }, { op: '<', v: nextMajor }];
76
+ if (n === 2)
77
+ return [{ op: '>=', v }, { op: '<', v: nextMinor }];
78
+ return [{ op: '=', v }];
79
+ }
80
+ return null;
81
+ }
82
+ function test(c, v) {
83
+ const d = compareSemVer(v, c.v);
84
+ switch (c.op) {
85
+ case '>=':
86
+ return d >= 0;
87
+ case '>':
88
+ return d > 0;
89
+ case '<=':
90
+ return d <= 0;
91
+ case '<':
92
+ return d < 0;
93
+ case '=':
94
+ return d === 0;
95
+ }
96
+ }
97
+ /** Is `range` well-formed in this subset? (Empty / `*` count as valid.) */
98
+ export function isValidRange(range) {
99
+ if (range === undefined || range.trim() === '')
100
+ return true;
101
+ return range.split('||').every((clause) => {
102
+ const toks = clause.trim().split(/\s+/).filter(Boolean);
103
+ if (!toks.length)
104
+ return false;
105
+ return toks.every((t) => tokenToComparators(t) !== null);
106
+ });
107
+ }
108
+ /** Does `version` satisfy `range`? A malformed version never does; a malformed
109
+ * range never matches either (callers validate ranges up front and report). */
110
+ export function satisfiesRange(version, range) {
111
+ if (range === undefined || range.trim() === '' || range.trim() === '*')
112
+ return parseSemVer(version) !== null;
113
+ const v = parseSemVer(version);
114
+ if (!v)
115
+ return false;
116
+ return range.split('||').some((clause) => {
117
+ const toks = clause.trim().split(/\s+/).filter(Boolean);
118
+ if (!toks.length)
119
+ return false;
120
+ const cmps = [];
121
+ for (const t of toks) {
122
+ const c = tokenToComparators(t);
123
+ if (c === null)
124
+ return false;
125
+ cmps.push(...c);
126
+ }
127
+ return cmps.every((c) => test(c, v));
128
+ });
129
+ }
@@ -0,0 +1,63 @@
1
+ import type { Lang, Project } from './ir.js';
2
+ import type { CommandSchema } from './schema.js';
3
+ export interface SerializeOptions {
4
+ /** Which catalog to read text from; defaults to project.meta.defaultLang. */
5
+ lang?: Lang;
6
+ /** Command schema registry: built-ins + the plugin commands the host knows
7
+ * (`commandRegistry(manifests)`). Defaults to the built-ins only — a plugin
8
+ * command absent from the registry serializes all-named, which the engine's
9
+ * positional reads would drop, so a host that serializes plugin commands
10
+ * must pass its registry. */
11
+ commands?: Record<string, CommandSchema>;
12
+ /** Emit `[label anchorLabel]` immediately before this node id (preview-from-here). */
13
+ anchorNodeId?: string;
14
+ /** Label name to inject at anchorNodeId; defaults to `__nilvn_here__`. */
15
+ anchorLabel?: string;
16
+ /** Emit `@key` references instead of resolved literal text, so the engine
17
+ * resolves them at runtime from its catalogs (enables in-game language
18
+ * switching). The engine ships every language's catalog alongside this DSL. */
19
+ keepKeys?: boolean;
20
+ /** Restrict serialization to these scene ids (in project order; unknown ids
21
+ * are ignored). Omitted = the whole project. The scoped output is the unit a
22
+ * future chunked/streaming export emits;
23
+ * full export is just the no-scope degenerate case of the same code path.
24
+ * NOTE: a scoped body may contain jumps to labels defined in OTHER scenes —
25
+ * resolved when the full product loads; preview must degrade such a jump
26
+ * gracefully rather than treat it as a parse error. */
27
+ scenes?: string[];
28
+ /** Chunked-export scoping: keep jump/choice targets that point at OTHER project
29
+ * scenes (they're cross-chunk jumps the runtime resolves via the manifest
30
+ * labelIndex), redirecting only genuinely-unconnected targets to the unset
31
+ * landing. Default (a scoped PREVIEW) redirects every out-of-scope target,
32
+ * since it can't play beyond the scope. Only meaningful with `scenes`. */
33
+ crossChunk?: boolean;
34
+ }
35
+ /** A serialized scope: the playable DSL plus the metadata a chunked export needs
36
+ * to emit. Full export = serializeChunk with no
37
+ * `scenes` scope (one chunk), so full and chunked share this one code path. */
38
+ export interface SerializedChunk {
39
+ /** The `.nvn` DSL for the scope (what the engine parser consumes). */
40
+ body: string;
41
+ /** Jump-target labels this scope DEFINES: scene ids + in-scene `label` nodes.
42
+ * The synthetic anchor/unset landing labels are excluded — they're preview-
43
+ * only / per-chunk-local, not cross-chunk targets. Feeds the export manifest's
44
+ * `labelIndex`. */
45
+ labels: string[];
46
+ /** Asset refs this scope references: per-scene command params + per-line voice.
47
+ * Project-level shared assets (declared resources, actor face sprites) are NOT
48
+ * scene-scoped and belong to the always-warm base, so they're not collected
49
+ * here. */
50
+ assetRefs: string[];
51
+ }
52
+ export declare function serializeChunk(project: Project, opts?: SerializeOptions): SerializedChunk;
53
+ /** Serialize to the `.nvn` DSL string (the common case: preview / interchange
54
+ * export). Thin wrapper over serializeChunk, so the scoped and full paths are
55
+ * identical and a caller that only wants the script stays unchanged. */
56
+ export declare function serializeProject(project: Project, opts?: SerializeOptions): string;
57
+ /** True when a value looks like an asset reference — used to pick asset paths out
58
+ * of arbitrary command params. Zero-dep, so it's the single shared predicate. */
59
+ export declare function isAssetRef(v: unknown): v is string;
60
+ /** Label prefix for A–B replay segment start anchors (`__replay_<segId>`). Shared
61
+ * with the engine's `playReplay` (which starts at this label) via the wire form
62
+ * the `[replaydef]` preamble carries — the engine never re-derives it. */
63
+ export declare const REPLAY_LABEL_PREFIX = "__replay_";