@kenjura/ursa 0.10.0 → 0.33.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.
@@ -1,69 +1,229 @@
1
1
  import dirTree from "directory-tree";
2
- import { extname, basename } from "path";
2
+ import { extname, basename, join, dirname } from "path";
3
+ import { existsSync } from "fs";
3
4
 
4
- export async function getAutomenu(source) {
5
- const tree = dirTree(source);
6
- const menuItems = [];
5
+ // Icon extensions to check for custom icons
6
+ const ICON_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico'];
7
7
 
8
- /** order of menu items:
9
- * - Home
10
- * - Top-level folders A-Z
11
- * - Top-level files A-Z
12
- **/
8
+ // Default icons (using emoji for simplicity, can be replaced with SVG)
9
+ const FOLDER_ICON = '📁';
10
+ const DOCUMENT_ICON = '📄';
11
+ const HOME_ICON = '🏠';
13
12
 
14
- menuItems.push({
15
- path: "/",
16
- name: "Home",
17
- type: "file",
18
- });
13
+ // Index file extensions to check for folder links
14
+ const INDEX_EXTENSIONS = ['.md', '.txt', '.yml', '.yaml'];
19
15
 
20
- const topLevelItems = tree.children.sort(childSorter);
21
- const topLevelHtml = topLevelItems
22
- .map((item) => renderMenuItem({ ...item, source }))
23
- .join("");
16
+ function hasIndexFile(dirPath) {
17
+ for (const ext of INDEX_EXTENSIONS) {
18
+ const indexPath = join(dirPath, `index${ext}`);
19
+ if (existsSync(indexPath)) {
20
+ return true;
21
+ }
22
+ }
23
+ return false;
24
+ }
24
25
 
25
- return `<ul>${topLevelHtml}</ul>`;
26
+ function findCustomIcon(dirPath, source) {
27
+ for (const ext of ICON_EXTENSIONS) {
28
+ const iconPath = join(dirPath, `icon${ext}`);
29
+ if (existsSync(iconPath)) {
30
+ // Return the web-accessible path
31
+ return iconPath.replace(source, '/');
32
+ }
33
+ }
34
+ return null;
26
35
  }
27
36
 
28
- function renderMenuItem({ path, name, children, source }) {
29
- const ext = extname(path);
30
- const href = path.replace(source, "").replace(ext, "");
31
- const label = basename(path, ext);
32
- // const childMenuItems = Array.isArray(children)
33
- // ? children
34
- // .map((child) => renderMenuItem({ ...child, source }))
35
- // .sort(menuItemSorter)
36
- // : null;
37
- const html = `
38
- <li data-has-children="${!!children}">
39
- <a href="/${href}">${label}</a>
40
- ${
41
- children
42
- ? `<ul>
43
- ${children
44
- .sort(childSorter)
45
- .map((child) => renderMenuItem({ ...child, source }))
46
- .join("")}
47
- </ul>`
48
- : ""
37
+ function getIcon(item, source, isHome = false) {
38
+ if (isHome) {
39
+ return `<span class="menu-icon">${HOME_ICON}</span>`;
49
40
  }
50
- </li>`;
51
- return html;
41
+
42
+ if (item.children) {
43
+ // It's a folder - check for custom icon
44
+ const customIcon = findCustomIcon(item.path, source);
45
+ if (customIcon) {
46
+ return `<span class="menu-icon"><img src="${customIcon}" alt="" /></span>`;
47
+ }
48
+ return `<span class="menu-icon">${FOLDER_ICON}</span>`;
49
+ }
50
+
51
+ // It's a file - check for custom icon in parent directory with matching name
52
+ const dir = dirname(item.path);
53
+ const base = basename(item.path, extname(item.path));
54
+ for (const ext of ICON_EXTENSIONS) {
55
+ const iconPath = join(dir, `${base}-icon${ext}`);
56
+ if (existsSync(iconPath)) {
57
+ return `<span class="menu-icon"><img src="${iconPath.replace(source, '/')}" alt="" /></span>`;
58
+ }
59
+ }
60
+
61
+ return `<span class="menu-icon">${DOCUMENT_ICON}</span>`;
62
+ }
63
+
64
+ /**
65
+ * Resolve an href to a valid .html file path, checking against validPaths.
66
+ * Returns { href, inactive, debug } where inactive is true if the link doesn't resolve to a valid path.
67
+ *
68
+ * Logic:
69
+ * - "/" -> "/index.html"
70
+ * - Any link lacking an extension:
71
+ * - Try adding ".html" - if path exists, use it
72
+ * - Try adding "/index.html" - if path exists, use it
73
+ * - Otherwise, mark as inactive
74
+ * - Links with extensions are checked directly
75
+ */
76
+ function resolveHref(rawHref, validPaths) {
77
+ const debugTries = [];
78
+
79
+ if (!rawHref) {
80
+ return { href: null, inactive: false, debug: 'null href' };
81
+ }
82
+
83
+ // Normalize for checking (lowercase)
84
+ const normalize = (path) => path.toLowerCase();
85
+
86
+ // Root link
87
+ if (rawHref === '/') {
88
+ const indexPath = '/index.html';
89
+ const exists = validPaths.has(normalize(indexPath));
90
+ debugTries.push(`${indexPath} → ${exists ? '✓' : '✗'}`);
91
+ if (exists) {
92
+ return { href: '/index.html', inactive: false, debug: debugTries.join(' | ') };
93
+ }
94
+ return { href: '/', inactive: true, debug: debugTries.join(' | ') };
95
+ }
96
+
97
+ // Check if the link already has an extension
98
+ const ext = extname(rawHref);
99
+ if (ext) {
100
+ // Has extension - check if path exists
101
+ const exists = validPaths.has(normalize(rawHref));
102
+ debugTries.push(`${rawHref} → ${exists ? '✓' : '✗'}`);
103
+ return { href: rawHref, inactive: !exists, debug: debugTries.join(' | ') };
104
+ }
105
+
106
+ // No extension - try .html first
107
+ const htmlPath = rawHref + '.html';
108
+ const htmlExists = validPaths.has(normalize(htmlPath));
109
+ debugTries.push(`${htmlPath} → ${htmlExists ? '✓' : '✗'}`);
110
+ if (htmlExists) {
111
+ return { href: htmlPath, inactive: false, debug: debugTries.join(' | ') };
112
+ }
113
+
114
+ // Try /index.html
115
+ const indexPath = rawHref + '/index.html';
116
+ const indexExists = validPaths.has(normalize(indexPath));
117
+ debugTries.push(`${indexPath} → ${indexExists ? '✓' : '✗'}`);
118
+ if (indexExists) {
119
+ return { href: indexPath, inactive: false, debug: debugTries.join(' | ') };
120
+ }
121
+
122
+ // Neither exists - mark as inactive, keep original href
123
+ return { href: rawHref, inactive: true, debug: debugTries.join(' | ') };
124
+ }
125
+
126
+ // Build a flat tree structure with path info for JS navigation
127
+ function buildMenuData(tree, source, validPaths, parentPath = '') {
128
+ const items = [];
129
+
130
+ for (const item of tree.children || []) {
131
+ const ext = extname(item.path);
132
+ const label = basename(item.path, ext);
133
+ const hasChildren = !!item.children;
134
+ const relativePath = item.path.replace(source, '');
135
+ const folderPath = parentPath ? `${parentPath}/${label}` : label;
136
+
137
+ let rawHref = null;
138
+ if (hasChildren) {
139
+ if (hasIndexFile(item.path)) {
140
+ // Construct proper path - relativePath already starts with /
141
+ const cleanPath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
142
+ rawHref = `${cleanPath}/index.html`.replace(/\/\//g, '/');
143
+ }
144
+ } else {
145
+ const cleanPath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
146
+ rawHref = cleanPath.replace(ext, '');
147
+ }
148
+
149
+ // Resolve the href and check if target exists
150
+ const { href, inactive, debug } = resolveHref(rawHref, validPaths);
151
+
152
+ const menuItem = {
153
+ label,
154
+ path: folderPath,
155
+ href,
156
+ inactive,
157
+ debug,
158
+ hasChildren,
159
+ icon: getIcon(item, source),
160
+ };
161
+
162
+ if (hasChildren) {
163
+ menuItem.children = buildMenuData(item, source, validPaths, folderPath);
164
+ }
165
+
166
+ items.push(menuItem);
167
+ }
168
+
169
+ return items.sort((a, b) => {
170
+ if (a.hasChildren && !b.hasChildren) return -1;
171
+ if (b.hasChildren && !a.hasChildren) return 1;
172
+ if (a.label > b.label) return 1;
173
+ if (a.label < b.label) return -1;
174
+ return 0;
175
+ });
176
+ }
177
+
178
+ export async function getAutomenu(source, validPaths) {
179
+ const tree = dirTree(source, {
180
+ exclude: /[\/\\]\.|node_modules/, // Exclude hidden folders (starting with .) and node_modules
181
+ });
182
+ const menuData = buildMenuData(tree, source, validPaths);
183
+
184
+ // Add home item with resolved href
185
+ const homeResolved = resolveHref('/', validPaths);
186
+ const fullMenuData = [
187
+ { label: 'Home', path: '', href: homeResolved.href, inactive: homeResolved.inactive, debug: homeResolved.debug, hasChildren: false, icon: `<span class="menu-icon">${HOME_ICON}</span>` },
188
+ ...menuData
189
+ ];
190
+
191
+ // Embed the menu data as JSON for JavaScript to use
192
+ const menuDataScript = `<script type="application/json" id="menu-data">${JSON.stringify(fullMenuData)}</script>`;
193
+
194
+ // Render the breadcrumb header (hidden by default, shown when navigating)
195
+ const breadcrumbHtml = `
196
+ <div class="menu-breadcrumb" style="display: none;">
197
+ <button class="menu-back" title="Go back">←</button>
198
+ <button class="menu-home" title="Go to root">🏠</button>
199
+ <span class="menu-current-path"></span>
200
+ </div>`;
52
201
 
53
- return {
54
- href,
55
- label,
56
- childMenuItems,
57
- };
202
+ // Render the initial menu (root level)
203
+ const menuHtml = renderMenuLevel(fullMenuData, 0);
204
+
205
+ return `${menuDataScript}${breadcrumbHtml}<ul class="menu-level" data-level="0">${menuHtml}</ul>`;
206
+ }
58
207
 
59
- /**
60
- * example output:
61
- * {
62
- * href:'Foo/Bar',
63
- * label:'Bar',
64
- * childMenuItems: [thisObject]
65
- * }
66
- */
208
+ function renderMenuLevel(items, level) {
209
+ return items.map(item => {
210
+ const hasChildrenClass = item.hasChildren ? ' has-children' : '';
211
+ const hasChildrenIndicator = item.hasChildren ? '<span class="menu-more">⋯</span>' : '';
212
+ const inactiveClass = item.inactive ? ' inactive' : '';
213
+
214
+ const labelHtml = item.href
215
+ ? `<a href="${item.href}" class="menu-label${inactiveClass}">${item.label}</a>`
216
+ : `<span class="menu-label">${item.label}</span>`;
217
+
218
+ return `
219
+ <li class="menu-item${hasChildrenClass}" data-path="${item.path}">
220
+ <div class="menu-item-row">
221
+ ${item.icon}
222
+ ${labelHtml}
223
+ ${hasChildrenIndicator}
224
+ </div>
225
+ </li>`;
226
+ }).join('');
67
227
  }
68
228
 
69
229
  function childSorter(a, b) {
@@ -0,0 +1,71 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFile, writeFile, mkdir } from 'fs/promises';
3
+ import { existsSync } from 'fs';
4
+ import { dirname, join } from 'path';
5
+
6
+ const URSA_DIR = '.ursa';
7
+ const HASH_CACHE_FILE = 'content-hashes.json';
8
+
9
+ /**
10
+ * Get the path to the .ursa directory for a given source directory
11
+ */
12
+ export function getUrsaDir(sourceDir) {
13
+ return join(sourceDir, URSA_DIR);
14
+ }
15
+
16
+ /**
17
+ * Generate a short hash of content
18
+ */
19
+ export function hashContent(content) {
20
+ return createHash('md5').update(content).digest('hex').substring(0, 12);
21
+ }
22
+
23
+ /**
24
+ * Load the hash cache from disk (.ursa folder in source directory)
25
+ */
26
+ export async function loadHashCache(sourceDir) {
27
+ const cachePath = join(getUrsaDir(sourceDir), HASH_CACHE_FILE);
28
+ try {
29
+ if (existsSync(cachePath)) {
30
+ const data = await readFile(cachePath, 'utf8');
31
+ return new Map(Object.entries(JSON.parse(data)));
32
+ }
33
+ } catch (e) {
34
+ console.warn('Could not load hash cache:', e.message);
35
+ }
36
+ return new Map();
37
+ }
38
+
39
+ /**
40
+ * Save the hash cache to disk (.ursa folder in source directory)
41
+ */
42
+ export async function saveHashCache(sourceDir, hashMap) {
43
+ const ursaDir = getUrsaDir(sourceDir);
44
+ const cachePath = join(ursaDir, HASH_CACHE_FILE);
45
+ try {
46
+ await mkdir(ursaDir, { recursive: true });
47
+ const obj = Object.fromEntries(hashMap);
48
+ await writeFile(cachePath, JSON.stringify(obj, null, 2));
49
+ console.log(`Saved ${hashMap.size} hashes to ${cachePath}`);
50
+ } catch (e) {
51
+ console.warn('Could not save hash cache:', e.message);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Check if a file needs regeneration based on content hash
57
+ */
58
+ export function needsRegeneration(filePath, content, hashCache) {
59
+ const newHash = hashContent(content);
60
+ const oldHash = hashCache.get(filePath);
61
+ return newHash !== oldHash;
62
+ }
63
+
64
+ /**
65
+ * Update the hash for a file in the cache
66
+ */
67
+ export function updateHash(filePath, content, hashCache) {
68
+ const hash = hashContent(content);
69
+ hashCache.set(filePath, hash);
70
+ return hash;
71
+ }
@@ -0,0 +1,26 @@
1
+ import { join, dirname, resolve } from "path";
2
+ import { existsSync } from "fs";
3
+
4
+ /**
5
+ * Recursively search for style.css or _style.css up the directory tree.
6
+ * Returns the contents of the first found file, or null if not found.
7
+ * @param {string} startDir - Directory to start searching from
8
+ * @param {string[]} [names=["style.css", "_style.css"]] - Filenames to look for
9
+ * @param {string} [baseDir] - Stop searching when this directory is reached
10
+ * @returns {Promise<string|null>} CSS contents or null
11
+ */
12
+ export async function findStyleCss(startDir, names = ["style-ursa.css", "style.css", "_style.css"], baseDir = null) {
13
+ let dir = resolve(startDir);
14
+ baseDir = baseDir ? resolve(baseDir) : dir.split(/[\\/]/)[0] === '' ? '/' : dir.split(/[\\/]/)[0];
15
+ while (true) {
16
+ for (const name of names) {
17
+ const candidate = join(dir, name);
18
+ if (existsSync(candidate)) {
19
+ return (await import('fs/promises')).readFile(candidate, "utf8");
20
+ }
21
+ }
22
+ if (dir === baseDir || dir === dirname(dir)) break;
23
+ dir = dirname(dir);
24
+ }
25
+ return null;
26
+ }
@@ -0,0 +1,246 @@
1
+ import { extname, dirname, join, normalize, posix } from "path";
2
+
3
+ /**
4
+ * Build a set of valid internal paths from the list of source files
5
+ * @param {string[]} sourceFiles - Array of source file paths
6
+ * @param {string} source - Source directory path
7
+ * @returns {Set<string>} Set of valid internal paths (without extension, lowercased)
8
+ */
9
+ export function buildValidPaths(sourceFiles, source) {
10
+ const validPaths = new Set();
11
+
12
+ for (const file of sourceFiles) {
13
+ // Get the path relative to source, without extension
14
+ const ext = extname(file);
15
+ let relativePath = file.replace(source, "").replace(ext, "");
16
+
17
+ // Normalize: ensure leading slash, lowercase for comparison
18
+ if (!relativePath.startsWith("/")) {
19
+ relativePath = "/" + relativePath;
20
+ }
21
+
22
+ // Add both with and without trailing slash for directories
23
+ validPaths.add(relativePath.toLowerCase());
24
+ validPaths.add((relativePath + ".html").toLowerCase());
25
+
26
+ // Also add /index.html variant for directory indexes
27
+ if (relativePath.endsWith("/index")) {
28
+ const dirPath = relativePath.replace(/\/index$/, "");
29
+ validPaths.add(dirPath.toLowerCase());
30
+ validPaths.add((dirPath + "/").toLowerCase());
31
+ validPaths.add((dirPath + "/index.html").toLowerCase());
32
+ }
33
+ }
34
+
35
+ // Add root
36
+ validPaths.add("/");
37
+ validPaths.add("/index.html");
38
+
39
+ return validPaths;
40
+ }
41
+
42
+ /**
43
+ * Check if a link is an internal link (not external)
44
+ * @param {string} href - The href value
45
+ * @returns {boolean}
46
+ */
47
+ function isInternalLink(href) {
48
+ if (!href) return false;
49
+
50
+ // External links start with http://, https://, //, mailto:, tel:, etc.
51
+ if (href.match(/^(https?:)?\/\/|^mailto:|^tel:|^javascript:|^#/i)) {
52
+ return false;
53
+ }
54
+
55
+ // Data URLs
56
+ if (href.startsWith("data:")) {
57
+ return false;
58
+ }
59
+
60
+ return true;
61
+ }
62
+
63
+ /**
64
+ * Check if a link is relative (starts with ./ or ../ or doesn't start with /)
65
+ * @param {string} href - The href value
66
+ * @returns {boolean}
67
+ */
68
+ function isRelativeLink(href) {
69
+ if (!href) return false;
70
+ return href.startsWith('./') || href.startsWith('../') || !href.startsWith('/');
71
+ }
72
+
73
+ /**
74
+ * Resolve a relative href to an absolute path based on the current document's path
75
+ * @param {string} href - The relative href
76
+ * @param {string} currentDocPath - The current document's URL path (e.g., "/character/index.html")
77
+ * @returns {string} Absolute path
78
+ */
79
+ function resolveRelativePath(href, currentDocPath) {
80
+ // Get the directory of the current document
81
+ const currentDir = posix.dirname(currentDocPath);
82
+
83
+ // Join and normalize
84
+ const resolved = posix.normalize(posix.join(currentDir, href));
85
+
86
+ return resolved;
87
+ }
88
+
89
+ /**
90
+ * Normalize an href for comparison against valid paths
91
+ * @param {string} href - The href to normalize
92
+ * @param {string} currentDocPath - The current document's URL path (for relative link resolution)
93
+ * @returns {string} Normalized path
94
+ */
95
+ function normalizeHref(href, currentDocPath = null) {
96
+ // Remove hash fragments
97
+ let normalized = href.split("#")[0];
98
+
99
+ // Remove query strings
100
+ normalized = normalized.split("?")[0];
101
+
102
+ // Resolve relative links if we have the current doc path
103
+ if (currentDocPath && isRelativeLink(normalized)) {
104
+ normalized = resolveRelativePath(normalized, currentDocPath);
105
+ }
106
+
107
+ // Ensure leading slash for absolute paths
108
+ if (!normalized.startsWith("/")) {
109
+ normalized = "/" + normalized;
110
+ }
111
+
112
+ // Decode URI components
113
+ try {
114
+ normalized = decodeURIComponent(normalized);
115
+ } catch (e) {
116
+ // Ignore decode errors
117
+ }
118
+
119
+ return normalized.toLowerCase();
120
+ }
121
+
122
+ /**
123
+ * Resolve an href to a valid path, trying .html and /index.html extensions.
124
+ * Returns { resolvedHref, inactive, debug } where:
125
+ * - resolvedHref is the corrected href (with .html extension if needed)
126
+ * - inactive is true if the link doesn't resolve to a valid path
127
+ * - debug contains information about what was tried
128
+ *
129
+ * @param {string} href - The original href
130
+ * @param {Set<string>} validPaths - Set of valid internal paths (lowercased)
131
+ * @param {string} currentDocPath - The current document's URL path (for relative link resolution)
132
+ * @returns {{ resolvedHref: string, inactive: boolean, debug: string }}
133
+ */
134
+ function resolveHref(href, validPaths, currentDocPath = null) {
135
+ const debugTries = [];
136
+
137
+ // Get hash fragment if present (to preserve it)
138
+ const hashIndex = href.indexOf('#');
139
+ const hash = hashIndex >= 0 ? href.substring(hashIndex) : '';
140
+ const hrefWithoutHash = hashIndex >= 0 ? href.substring(0, hashIndex) : href;
141
+
142
+ // Normalize for checking (resolve relative paths if currentDocPath provided)
143
+ const normalized = normalizeHref(hrefWithoutHash, currentDocPath);
144
+
145
+ // Calculate the resolved absolute href (for updating the link)
146
+ const isRelative = isRelativeLink(hrefWithoutHash);
147
+ const absoluteHref = isRelative && currentDocPath
148
+ ? resolveRelativePath(hrefWithoutHash, currentDocPath)
149
+ : hrefWithoutHash;
150
+
151
+ // If exact match exists, return resolved absolute path
152
+ if (validPaths.has(normalized)) {
153
+ debugTries.push(`${normalized} → ✓ (exact)`);
154
+ return { resolvedHref: absoluteHref + hash, inactive: false, debug: debugTries.join(' | ') };
155
+ }
156
+
157
+ // Check if the href already has an extension
158
+ const ext = extname(hrefWithoutHash);
159
+ if (ext) {
160
+ // Has extension but doesn't exist
161
+ debugTries.push(`${normalized} → ✗`);
162
+ return { resolvedHref: absoluteHref + hash, inactive: true, debug: debugTries.join(' | ') };
163
+ }
164
+
165
+ // No extension - try .html first
166
+ const htmlPath = normalized + '.html';
167
+ debugTries.push(`${htmlPath} → ${validPaths.has(htmlPath) ? '✓' : '✗'}`);
168
+ if (validPaths.has(htmlPath)) {
169
+ // Construct the resolved href as absolute path with .html
170
+ const resolvedHref = absoluteHref + '.html' + hash;
171
+ return { resolvedHref, inactive: false, debug: debugTries.join(' | ') };
172
+ }
173
+
174
+ // Try /index.html
175
+ const indexPath = normalized.endsWith('/')
176
+ ? normalized + 'index.html'
177
+ : normalized + '/index.html';
178
+ debugTries.push(`${indexPath} → ${validPaths.has(indexPath) ? '✓' : '✗'}`);
179
+ if (validPaths.has(indexPath)) {
180
+ // Construct the resolved href as absolute path with /index.html
181
+ const resolvedHref = (absoluteHref.endsWith('/')
182
+ ? absoluteHref + 'index.html'
183
+ : absoluteHref + '/index.html') + hash;
184
+ return { resolvedHref, inactive: false, debug: debugTries.join(' | ') };
185
+ }
186
+
187
+ // Neither exists - mark as inactive, keep absolute href
188
+ return { resolvedHref: absoluteHref + hash, inactive: true, debug: debugTries.join(' | ') };
189
+ }
190
+
191
+ /**
192
+ * Process HTML to resolve internal links and add class="inactive" to broken links.
193
+ * This both:
194
+ * 1. Resolves relative links to absolute paths
195
+ * 2. Resolves extensionless links to .html (e.g., /foo/bar -> /foo/bar.html)
196
+ * 3. Marks broken links with the "inactive" class
197
+ *
198
+ * @param {string} html - The HTML content
199
+ * @param {Set<string>} validPaths - Set of valid internal paths
200
+ * @param {string} currentDocPath - The current document's URL path (e.g., "/character/index.html")
201
+ * @param {boolean} includeDebug - Whether to include debug info in link text
202
+ * @returns {string} Processed HTML with resolved links and inactive class on broken links
203
+ */
204
+ export function markInactiveLinks(html, validPaths, currentDocPath = '/', includeDebug = false) {
205
+ // Match anchor tags with href attribute
206
+ // This regex captures: everything before href, the href value, everything after, and the link text
207
+ return html.replace(/<a\s+([^>]*?)href=["']([^"']+)["']([^>]*)>([^<]*)<\/a>/gi, (match, before, href, after, text) => {
208
+ // Skip external links
209
+ if (!isInternalLink(href)) {
210
+ return match;
211
+ }
212
+
213
+ // Resolve the href (passing current doc path for relative link resolution)
214
+ const { resolvedHref, inactive, debug } = resolveHref(href, validPaths, currentDocPath);
215
+
216
+ // Build the class attribute
217
+ let newBefore = before;
218
+ let newAfter = after;
219
+
220
+ if (inactive) {
221
+ // Check if class already exists in before or after
222
+ const classInBefore = before.match(/class=["']([^"']*)["']/i);
223
+ const classInAfter = after.match(/class=["']([^"']*)["']/i);
224
+
225
+ if (classInBefore) {
226
+ const existingClass = classInBefore[1];
227
+ if (!existingClass.includes('inactive')) {
228
+ newBefore = before.replace(classInBefore[0], `class="${existingClass} inactive"`);
229
+ }
230
+ } else if (classInAfter) {
231
+ const existingClass = classInAfter[1];
232
+ if (!existingClass.includes('inactive')) {
233
+ newAfter = after.replace(classInAfter[0], `class="${existingClass} inactive"`);
234
+ }
235
+ } else {
236
+ // Add class attribute
237
+ newBefore = `class="inactive" ${before}`;
238
+ }
239
+ }
240
+
241
+ // Add debug text if requested
242
+ const debugText = includeDebug ? ` [DEBUG: ${debug}]` : '';
243
+
244
+ return `<a ${newBefore}href="${resolvedHref}"${newAfter}>${text}${debugText}</a>`;
245
+ });
246
+ }
@@ -3,6 +3,9 @@ import { parse } from "yaml";
3
3
  export function extractMetadata(rawBody) {
4
4
  const frontMatter = matchFrontMatter(rawBody);
5
5
  if (frontMatter === null) return null;
6
+
7
+ // Don't try to parse empty or whitespace-only content
8
+ if (frontMatter.trim().length === 0) return null;
6
9
 
7
10
  const parsedYml = parse(frontMatter);
8
11
  return parsedYml;
@@ -16,15 +19,23 @@ export function extractRawMetadata(rawBody) {
16
19
  }
17
20
 
18
21
  function matchFrontMatter(str) {
19
- const match = str.match(/---(.*?)---/s);
20
- if (Array.isArray(match) && match.length > 1) {
21
- return match[1];
22
- } else return null;
22
+ // Only match YAML front matter at the start of the file
23
+ // Must have --- at line start, content, then closing --- also at line start
24
+ // The (?=\n|$) ensures the closing --- is followed by newline or end of string
25
+ const match = str.match(/^---\n([\s\S]+?)\n---(?=\n|$)/);
26
+ if (!match || match.length < 2) return null;
27
+
28
+ // Return null if the captured content is empty or only whitespace
29
+ const content = match[1].trim();
30
+ return content.length > 0 ? match[1] : null;
23
31
  }
24
32
 
25
33
  function matchAllFrontMatter(str) {
26
- const match = str.match(/---(.*?)---\n+/s);
27
- if (Array.isArray(match) && match.length > 0) {
28
- return match[0];
29
- } else return null;
34
+ // Only match YAML front matter at the start of the file
35
+ const match = str.match(/^---\n([\s\S]+?)\n---(?=\n|$)/);
36
+ if (!match || match.length < 2) return null;
37
+
38
+ // Check if there's actual content between the delimiters
39
+ const content = match[1].trim();
40
+ return content.length > 0 ? match[0] + '\n' : null;
30
41
  }