embridge 0.2.2

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xpiu and other Embridge contributors
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 ADDED
@@ -0,0 +1,77 @@
1
+ # Embridge
2
+
3
+ CLI and parser for validating Embridge Markdown files.
4
+
5
+ The package validates Embridge Markdown. It does not rewrite files or change the
6
+ Embridge format version declared in a document.
7
+
8
+ ## Quick Start
9
+
10
+ Validate one or more files without installing the package first:
11
+
12
+ ```sh
13
+ npx embridge validate file.md
14
+ ```
15
+
16
+ Convert one file to the parser JSON result:
17
+
18
+ ```sh
19
+ npx embridge to-json file.md
20
+ ```
21
+
22
+ Install it as a development dependency:
23
+
24
+ ```sh
25
+ npm install --save-dev embridge
26
+ npx embridge validate file.md
27
+ ```
28
+
29
+ ## Commands
30
+
31
+ ```sh
32
+ embridge validate <file...>
33
+ embridge to-json <file.md>
34
+ embridge help
35
+ embridge --help
36
+ embridge --version
37
+ ```
38
+
39
+ `validate` parses each file and prints diagnostics to stderr. It prints nothing
40
+ when parsing completes without diagnostics.
41
+
42
+ Exit codes:
43
+
44
+ - `0`: parsing completed without diagnostics
45
+ - `1`: parsing completed and one or more diagnostics were found
46
+ - `2`: command usage error, unreadable file, or unexpected runtime failure
47
+
48
+ Diagnostics include the file name, line number when available, severity, and
49
+ message:
50
+
51
+ ```text
52
+ file.md:12: warning: Duplicate item id "abc123"
53
+ ```
54
+
55
+ `to-json` prints the full parser result as pretty JSON. Parser diagnostics do
56
+ not make `to-json` fail because the JSON output is useful for diagnosis.
57
+
58
+ ## Library Usage
59
+
60
+ ```js
61
+ const { parseEmbridge } = require("embridge");
62
+
63
+ const tree = parseEmbridge(markdown, { sourceName: "file.md" });
64
+ ```
65
+
66
+ The parser result includes `documentMetadata`, parsed `lists`, and
67
+ `diagnostics`.
68
+
69
+ ## Format Documentation
70
+
71
+ The Embridge format specification lives in the main repository:
72
+
73
+ - https://github.com/embridge-foundation/embridge
74
+ - https://embridge.net
75
+
76
+ This package currently ships the JavaScript reference parser and CLI from the
77
+ repository's `tools/reference-parser/` directory.
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { parseEmbridge } = require('../src');
7
+
8
+ const args = process.argv.slice(2);
9
+
10
+ if (args[0] === '--check') {
11
+ const fixturesDir = args[1];
12
+ const expectedDir = args[2];
13
+ if (!fixturesDir || !expectedDir) usage(1);
14
+ const ok = checkFixtures(fixturesDir, expectedDir);
15
+ process.exit(ok ? 0 : 1);
16
+ }
17
+
18
+ if (args.length !== 1) usage(1);
19
+
20
+ const inputPath = args[0];
21
+ const input = fs.readFileSync(inputPath, 'utf8');
22
+ const result = parseEmbridge(input, { sourceName: inputPath });
23
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
24
+
25
+ function usage(exitCode) {
26
+ const text = [
27
+ 'Usage:',
28
+ ' embridge-parse <file.md>',
29
+ ' embridge-parse --check <fixtures-dir> <expected-dir>',
30
+ ].join('\n');
31
+ (exitCode === 0 ? console.log : console.error)(text);
32
+ process.exit(exitCode);
33
+ }
34
+
35
+ function checkFixtures(fixturesDir, expectedDir) {
36
+ const fixtures = fs.readdirSync(fixturesDir)
37
+ .filter((file) => file.endsWith('.md'))
38
+ .sort();
39
+
40
+ let passed = 0;
41
+ let failed = 0;
42
+
43
+ for (const fixture of fixtures) {
44
+ const base = fixture.replace(/\.md$/, '');
45
+ const expectedPath = path.join(expectedDir, `${base}.json`);
46
+ if (!fs.existsSync(expectedPath)) continue;
47
+
48
+ const input = fs.readFileSync(path.join(fixturesDir, fixture), 'utf8');
49
+ const expected = JSON.parse(fs.readFileSync(expectedPath, 'utf8'));
50
+ const actual = parseEmbridge(input, { sourceName: fixture });
51
+
52
+ if (matchesExpected(actual, expected)) {
53
+ passed += 1;
54
+ console.log(`PASS ${base}`);
55
+ } else {
56
+ failed += 1;
57
+ console.log(`FAIL ${base}`);
58
+ const diff = firstDifference(actual, expected);
59
+ if (diff) console.log(` ${diff}`);
60
+ }
61
+ }
62
+
63
+ console.log('');
64
+ console.log(`Results: ${passed} passed, ${failed} failed`);
65
+ return failed === 0;
66
+ }
67
+
68
+ function matchesExpected(actual, expected) {
69
+ return JSON.stringify(normalizeForComparison(actual)) === JSON.stringify(normalizeForComparison(expected));
70
+ }
71
+
72
+ function normalizeForComparison(tree) {
73
+ const clone = JSON.parse(JSON.stringify(tree));
74
+ clone.diagnostics = (clone.diagnostics || []).map((diagnostic) => ({
75
+ line: diagnostic.line,
76
+ severity: diagnostic.severity,
77
+ }));
78
+ return clone;
79
+ }
80
+
81
+ function firstDifference(actual, expected) {
82
+ const actualNorm = normalizeForComparison(actual);
83
+ const expectedNorm = normalizeForComparison(expected);
84
+ return walkDiff(actualNorm, expectedNorm, '$');
85
+ }
86
+
87
+ function walkDiff(actual, expected, pathName) {
88
+ if (JSON.stringify(actual) === JSON.stringify(expected)) return null;
89
+ if (typeof actual !== typeof expected) return `${pathName}: got ${typeof actual}, expected ${typeof expected}`;
90
+ if (actual === null || expected === null || typeof actual !== 'object') {
91
+ return `${pathName}: got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`;
92
+ }
93
+
94
+ const actualKeys = Array.isArray(actual) ? actual.map((_, i) => String(i)) : Object.keys(actual);
95
+ const expectedKeys = Array.isArray(expected) ? expected.map((_, i) => String(i)) : Object.keys(expected);
96
+ const keys = Array.from(new Set([...actualKeys, ...expectedKeys]));
97
+
98
+ for (const key of keys) {
99
+ const diff = walkDiff(actual[key], expected[key], Array.isArray(actual) ? `${pathName}[${key}]` : `${pathName}.${key}`);
100
+ if (diff) return diff;
101
+ }
102
+
103
+ return `${pathName}: values differ`;
104
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('../src/cli').main(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "embridge",
3
+ "version": "0.2.2",
4
+ "description": "CLI and parser for validating Embridge Markdown files",
5
+ "type": "commonjs",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "embridge": "bin/embridge.js",
9
+ "embridge-parse": "bin/embridge-parse.js"
10
+ },
11
+ "files": [
12
+ "bin/",
13
+ "src/",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "node test/conformance.test.js && node test/release-consistency.test.js && node test/cli.test.js",
19
+ "check": "node bin/embridge-parse.js --check ../../tests/fixtures ../../tests/expected"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/embridge-foundation/embridge.git",
24
+ "directory": "tools/reference-parser"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/embridge-foundation/embridge/issues"
28
+ },
29
+ "homepage": "https://github.com/embridge-foundation/embridge#readme",
30
+ "keywords": [
31
+ "embridge",
32
+ "markdown",
33
+ "tasks",
34
+ "todo",
35
+ "parser",
36
+ "validator",
37
+ "cli"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "license": "MIT"
43
+ }
package/src/ast.js ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ function createDocument(documentMetadata) {
4
+ return {
5
+ documentMetadata: documentMetadata || null,
6
+ lists: [],
7
+ diagnostics: [],
8
+ };
9
+ }
10
+
11
+ function createList(title) {
12
+ return {
13
+ title: title === undefined ? null : title,
14
+ preamble: null,
15
+ items: [],
16
+ };
17
+ }
18
+
19
+ function createItem(title, completed, marker) {
20
+ return {
21
+ title,
22
+ completed,
23
+ marker,
24
+ fields: {},
25
+ description: null,
26
+ comments: [],
27
+ subitems: [],
28
+ };
29
+ }
30
+
31
+ function createComment(replyDepth, author, timestamp, text) {
32
+ return {
33
+ replyDepth,
34
+ author,
35
+ timestamp,
36
+ text,
37
+ };
38
+ }
39
+
40
+ module.exports = {
41
+ createDocument,
42
+ createList,
43
+ createItem,
44
+ createComment,
45
+ };
package/src/cli.js ADDED
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { parseEmbridge } = require('./index');
6
+
7
+ function main(args, options = {}) {
8
+ const code = run(args, options);
9
+ if (!options.noExit) process.exitCode = code;
10
+ return code;
11
+ }
12
+
13
+ function run(args, options = {}) {
14
+ const stdout = options.stdout || process.stdout;
15
+ const stderr = options.stderr || process.stderr;
16
+
17
+ if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') {
18
+ write(stdout, helpText());
19
+ return 0;
20
+ }
21
+
22
+ if (args[0] === '--version' || args[0] === '-v') {
23
+ write(stdout, `${readPackageVersion()}\n`);
24
+ return 0;
25
+ }
26
+
27
+ if (args[0] === 'validate') {
28
+ return validate(args.slice(1), stderr);
29
+ }
30
+
31
+ if (args[0] === 'to-json') {
32
+ return toJson(args.slice(1), stdout, stderr);
33
+ }
34
+
35
+ write(stderr, `Unknown command: ${args[0]}\n\n${usageText()}\n`);
36
+ return 2;
37
+ }
38
+
39
+ function validate(files, stderr) {
40
+ if (files.length === 0) {
41
+ write(stderr, `Missing file path.\n\n${usageText()}\n`);
42
+ return 2;
43
+ }
44
+
45
+ let hasDiagnostics = false;
46
+
47
+ for (const file of files) {
48
+ let input;
49
+ try {
50
+ input = fs.readFileSync(file, 'utf8');
51
+ } catch (error) {
52
+ write(stderr, `${file}: ${error.message}\n`);
53
+ return 2;
54
+ }
55
+
56
+ let result;
57
+ try {
58
+ result = parseEmbridge(input, { sourceName: file });
59
+ } catch (error) {
60
+ write(stderr, `${file}: ${error.message}\n`);
61
+ return 2;
62
+ }
63
+
64
+ for (const diagnostic of result.diagnostics || []) {
65
+ hasDiagnostics = true;
66
+ write(stderr, formatDiagnostic(file, diagnostic));
67
+ }
68
+ }
69
+
70
+ return hasDiagnostics ? 1 : 0;
71
+ }
72
+
73
+ function toJson(files, stdout, stderr) {
74
+ if (files.length !== 1) {
75
+ write(stderr, `Expected exactly one file path.\n\n${usageText()}\n`);
76
+ return 2;
77
+ }
78
+
79
+ const file = files[0];
80
+ let input;
81
+ try {
82
+ input = fs.readFileSync(file, 'utf8');
83
+ } catch (error) {
84
+ write(stderr, `${file}: ${error.message}\n`);
85
+ return 2;
86
+ }
87
+
88
+ let result;
89
+ try {
90
+ result = parseEmbridge(input, { sourceName: file });
91
+ } catch (error) {
92
+ write(stderr, `${file}: ${error.message}\n`);
93
+ return 2;
94
+ }
95
+
96
+ write(stdout, `${JSON.stringify(result, null, 2)}\n`);
97
+ return 0;
98
+ }
99
+
100
+ function formatDiagnostic(file, diagnostic) {
101
+ const location = diagnostic.line ? `${file}:${diagnostic.line}` : file;
102
+ const severity = diagnostic.severity || 'warning';
103
+ const message = diagnostic.message || 'diagnostic';
104
+ return `${location}: ${severity}: ${message}\n`;
105
+ }
106
+
107
+ function readPackageVersion() {
108
+ const packagePath = path.join(__dirname, '..', 'package.json');
109
+ return JSON.parse(fs.readFileSync(packagePath, 'utf8')).version;
110
+ }
111
+
112
+ function helpText() {
113
+ return [
114
+ usageText(),
115
+ '',
116
+ 'Commands:',
117
+ ' validate <file...> Validate Embridge Markdown files',
118
+ ' to-json <file.md> Parse an Embridge Markdown file as JSON',
119
+ '',
120
+ 'Options:',
121
+ ' -h, --help Show this help',
122
+ ' -v, --version Show the package version',
123
+ '',
124
+ ].join('\n');
125
+ }
126
+
127
+ function usageText() {
128
+ return 'Usage: embridge <command> [options]';
129
+ }
130
+
131
+ function write(stream, text) {
132
+ stream.write(text);
133
+ }
134
+
135
+ module.exports = {
136
+ main,
137
+ run,
138
+ toJson,
139
+ validate,
140
+ };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const { createComment } = require('./ast');
4
+
5
+ function parseCommentLine(line) {
6
+ const match = line.match(/^(\s*)(>+)\s?(.*)$/);
7
+ if (!match) return null;
8
+
9
+ const indent = match[1].length;
10
+ const replyDepth = match[2].length;
11
+ const raw = match[3].trim();
12
+
13
+ let prefix = raw.match(/^@([^\[\s:]+)\s+\[([^\]]+)\]\s*:\s*(.*)$/);
14
+ if (prefix) {
15
+ return {
16
+ indent,
17
+ comment: createComment(replyDepth, prefix[1], prefix[2], prefix[3]),
18
+ hasPrefix: true,
19
+ };
20
+ }
21
+
22
+ prefix = raw.match(/^@([^\[\s:]+)\s*:\s*(.*)$/);
23
+ if (prefix) {
24
+ return {
25
+ indent,
26
+ comment: createComment(replyDepth, prefix[1], null, prefix[2]),
27
+ hasPrefix: true,
28
+ };
29
+ }
30
+
31
+ prefix = raw.match(/^\[([^\]]+)\]\s*:\s*(.*)$/);
32
+ if (prefix) {
33
+ return {
34
+ indent,
35
+ comment: createComment(replyDepth, null, prefix[1], prefix[2]),
36
+ hasPrefix: true,
37
+ };
38
+ }
39
+
40
+ return {
41
+ indent,
42
+ comment: createComment(replyDepth, null, null, raw),
43
+ hasPrefix: false,
44
+ };
45
+ }
46
+
47
+ function appendOrAddComment(item, parsed) {
48
+ const previous = item.comments[item.comments.length - 1];
49
+
50
+ if (!parsed.hasPrefix && previous && previous.replyDepth === parsed.comment.replyDepth) {
51
+ previous.text += `\n${parsed.comment.text}`;
52
+ return;
53
+ }
54
+
55
+ item.comments.push(parsed.comment);
56
+ }
57
+
58
+ module.exports = {
59
+ parseCommentLine,
60
+ appendOrAddComment,
61
+ };
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ function warning(line, message) {
4
+ return {
5
+ line,
6
+ severity: 'warning',
7
+ message,
8
+ };
9
+ }
10
+
11
+ module.exports = {
12
+ warning,
13
+ };
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ const SHORT_INLINE_FORMAT_RE = /^embridge\s+v\d+\.\d+\.\d+(?:,\s*.+)?$/i;
4
+
5
+ function extractDocumentMetadata(markdown) {
6
+ const source = markdown.replace(/^\uFEFF/, '');
7
+ const comments = findBoundaryHtmlComments(source);
8
+ const skippedLines = new Set();
9
+
10
+ if (comments.length === 0) {
11
+ return {
12
+ documentMetadata: null,
13
+ skippedLines,
14
+ };
15
+ }
16
+
17
+ const metadataCandidates = [];
18
+ for (const comment of comments) {
19
+ for (let line = comment.startLine; line <= comment.endLine; line += 1) {
20
+ skippedLines.add(line);
21
+ }
22
+
23
+ const documentMetadata = parseDocumentMetadata(comment.content);
24
+ if (documentMetadata) {
25
+ metadataCandidates.push({
26
+ documentMetadata,
27
+ kind: isInlineFormatTag(comment.content) ? 'inline' : 'block',
28
+ });
29
+ }
30
+ }
31
+
32
+ const blockCandidate = lastCandidateOfKind(metadataCandidates, 'block');
33
+ const inlineCandidate = lastCandidateOfKind(metadataCandidates, 'inline');
34
+ const selected = blockCandidate || inlineCandidate;
35
+
36
+ return {
37
+ documentMetadata: selected ? selected.documentMetadata : null,
38
+ skippedLines,
39
+ };
40
+ }
41
+
42
+ function findBoundaryHtmlComments(source) {
43
+ const lines = source.split(/\r?\n/);
44
+ const comments = [];
45
+ const seen = new Set();
46
+
47
+ for (let index = 0; index < lines.length; index += 1) {
48
+ if (/^\s*$/.test(lines[index])) continue;
49
+
50
+ const comment = readStandaloneHtmlCommentAt(lines, index);
51
+ if (!comment) break;
52
+ addComment(comments, seen, comment);
53
+ index = comment.endIndex;
54
+ }
55
+
56
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
57
+ if (/^\s*$/.test(lines[index])) continue;
58
+
59
+ const comment = readStandaloneHtmlCommentEndingAt(lines, index);
60
+ if (!comment) break;
61
+ addComment(comments, seen, comment);
62
+ index = comment.startIndex;
63
+ }
64
+
65
+ comments.sort((a, b) => a.startLine - b.startLine);
66
+ return comments;
67
+ }
68
+
69
+ function readStandaloneHtmlCommentAt(lines, startIndex) {
70
+ const singleLine = lines[startIndex].match(/^\s*<!--([\s\S]*?)-->\s*$/);
71
+ if (singleLine) return toComment(singleLine[1], startIndex, startIndex);
72
+
73
+ if (!/^\s*<!--\s*$/.test(lines[startIndex])) return null;
74
+
75
+ const content = [];
76
+ let index = startIndex + 1;
77
+ while (index < lines.length && lines[index].trim() !== '-->') {
78
+ content.push(lines[index]);
79
+ index += 1;
80
+ }
81
+
82
+ return index < lines.length ? toComment(content.join('\n'), startIndex, index) : null;
83
+ }
84
+
85
+ function readStandaloneHtmlCommentEndingAt(lines, endIndex) {
86
+ const singleLine = lines[endIndex].match(/^\s*<!--([\s\S]*?)-->\s*$/);
87
+ if (singleLine) return toComment(singleLine[1], endIndex, endIndex);
88
+ if (lines[endIndex].trim() !== '-->') return null;
89
+
90
+ for (let index = endIndex - 1; index >= 0; index -= 1) {
91
+ if (/^\s*<!--\s*$/.test(lines[index])) {
92
+ return toComment(lines.slice(index + 1, endIndex).join('\n'), index, endIndex);
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function toComment(content, startIndex, endIndex) {
99
+ return {
100
+ content,
101
+ startIndex,
102
+ endIndex,
103
+ startLine: startIndex + 1,
104
+ endLine: endIndex + 1,
105
+ };
106
+ }
107
+
108
+ function addComment(comments, seen, comment) {
109
+ const key = `${comment.startLine}:${comment.endLine}`;
110
+ if (seen.has(key)) return;
111
+ seen.add(key);
112
+ comments.push(comment);
113
+ }
114
+
115
+ function lastCandidateOfKind(candidates, kind) {
116
+ for (let index = candidates.length - 1; index >= 0; index -= 1) {
117
+ if (candidates[index].kind === kind) return candidates[index];
118
+ }
119
+ return null;
120
+ }
121
+
122
+ function isInlineFormatTag(content) {
123
+ const trimmed = content.trim();
124
+ return SHORT_INLINE_FORMAT_RE.test(trimmed) ||
125
+ /^format\s*:\s*.+$/i.test(trimmed);
126
+ }
127
+
128
+ function emptyDocumentMetadata() {
129
+ return {
130
+ title: null,
131
+ sync: null,
132
+ uuid: null,
133
+ lists: null,
134
+ fields: null,
135
+ syntax: null,
136
+ format: null,
137
+ };
138
+ }
139
+
140
+ function parseDocumentMetadata(content) {
141
+ const trimmed = content.trim();
142
+ const result = emptyDocumentMetadata();
143
+ let recognized = false;
144
+
145
+ if (!trimmed) return null;
146
+ if (SHORT_INLINE_FORMAT_RE.test(trimmed)) {
147
+ result.format = trimmed;
148
+ return result;
149
+ }
150
+
151
+ for (const rawLine of trimmed.split(/\r?\n/)) {
152
+ const line = rawLine.trim();
153
+ if (!line) continue;
154
+
155
+ const match = line.match(/^([A-Za-z][A-Za-z0-9-]*)\s*:\s*(.*)$/);
156
+ if (!match) continue;
157
+
158
+ const key = match[1].toLowerCase();
159
+ const value = match[2].trim();
160
+
161
+ if (key === 'title') {
162
+ result.title = value;
163
+ recognized = true;
164
+ } else if (key === 'sync') {
165
+ result.sync = value;
166
+ recognized = true;
167
+ } else if (key === 'uuid') {
168
+ result.uuid = value;
169
+ recognized = true;
170
+ } else if (key === 'lists') {
171
+ result.lists = parseLists(value);
172
+ recognized = true;
173
+ } else if (key === 'fields') {
174
+ result.fields = parseFields(value);
175
+ recognized = true;
176
+ } else if (key === 'syntax') {
177
+ result.syntax = parseSyntax(value);
178
+ recognized = true;
179
+ } else if (key === 'format') {
180
+ result.format = value;
181
+ recognized = true;
182
+ } else if (key === 'embridge') {
183
+ result.format = `embridge:${value}`;
184
+ recognized = true;
185
+ }
186
+ }
187
+
188
+ return recognized ? result : null;
189
+ }
190
+
191
+ function parseFields(value) {
192
+ const fields = value.split(',').map((part) => part.trim()).filter(Boolean);
193
+ return fields.length > 0 ? fields : null;
194
+ }
195
+
196
+ function parseSyntax(value) {
197
+ const match = value.match(/(?:^|,\s*)mode\s*:\s*(marker|blank-lines)\s*(?:,|$)/);
198
+ return match ? { mode: match[1] } : null;
199
+ }
200
+
201
+ function parseLists(value) {
202
+ const lists = [];
203
+ const re = /"((?:[^"]|"")*)"\s*([^\s,]+)/g;
204
+ let match;
205
+ while ((match = re.exec(value)) !== null) {
206
+ lists.push({
207
+ title: match[1].replace(/""/g, '"'),
208
+ id: match[2],
209
+ });
210
+ }
211
+ return lists.length > 0 ? lists : null;
212
+ }
213
+
214
+ module.exports = {
215
+ extractDocumentMetadata,
216
+ parseDocumentMetadata,
217
+ };
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ const { parseEmbridge } = require('./parser');
4
+
5
+ module.exports = {
6
+ parseEmbridge,
7
+ };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const DESCRIPTION_KEYS = new Set(['description', 'desc', 'descr']);
4
+
5
+ function parseMetadataFields(raw) {
6
+ return parseMetadataLine(raw).fields;
7
+ }
8
+
9
+ function parseMetadataLine(raw) {
10
+ const fields = {};
11
+ const repeatedKeys = [];
12
+ let i = 0;
13
+ let unparsedTail = null;
14
+
15
+ while (i < raw.length) {
16
+ while (i < raw.length && /[\s,]/.test(raw[i])) i += 1;
17
+ if (i >= raw.length) break;
18
+
19
+ const keyMatch = raw.slice(i).match(/^([A-Za-z][A-Za-z0-9-]*)\s*:\s*/);
20
+ if (!keyMatch) {
21
+ unparsedTail = raw.slice(i).trim();
22
+ break;
23
+ }
24
+
25
+ const key = keyMatch[1];
26
+ i += keyMatch[0].length;
27
+
28
+ let value = '';
29
+ if (raw[i] === '"') {
30
+ i += 1;
31
+ while (i < raw.length) {
32
+ if (raw[i] === '"' && raw[i + 1] === '"') {
33
+ value += '"';
34
+ i += 2;
35
+ } else if (raw[i] === '"') {
36
+ i += 1;
37
+ break;
38
+ } else {
39
+ value += raw[i];
40
+ i += 1;
41
+ }
42
+ }
43
+ while (i < raw.length && raw[i] !== ',') i += 1;
44
+ } else {
45
+ const start = i;
46
+ while (i < raw.length && raw[i] !== ',') i += 1;
47
+ value = raw.slice(start, i).trim();
48
+ }
49
+
50
+ if (Object.prototype.hasOwnProperty.call(fields, key)) repeatedKeys.push(key);
51
+ fields[key] = value;
52
+ if (raw[i] === ',') i += 1;
53
+ }
54
+
55
+ return { fields, repeatedKeys, unparsedTail };
56
+ }
57
+
58
+ function hasAnyKeyValue(raw) {
59
+ return /[A-Za-z][A-Za-z0-9-]*\s*:/.test(raw);
60
+ }
61
+
62
+ function descriptionFromFields(fields) {
63
+ for (const key of Object.keys(fields)) {
64
+ if (isDescriptionKey(key)) return fields[key];
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function isDescriptionKey(key) {
70
+ return DESCRIPTION_KEYS.has(String(key || '').toLowerCase());
71
+ }
72
+
73
+ module.exports = {
74
+ parseMetadataLine,
75
+ parseMetadataFields,
76
+ hasAnyKeyValue,
77
+ descriptionFromFields,
78
+ isDescriptionKey,
79
+ };
package/src/parser.js ADDED
@@ -0,0 +1,577 @@
1
+ 'use strict';
2
+
3
+ const { createDocument, createList, createItem } = require('./ast');
4
+ const { warning } = require('./diagnostics');
5
+ const { extractDocumentMetadata } = require('./document-metadata');
6
+ const { parseCommentLine, appendOrAddComment } = require('./comments');
7
+ const {
8
+ parseMetadataLine,
9
+ hasAnyKeyValue,
10
+ descriptionFromFields,
11
+ isDescriptionKey,
12
+ } = require('./metadata');
13
+
14
+ function parseEmbridge(markdown, options) {
15
+ const source = String(markdown || '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
16
+ const metadataInfo = extractDocumentMetadata(source);
17
+ const document = createDocument(metadataInfo.documentMetadata);
18
+ const state = createState(document, metadataInfo.skippedLines);
19
+ const lines = source.split('\n');
20
+ const mode = (options && options.mode) ||
21
+ (metadataInfo.documentMetadata && metadataInfo.documentMetadata.syntax && metadataInfo.documentMetadata.syntax.mode) ||
22
+ 'marker';
23
+
24
+ if (mode === 'blank-lines') parseBlankLinesMode(lines, state);
25
+ else parseMarkerMode(lines, state);
26
+
27
+ resolveListRegistry(document);
28
+
29
+ return document;
30
+ }
31
+
32
+ function resolveListRegistry(document) {
33
+ const registry = document.documentMetadata && document.documentMetadata.lists;
34
+ if (!registry || registry.length === 0) return;
35
+
36
+ const entriesByTitle = groupByTitle(registry);
37
+ const listsByTitle = groupByTitle(document.lists);
38
+
39
+ for (const [title, lists] of listsByTitle.entries()) {
40
+ if (title === null) continue;
41
+ const entries = entriesByTitle.get(title);
42
+ if (!entries || entries.length === 0) {
43
+ applyInlineListIds(lists);
44
+ continue;
45
+ }
46
+
47
+ const pairCount = Math.min(entries.length, lists.length);
48
+ for (let index = 0; index < pairCount; index += 1) {
49
+ lists[index].id = entries[index].id;
50
+ }
51
+
52
+ for (let index = pairCount; index < lists.length; index += 1) {
53
+ applyInlineListId(lists[index]);
54
+ }
55
+ }
56
+ }
57
+
58
+ function groupByTitle(values) {
59
+ const groups = new Map();
60
+ for (const value of values) {
61
+ const title = value.title;
62
+ if (!groups.has(title)) groups.set(title, []);
63
+ groups.get(title).push(value);
64
+ }
65
+ return groups;
66
+ }
67
+
68
+ function applyInlineListIds(lists) {
69
+ for (const list of lists) applyInlineListId(list);
70
+ }
71
+
72
+ function applyInlineListId(list) {
73
+ if (list.fields && Object.prototype.hasOwnProperty.call(list.fields, 'id')) {
74
+ list.id = list.fields.id;
75
+ }
76
+ }
77
+
78
+ function createState(document, skippedLines) {
79
+ return {
80
+ document,
81
+ skippedLines,
82
+ currentList: null,
83
+ itemStack: [],
84
+ seenItemIds: new Map(),
85
+ itemIds: new WeakMap(),
86
+ sectionMetaEligible: false,
87
+ itemMetaEligible: false,
88
+ };
89
+ }
90
+
91
+ function parseMarkerMode(lines, state) {
92
+ for (let index = 0; index < lines.length; index += 1) {
93
+ const lineNo = index + 1;
94
+ if (state.skippedLines.has(lineNo)) continue;
95
+
96
+ const line = lines[index];
97
+ if (/^\s*$/.test(line)) {
98
+ if (sectionMetadataTarget(state)) state.sectionMetaEligible = false;
99
+ state.itemMetaEligible = false;
100
+ continue;
101
+ }
102
+
103
+ const heading = line.match(/^# (.+)$/);
104
+ if (heading) {
105
+ state.currentList = createList(heading[1].trim());
106
+ state.document.lists.push(state.currentList);
107
+ state.itemStack = [];
108
+ state.sectionMetaEligible = true;
109
+ state.itemMetaEligible = false;
110
+ continue;
111
+ }
112
+
113
+ const sectionTarget = sectionMetadataTarget(state);
114
+
115
+ const invalid = invalidMarkerDiagnostic(line, lineNo);
116
+ if (invalid) {
117
+ state.sectionMetaEligible = false;
118
+ state.itemMetaEligible = false;
119
+ state.document.diagnostics.push(invalid);
120
+ continue;
121
+ }
122
+
123
+ const itemToken = parseMarkerItem(line);
124
+ if (itemToken) {
125
+ state.sectionMetaEligible = false;
126
+ addItem(state, itemToken, lineNo);
127
+ state.itemMetaEligible = true;
128
+ continue;
129
+ }
130
+
131
+ const parsedComment = parseCommentLine(line);
132
+ if (parsedComment) {
133
+ state.sectionMetaEligible = false;
134
+ state.itemMetaEligible = false;
135
+ const target = findItemForIndent(state, parsedComment.indent) || mostRecentItem(state);
136
+ if (target) appendOrAddComment(target, parsedComment);
137
+ continue;
138
+ }
139
+
140
+ if (/^\s*"/.test(line)) {
141
+ if (!sectionTarget && !state.itemMetaEligible) {
142
+ warnNonConformantMetadataLikeLine(state, lineNo);
143
+ continue;
144
+ }
145
+ const result = readDescription(lines, index, state.skippedLines);
146
+ index = result.endIndex;
147
+ if (sectionTarget) applyDescription(state, sectionTarget, result, lineNo, {
148
+ mergeFields: true,
149
+ trackItemIds: false,
150
+ });
151
+ else attachDescription(state, result, lineNo);
152
+ continue;
153
+ }
154
+
155
+ if (hasAnyKeyValue(line)) {
156
+ if (sectionTarget) applyMetadata(state, sectionTarget, line.trim(), lineNo, {
157
+ mergeFields: true,
158
+ trackItemIds: false,
159
+ });
160
+ else if (state.itemMetaEligible) attachMetadata(state, line.trim(), lineNo);
161
+ else warnNonConformantMetadataLikeLine(state, lineNo);
162
+ continue;
163
+ }
164
+
165
+ if (sectionTarget) {
166
+ state.sectionMetaEligible = false;
167
+ state.document.diagnostics.push(warning(lineNo, 'non-conformant section metadata (not key: value metadata)'));
168
+ continue;
169
+ }
170
+
171
+ if (mostRecentItem(state)) {
172
+ state.itemMetaEligible = false;
173
+ state.document.diagnostics.push(warning(lineNo, 'non-conformant free-form text (not key: value metadata)'));
174
+ }
175
+ }
176
+ }
177
+
178
+ function parseBlankLinesMode(lines, state) {
179
+ let inPreamble = false;
180
+ let blockOpen = false;
181
+
182
+ for (let index = 0; index < lines.length; index += 1) {
183
+ const lineNo = index + 1;
184
+ if (state.skippedLines.has(lineNo)) continue;
185
+
186
+ const line = lines[index];
187
+ if (/^\s*$/.test(line)) {
188
+ blockOpen = false;
189
+ state.sectionMetaEligible = false;
190
+ state.itemMetaEligible = false;
191
+ if (inPreamble) {
192
+ if (state.currentList && state.currentList.preamble && state.currentList.preamble.length === 0) {
193
+ state.currentList.preamble = null;
194
+ }
195
+ inPreamble = false;
196
+ }
197
+ continue;
198
+ }
199
+
200
+ const heading = line.match(/^# (.+)$/);
201
+ if (heading) {
202
+ state.currentList = createList(heading[1].trim());
203
+ state.currentList.preamble = [];
204
+ state.document.lists.push(state.currentList);
205
+ state.itemStack = [];
206
+ inPreamble = true;
207
+ state.sectionMetaEligible = true;
208
+ state.itemMetaEligible = false;
209
+ blockOpen = false;
210
+ continue;
211
+ }
212
+
213
+ const invalid = invalidMarkerDiagnostic(line, lineNo);
214
+ if (invalid) {
215
+ state.sectionMetaEligible = false;
216
+ state.itemMetaEligible = false;
217
+ state.document.diagnostics.push(invalid);
218
+ continue;
219
+ }
220
+
221
+ const itemToken = parseMarkerItem(line);
222
+ if (itemToken) {
223
+ state.sectionMetaEligible = false;
224
+ if (inPreamble) {
225
+ if (state.currentList && state.currentList.preamble && state.currentList.preamble.length === 0) {
226
+ state.currentList.preamble = null;
227
+ }
228
+ inPreamble = false;
229
+ }
230
+ addItem(state, itemToken, lineNo);
231
+ state.itemMetaEligible = true;
232
+ blockOpen = true;
233
+ continue;
234
+ }
235
+
236
+ if (inPreamble && state.currentList && state.currentList.items.length === 0) {
237
+ if (state.sectionMetaEligible && /^\s*"/.test(line)) {
238
+ const result = readDescription(lines, index, state.skippedLines);
239
+ index = result.endIndex;
240
+ applyDescription(state, state.currentList, result, lineNo, {
241
+ mergeFields: true,
242
+ trackItemIds: false,
243
+ });
244
+ continue;
245
+ }
246
+
247
+ if (state.sectionMetaEligible && hasAnyKeyValue(line)) {
248
+ applyMetadata(state, state.currentList, line.trim(), lineNo, {
249
+ mergeFields: true,
250
+ trackItemIds: false,
251
+ });
252
+ continue;
253
+ }
254
+
255
+ state.sectionMetaEligible = false;
256
+ state.itemMetaEligible = false;
257
+ state.currentList.preamble.push(line.trim());
258
+ continue;
259
+ }
260
+
261
+ const parsedComment = parseCommentLine(line);
262
+ if (parsedComment) {
263
+ if (!blockOpen) {
264
+ state.document.diagnostics.push(warning(lineNo, 'orphaned comment after blank-line boundary with no parent item in current block'));
265
+ continue;
266
+ }
267
+ state.itemMetaEligible = false;
268
+ const target = findItemForIndent(state, parsedComment.indent) || mostRecentItem(state);
269
+ if (target) appendOrAddComment(target, parsedComment);
270
+ continue;
271
+ }
272
+
273
+ if (/^\s*"/.test(line)) {
274
+ if (!blockOpen) continue;
275
+ if (!state.itemMetaEligible) {
276
+ warnNonConformantMetadataLikeLine(state, lineNo);
277
+ continue;
278
+ }
279
+ const result = readDescription(lines, index, state.skippedLines);
280
+ index = result.endIndex;
281
+ attachDescription(state, result, lineNo);
282
+ continue;
283
+ }
284
+
285
+ if (hasAnyKeyValue(line) && blockOpen) {
286
+ if (state.itemMetaEligible) attachMetadata(state, line.trim(), lineNo);
287
+ else warnNonConformantMetadataLikeLine(state, lineNo);
288
+ continue;
289
+ }
290
+
291
+ const blankItem = parseBlankLineItem(line);
292
+ addItem(state, blankItem, lineNo);
293
+ state.itemMetaEligible = true;
294
+ blockOpen = true;
295
+ }
296
+
297
+ if (state.currentList && state.currentList.preamble && state.currentList.preamble.length === 0) {
298
+ state.currentList.preamble = null;
299
+ }
300
+ }
301
+
302
+ function parseMarkerItem(line) {
303
+ const match = line.match(/^(\s*)(?:- |((?:0|[1-9]\d*)\.) )(\[[ xX]\] )?(.*)$/);
304
+ if (!match) return null;
305
+
306
+ const indent = match[1].length;
307
+ const numberText = match[2] ? match[2].slice(0, -1) : null;
308
+ const markerWidth = match[2] ? match[2].length + 1 : 2;
309
+ const marker = numberText === null
310
+ ? { type: 'bullet' }
311
+ : { type: 'ordered', number: Number(numberText) };
312
+ const completed = parseCheckbox(match[3]);
313
+ const title = match[4].trim();
314
+
315
+ return { indent, marker, markerWidth, completed, title };
316
+ }
317
+
318
+ function parseBlankLineItem(line) {
319
+ const indent = (line.match(/^\s*/) || [''])[0].length;
320
+ let title = line.trim();
321
+ let completed = null;
322
+
323
+ const checkbox = title.match(/^\[([ xX])\]\s+(.*)$/);
324
+ if (checkbox) {
325
+ completed = checkbox[1] === 'x' || checkbox[1] === 'X';
326
+ title = checkbox[2].trim();
327
+ }
328
+
329
+ return {
330
+ indent,
331
+ marker: { type: 'none' },
332
+ markerWidth: null,
333
+ completed,
334
+ title,
335
+ };
336
+ }
337
+
338
+ function parseCheckbox(raw) {
339
+ if (!raw) return null;
340
+ if (raw === '[ ] ') return false;
341
+ return true;
342
+ }
343
+
344
+ function invalidMarkerDiagnostic(line, lineNo) {
345
+ const leadingZero = line.match(/^\s*(0\d+)\. /);
346
+ if (leadingZero) {
347
+ return warning(lineNo, `'${leadingZero[1]}.' has leading zeros and is not recognized as an ordered marker`);
348
+ }
349
+
350
+ const dashNoSpace = line.match(/^\s*(-(?:\S|[^\S ]).*)/);
351
+ if (dashNoSpace) {
352
+ return warning(lineNo, `'${dashNoSpace[1]}' is not a valid item (space required after marker)`);
353
+ }
354
+
355
+ const orderedNoSpace = line.match(/^\s*(\d+\.(?:\S|[^\S ]).*)/);
356
+ if (orderedNoSpace) {
357
+ return warning(lineNo, `'${orderedNoSpace[1]}' is not a valid item (space required after marker)`);
358
+ }
359
+
360
+ return null;
361
+ }
362
+
363
+ function addItem(state, token, lineNo) {
364
+ ensureList(state);
365
+
366
+ const item = createItem(token.title, token.completed, token.marker);
367
+
368
+ while (state.itemStack.length > 0 && state.itemStack[state.itemStack.length - 1].indent >= token.indent) {
369
+ state.itemStack.pop();
370
+ }
371
+
372
+ if (state.itemStack.length === 0) {
373
+ state.currentList.items.push(item);
374
+ } else {
375
+ const parent = state.itemStack[state.itemStack.length - 1];
376
+ warnForNonCanonicalIndent(state, token, parent, lineNo);
377
+ parent.item.subitems.push(item);
378
+ }
379
+
380
+ state.itemStack.push({ item, indent: token.indent, markerWidth: token.markerWidth });
381
+ }
382
+
383
+ function warnForNonCanonicalIndent(state, token, parent, lineNo) {
384
+ if (parent.markerWidth === null) return;
385
+
386
+ const canonicalIndent = parent.indent + parent.markerWidth;
387
+ if (token.indent === canonicalIndent) return;
388
+
389
+ state.document.diagnostics.push(warning(
390
+ lineNo,
391
+ `non-canonical indentation (${token.indent} space${token.indent === 1 ? '' : 's'}); child SHOULD align to parent content column (${canonicalIndent} spaces)`,
392
+ ));
393
+ }
394
+
395
+ function attachMetadata(state, raw, lineNo) {
396
+ const item = mostRecentItem(state);
397
+ if (!item) return;
398
+
399
+ applyMetadata(state, item, raw, lineNo, { mergeFields: true, trackItemIds: true });
400
+ }
401
+
402
+ function attachDescription(state, result, lineNo) {
403
+ const item = mostRecentItem(state);
404
+ if (!item) return;
405
+
406
+ applyDescription(state, item, result, lineNo, { mergeFields: true, trackItemIds: true });
407
+ }
408
+
409
+ // Shared by item and section (list) targets. Unknown fields are preserved for
410
+ // forward compatibility; tooling SHOULD normalize canonical list fields into
411
+ // document metadata.
412
+ function applyMetadata(state, target, raw, lineNo, options) {
413
+ const result = parseMetadataLine(raw);
414
+ const fields = result.fields;
415
+ if (Object.keys(fields).length === 0) {
416
+ state.document.diagnostics.push(warning(lineNo, 'non-conformant free-form text (not key: value metadata)'));
417
+ return;
418
+ }
419
+
420
+ recordRepeatedMetadataFields(state, target, fields, result.repeatedKeys, lineNo);
421
+ assignFields(target, fields, options);
422
+ const description = descriptionFromFields(fields);
423
+ if (description !== null) target.description = description;
424
+ if (options && options.trackItemIds) recordDuplicateItemId(state, target, fields, lineNo);
425
+ recordUnparsedMetadataTail(state, result, lineNo);
426
+ }
427
+
428
+ function applyDescription(state, target, result, lineNo, options) {
429
+ if (target.description != null) {
430
+ state.document.diagnostics.push(warning(lineNo, "repeated metadata field 'description'"));
431
+ }
432
+ target.description = result.value;
433
+ if (result.trailingMeta) {
434
+ const metadata = parseMetadataLine(result.trailingMeta);
435
+ const fields = metadata.fields;
436
+ recordRepeatedMetadataFields(state, target, fields, metadata.repeatedKeys, lineNo);
437
+ if (Object.keys(fields).length > 0) assignFields(target, fields, options);
438
+ if (options && options.trackItemIds) recordDuplicateItemId(state, target, fields, lineNo);
439
+ recordUnparsedMetadataTail(state, metadata, lineNo);
440
+ }
441
+ }
442
+
443
+ function recordRepeatedMetadataFields(state, target, fields, repeatedKeys, lineNo) {
444
+ const warned = new Set();
445
+ for (const key of repeatedKeys || []) warnRepeatedMetadataField(state, warned, key, lineNo);
446
+
447
+ for (const key of Object.keys(fields)) {
448
+ if (Object.prototype.hasOwnProperty.call(target.fields || {}, key)) {
449
+ warnRepeatedMetadataField(state, warned, key, lineNo);
450
+ continue;
451
+ }
452
+
453
+ if (isDescriptionKey(key) && target.description != null) {
454
+ warnRepeatedMetadataField(state, warned, 'description', lineNo);
455
+ }
456
+ }
457
+ }
458
+
459
+ function warnRepeatedMetadataField(state, warned, key, lineNo) {
460
+ if (warned.has(key)) return;
461
+ warned.add(key);
462
+ state.document.diagnostics.push(warning(lineNo, `repeated metadata field '${key}'`));
463
+ }
464
+
465
+ function assignFields(target, fields, options) {
466
+ if (options && options.mergeFields) {
467
+ target.fields = Object.assign({}, target.fields || {}, fields);
468
+ } else {
469
+ target.fields = fields;
470
+ }
471
+ }
472
+
473
+ function readDescription(lines, startIndex, skippedLines) {
474
+ let lineIndex = startIndex;
475
+ let line = lines[lineIndex];
476
+ let pos = line.indexOf('"') + 1;
477
+ let value = '';
478
+
479
+ while (lineIndex < lines.length) {
480
+ if (skippedLines.has(lineIndex + 1)) break;
481
+ line = lines[lineIndex];
482
+
483
+ while (pos < line.length) {
484
+ const ch = line[pos];
485
+ if (ch === '"' && line[pos + 1] === '"') {
486
+ value += '"';
487
+ pos += 2;
488
+ } else if (ch === '"') {
489
+ const trailing = line.slice(pos + 1).trim();
490
+ return {
491
+ value,
492
+ trailingMeta: trailing.replace(/^,\s*/, '') || null,
493
+ endIndex: lineIndex,
494
+ };
495
+ } else {
496
+ value += ch;
497
+ pos += 1;
498
+ }
499
+ }
500
+
501
+ lineIndex += 1;
502
+ if (lineIndex < lines.length && !skippedLines.has(lineIndex + 1)) {
503
+ value += '\n';
504
+ pos = 0;
505
+ }
506
+ }
507
+
508
+ return {
509
+ value,
510
+ trailingMeta: null,
511
+ endIndex: Math.max(startIndex, lineIndex - 1),
512
+ };
513
+ }
514
+
515
+ function recordDuplicateItemId(state, item, fields, lineNo) {
516
+ for (const key of Object.keys(fields)) {
517
+ if (key.toLowerCase() !== 'id') continue;
518
+ const id = fields[key];
519
+ if (!id) continue;
520
+
521
+ const previousId = state.itemIds.get(item);
522
+ if (previousId && state.seenItemIds.get(previousId) === item) {
523
+ state.seenItemIds.delete(previousId);
524
+ }
525
+
526
+ const existingItem = state.seenItemIds.get(id);
527
+ if (existingItem && existingItem !== item) {
528
+ state.document.diagnostics.push(warning(lineNo, `duplicate item id '${id}'`));
529
+ } else {
530
+ state.seenItemIds.set(id, item);
531
+ }
532
+ state.itemIds.set(item, id);
533
+ }
534
+ }
535
+
536
+ function recordUnparsedMetadataTail(state, result, lineNo) {
537
+ if (!result.unparsedTail) return;
538
+ state.document.diagnostics.push(warning(
539
+ lineNo,
540
+ `metadata text after comma ignored; quote values containing commas ('${result.unparsedTail}')`,
541
+ ));
542
+ }
543
+
544
+ function ensureList(state) {
545
+ if (!state.currentList) {
546
+ state.currentList = createList(null);
547
+ state.document.lists.push(state.currentList);
548
+ }
549
+ }
550
+
551
+ function mostRecentItem(state) {
552
+ return state.itemStack.length > 0 ? state.itemStack[state.itemStack.length - 1].item : null;
553
+ }
554
+
555
+ function sectionMetadataTarget(state) {
556
+ return (state.sectionMetaEligible && state.currentList && !mostRecentItem(state))
557
+ ? state.currentList
558
+ : null;
559
+ }
560
+
561
+ function warnNonConformantMetadataLikeLine(state, lineNo) {
562
+ state.document.diagnostics.push(warning(
563
+ lineNo,
564
+ 'metadata-like line appears after metadata eligibility closed',
565
+ ));
566
+ }
567
+
568
+ function findItemForIndent(state, indent) {
569
+ for (let i = state.itemStack.length - 1; i >= 0; i -= 1) {
570
+ if (state.itemStack[i].indent === indent) return state.itemStack[i].item;
571
+ }
572
+ return null;
573
+ }
574
+
575
+ module.exports = {
576
+ parseEmbridge,
577
+ };