@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.
- package/CHANGELOG.md +162 -0
- package/README.md +182 -19
- package/bin/ursa.js +208 -0
- package/lib/index.js +7 -2
- package/meta/character-sheet-template.html +2 -0
- package/meta/default-template.html +29 -5
- package/meta/default.css +451 -115
- package/meta/menu.js +387 -0
- package/meta/search.js +208 -0
- package/meta/sectionify.js +36 -0
- package/meta/sticky.js +73 -0
- package/meta/toc-generator.js +124 -0
- package/meta/toc.js +93 -0
- package/package.json +25 -4
- package/src/helper/WikiImage.js +138 -0
- package/src/helper/automenu.js +215 -55
- package/src/helper/contentHash.js +71 -0
- package/src/helper/findStyleCss.js +26 -0
- package/src/helper/linkValidator.js +246 -0
- package/src/helper/metadataExtractor.js +19 -8
- package/src/helper/whitelistFilter.js +66 -0
- package/src/helper/wikitextHelper.js +6 -3
- package/src/jobs/generate.js +353 -112
- package/src/serve.js +138 -37
- package/.nvmrc +0 -1
- package/.vscode/launch.json +0 -20
- package/TODO.md +0 -16
- package/nodemon.json +0 -16
package/src/helper/automenu.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|