flex-md 3.5.0 → 4.0.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.
Files changed (72) hide show
  1. package/README.md +423 -39
  2. package/dist/index.cjs +62 -3
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.js +2 -0
  5. package/dist/ofs/parser.js +31 -10
  6. package/dist/tokens/auto-fix.d.ts +10 -0
  7. package/dist/tokens/auto-fix.js +56 -0
  8. package/dist/tokens/cognitive-cost.d.ts +10 -0
  9. package/dist/tokens/cognitive-cost.js +205 -0
  10. package/dist/tokens/compliance.d.ts +10 -0
  11. package/dist/tokens/compliance.js +70 -0
  12. package/dist/tokens/confidence.d.ts +6 -0
  13. package/dist/tokens/confidence.js +332 -0
  14. package/dist/tokens/estimator.d.ts +12 -0
  15. package/dist/tokens/estimator.js +138 -0
  16. package/dist/tokens/improvements.d.ts +10 -0
  17. package/dist/tokens/improvements.js +697 -0
  18. package/dist/tokens/index.d.ts +24 -0
  19. package/dist/tokens/index.js +31 -0
  20. package/dist/tokens/parser.d.ts +3 -0
  21. package/dist/tokens/parser.js +97 -0
  22. package/dist/tokens/patterns.d.ts +9 -0
  23. package/dist/tokens/patterns.js +20 -0
  24. package/dist/tokens/smart-report.d.ts +10 -0
  25. package/dist/tokens/smart-report.js +187 -0
  26. package/dist/tokens/spec-estimator.d.ts +7 -0
  27. package/dist/tokens/spec-estimator.js +68 -0
  28. package/dist/tokens/types.d.ts +185 -0
  29. package/dist/tokens/validator.d.ts +16 -0
  30. package/dist/tokens/validator.js +59 -0
  31. package/docs/Recommended New Strategies for AI Request Builder.md +691 -0
  32. package/package.json +5 -4
  33. package/dist/detection/detector.d.ts +0 -6
  34. package/dist/detection/detector.js +0 -104
  35. package/dist/detection/extractor.d.ts +0 -10
  36. package/dist/detection/extractor.js +0 -54
  37. package/dist/issues/build.d.ts +0 -26
  38. package/dist/issues/build.js +0 -62
  39. package/dist/md/lists.d.ts +0 -14
  40. package/dist/md/lists.js +0 -33
  41. package/dist/md/tables.d.ts +0 -25
  42. package/dist/md/tables.js +0 -72
  43. package/dist/ofs/extractor.d.ts +0 -9
  44. package/dist/ofs/extractor.js +0 -75
  45. package/dist/ofs/issues.d.ts +0 -14
  46. package/dist/ofs/issues.js +0 -92
  47. package/dist/ofs/validator.d.ts +0 -10
  48. package/dist/ofs/validator.js +0 -91
  49. package/dist/outline/builder.d.ts +0 -10
  50. package/dist/outline/builder.js +0 -85
  51. package/dist/outline/renderer.d.ts +0 -6
  52. package/dist/outline/renderer.js +0 -23
  53. package/dist/parser.d.ts +0 -2
  54. package/dist/parser.js +0 -199
  55. package/dist/parsers/lists.d.ts +0 -6
  56. package/dist/parsers/lists.js +0 -36
  57. package/dist/parsers/tables.d.ts +0 -10
  58. package/dist/parsers/tables.js +0 -58
  59. package/dist/stringify.d.ts +0 -2
  60. package/dist/stringify.js +0 -110
  61. package/dist/test-pipeline.js +0 -53
  62. package/dist/test-runner.d.ts +0 -1
  63. package/dist/test-runner.js +0 -331
  64. package/dist/test-strictness.d.ts +0 -1
  65. package/dist/test-strictness.js +0 -213
  66. package/dist/util.d.ts +0 -5
  67. package/dist/util.js +0 -64
  68. package/dist/validate/policy.d.ts +0 -10
  69. package/dist/validate/policy.js +0 -17
  70. package/dist/validator.d.ts +0 -2
  71. package/dist/validator.js +0 -80
  72. /package/dist/{test-pipeline.d.ts → tokens/types.js} +0 -0
@@ -1,91 +0,0 @@
1
- import { buildOutline } from "../outline/builder.js";
2
- /**
3
- * Validate Markdown output against an OutputFormatSpec.
4
- */
5
- export function validateOutput(md, spec) {
6
- const outline = buildOutline(md);
7
- const errors = [];
8
- const warnings = [];
9
- // Index nodes by normalized title
10
- const matches = collectMatches(outline.nodes);
11
- // Validate each required section
12
- for (const section of spec.sections) {
13
- const key = normalizeTitle(section.name);
14
- const nodes = matches.get(key) ?? [];
15
- if (nodes.length === 0) {
16
- errors.push(`missing_section:${section.name}`);
17
- continue;
18
- }
19
- // Choose best node (highest level, i.e., smallest level number)
20
- const chosen = chooseBestNode(nodes);
21
- const body = chosen.content_md.trim();
22
- // Check for empty sections
23
- if (spec.emptySectionValue) {
24
- if (body === "" && !normalizeNone(body, spec.emptySectionValue)) {
25
- errors.push(`empty_section_without_none:${section.name}`);
26
- }
27
- // If it's "None", skip further validation
28
- if (normalizeNone(body, spec.emptySectionValue)) {
29
- continue;
30
- }
31
- }
32
- // Validate section kind
33
- if (section.kind === "list") {
34
- if (!/^\s*-\s+/m.test(body)) {
35
- errors.push(`section_not_bullets:${section.name}`);
36
- }
37
- }
38
- if (section.kind === "ordered_list") {
39
- if (!/^\s*\d+\.\s+/m.test(body)) {
40
- errors.push(`section_not_numbered:${section.name}`);
41
- }
42
- }
43
- }
44
- // Validate tables (if any)
45
- // Note: Full table validation would require parsing tables from the markdown
46
- // For now, we just check if tables exist when required
47
- // This is a simplified version; full implementation would use extractAllTables
48
- return {
49
- ok: errors.length === 0,
50
- errors,
51
- warnings
52
- };
53
- }
54
- /**
55
- * Collect all nodes by normalized title.
56
- */
57
- function collectMatches(nodes) {
58
- const map = new Map();
59
- function visit(node) {
60
- const key = normalizeTitle(node.title);
61
- const existing = map.get(key) ?? [];
62
- existing.push(node);
63
- map.set(key, existing);
64
- node.children.forEach(visit);
65
- }
66
- nodes.forEach(visit);
67
- return map;
68
- }
69
- /**
70
- * Choose the best node from multiple matches.
71
- * Prefer highest-level heading (smallest level number).
72
- */
73
- function chooseBestNode(nodes) {
74
- if (nodes.length === 1)
75
- return nodes[0];
76
- // Sort by level (ascending) and take first
77
- const sorted = [...nodes].sort((a, b) => a.level - b.level);
78
- return sorted[0];
79
- }
80
- /**
81
- * Normalize title for comparison: lowercase, remove trailing punctuation.
82
- */
83
- function normalizeTitle(t) {
84
- return t.trim().replace(/[:\-–—]\s*$/, "").trim().toLowerCase();
85
- }
86
- /**
87
- * Check if body is the "None" value.
88
- */
89
- function normalizeNone(body, noneValue) {
90
- return body.trim().toLowerCase() === noneValue.toLowerCase();
91
- }
@@ -1,10 +0,0 @@
1
- import type { MdOutline } from "../types.js";
2
- /**
3
- * Build a nested outline tree from Markdown headings.
4
- * Accepts any heading level (#..######) and builds parent/child relationships.
5
- */
6
- export declare function buildOutline(md: string): MdOutline;
7
- /**
8
- * Convert title to slug: lowercase, replace spaces with _, remove special chars
9
- */
10
- export declare function slugify(t: string): string;
@@ -1,85 +0,0 @@
1
- /**
2
- * Build a nested outline tree from Markdown headings.
3
- * Accepts any heading level (#..######) and builds parent/child relationships.
4
- */
5
- export function buildOutline(md) {
6
- const lines = md.split("\n");
7
- const nodes = [];
8
- const stack = [];
9
- // Collect heading positions
10
- const headings = [];
11
- for (let i = 0; i < lines.length; i++) {
12
- const m = lines[i].match(/^(#{1,6})\s+(.+)\s*$/);
13
- if (m) {
14
- headings.push({
15
- idx: i,
16
- level: m[1].length,
17
- title: cleanTitle(m[2])
18
- });
19
- }
20
- }
21
- // If no headings, return empty outline
22
- if (headings.length === 0) {
23
- return { type: "md_outline", nodes: [] };
24
- }
25
- // Build tree using stack-based algorithm
26
- for (let h = 0; h < headings.length; h++) {
27
- const cur = headings[h];
28
- const next = headings[h + 1];
29
- const contentStart = cur.idx + 1;
30
- const contentEnd = next ? next.idx : lines.length;
31
- const content_md = lines.slice(contentStart, contentEnd).join("\n").trimEnd() + "\n";
32
- const node = {
33
- title: cur.title,
34
- level: cur.level,
35
- key: "", // filled later
36
- content_md,
37
- children: []
38
- };
39
- // Attach using stack: pop nodes with level >= current level
40
- while (stack.length && stack[stack.length - 1].level >= node.level) {
41
- stack.pop();
42
- }
43
- if (!stack.length) {
44
- nodes.push(node);
45
- }
46
- else {
47
- stack[stack.length - 1].children.push(node);
48
- }
49
- stack.push(node);
50
- }
51
- // Fill keys deterministically (slug + dedup)
52
- assignKeys(nodes);
53
- return { type: "md_outline", nodes };
54
- }
55
- /**
56
- * Clean heading title: remove trailing punctuation like :, -, –, —
57
- */
58
- function cleanTitle(t) {
59
- return t.trim().replace(/[:\-–—]\s*$/, "").trim();
60
- }
61
- /**
62
- * Convert title to slug: lowercase, replace spaces with _, remove special chars
63
- */
64
- export function slugify(t) {
65
- return t.toLowerCase()
66
- .replace(/[:\-–—]+$/g, "")
67
- .replace(/\s+/g, "_")
68
- .replace(/[^a-z0-9_]/g, "")
69
- .replace(/_+/g, "_")
70
- .replace(/^_+|_+$/g, "");
71
- }
72
- /**
73
- * Assign unique keys to all nodes in the tree.
74
- * Uses slugified titles with deduplication (e.g., "section", "section__2", "section__3")
75
- */
76
- function assignKeys(nodes, seen = new Map()) {
77
- const visit = (n) => {
78
- const base = slugify(n.title) || "section";
79
- const count = (seen.get(base) ?? 0) + 1;
80
- seen.set(base, count);
81
- n.key = count === 1 ? base : `${base}__${count}`;
82
- n.children.forEach(visit);
83
- };
84
- nodes.forEach(visit);
85
- }
@@ -1,6 +0,0 @@
1
- import type { MdOutline } from "../types.js";
2
- /**
3
- * Render an outline tree back to Markdown.
4
- * Never renders internal keys, ids, or dedup suffixes.
5
- */
6
- export declare function renderOutline(outline: MdOutline): string;
@@ -1,23 +0,0 @@
1
- /**
2
- * Render an outline tree back to Markdown.
3
- * Never renders internal keys, ids, or dedup suffixes.
4
- */
5
- export function renderOutline(outline) {
6
- const parts = [];
7
- function renderNode(node) {
8
- // Render heading
9
- const hashes = "#".repeat(node.level);
10
- parts.push(`${hashes} ${node.title}\n`);
11
- // Render content
12
- if (node.content_md && node.content_md.trim()) {
13
- parts.push(node.content_md);
14
- if (!node.content_md.endsWith("\n")) {
15
- parts.push("\n");
16
- }
17
- }
18
- // Render children recursively
19
- node.children.forEach(renderNode);
20
- }
21
- outline.nodes.forEach(renderNode);
22
- return parts.join("");
23
- }
package/dist/parser.d.ts DELETED
@@ -1,2 +0,0 @@
1
- import type { FlexDocument, ParseOptions } from "./types.js";
2
- export declare function parseFlexMd(input: string, options?: ParseOptions): FlexDocument;
package/dist/parser.js DELETED
@@ -1,199 +0,0 @@
1
- import { splitTokensPreservingQuotes, unquote } from "./util.js";
2
- const HEADER_RE = /^\[\[(.+)\]\]\s*$/;
3
- const META_RE = /^@([^:]+):\s*(.*)$/;
4
- const PAYLOAD_DECL_RE = /^@payload:name:\s*(.+)\s*$/;
5
- function parseHeader(inner) {
6
- // Supports:
7
- // 1) [[message role=user id=m1 ts=...]]
8
- // 2) shorthand: [[user m1]] => type=message role=user id=m1
9
- const tokens = splitTokensPreservingQuotes(inner.trim()).map(unquote);
10
- if (tokens.length === 0)
11
- return { type: "message" };
12
- // shorthand: [[user m1]] or [[assistant m2]]
13
- if (tokens.length === 2 && !tokens[0].includes("=") && !tokens[1].includes("=")) {
14
- return { type: "message", role: tokens[0], id: tokens[1] };
15
- }
16
- const type = tokens[0];
17
- const out = { type };
18
- for (const t of tokens.slice(1)) {
19
- const idx = t.indexOf("=");
20
- if (idx <= 0)
21
- continue;
22
- const key = t.slice(0, idx).trim();
23
- const val = unquote(t.slice(idx + 1).trim());
24
- out[key] = val;
25
- }
26
- return out;
27
- }
28
- function parseMetaValue(key, value, arrayKeys, options) {
29
- const v = value.trim();
30
- // Handle array keys first
31
- if (arrayKeys.has(key)) {
32
- const parts = v
33
- .split(",")
34
- .map((p) => p.trim())
35
- .filter((p) => p.length > 0);
36
- return parts;
37
- }
38
- // Apply type mode
39
- const mode = options.metaTypeMode ?? "strings";
40
- if (mode === "schema" && options.metaSchema?.[key]) {
41
- return parseWithSchema(v, options.metaSchema[key]);
42
- }
43
- if (mode === "infer") {
44
- return inferType(v);
45
- }
46
- // Default: strings mode
47
- return v;
48
- }
49
- /**
50
- * Parse value according to schema type.
51
- */
52
- function parseWithSchema(value, type) {
53
- switch (type) {
54
- case "boolean":
55
- return value.toLowerCase() === "true";
56
- case "null":
57
- return null;
58
- case "number": {
59
- const num = Number(value);
60
- return isNaN(num) ? value : num;
61
- }
62
- default:
63
- return value;
64
- }
65
- }
66
- /**
67
- * Safely infer type from string value.
68
- */
69
- function inferType(value) {
70
- const lower = value.toLowerCase();
71
- // Boolean
72
- if (lower === "true")
73
- return true;
74
- if (lower === "false")
75
- return false;
76
- // Null
77
- if (lower === "null")
78
- return null;
79
- // Number (avoid leading zeros like "0012" unless it's just "0" or "0.xxx")
80
- if (/^-?\d+(\.\d+)?$/.test(value) && !/^0\d/.test(value)) {
81
- const num = Number(value);
82
- if (!isNaN(num))
83
- return num;
84
- }
85
- return value;
86
- }
87
- function tryParsePayload(lang, raw) {
88
- const l = (lang ?? "").toLowerCase();
89
- if (l === "json") {
90
- try {
91
- const value = JSON.parse(raw);
92
- return { lang, value, raw };
93
- }
94
- catch (e) {
95
- return { lang, value: raw, raw, parseError: String(e?.message ?? e) };
96
- }
97
- }
98
- return { lang, value: raw, raw };
99
- }
100
- export function parseFlexMd(input, options = {}) {
101
- const arrayKeys = new Set((options.arrayMetaKeys ?? ["tags", "refs"]).map((s) => s.trim()));
102
- const lines = input.split("\n");
103
- const endsWithNewline = input.endsWith("\n");
104
- const frames = [];
105
- let cur = null;
106
- // body accumulator for current frame (excluding meta and payload blocks)
107
- let bodyLines = [];
108
- // payload state: if declared, next code fence becomes its payload
109
- let pendingPayloadName = null;
110
- function flushCurrent() {
111
- if (!cur)
112
- return;
113
- const body = bodyLines.join("\n");
114
- // Preserve original trailing newline behavior inside body (best-effort):
115
- cur.body_md = body.length ? body + "\n" : "";
116
- // Trim to empty if it's only whitespace/newlines
117
- if (cur.body_md.trim().length === 0)
118
- delete cur.body_md;
119
- frames.push(cur);
120
- cur = null;
121
- bodyLines = [];
122
- pendingPayloadName = null;
123
- }
124
- let i = 0;
125
- while (i < lines.length) {
126
- const line = lines[i];
127
- // Frame header
128
- const hm = line.match(HEADER_RE);
129
- if (hm) {
130
- flushCurrent();
131
- cur = { ...parseHeader(hm[1]) };
132
- i++;
133
- continue;
134
- }
135
- // If we haven't seen a header yet, start an implicit frame.
136
- cur ??= { type: "message" };
137
- // Payload declaration
138
- const pm = line.match(PAYLOAD_DECL_RE);
139
- if (pm) {
140
- pendingPayloadName = pm[1].trim();
141
- i++;
142
- continue;
143
- }
144
- // If a payload was declared, capture the next fenced code block
145
- if (pendingPayloadName) {
146
- const fenceStart = line.match(/^(```|~~~)\s*([A-Za-z0-9_-]+)?\s*$/);
147
- if (fenceStart) {
148
- const fence = fenceStart[1];
149
- const lang = fenceStart[2]?.trim();
150
- const rawLines = [];
151
- i++;
152
- while (i < lines.length && lines[i].trimEnd() !== fence) {
153
- rawLines.push(lines[i]);
154
- i++;
155
- }
156
- // consume closing fence if present
157
- if (i < lines.length && lines[i].trimEnd() === fence)
158
- i++;
159
- const raw = rawLines.join("\n");
160
- cur.payloads ??= {};
161
- cur.payloads[pendingPayloadName] = tryParsePayload(lang, raw);
162
- pendingPayloadName = null;
163
- continue;
164
- }
165
- else {
166
- // payload declared but no fence; treat as body line
167
- bodyLines.push(line);
168
- pendingPayloadName = null;
169
- i++;
170
- continue;
171
- }
172
- }
173
- // Metadata line
174
- const mm = line.match(META_RE);
175
- if (mm) {
176
- const key = mm[1].trim();
177
- const value = mm[2] ?? "";
178
- cur.meta ??= {};
179
- cur.meta[key] = parseMetaValue(key, value, arrayKeys, options);
180
- i++;
181
- continue;
182
- }
183
- // Default: part of body
184
- bodyLines.push(line);
185
- i++;
186
- }
187
- flushCurrent();
188
- // Preserve overall trailing newline more closely: if input had no final newline,
189
- // avoid forcing one by trimming last frame body newline.
190
- if (!endsWithNewline && frames.length > 0) {
191
- const last = frames[frames.length - 1];
192
- if (typeof last.body_md === "string" && last.body_md.endsWith("\n")) {
193
- last.body_md = last.body_md.slice(0, -1);
194
- if (last.body_md.trim().length === 0)
195
- delete last.body_md;
196
- }
197
- }
198
- return { frames };
199
- }
@@ -1,6 +0,0 @@
1
- import type { ParsedList } from "../types.js";
2
- /**
3
- * Parse nested Markdown lists into a tree structure.
4
- * Supports both unordered (-) and ordered (1.) lists.
5
- */
6
- export declare function parseList(md: string): ParsedList | null;
@@ -1,36 +0,0 @@
1
- /**
2
- * Parse nested Markdown lists into a tree structure.
3
- * Supports both unordered (-) and ordered (1.) lists.
4
- */
5
- export function parseList(md) {
6
- const lines = md.split("\n");
7
- const isListLine = (s) => /^\s*(-\s+|\d+\.\s+)/.test(s);
8
- const listLines = lines.filter(isListLine);
9
- if (!listLines.length)
10
- return null;
11
- // Determine if ordered by presence of numbered item
12
- const ordered = listLines.some(l => /^\s*\d+\.\s+/.test(l));
13
- const root = [];
14
- const stack = [];
15
- for (const line of listLines) {
16
- const indent = (line.match(/^\s*/)?.[0].length) ?? 0;
17
- const mOrdered = line.match(/^\s*(\d+)\.\s+(.*)$/);
18
- const mUn = line.match(/^\s*-\s+(.*)$/);
19
- const text = (mOrdered?.[2] ?? mUn?.[1] ?? "").trim();
20
- const item = { text, children: [] };
21
- if (mOrdered)
22
- item.index = Number(mOrdered[1]);
23
- // Pop stack until we find parent (lower indent)
24
- while (stack.length && stack[stack.length - 1].indent >= indent) {
25
- stack.pop();
26
- }
27
- if (!stack.length) {
28
- root.push(item);
29
- }
30
- else {
31
- stack[stack.length - 1].item.children.push(item);
32
- }
33
- stack.push({ indent, item });
34
- }
35
- return { kind: "list", ordered, items: root };
36
- }
@@ -1,10 +0,0 @@
1
- import type { ParsedTable } from "../types.js";
2
- /**
3
- * Parse a single GFM pipe table block.
4
- * Returns null if the block is not a valid table.
5
- */
6
- export declare function parsePipeTable(block: string): ParsedTable | null;
7
- /**
8
- * Extract all pipe tables from a Markdown document.
9
- */
10
- export declare function extractAllTables(md: string): ParsedTable[];
@@ -1,58 +0,0 @@
1
- /**
2
- * Parse a single GFM pipe table block.
3
- * Returns null if the block is not a valid table.
4
- */
5
- export function parsePipeTable(block) {
6
- const lines = block.split("\n").map(l => l.trim()).filter(Boolean);
7
- if (lines.length < 2)
8
- return null;
9
- const header = lines[0];
10
- const sep = lines[1];
11
- if (!header || !header.includes("|"))
12
- return null;
13
- if (!sep || !sep.match(/^\|?[\s:-]+\|/))
14
- return null;
15
- const parseRow = (row) => row.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim());
16
- const columns = parseRow(header);
17
- const rows = lines.slice(2).map(parseRow);
18
- // Normalize row lengths
19
- for (const r of rows) {
20
- while (r.length < columns.length)
21
- r.push("");
22
- }
23
- // Detect ordered table (first column is "#")
24
- const isOrdered = columns[0] === "#";
25
- const kind = isOrdered ? "ordered_table" : "table";
26
- return { kind, columns, rows };
27
- }
28
- /**
29
- * Extract all pipe tables from a Markdown document.
30
- */
31
- export function extractAllTables(md) {
32
- const tables = [];
33
- const lines = md.split("\n");
34
- let i = 0;
35
- while (i < lines.length) {
36
- const line = lines[i];
37
- // Look for potential table start (contains |)
38
- if (line && line.includes("|")) {
39
- // Collect consecutive lines that look like table rows
40
- const tableLines = [];
41
- let j = i;
42
- while (j < lines.length && lines[j] && lines[j].includes("|")) {
43
- tableLines.push(lines[j]);
44
- j++;
45
- }
46
- if (tableLines.length >= 2) {
47
- const table = parsePipeTable(tableLines.join("\n"));
48
- if (table) {
49
- tables.push(table);
50
- i = j;
51
- continue;
52
- }
53
- }
54
- }
55
- i++;
56
- }
57
- return tables;
58
- }
@@ -1,2 +0,0 @@
1
- import type { FlexDocument, StringifyOptions } from "./types.js";
2
- export declare function stringifyFlexMd(doc: FlexDocument, options?: StringifyOptions): string;
package/dist/stringify.js DELETED
@@ -1,110 +0,0 @@
1
- import { isBlank, isEmptyObject, quoteIfNeeded } from "./util.js";
2
- function metaValueToString(key, v, arrayKeys) {
3
- if (v === null)
4
- return null;
5
- if (Array.isArray(v)) {
6
- if (v.length === 0)
7
- return null;
8
- // Render arrays as comma-separated by default for known keys, otherwise JSON
9
- if (arrayKeys.has(key))
10
- return v.join(", ");
11
- return JSON.stringify(v);
12
- }
13
- if (typeof v === "string")
14
- return v;
15
- if (typeof v === "number" || typeof v === "boolean")
16
- return String(v);
17
- // Fallback
18
- return String(v);
19
- }
20
- function shouldSkipMetaValue(v) {
21
- if (v === null || v === undefined)
22
- return true;
23
- if (typeof v === "string" && v.trim() === "")
24
- return true;
25
- if (Array.isArray(v) && v.length === 0)
26
- return true;
27
- return false;
28
- }
29
- function buildHeader(frame) {
30
- // [[type key=value ...]]
31
- const parts = [`[[${frame.type}`];
32
- // include common attrs if present (role/id/ts) and any other top-level string attrs
33
- const knownOrder = ["role", "id", "ts"];
34
- for (const k of knownOrder) {
35
- const val = frame[k];
36
- if (typeof val === "string" && val.trim().length) {
37
- parts.push(`${String(k)}=${quoteIfNeeded(val)}`);
38
- }
39
- }
40
- // If the user adds extra header-like attributes, you can support them by convention:
41
- // we intentionally do NOT auto-include unknown keys to keep JSON schema clean.
42
- return parts.join(" ") + "]]";
43
- }
44
- export function stringifyFlexMd(doc, options = {}) {
45
- const skipEmpty = options.skipEmpty ?? true;
46
- const fence = options.fence ?? "```";
47
- const arrayKeys = new Set((options.arrayMetaKeys ?? ["tags", "refs"]).map((s) => s.trim()));
48
- const out = [];
49
- for (let idx = 0; idx < doc.frames.length; idx++) {
50
- const frame = doc.frames[idx];
51
- out.push(buildHeader(frame));
52
- // META
53
- if (!isEmptyObject(frame.meta)) {
54
- const keys = Object.keys(frame.meta ?? {});
55
- for (const k of keys) {
56
- const v = frame.meta[k];
57
- if (skipEmpty && shouldSkipMetaValue(v))
58
- continue;
59
- const rendered = metaValueToString(k, v, arrayKeys);
60
- if (skipEmpty && (rendered === null || rendered.trim() === ""))
61
- continue;
62
- out.push(`@${k}: ${rendered ?? ""}`.trimEnd());
63
- }
64
- }
65
- // BODY
66
- if (typeof frame.body_md === "string") {
67
- const body = frame.body_md;
68
- if (!(skipEmpty && isBlank(body))) {
69
- // Keep body verbatim, but avoid double-blanking: ensure we don't accidentally
70
- // merge with next header by always preserving its internal newlines.
71
- const normalized = body.endsWith("\n") ? body.slice(0, -1) : body;
72
- if (normalized.length > 0)
73
- out.push(normalized);
74
- }
75
- }
76
- // PAYLOADS
77
- const payloads = frame.payloads ?? {};
78
- const payloadNames = Object.keys(payloads);
79
- if (!(skipEmpty && payloadNames.length === 0)) {
80
- for (const name of payloadNames) {
81
- const p = payloads[name];
82
- // Skip empty payload if requested
83
- if (skipEmpty &&
84
- (p == null ||
85
- (typeof p.raw === "string" && p.raw.trim() === "") ||
86
- (p.value == null && (p.raw ?? "").trim() === ""))) {
87
- continue;
88
- }
89
- out.push(`@payload:name: ${name}`);
90
- const lang = (p.lang ?? "").trim();
91
- const header = lang ? `${fence}${lang}` : fence;
92
- out.push(header);
93
- // Prefer raw if present; if missing raw but value exists, serialize value
94
- const raw = typeof p.raw === "string" && p.raw.length > 0
95
- ? p.raw
96
- : p.value !== undefined
97
- ? typeof p.value === "string"
98
- ? p.value
99
- : JSON.stringify(p.value, null, 2)
100
- : "";
101
- out.push(raw);
102
- out.push(fence);
103
- }
104
- }
105
- // Frame separator: blank line between frames (readable) unless last
106
- if (idx !== doc.frames.length - 1)
107
- out.push("");
108
- }
109
- return out.join("\n") + "\n";
110
- }