flex-md 1.1.0 → 2.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.
@@ -0,0 +1,36 @@
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
+ }
@@ -0,0 +1,10 @@
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[];
@@ -0,0 +1,58 @@
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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,328 @@
1
+ import {
2
+ // Layer A
3
+ parseFlexMd, stringifyFlexMd, validateFlexMd,
4
+ // Layer B
5
+ parseOutputFormatSpec, stringifyOutputFormatSpec, enrichInstructions, validateOutput, extractOutput,
6
+ // Outline
7
+ buildOutline, renderOutline,
8
+ // Parsers
9
+ parseList, parsePipeTable, extractAllTables,
10
+ // Layer C
11
+ detectObjects, parseAny } from "./index.js";
12
+ let passed = 0;
13
+ let failed = 0;
14
+ function test(name, fn) {
15
+ try {
16
+ fn();
17
+ console.log(`✅ ${name}`);
18
+ passed++;
19
+ }
20
+ catch (e) {
21
+ console.error(`❌ ${name}`);
22
+ console.error(e);
23
+ failed++;
24
+ }
25
+ }
26
+ function assert(condition, message) {
27
+ if (!condition) {
28
+ throw new Error(`Assertion failed: ${message}`);
29
+ }
30
+ }
31
+ console.log("🧪 Running FlexMD v1.1 Tests\n");
32
+ // ============================================================================
33
+ // Layer A Tests
34
+ // ============================================================================
35
+ console.log("📦 Layer A - FlexMD Frames\n");
36
+ test("Parse basic FlexMD frame", () => {
37
+ const md = `[[message role=user id=m1]]
38
+ @tags: auth, login
39
+ Hello world
40
+ `;
41
+ const doc = parseFlexMd(md);
42
+ assert(doc.frames.length === 1, "Should have 1 frame");
43
+ assert(doc.frames[0].type === "message", "Type should be message");
44
+ assert(doc.frames[0].role === "user", "Role should be user");
45
+ assert(Array.isArray(doc.frames[0].meta?.tags), "Tags should be array");
46
+ });
47
+ test("Stringify FlexMD frame", () => {
48
+ const doc = {
49
+ frames: [{
50
+ type: "message",
51
+ role: "user",
52
+ id: "m1",
53
+ meta: { tags: ["auth", "login"] },
54
+ body_md: "Hello world\n"
55
+ }]
56
+ };
57
+ const md = stringifyFlexMd(doc);
58
+ assert(md.includes("[[message"), "Should contain frame header");
59
+ assert(md.includes("@tags:"), "Should contain meta");
60
+ });
61
+ test("Parse with type inference", () => {
62
+ const md = `[[message]]
63
+ @priority: 5
64
+ @enabled: true
65
+ @value: null
66
+ Body text
67
+ `;
68
+ const doc = parseFlexMd(md, { metaTypeMode: "infer" });
69
+ assert(doc.frames[0].meta?.priority === 5, "Should infer number");
70
+ assert(doc.frames[0].meta?.enabled === true, "Should infer boolean");
71
+ assert(doc.frames[0].meta?.value === null, "Should infer null");
72
+ });
73
+ test("Validate FlexMD", () => {
74
+ const valid = `[[message role=user]]
75
+ Hello
76
+ `;
77
+ const result = validateFlexMd(valid);
78
+ assert(result.valid === true, "Should be valid");
79
+ });
80
+ // ============================================================================
81
+ // Layer B Tests
82
+ // ============================================================================
83
+ console.log("\n📋 Layer B - Output Format Spec\n");
84
+ test("Parse OFS", () => {
85
+ const md = `## Output format (Markdown)
86
+ Include these sections somewhere (order does not matter):
87
+
88
+ - Short answer — prose
89
+ - Reasoning — ordered list
90
+ - Assumptions — list
91
+ `;
92
+ const spec = parseOutputFormatSpec(md);
93
+ assert(spec.sections.length === 3, "Should have 3 sections");
94
+ assert(spec.sections[0].name === "Short answer", "First section name");
95
+ assert(spec.sections[0].kind === "prose", "First section kind");
96
+ assert(spec.sections[1].kind === "ordered_list", "Second section kind");
97
+ });
98
+ test("Stringify OFS", () => {
99
+ const spec = {
100
+ descriptorType: "output_format_spec",
101
+ format: "markdown",
102
+ sectionOrderMatters: false,
103
+ sections: [
104
+ { name: "Answer", kind: "prose" }
105
+ ],
106
+ tablesOptional: true,
107
+ tables: [],
108
+ emptySectionValue: "None"
109
+ };
110
+ const md = stringifyOutputFormatSpec(spec);
111
+ assert(md.includes("Answer — prose"), "Should contain section");
112
+ assert(md.includes("None"), "Should contain empty value");
113
+ });
114
+ test("Enrich instructions", () => {
115
+ const spec = {
116
+ descriptorType: "output_format_spec",
117
+ format: "markdown",
118
+ sectionOrderMatters: false,
119
+ sections: [
120
+ { name: "Steps", kind: "ordered_list" },
121
+ { name: "Notes", kind: "list" }
122
+ ],
123
+ tablesOptional: true,
124
+ tables: [],
125
+ emptySectionValue: "None"
126
+ };
127
+ const instructions = enrichInstructions(spec);
128
+ assert(instructions.includes("numbered items"), "Should mention ordered lists");
129
+ assert(instructions.includes("bullets"), "Should mention lists");
130
+ assert(instructions.includes("None"), "Should mention empty value");
131
+ });
132
+ test("Validate output against OFS", () => {
133
+ const spec = {
134
+ descriptorType: "output_format_spec",
135
+ format: "markdown",
136
+ sectionOrderMatters: false,
137
+ sections: [
138
+ { name: "Answer", kind: "prose" }
139
+ ],
140
+ tablesOptional: true,
141
+ tables: []
142
+ };
143
+ const md = `## Answer\nThe answer is 42.`;
144
+ const result = validateOutput(md, spec);
145
+ assert(result.ok === true, "Should validate successfully");
146
+ });
147
+ test("Extract output from OFS", () => {
148
+ const spec = {
149
+ descriptorType: "output_format_spec",
150
+ format: "markdown",
151
+ sectionOrderMatters: false,
152
+ sections: [
153
+ { name: "Answer", kind: "prose" },
154
+ { name: "Steps", kind: "ordered_list" }
155
+ ],
156
+ tablesOptional: true,
157
+ tables: []
158
+ };
159
+ const md = `## Answer
160
+ The answer is 42.
161
+
162
+ ## Steps
163
+ 1. First step
164
+ 2. Second step
165
+ `;
166
+ const extracted = extractOutput(md, spec);
167
+ assert(extracted.sectionsByName["Answer"].md.includes("42"), "Should extract answer");
168
+ assert(extracted.sectionsByName["Steps"].list?.ordered === true, "Should parse ordered list");
169
+ assert(extracted.sectionsByName["Steps"].list?.items.length === 2, "Should have 2 items");
170
+ });
171
+ // ============================================================================
172
+ // Outline Tests
173
+ // ============================================================================
174
+ console.log("\n🌳 Outline & Tree Building\n");
175
+ test("Build outline from headings", () => {
176
+ const md = `# Title
177
+ Content
178
+
179
+ ## Section 1
180
+ More content
181
+
182
+ ### Subsection 1.1
183
+ Details
184
+
185
+ ## Section 2
186
+ Other content
187
+ `;
188
+ const outline = buildOutline(md);
189
+ assert(outline.nodes.length === 1, "Should have 1 root node");
190
+ assert(outline.nodes[0].title === "Title", "Root title");
191
+ assert(outline.nodes[0].children.length === 2, "Should have 2 children");
192
+ assert(outline.nodes[0].children[0].children.length === 1, "First child has 1 child");
193
+ });
194
+ test("Render outline back to Markdown", () => {
195
+ const outline = {
196
+ type: "md_outline",
197
+ nodes: [{
198
+ title: "Title",
199
+ level: 1,
200
+ key: "title",
201
+ content_md: "Content\n",
202
+ children: [{
203
+ title: "Section",
204
+ level: 2,
205
+ key: "section",
206
+ content_md: "More\n",
207
+ children: []
208
+ }]
209
+ }]
210
+ };
211
+ const md = renderOutline(outline);
212
+ assert(md.includes("# Title"), "Should have h1");
213
+ assert(md.includes("## Section"), "Should have h2");
214
+ assert(!md.includes("key"), "Should not include internal keys");
215
+ });
216
+ // ============================================================================
217
+ // Parser Tests
218
+ // ============================================================================
219
+ console.log("\n📝 List & Table Parsers\n");
220
+ test("Parse unordered list", () => {
221
+ const md = `- Item 1
222
+ - Nested 1.1
223
+ - Item 2
224
+ `;
225
+ const list = parseList(md);
226
+ assert(list !== null, "Should parse list");
227
+ assert(list.ordered === false, "Should be unordered");
228
+ assert(list.items.length === 2, "Should have 2 items");
229
+ assert(list.items[0].children.length === 1, "First item has 1 child");
230
+ });
231
+ test("Parse ordered list", () => {
232
+ const md = `1. First
233
+ 1. Sub-first
234
+ 2. Second
235
+ `;
236
+ const list = parseList(md);
237
+ assert(list !== null, "Should parse list");
238
+ assert(list.ordered === true, "Should be ordered");
239
+ assert(list.items.length === 2, "Should have 2 items");
240
+ assert(list.items[0].index === 1, "First item index");
241
+ });
242
+ test("Parse pipe table", () => {
243
+ const md = `| Name | Age |
244
+ |------|-----|
245
+ | Alice | 30 |
246
+ | Bob | 25 |
247
+ `;
248
+ const table = parsePipeTable(md);
249
+ assert(table !== null, "Should parse table");
250
+ assert(table.columns.length === 2, "Should have 2 columns");
251
+ assert(table.rows.length === 2, "Should have 2 rows");
252
+ assert(table.kind === "table", "Should be regular table");
253
+ });
254
+ test("Parse ordered table", () => {
255
+ const md = `| # | Task | Status |
256
+ |---|------|--------|
257
+ | 1 | Do it | Done |
258
+ | 2 | Check | Pending |
259
+ `;
260
+ const table = parsePipeTable(md);
261
+ assert(table !== null, "Should parse table");
262
+ assert(table.kind === "ordered_table", "Should be ordered table");
263
+ assert(table.columns[0] === "#", "First column should be #");
264
+ });
265
+ test("Extract all tables", () => {
266
+ const md = `Some text
267
+
268
+ | A | B |
269
+ |---|---|
270
+ | 1 | 2 |
271
+
272
+ More text
273
+
274
+ | C | D |
275
+ |---|---|
276
+ | 3 | 4 |
277
+ `;
278
+ const tables = extractAllTables(md);
279
+ assert(tables.length === 2, "Should find 2 tables");
280
+ });
281
+ // ============================================================================
282
+ // Layer C Tests
283
+ // ============================================================================
284
+ console.log("\n🔍 Layer C - Detection & Extraction\n");
285
+ test("Detect flexmd fence", () => {
286
+ const text = `Some text
287
+ \`\`\`flexmd
288
+ [[message]]
289
+ Hello
290
+ \`\`\`
291
+ More text
292
+ `;
293
+ const detected = detectObjects(text);
294
+ assert(detected.length > 0, "Should detect object");
295
+ assert(detected[0].kind === "flexmd_fence", "Should be flexmd fence");
296
+ assert(detected[0].confidence === 1.0, "Should have high confidence");
297
+ });
298
+ test("Detect JSON FlexDocument", () => {
299
+ const text = `\`\`\`json
300
+ {"frames": [{"type": "message", "body_md": "Hello"}]}
301
+ \`\`\`
302
+ `;
303
+ const detected = detectObjects(text);
304
+ assert(detected.length > 0, "Should detect object");
305
+ assert(detected[0].kind === "flexdoc_json_fence", "Should be JSON FlexDoc");
306
+ });
307
+ test("Parse any text", () => {
308
+ const text = `Random text
309
+ \`\`\`flexmd
310
+ [[message role=user]]
311
+ Hello
312
+ \`\`\`
313
+ More text
314
+ `;
315
+ const result = parseAny(text);
316
+ assert(result.flexDocs.length === 1, "Should extract 1 FlexDoc");
317
+ assert(result.flexDocs[0].frames.length === 1, "Should have 1 frame");
318
+ });
319
+ // ============================================================================
320
+ // Summary
321
+ // ============================================================================
322
+ console.log("\n" + "=".repeat(50));
323
+ console.log(`✅ Passed: ${passed}`);
324
+ console.log(`❌ Failed: ${failed}`);
325
+ console.log("=" + "=".repeat(50));
326
+ if (failed > 0) {
327
+ throw new Error("Tests failed");
328
+ }
package/dist/types.d.ts CHANGED
@@ -22,6 +22,8 @@ export interface FlexFrame {
22
22
  payloads?: Record<string, FlexPayload>;
23
23
  }
24
24
  export interface FlexDocument {
25
+ title?: string;
26
+ meta?: Record<string, FlexMetaValue>;
25
27
  frames: FlexFrame[];
26
28
  }
27
29
  export interface ParseOptions {
@@ -30,6 +32,18 @@ export interface ParseOptions {
30
32
  * Default: ["tags", "refs"]
31
33
  */
32
34
  arrayMetaKeys?: string[];
35
+ /**
36
+ * How to parse metadata values:
37
+ * - "strings": all values remain strings (default)
38
+ * - "infer": safely infer boolean, null, number
39
+ * - "schema": use metaSchema to determine types
40
+ */
41
+ metaTypeMode?: "strings" | "infer" | "schema";
42
+ /**
43
+ * Schema for metadata value types (used when metaTypeMode="schema")
44
+ * Example: { priority: "number", enabled: "boolean" }
45
+ */
46
+ metaSchema?: Record<string, "string" | "number" | "boolean" | "null">;
33
47
  }
34
48
  export interface StringifyOptions {
35
49
  /**
@@ -56,3 +70,71 @@ export interface FlexValidationResult {
56
70
  valid: boolean;
57
71
  errors: FlexValidationError[];
58
72
  }
73
+ export type SectionKind = "prose" | "list" | "ordered_list";
74
+ export interface OfsSection {
75
+ name: string;
76
+ kind: SectionKind;
77
+ hint?: string;
78
+ }
79
+ export type TableKind = "table" | "ordered_table";
80
+ export interface OfsTable {
81
+ columns: string[];
82
+ kind: TableKind;
83
+ by?: string;
84
+ }
85
+ export interface OutputFormatSpec {
86
+ descriptorType: "output_format_spec";
87
+ format: "markdown";
88
+ sectionOrderMatters: false;
89
+ sections: OfsSection[];
90
+ tablesOptional: boolean;
91
+ tables: OfsTable[];
92
+ emptySectionValue?: string;
93
+ }
94
+ export interface MdNode {
95
+ title: string;
96
+ level: number;
97
+ key: string;
98
+ id?: string;
99
+ content_md: string;
100
+ children: MdNode[];
101
+ }
102
+ export interface MdOutline {
103
+ type: "md_outline";
104
+ nodes: MdNode[];
105
+ }
106
+ export interface ListItem {
107
+ text: string;
108
+ index?: number;
109
+ children: ListItem[];
110
+ }
111
+ export interface ParsedList {
112
+ kind: "list";
113
+ ordered: boolean;
114
+ items: ListItem[];
115
+ }
116
+ export interface ParsedTable {
117
+ kind: "table" | "ordered_table";
118
+ by?: string;
119
+ columns: string[];
120
+ rows: string[][];
121
+ }
122
+ export interface ExtractedResult {
123
+ outline: MdOutline;
124
+ sectionsByName: Record<string, {
125
+ nodeKey: string;
126
+ nodeLevel: number;
127
+ md: string;
128
+ list?: ParsedList;
129
+ }>;
130
+ tables: ParsedTable[];
131
+ }
132
+ export type DetectedKind = "flexmd_fence" | "flexdoc_json_fence" | "raw_flexmd" | "markdown_snippet" | "none";
133
+ export interface DetectedObject {
134
+ kind: DetectedKind;
135
+ confidence: number;
136
+ start: number;
137
+ end: number;
138
+ raw: string;
139
+ inner?: string;
140
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flex-md",
3
- "version": "1.1.0",
4
- "description": "Parse and stringify FlexMD Frames (semi-structured Markdown) to/from JSON.",
3
+ "version": "2.0.0",
4
+ "description": "Parse and stringify FlexMD: semi-structured Markdown with three powerful layers - Frames, Output Format Spec (OFS), and Detection/Extraction.",
5
5
  "license": "MIT",
6
6
  "author": "",
7
7
  "keywords": [
@@ -10,7 +10,11 @@
10
10
  "serialization",
11
11
  "llm",
12
12
  "prompting",
13
- "semi-structured"
13
+ "semi-structured",
14
+ "ofs",
15
+ "output-format",
16
+ "detection",
17
+ "extraction"
14
18
  ],
15
19
  "type": "module",
16
20
  "main": "./dist/index.cjs",
@@ -24,11 +28,12 @@
24
28
  }
25
29
  },
26
30
  "files": [
27
- "dist"
31
+ "dist",
32
+ "SPEC.md"
28
33
  ],
29
34
  "scripts": {
30
35
  "build": "tsc && node ./scripts/cjs-bridge.mjs",
31
- "test": "node ./dist/index.js"
36
+ "test": "npm run build && node ./dist/test-runner.js"
32
37
  },
33
38
  "devDependencies": {
34
39
  "typescript": "^5.6.3"