note-outline.mjs file-level

at sha256:0 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:9 feat(calendar): hosted bridge/gateway route parity and timeline noteRec… · aaronrene · Jun 19, 2026
1 /**
2 * Derived read-only outline for a single Markdown note.
3 *
4 * This module intentionally has no storage, search, vector, memory, MCP, Hub, or
5 * PageIndex behavior. It parses current Markdown content and returns a minimal
6 * data-only outline.
7 */
8
9 import path from 'path';
10 import { unified } from 'unified';
11 import remarkParse from 'remark-parse';
12 import { normalizeSlug, parseFrontmatterAndBody } from './vault.mjs';
13
14 export const NOTE_OUTLINE_SCHEMA = 'knowtation.note_outline/v1';
15 export const MAX_NOTE_OUTLINE_INPUT_CHARS = 1_000_000;
16 export const MAX_NOTE_OUTLINE_HEADINGS = 500;
17
18 const parser = unified().use(remarkParse);
19
20 /**
21 * Build a NoteOutline from a raw Markdown file string.
22 * @param {string} notePath
23 * @param {string} markdown
24 * @param {{ maxInputChars?: number, maxHeadings?: number }} [options]
25 * @returns {{ schema: string, path: string, title: string|null, headings: { level: number, text: string, id: string }[], truncated: boolean }}
26 */
27 export function buildNoteOutlineFromMarkdown(notePath, markdown, options = {}) {
28 if (typeof markdown !== 'string') {
29 throw new TypeError('buildNoteOutlineFromMarkdown: markdown must be a string');
30 }
31 const { frontmatter, body } = parseFrontmatterAndBody(markdown);
32 return buildNoteOutline({ path: notePath, frontmatter, body }, options);
33 }
34
35 /**
36 * Build a NoteOutline from a parsed vault note.
37 * @param {{ path: string, frontmatter?: Record<string, unknown>, body: string }} note
38 * @param {{ maxInputChars?: number, maxHeadings?: number }} [options]
39 * @returns {{ schema: string, path: string, title: string|null, headings: { level: number, text: string, id: string }[], truncated: boolean }}
40 */
41 export function buildNoteOutline(note, options = {}) {
42 if (note == null || typeof note !== 'object') {
43 throw new TypeError('buildNoteOutline: note is required');
44 }
45 const safePath = normalizeOutlinePath(note.path);
46 if (typeof note.body !== 'string') {
47 throw new TypeError('buildNoteOutline: note.body must be a string');
48 }
49
50 const maxInputChars = normalizePositiveInteger(
51 options.maxInputChars,
52 MAX_NOTE_OUTLINE_INPUT_CHARS,
53 'maxInputChars'
54 );
55 const maxHeadings = normalizePositiveInteger(
56 options.maxHeadings,
57 MAX_NOTE_OUTLINE_HEADINGS,
58 'maxHeadings'
59 );
60
61 if (note.body.length > maxInputChars) {
62 throw new Error(
63 `Note outline input exceeds ${maxInputChars} characters; refusing to parse unbounded Markdown.`
64 );
65 }
66
67 const tree = parser.parse(note.body);
68 const headingNodes = [];
69 collectHeadings(tree, headingNodes);
70
71 const visibleHeadings = headingNodes.slice(0, maxHeadings);
72 const headings = visibleHeadings.map((headingNode, index) => {
73 const level = normalizeHeadingDepth(headingNode.depth);
74 const text = normalizeHeadingText(extractPlainText(headingNode));
75 return {
76 level,
77 text,
78 id: headingId(level, text, index + 1),
79 };
80 });
81
82 return {
83 schema: NOTE_OUTLINE_SCHEMA,
84 path: safePath,
85 title: displayTitle(note.frontmatter, safePath),
86 headings,
87 truncated: headingNodes.length > maxHeadings,
88 };
89 }
90
91 /**
92 * @param {unknown} value
93 * @param {number} fallback
94 * @param {string} name
95 * @returns {number}
96 */
97 function normalizePositiveInteger(value, fallback, name) {
98 if (value == null) return fallback;
99 const n = Number(value);
100 if (!Number.isInteger(n) || n < 1) {
101 throw new TypeError(`buildNoteOutline: ${name} must be a positive integer`);
102 }
103 return n;
104 }
105
106 /**
107 * @param {unknown} rawPath
108 * @returns {string}
109 */
110 function normalizeOutlinePath(rawPath) {
111 if (typeof rawPath !== 'string' || rawPath.trim() === '') {
112 throw new TypeError('buildNoteOutline: note.path must be a non-empty string');
113 }
114 const forward = rawPath.trim().replace(/\\/g, '/');
115 if (path.isAbsolute(rawPath) || /^[A-Za-z]:\//.test(forward)) {
116 throw new Error(`Invalid path: path must be vault-relative (${rawPath})`);
117 }
118 const parts = forward.split('/').filter(Boolean);
119 if (parts.includes('..')) {
120 throw new Error(`Invalid path: path cannot escape vault (${rawPath})`);
121 }
122 return parts.join('/');
123 }
124
125 /**
126 * @param {Record<string, unknown>|undefined} frontmatter
127 * @param {string} notePath
128 * @returns {string|null}
129 */
130 function displayTitle(frontmatter, notePath) {
131 const fm = frontmatter && typeof frontmatter === 'object' && !Array.isArray(frontmatter)
132 ? frontmatter
133 : {};
134 if (fm.title != null && String(fm.title).trim() !== '') {
135 return String(fm.title).trim();
136 }
137 const base = path.basename(notePath).replace(/\.(md|markdown)$/i, '');
138 const cleaned = base.replace(/[-_]+/g, ' ').trim();
139 return cleaned || null;
140 }
141
142 /**
143 * @param {unknown} depth
144 * @returns {number}
145 */
146 function normalizeHeadingDepth(depth) {
147 const n = Number(depth);
148 if (Number.isInteger(n) && n >= 1 && n <= 6) return n;
149 return 1;
150 }
151
152 /**
153 * @param {unknown} node
154 * @param {unknown[]} out
155 */
156 function collectHeadings(node, out) {
157 if (node == null || typeof node !== 'object') return;
158 if (node.type === 'heading') {
159 out.push(node);
160 return;
161 }
162 if (Array.isArray(node.children)) {
163 for (const child of node.children) {
164 collectHeadings(child, out);
165 }
166 }
167 }
168
169 /**
170 * @param {unknown} node
171 * @returns {string}
172 */
173 function extractPlainText(node) {
174 if (node == null || typeof node !== 'object') return '';
175 if (node.type === 'text' || node.type === 'inlineCode' || node.type === 'html') {
176 return String(node.value ?? '');
177 }
178 if (node.type === 'break') return ' ';
179 if ((node.type === 'image' || node.type === 'imageReference') && node.alt != null) {
180 return String(node.alt);
181 }
182 if (!Array.isArray(node.children)) return '';
183 return node.children.map((child) => extractPlainText(child)).join(' ');
184 }
185
186 /**
187 * @param {string} raw
188 * @returns {string}
189 */
190 function normalizeHeadingText(raw) {
191 return String(raw)
192 .normalize('NFC')
193 .replace(/\s+/g, ' ')
194 .trim();
195 }
196
197 /**
198 * @param {number} level
199 * @param {string} text
200 * @param {number} ordinal
201 * @returns {string}
202 */
203 function headingId(level, text, ordinal) {
204 const slug = normalizeSlug(text) || 'heading';
205 return `h${level}-${slug}-${String(ordinal).padStart(4, '0')}`;
206 }