@kenjura/ursa 0.75.0 → 0.77.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 (36) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/meta/default.css +149 -3
  3. package/meta/templates/default-template/default.css +1268 -0
  4. package/meta/{default-template.html → templates/default-template/index.html} +49 -2
  5. package/meta/{menu.js → templates/default-template/menu.js} +1 -1
  6. package/meta/templates/default-template/sectionify.js +46 -0
  7. package/meta/templates/default-template/widgets.js +701 -0
  8. package/package.json +1 -1
  9. package/src/dev.js +125 -34
  10. package/src/helper/assetBundler.js +471 -0
  11. package/src/helper/build/autoIndex.js +26 -23
  12. package/src/helper/build/cacheBust.js +79 -0
  13. package/src/helper/build/navCache.js +4 -0
  14. package/src/helper/build/templates.js +176 -19
  15. package/src/helper/build/watchCache.js +7 -0
  16. package/src/helper/customMenu.js +4 -2
  17. package/src/helper/dependencyTracker.js +269 -0
  18. package/src/helper/findScriptJs.js +29 -0
  19. package/src/helper/findStyleCss.js +29 -0
  20. package/src/helper/portUtils.js +132 -0
  21. package/src/jobs/generate.js +276 -59
  22. package/src/serve.js +446 -162
  23. package/meta/character-sheet.css +0 -50
  24. package/meta/widgets.js +0 -376
  25. /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
  26. /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
  27. /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
  28. /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
  29. /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
  30. /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
  31. /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
  32. /package/meta/{search.js → templates/default-template/search.js} +0 -0
  33. /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
  34. /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
  35. /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
  36. /package/meta/{template2.html → templates/template2/index.html} +0 -0
@@ -1,30 +1,187 @@
1
1
  // Template helpers for build
2
- import { readFile } from "fs/promises";
3
- import { parse } from "path";
4
- import { recurse } from "../recursive-readdir.js";
2
+ import { readFile, readdir } from "fs/promises";
3
+ import { join, basename } from "path";
4
+ import { existsSync } from "fs";
5
5
 
6
6
  /**
7
7
  * Get all templates from meta directory
8
+ * Templates are now organized in meta/templates/{templateName}/index.html
9
+ * Falls back to legacy flat structure if templates folder doesn't exist
8
10
  * @param {string} meta - Full path to meta files directory
9
- * @returns {Promise<Object>} Map of templateName to templateBody
11
+ * @returns {Promise<Object>} Map of templateName to { html, dir }
10
12
  */
11
13
  export async function getTemplates(meta) {
12
- const allMetaFilenames = await recurse(meta);
13
- const allHtmlFilenames = allMetaFilenames.filter((filename) =>
14
- filename.match(/\.html/)
15
- );
16
-
14
+ const templatesDir = join(meta, "templates");
17
15
  let templates = {};
18
- const templatesArray = await Promise.all(
19
- allHtmlFilenames.map(async (filename) => {
20
- const { name } = parse(filename);
21
- const fileContent = await readFile(filename, "utf8");
22
- return [name, fileContent];
23
- })
24
- );
25
- templatesArray.forEach(
26
- ([templateName, templateText]) => (templates[templateName] = templateText)
27
- );
16
+
17
+ // Check if new templates folder structure exists
18
+ if (existsSync(templatesDir)) {
19
+ const entries = await readdir(templatesDir, { withFileTypes: true });
20
+ const templateFolders = entries.filter(e => e.isDirectory());
21
+
22
+ const templatesArray = await Promise.all(
23
+ templateFolders.map(async (folder) => {
24
+ const templateName = folder.name;
25
+ const templateDir = join(templatesDir, templateName);
26
+ const indexPath = join(templateDir, "index.html");
27
+
28
+ if (existsSync(indexPath)) {
29
+ const fileContent = await readFile(indexPath, "utf8");
30
+ return [templateName, { html: fileContent, dir: templateDir }];
31
+ }
32
+ return null;
33
+ })
34
+ );
35
+
36
+ templatesArray
37
+ .filter(Boolean)
38
+ .forEach(([name, data]) => (templates[name] = data));
39
+ }
40
+
41
+ // Legacy fallback: check for *-template.html files in meta root
42
+ // This allows backward compatibility during migration
43
+ if (Object.keys(templates).length === 0) {
44
+ const { recurse } = await import("../recursive-readdir.js");
45
+ const allMetaFilenames = await recurse(meta);
46
+ const legacyTemplates = allMetaFilenames.filter((filename) =>
47
+ filename.match(/-template\.html$/)
48
+ );
49
+
50
+ const templatesArray = await Promise.all(
51
+ legacyTemplates.map(async (filename) => {
52
+ const name = basename(filename).replace("-template.html", "");
53
+ const fileContent = await readFile(filename, "utf8");
54
+ // For legacy templates, the dir is the meta folder itself
55
+ return [name, { html: fileContent, dir: meta }];
56
+ })
57
+ );
58
+ templatesArray.forEach(
59
+ ([templateName, data]) => (templates[templateName] = data)
60
+ );
61
+ }
28
62
 
29
63
  return templates;
30
64
  }
65
+
66
+ /**
67
+ * Get template HTML content (backward compatible)
68
+ * @param {Object} templates - Templates map from getTemplates()
69
+ * @param {string} name - Template name
70
+ * @returns {string|null} Template HTML or null
71
+ */
72
+ export function getTemplateHtml(templates, name) {
73
+ const template = templates[name];
74
+ if (!template) return null;
75
+ // Support both new { html, dir } format and legacy string format
76
+ return typeof template === 'string' ? template : template.html;
77
+ }
78
+
79
+ /**
80
+ * Get template directory (for resolving assets)
81
+ * @param {Object} templates - Templates map from getTemplates()
82
+ * @param {string} name - Template name
83
+ * @returns {string|null} Template directory path or null
84
+ */
85
+ export function getTemplateDir(templates, name) {
86
+ const template = templates[name];
87
+ if (!template) return null;
88
+ return typeof template === 'string' ? null : template.dir;
89
+ }
90
+
91
+ /**
92
+ * Copy meta assets to output/public directory.
93
+ * Handles the new template folder structure:
94
+ * - meta/shared/* → output/public/*
95
+ * - meta/templates/{name}/* (except index.html) → output/public/*
96
+ *
97
+ * Also handles legacy flat structure for backward compatibility.
98
+ *
99
+ * @param {string} metaDir - Path to meta directory
100
+ * @param {string} outputPublicDir - Path to output/public directory
101
+ * @returns {Promise<{ copiedFiles: string[], orphanedFiles: string[] }>}
102
+ */
103
+ export async function copyMetaAssets(metaDir, outputPublicDir) {
104
+ const { copy } = await import('fs-extra');
105
+ const { readdir, stat } = await import('fs/promises');
106
+ const { relative } = await import('path');
107
+
108
+ const copiedFiles = [];
109
+ const orphanedFiles = [];
110
+ const templatesDir = join(metaDir, 'templates');
111
+ const sharedDir = join(metaDir, 'shared');
112
+
113
+ // Helper to copy a file
114
+ const copyFile = async (src, dest) => {
115
+ await copy(src, dest, { overwrite: true });
116
+ copiedFiles.push(relative(metaDir, src));
117
+ };
118
+
119
+ // Helper to recursively copy directory contents (not the directory itself)
120
+ const copyDirContents = async (srcDir, destDir, excludeFiles = []) => {
121
+ if (!existsSync(srcDir)) return;
122
+
123
+ const entries = await readdir(srcDir, { withFileTypes: true });
124
+ for (const entry of entries) {
125
+ if (entry.name.startsWith('.')) continue; // Skip hidden files
126
+ if (excludeFiles.includes(entry.name)) continue;
127
+
128
+ const srcPath = join(srcDir, entry.name);
129
+ const destPath = join(destDir, entry.name);
130
+
131
+ if (entry.isDirectory()) {
132
+ await copyDirContents(srcPath, destPath, excludeFiles);
133
+ } else {
134
+ await copyFile(srcPath, destPath);
135
+ }
136
+ }
137
+ };
138
+
139
+ // Check if new structure exists
140
+ if (existsSync(templatesDir)) {
141
+ // Copy shared assets
142
+ await copyDirContents(sharedDir, outputPublicDir);
143
+
144
+ // Copy each template's assets (except index.html)
145
+ const templateFolders = await readdir(templatesDir, { withFileTypes: true });
146
+ for (const folder of templateFolders) {
147
+ if (!folder.isDirectory()) continue;
148
+ const templateDir = join(templatesDir, folder.name);
149
+ await copyDirContents(templateDir, outputPublicDir, ['index.html']);
150
+ }
151
+
152
+ // Check for orphaned files in meta root (excluding templates and shared folders)
153
+ const rootEntries = await readdir(metaDir, { withFileTypes: true });
154
+ for (const entry of rootEntries) {
155
+ if (entry.name.startsWith('.')) continue;
156
+ if (entry.name === 'templates' || entry.name === 'shared') continue;
157
+
158
+ // This is an orphaned file/folder in meta root
159
+ const fullPath = join(metaDir, entry.name);
160
+ if (entry.isDirectory()) {
161
+ // Recursively find all orphaned files
162
+ const findOrphans = async (dir) => {
163
+ const entries = await readdir(dir, { withFileTypes: true });
164
+ for (const e of entries) {
165
+ if (e.name.startsWith('.')) continue;
166
+ const p = join(dir, e.name);
167
+ if (e.isDirectory()) {
168
+ await findOrphans(p);
169
+ } else {
170
+ orphanedFiles.push(relative(metaDir, p));
171
+ }
172
+ }
173
+ };
174
+ await findOrphans(fullPath);
175
+ } else {
176
+ orphanedFiles.push(entry.name);
177
+ }
178
+ }
179
+ } else {
180
+ // Legacy: copy entire meta folder to public (old behavior)
181
+ await copy(metaDir, outputPublicDir, {
182
+ filter: (src) => !basename(src).startsWith('.') && !src.endsWith('.html')
183
+ });
184
+ }
185
+
186
+ return { copiedFiles, orphanedFiles };
187
+ }
@@ -1,5 +1,7 @@
1
1
  // Watch mode cache and clear function for build
2
2
 
3
+ import { dependencyTracker } from "../dependencyTracker.js";
4
+
3
5
  export const watchModeCache = {
4
6
  templates: null,
5
7
  menu: null,
@@ -10,6 +12,8 @@ export const watchModeCache = {
10
12
  output: null,
11
13
  hashCache: null,
12
14
  cacheBustTimestamp: null,
15
+ cacheBustHashes: null, // CacheBustHashMap instance for per-file cache-busting
16
+ allArticlePaths: null, // Array of all article paths (for full rebuild tracking)
13
17
  lastFullBuild: 0,
14
18
  isInitialized: false,
15
19
  };
@@ -20,7 +24,10 @@ export function clearWatchCache(cssPathCache) {
20
24
  watchModeCache.footer = null;
21
25
  watchModeCache.validPaths = null;
22
26
  watchModeCache.hashCache = null;
27
+ watchModeCache.cacheBustHashes = null;
28
+ watchModeCache.allArticlePaths = null;
23
29
  watchModeCache.isInitialized = false;
30
+ dependencyTracker.init("");
24
31
  if (cssPathCache) cssPathCache.clear();
25
32
  console.log('Watch cache cleared');
26
33
  }
@@ -267,11 +267,13 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 10, i
267
267
  const topLevelFolders = processedItems.filter(item => item.hasChildren);
268
268
  const topLevelFiles = processedItems.filter(item => !item.hasChildren);
269
269
 
270
- const relativePath = '/' + relative(sourceRoot, folderPath).replace(/\\/g, '/');
270
+ const relPath = relative(sourceRoot, folderPath).replace(/\\/g, '/');
271
+ // Avoid double slash: if relPath is empty (root), href is '/index.html', otherwise '/relPath/index.html'
272
+ const homeHref = relPath ? `/${relPath}/index.html` : '/index.html';
271
273
  const homeItem = {
272
274
  label: 'Home',
273
275
  path: 'home',
274
- href: relativePath + '/index.html',
276
+ href: homeHref,
275
277
  hasChildren: topLevelFiles.length > 0,
276
278
  icon: `<span class="menu-icon">${HOME_ICON}</span>`,
277
279
  children: topLevelFiles,
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Dependency tracker for Ursa's regeneration system.
3
+ *
4
+ * Tracks which documents depend on which files so that when a file changes,
5
+ * we can determine exactly which documents need regeneration.
6
+ *
7
+ * Dependency types:
8
+ * - template: document uses a specific meta template
9
+ * - style: document inherits a specific style.css
10
+ * - script: document inherits a specific script.js
11
+ * - static: document references a static asset (image, font, etc.)
12
+ * - meta-asset: document depends on a meta CSS/JS file (via template bundling)
13
+ *
14
+ * The tracker maintains two indexes:
15
+ * 1. fileToDocuments: Map<dependencyPath, Set<documentPath>> — given a changed file, which documents need regeneration?
16
+ * 2. documentToFiles: Map<documentPath, Set<dependencyPath>> — given a document, what are its dependencies? (for cleanup)
17
+ */
18
+
19
+ import { dirname, join, relative, resolve } from "path";
20
+
21
+ export class DependencyTracker {
22
+ constructor() {
23
+ /** @type {Map<string, Set<string>>} dependency file path → set of document paths */
24
+ this.fileToDocuments = new Map();
25
+ /** @type {Map<string, Set<string>>} document path → set of dependency file paths */
26
+ this.documentToFiles = new Map();
27
+ /** @type {string} source directory root (absolute, with trailing slash) */
28
+ this.sourceDir = "";
29
+ }
30
+
31
+ /**
32
+ * Initialize with the source directory.
33
+ * @param {string} sourceDir - Absolute path to source directory (with or without trailing slash)
34
+ */
35
+ init(sourceDir) {
36
+ this.sourceDir = resolve(sourceDir) + "/";
37
+ this.fileToDocuments.clear();
38
+ this.documentToFiles.clear();
39
+ }
40
+
41
+ /**
42
+ * Register that a document depends on a file.
43
+ * @param {string} documentPath - Absolute path to the document
44
+ * @param {string} dependencyPath - Absolute path to the dependency file
45
+ */
46
+ addDependency(documentPath, dependencyPath) {
47
+ // file → documents
48
+ if (!this.fileToDocuments.has(dependencyPath)) {
49
+ this.fileToDocuments.set(dependencyPath, new Set());
50
+ }
51
+ this.fileToDocuments.get(dependencyPath).add(documentPath);
52
+
53
+ // document → files
54
+ if (!this.documentToFiles.has(documentPath)) {
55
+ this.documentToFiles.set(documentPath, new Set());
56
+ }
57
+ this.documentToFiles.get(documentPath).add(dependencyPath);
58
+ }
59
+
60
+ /**
61
+ * Register dependencies for a document: template, style.css files, script.js files.
62
+ * @param {string} documentPath - Absolute path to the document
63
+ * @param {{ templateName: string, cssPaths: string[], scriptPaths: string[], metaAssets: string[] }} deps
64
+ */
65
+ registerDocument(documentPath, { templateName, cssPaths = [], scriptPaths = [], metaAssets = [] } = {}) {
66
+ // Clear old dependencies for this document
67
+ this.clearDocument(documentPath);
68
+
69
+ // Template dependency (use a virtual path so template changes can be looked up)
70
+ if (templateName) {
71
+ this.addDependency(documentPath, `template:${templateName}`);
72
+ }
73
+
74
+ // Style.css dependencies
75
+ for (const cssPath of cssPaths) {
76
+ this.addDependency(documentPath, cssPath);
77
+ }
78
+
79
+ // Script.js dependencies
80
+ for (const scriptPath of scriptPaths) {
81
+ this.addDependency(documentPath, scriptPath);
82
+ }
83
+
84
+ // Meta template assets (CSS/JS referenced by the template)
85
+ for (const metaAsset of metaAssets) {
86
+ this.addDependency(documentPath, metaAsset);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Clear all dependencies for a document.
92
+ * @param {string} documentPath
93
+ */
94
+ clearDocument(documentPath) {
95
+ const deps = this.documentToFiles.get(documentPath);
96
+ if (deps) {
97
+ for (const dep of deps) {
98
+ const docSet = this.fileToDocuments.get(dep);
99
+ if (docSet) {
100
+ docSet.delete(documentPath);
101
+ if (docSet.size === 0) this.fileToDocuments.delete(dep);
102
+ }
103
+ }
104
+ this.documentToFiles.delete(documentPath);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Get all documents that depend on a given file.
110
+ * @param {string} filePath - Absolute path to the changed file
111
+ * @returns {Set<string>} Set of document paths that need regeneration
112
+ */
113
+ getAffectedDocuments(filePath) {
114
+ return this.fileToDocuments.get(filePath) || new Set();
115
+ }
116
+
117
+ /**
118
+ * Get all documents that use a specific template.
119
+ * @param {string} templateName - Template name (e.g., "default-template")
120
+ * @returns {Set<string>} Set of document paths
121
+ */
122
+ getDocumentsUsingTemplate(templateName) {
123
+ return this.getAffectedDocuments(`template:${templateName}`);
124
+ }
125
+
126
+ /**
127
+ * Determine which documents are affected by a changed file.
128
+ * This is the main entry point for the invalidation logic.
129
+ *
130
+ * @param {string} changedFile - Absolute path to the changed file
131
+ * @param {string} sourceDir - Absolute path to source directory
132
+ * @returns {{ affectedDocuments: string[], reason: string, requiresFullRebuild: boolean }}
133
+ */
134
+ getInvalidationPlan(changedFile, sourceDir) {
135
+ const normalizedSource = resolve(sourceDir) + "/";
136
+ const relativePath = changedFile.replace(normalizedSource, "");
137
+ const fileName = relativePath.split("/").pop();
138
+
139
+ // 1. Menu/config changes → full rebuild (affects navigation structure)
140
+ if (
141
+ fileName === "menu.md" || fileName === "menu.txt" || fileName === "_menu" ||
142
+ fileName === "config.json" || fileName === "_config"
143
+ ) {
144
+ return {
145
+ affectedDocuments: [],
146
+ reason: `Menu/config change: ${relativePath}`,
147
+ requiresFullRebuild: true,
148
+ };
149
+ }
150
+
151
+ // 2. style.css or script.js → affects current folder + all subfolders
152
+ // These are "inherited" files — documents in the folder and all subfolders include them.
153
+ if (
154
+ fileName === "style.css" || fileName === "_style.css" || fileName === "style-ursa.css" ||
155
+ fileName === "script.js" || fileName === "_script.js"
156
+ ) {
157
+ // Get all documents directly registered as depending on this file
158
+ const directDeps = this.getAffectedDocuments(changedFile);
159
+
160
+ if (directDeps.size > 0) {
161
+ return {
162
+ affectedDocuments: [...directDeps],
163
+ reason: `Inherited ${fileName} changed: ${relativePath} (${directDeps.size} documents)`,
164
+ requiresFullRebuild: false,
165
+ };
166
+ }
167
+
168
+ // Fallback: if dependency tracker wasn't populated, scope by directory
169
+ const changedDir = dirname(changedFile);
170
+ const allDocs = [...this.documentToFiles.keys()];
171
+ const affected = allDocs.filter((doc) => doc.startsWith(changedDir));
172
+ return {
173
+ affectedDocuments: affected,
174
+ reason: `Inherited ${fileName} changed: ${relativePath} (${affected.length} documents in subtree, fallback)`,
175
+ requiresFullRebuild: false,
176
+ };
177
+ }
178
+
179
+ // 3. Article file changes → just that document
180
+ if (/\.(md|mdx|txt|yml|yaml)$/.test(fileName)) {
181
+ return {
182
+ affectedDocuments: [changedFile],
183
+ reason: `Article changed: ${relativePath}`,
184
+ requiresFullRebuild: false,
185
+ };
186
+ }
187
+
188
+ // 4. Other static files (images, fonts, etc.) → find documents that reference them
189
+ const directDeps = this.getAffectedDocuments(changedFile);
190
+ if (directDeps.size > 0) {
191
+ return {
192
+ affectedDocuments: [...directDeps],
193
+ reason: `Static asset changed: ${relativePath} (${directDeps.size} referencing documents)`,
194
+ requiresFullRebuild: false,
195
+ };
196
+ }
197
+
198
+ // If we don't track this file, it's safe to just broadcast reload (dev mode only)
199
+ return {
200
+ affectedDocuments: [],
201
+ reason: `Unknown file changed: ${relativePath} (no registered dependents)`,
202
+ requiresFullRebuild: false,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Determine which documents are affected by a meta file change.
208
+ * @param {string} changedFile - Absolute path to the changed meta file
209
+ * @param {string} metaDir - Absolute path to meta directory
210
+ * @returns {{ affectedDocuments: string[], reason: string, requiresFullRebuild: boolean }}
211
+ */
212
+ getMetaInvalidationPlan(changedFile, metaDir) {
213
+ const normalizedMeta = resolve(metaDir) + "/";
214
+ const relativePath = changedFile.replace(normalizedMeta, "");
215
+ const fileName = relativePath.split("/").pop();
216
+
217
+ // Template file changed → regenerate all documents using that template
218
+ if (fileName.endsWith(".html")) {
219
+ const templateName = fileName.replace(".html", "");
220
+ const affected = this.getDocumentsUsingTemplate(templateName);
221
+ if (affected.size > 0) {
222
+ return {
223
+ affectedDocuments: [...affected],
224
+ reason: `Template changed: ${templateName} (${affected.size} documents)`,
225
+ requiresFullRebuild: false,
226
+ };
227
+ }
228
+ // If no documents tracked, fall back to full rebuild (safe default)
229
+ return {
230
+ affectedDocuments: [],
231
+ reason: `Template changed: ${templateName} (no tracked documents, full rebuild)`,
232
+ requiresFullRebuild: true,
233
+ };
234
+ }
235
+
236
+ // Meta CSS or JS file changed → all documents are affected
237
+ // (meta assets are bundled into every template)
238
+ if (fileName.endsWith(".css") || fileName.endsWith(".js")) {
239
+ const allDocs = [...this.documentToFiles.keys()];
240
+ return {
241
+ affectedDocuments: allDocs,
242
+ reason: `Meta asset changed: ${relativePath} (affects all ${allDocs.length} documents)`,
243
+ requiresFullRebuild: false,
244
+ };
245
+ }
246
+
247
+ // Other meta file → full rebuild to be safe
248
+ return {
249
+ affectedDocuments: [],
250
+ reason: `Meta file changed: ${relativePath}`,
251
+ requiresFullRebuild: true,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Get stats about the dependency graph.
257
+ * @returns {{ totalDocuments: number, totalDependencies: number, uniqueFiles: number }}
258
+ */
259
+ getStats() {
260
+ return {
261
+ totalDocuments: this.documentToFiles.size,
262
+ totalDependencies: [...this.documentToFiles.values()].reduce((sum, s) => sum + s.size, 0),
263
+ uniqueFiles: this.fileToDocuments.size,
264
+ };
265
+ }
266
+ }
267
+
268
+ // Singleton instance
269
+ export const dependencyTracker = new DependencyTracker();
@@ -24,3 +24,32 @@ export async function findScriptJs(startDir, names = ["script.js", "_script.js"]
24
24
  }
25
25
  return null;
26
26
  }
27
+
28
+ /**
29
+ * Find ALL script.js or _script.js files from the docroot down to startDir.
30
+ * Walks up from startDir to docroot collecting all matches, then returns them
31
+ * sorted from shortest path (closest to docroot) to longest (closest to startDir).
32
+ * @param {string} startDir - Directory to start searching from (deepest)
33
+ * @param {string} docroot - The root directory to stop at (shallowest)
34
+ * @param {string[]} [names=["script.js", "_script.js"]] - Filenames to look for
35
+ * @returns {Promise<string[]>} Array of script file paths, ordered from shallowest to deepest
36
+ */
37
+ export async function findAllScriptJs(startDir, docroot, names = ["script.js", "_script.js"]) {
38
+ const found = [];
39
+ let dir = resolve(startDir);
40
+ const base = resolve(docroot);
41
+ while (true) {
42
+ for (const name of names) {
43
+ const candidate = join(dir, name);
44
+ if (existsSync(candidate)) {
45
+ found.push(candidate);
46
+ break; // Only one match per directory (prefer script.js over _script.js)
47
+ }
48
+ }
49
+ if (dir === base || dir === dirname(dir)) break;
50
+ dir = dirname(dir);
51
+ }
52
+ // Sort from shortest path (docroot) to longest (startDir)
53
+ found.sort((a, b) => a.length - b.length);
54
+ return found;
55
+ }
@@ -24,3 +24,32 @@ export async function findStyleCss(startDir, names = ["style-ursa.css", "style.c
24
24
  }
25
25
  return null;
26
26
  }
27
+
28
+ /**
29
+ * Find ALL style.css or _style.css files from the docroot down to startDir.
30
+ * Walks up from startDir to docroot collecting all matches, then returns them
31
+ * sorted from shortest path (closest to docroot) to longest (closest to startDir).
32
+ * @param {string} startDir - Directory to start searching from (deepest)
33
+ * @param {string} docroot - The root directory to stop at (shallowest)
34
+ * @param {string[]} [names=["style-ursa.css", "style.css", "_style.css"]] - Filenames to look for
35
+ * @returns {Promise<string[]>} Array of CSS file paths, ordered from shallowest to deepest
36
+ */
37
+ export async function findAllStyleCss(startDir, docroot, names = ["style-ursa.css", "style.css", "_style.css"]) {
38
+ const found = [];
39
+ let dir = resolve(startDir);
40
+ const base = resolve(docroot);
41
+ while (true) {
42
+ for (const name of names) {
43
+ const candidate = join(dir, name);
44
+ if (existsSync(candidate)) {
45
+ found.push(candidate);
46
+ break; // Only one match per directory (prefer style-ursa.css > style.css > _style.css)
47
+ }
48
+ }
49
+ if (dir === base || dir === dirname(dir)) break;
50
+ dir = dirname(dir);
51
+ }
52
+ // Sort from shortest path (docroot) to longest (startDir)
53
+ found.sort((a, b) => a.length - b.length);
54
+ return found;
55
+ }