@kenjura/ursa 0.53.0 → 0.54.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 CHANGED
@@ -1,5 +1,12 @@
1
+ # 0.54.0
2
+ 2025-12-21
3
+
4
+ - added cache-busting timestamps to static files
5
+ - cleaned up generate.js by moving helper functions to separate files
6
+
7
+
1
8
  # 0.53.0
2
- 2025-01-01
9
+ 2025-12-21
3
10
 
4
11
  ### Menu Size Optimization
5
12
  - **External Menu JSON**: Menu data is now stored in `/public/menu-data.json` instead of being embedded in every HTML file. This dramatically reduces HTML file sizes for sites with large folder structures (e.g., from 2-3MB per file down to ~50KB).
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.53.0",
5
+ "version": "0.54.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -0,0 +1,197 @@
1
+ // Auto-index generation helpers for build
2
+ import { existsSync } from "fs";
3
+ import { readdir, readFile } from "fs/promises";
4
+ import { basename, dirname, extname, join } from "path";
5
+ import { outputFile } from "fs-extra";
6
+ import { findStyleCss } from "../findStyleCss.js";
7
+ import { toTitleCase } from "./titleCase.js";
8
+ import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
9
+
10
+ /**
11
+ * Generate automatic index.html files for folders that don't have one
12
+ * @param {string} output - Output directory path
13
+ * @param {string[]} directories - List of source directories
14
+ * @param {string} source - Source directory path
15
+ * @param {object} templates - Template map
16
+ * @param {string} menu - Rendered menu HTML
17
+ * @param {string} footer - Footer HTML
18
+ * @param {string[]} generatedArticles - List of source article paths that were generated
19
+ * @param {Set<string>} copiedCssFiles - Set of CSS files already copied to output
20
+ * @param {Set<string>} existingHtmlFiles - Set of existing HTML files in source (relative paths)
21
+ * @param {string} cacheBustTimestamp - Cache-busting timestamp
22
+ * @param {object} progress - Progress reporter instance
23
+ */
24
+ export async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress) {
25
+ // Alternate index file names to look for (in priority order)
26
+ const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
27
+
28
+ // Normalize paths (remove trailing slashes for consistent replacement)
29
+ const sourceNorm = source.replace(/\/+$/, '');
30
+ const outputNorm = output.replace(/\/+$/, '');
31
+
32
+ // Build set of directories that already have an index.html from a source index.md/txt/yml
33
+ const dirsWithSourceIndex = new Set();
34
+ for (const articlePath of generatedArticles) {
35
+ const base = basename(articlePath, extname(articlePath));
36
+ if (base === 'index') {
37
+ const dir = dirname(articlePath);
38
+ const outputDir = dir.replace(sourceNorm, outputNorm);
39
+ dirsWithSourceIndex.add(outputDir);
40
+ }
41
+ }
42
+
43
+ // Get all output directories (including root)
44
+ const outputDirs = new Set([outputNorm]);
45
+ for (const dir of directories) {
46
+ // Handle both with and without trailing slash in source
47
+ const outputDir = dir.replace(sourceNorm, outputNorm);
48
+ outputDirs.add(outputDir);
49
+ }
50
+
51
+ let generatedCount = 0;
52
+ let renamedCount = 0;
53
+ let skippedHtmlCount = 0;
54
+
55
+ for (const dir of outputDirs) {
56
+ const indexPath = join(dir, 'index.html');
57
+
58
+ // Skip if this directory had a source index.md/txt/yml that was already processed
59
+ if (dirsWithSourceIndex.has(dir)) {
60
+ continue;
61
+ }
62
+
63
+ // Check if there's an existing index.html in the source directory (don't overwrite it)
64
+ const sourceDir = dir.replace(outputNorm, sourceNorm);
65
+ const relativeIndexPath = join(sourceDir, 'index.html').replace(sourceNorm + '/', '');
66
+ if (existingHtmlFiles && existingHtmlFiles.has(relativeIndexPath)) {
67
+ skippedHtmlCount++;
68
+ continue; // Don't overwrite existing source HTML
69
+ }
70
+
71
+ // Skip if index.html already exists in output (e.g., created by previous run)
72
+ if (existsSync(indexPath)) {
73
+ continue;
74
+ }
75
+
76
+ // Get folder name for (foldername).html check
77
+ const folderName = basename(dir);
78
+ const folderNameAlternate = `${folderName}.html`;
79
+
80
+ // Check for alternate index files
81
+ let foundAlternate = null;
82
+ for (const alt of [...INDEX_ALTERNATES, folderNameAlternate]) {
83
+ const altPath = join(dir, alt);
84
+ if (existsSync(altPath)) {
85
+ foundAlternate = altPath;
86
+ break;
87
+ }
88
+ }
89
+
90
+ if (foundAlternate) {
91
+ // Rename/copy alternate to index.html
92
+ try {
93
+ const content = await readFile(foundAlternate, 'utf8');
94
+ await outputFile(indexPath, content);
95
+ renamedCount++;
96
+ progress.status('Auto-index', `Promoted ${basename(foundAlternate)} → index.html in ${dir.replace(outputNorm, '') || '/'}`);
97
+ } catch (e) {
98
+ progress.log(`Error promoting ${foundAlternate} to index.html: ${e.message}`);
99
+ }
100
+ } else {
101
+ // Generate a simple index listing direct children
102
+ try {
103
+ const children = await readdir(dir, { withFileTypes: true });
104
+
105
+ // Filter to only include relevant files and folders
106
+ const items = children
107
+ .filter(child => {
108
+ // Skip hidden files and index alternates we just checked
109
+ if (child.name.startsWith('.')) return false;
110
+ if (child.name === 'index.html') return false;
111
+ // Include directories and html files
112
+ return child.isDirectory() || child.name.endsWith('.html');
113
+ })
114
+ .map(child => {
115
+ const isDir = child.isDirectory();
116
+ const name = isDir ? child.name : child.name.replace('.html', '');
117
+ const href = isDir ? `${child.name}/` : child.name;
118
+ const displayName = toTitleCase(name);
119
+ const icon = isDir ? '📁' : '📄';
120
+ return `<li>${icon} <a href="${href}">${displayName}</a></li>`;
121
+ });
122
+
123
+ if (items.length === 0) {
124
+ // Empty folder, skip generating index
125
+ continue;
126
+ }
127
+
128
+ const folderDisplayName = dir === outputNorm ? 'Home' : toTitleCase(folderName);
129
+ const indexHtml = `<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
130
+
131
+ const template = templates["default-template"];
132
+ if (!template) {
133
+ progress.log(`Warning: No default template for auto-index in ${dir}`);
134
+ continue;
135
+ }
136
+
137
+ // Find nearest style.css for this directory
138
+ let styleLink = "";
139
+ try {
140
+ // Map output dir back to source dir to find style.css
141
+ const sourceDir = dir.replace(outputNorm, sourceNorm);
142
+ const cssPath = await findStyleCss(sourceDir);
143
+ if (cssPath) {
144
+ // Calculate output path for the CSS file (mirrors source structure)
145
+ const cssOutputPath = cssPath.replace(sourceNorm, outputNorm);
146
+ const cssUrlPath = '/' + cssPath.replace(sourceNorm, '');
147
+
148
+ // Copy CSS file if not already copied
149
+ if (!copiedCssFiles.has(cssPath)) {
150
+ const cssContent = await readFile(cssPath, 'utf8');
151
+ await outputFile(cssOutputPath, cssContent);
152
+ copiedCssFiles.add(cssPath);
153
+ }
154
+
155
+ // Generate link tag
156
+ styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
157
+ }
158
+ } catch (e) {
159
+ // ignore CSS lookup errors
160
+ }
161
+
162
+ let finalHtml = template;
163
+ const replacements = {
164
+ "${menu}": menu,
165
+ "${body}": indexHtml,
166
+ "${searchIndex}": "[]",
167
+ "${title}": folderDisplayName,
168
+ "${meta}": "{}",
169
+ "${transformedMetadata}": "",
170
+ "${styleLink}": styleLink,
171
+ "${footer}": footer
172
+ };
173
+ for (const [key, value] of Object.entries(replacements)) {
174
+ finalHtml = finalHtml.replace(key, value);
175
+ }
176
+ // Add cache-busting timestamps to static file references
177
+ finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
178
+
179
+ await outputFile(indexPath, finalHtml);
180
+ generatedCount++;
181
+ progress.status('Auto-index', `Generated index.html for ${dir.replace(outputNorm, '') || '/'}`);
182
+ } catch (e) {
183
+ progress.log(`Error generating auto-index for ${dir}: ${e.message}`);
184
+ }
185
+ }
186
+ }
187
+
188
+ if (generatedCount > 0 || renamedCount > 0 || skippedHtmlCount > 0) {
189
+ let summary = `${generatedCount} generated, ${renamedCount} promoted`;
190
+ if (skippedHtmlCount > 0) {
191
+ summary += `, ${skippedHtmlCount} skipped (existing HTML)`;
192
+ }
193
+ progress.done('Auto-index', summary);
194
+ } else {
195
+ progress.log(`Auto-index: All folders already have index.html`);
196
+ }
197
+ }
@@ -0,0 +1,19 @@
1
+ // Batch processing helpers for build
2
+
3
+ /**
4
+ * Process items in batches to limit memory usage
5
+ * @param {Array} items - Items to process
6
+ * @param {Function} processor - Async function to process each item
7
+ * @param {number} batchSize - Max concurrent operations
8
+ */
9
+ export async function processBatched(items, processor, batchSize = 50) {
10
+ const results = [];
11
+ for (let i = 0; i < items.length; i += batchSize) {
12
+ const batch = items.slice(i, i + batchSize);
13
+ const batchResults = await Promise.all(batch.map(processor));
14
+ results.push(...batchResults);
15
+ // Allow GC to run between batches
16
+ if (global.gc) global.gc();
17
+ }
18
+ return results;
19
+ }
@@ -0,0 +1,62 @@
1
+ // Cache busting helpers for build
2
+
3
+ /**
4
+ * Generate a cache-busting timestamp in ISO format (e.g., 20251221T221700Z)
5
+ * @returns {string} Timestamp string suitable for query params
6
+ */
7
+ export function generateCacheBustTimestamp() {
8
+ const now = new Date();
9
+ const year = now.getUTCFullYear();
10
+ const month = String(now.getUTCMonth() + 1).padStart(2, '0');
11
+ const day = String(now.getUTCDate()).padStart(2, '0');
12
+ const hours = String(now.getUTCHours()).padStart(2, '0');
13
+ const minutes = String(now.getUTCMinutes()).padStart(2, '0');
14
+ const seconds = String(now.getUTCSeconds()).padStart(2, '0');
15
+ return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
16
+ }
17
+
18
+ /**
19
+ * Add cache-busting timestamp to url() references in CSS content
20
+ * @param {string} cssContent - The CSS file content
21
+ * @param {string} timestamp - The cache-busting timestamp
22
+ * @returns {string} CSS with timestamped URLs
23
+ */
24
+ export function addTimestampToCssUrls(cssContent, timestamp) {
25
+ // Match url(...) in any context, including CSS variables, with optional whitespace and quotes
26
+ // Exclude data: URLs and already-timestamped URLs
27
+ return cssContent.replace(
28
+ /url\(\s*(['"]?)(?!data:)([^'"\)]+?)\1\s*\)/gi,
29
+ (match, quote, url) => {
30
+ // Don't add timestamp if already has query string
31
+ if (url.includes('?')) {
32
+ return match;
33
+ }
34
+ return `url(${quote}${url}?v=${timestamp}${quote})`;
35
+ }
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Add cache-busting timestamp to static file references in HTML
41
+ * @param {string} html - The HTML content
42
+ * @param {string} timestamp - The cache-busting timestamp
43
+ * @returns {string} HTML with timestamped static file references
44
+ */
45
+ export function addTimestampToHtmlStaticRefs(html, timestamp) {
46
+ // Add timestamp to CSS links
47
+ html = html.replace(
48
+ /(<link[^>]+href=["'])([^"']+\.css)(["'][^>]*>)/gi,
49
+ `$1$2?v=${timestamp}$3`
50
+ );
51
+ // Add timestamp to JS scripts
52
+ html = html.replace(
53
+ /(<script[^>]+src=["'])([^"']+\.js)(["'][^>]*>)/gi,
54
+ `$1$2?v=${timestamp}$3`
55
+ );
56
+ // Add timestamp to images in img tags
57
+ html = html.replace(
58
+ /(<img[^>]+src=["'])([^"']+\.(jpg|jpeg|png|gif|webp|svg|ico))(["'][^>]*>)/gi,
59
+ `$1$2?v=${timestamp}$4`
60
+ );
61
+ return html;
62
+ }
@@ -0,0 +1,67 @@
1
+ // Exclude/filter helpers for build
2
+ import { existsSync } from "fs";
3
+ import { readFile, stat } from "fs/promises";
4
+
5
+ /**
6
+ * Parse exclude option - can be comma-separated paths or a file path
7
+ * @param {string} excludeOption - The exclude option value
8
+ * @param {string} source - Source directory path
9
+ * @returns {Promise<Set<string>>} Set of excluded folder paths (normalized)
10
+ */
11
+ export async function parseExcludeOption(excludeOption, source) {
12
+ const excludedPaths = new Set();
13
+
14
+ if (!excludeOption) return excludedPaths;
15
+
16
+ // Check if it's a file path (exists as a file)
17
+ const isFile = existsSync(excludeOption) && (await stat(excludeOption)).isFile();
18
+
19
+ let patterns;
20
+ if (isFile) {
21
+ // Read patterns from file (one per line)
22
+ const content = await readFile(excludeOption, 'utf8');
23
+ patterns = content.split('\n')
24
+ .map(line => line.trim())
25
+ .filter(line => line && !line.startsWith('#')); // Skip empty lines and comments
26
+ } else {
27
+ // Treat as comma-separated list
28
+ patterns = excludeOption.split(',').map(p => p.trim()).filter(Boolean);
29
+ }
30
+
31
+ // Normalize patterns to absolute paths
32
+ for (const pattern of patterns) {
33
+ // Remove leading/trailing slashes and normalize
34
+ const normalized = pattern.replace(/^\/+|\/+$/g, '');
35
+ // Store as relative path for easier matching
36
+ excludedPaths.add(normalized);
37
+ }
38
+
39
+ return excludedPaths;
40
+ }
41
+
42
+ /**
43
+ * Create a filter function that excludes files in specified folders
44
+ * @param {Set<string>} excludedPaths - Set of excluded folder paths
45
+ * @param {string} source - Source directory path
46
+ * @returns {Function} Filter function
47
+ */
48
+ export function createExcludeFilter(excludedPaths, source) {
49
+ if (excludedPaths.size === 0) {
50
+ return () => true; // No exclusions, allow all
51
+ }
52
+
53
+ return (filePath) => {
54
+ // Get path relative to source
55
+ const relativePath = filePath.replace(source, '').replace(/^\/+/, '');
56
+
57
+ // Check if file is in any excluded folder
58
+ for (const excluded of excludedPaths) {
59
+ if (relativePath === excluded ||
60
+ relativePath.startsWith(excluded + '/') ||
61
+ relativePath.startsWith(excluded + '\\')) {
62
+ return false; // Exclude this file
63
+ }
64
+ }
65
+ return true; // Include this file
66
+ };
67
+ }
@@ -0,0 +1,113 @@
1
+ // Footer generation helpers for build
2
+ import { existsSync } from "fs";
3
+ import { readFile } from "fs/promises";
4
+ import { dirname, join, resolve } from "path";
5
+ import { URL } from "url";
6
+ import { renderFile } from "../fileRenderer.js";
7
+
8
+ /**
9
+ * Generate footer HTML from footer.md and package.json
10
+ * @param {string} source - resolved source path with trailing slash
11
+ * @param {string} _source - original source path
12
+ * @param {number} buildId - the current build ID
13
+ * @returns {Promise<string>} Footer HTML
14
+ */
15
+ export async function getFooter(source, _source, buildId) {
16
+ const footerParts = [];
17
+
18
+ // Try to read footer.md from source root
19
+ const footerPath = join(source, 'footer.md');
20
+ try {
21
+ if (existsSync(footerPath)) {
22
+ const footerMd = await readFile(footerPath, 'utf8');
23
+ const footerHtml = renderFile({ fileContents: footerMd, type: '.md' });
24
+ footerParts.push(`<div class="footer-content">${footerHtml}</div>`);
25
+ }
26
+ } catch (e) {
27
+ console.error(`Error reading footer.md: ${e.message}`);
28
+ }
29
+
30
+ // Try to read package.json from doc repo (check both source dir and parent)
31
+ let docPackage = null;
32
+ const sourceDir = resolve(_source);
33
+ const packagePaths = [
34
+ join(sourceDir, 'package.json'), // In source dir itself
35
+ join(sourceDir, '..', 'package.json'), // One level up (if docs is a subfolder)
36
+ ];
37
+
38
+ for (const packagePath of packagePaths) {
39
+ try {
40
+ if (existsSync(packagePath)) {
41
+ const packageJson = await readFile(packagePath, 'utf8');
42
+ docPackage = JSON.parse(packageJson);
43
+ console.log(`Found doc package.json at ${packagePath}`);
44
+ break;
45
+ }
46
+ } catch (e) {
47
+ // Continue to next path
48
+ }
49
+ }
50
+
51
+ // Get ursa version from ursa's own package.json
52
+ // Use import.meta.url to find the package.json relative to this file
53
+ let ursaVersion = 'unknown';
54
+ try {
55
+ // From src/helper/build/footer.js, go up to package root
56
+ const currentFileUrl = new URL(import.meta.url);
57
+ const currentDir = dirname(currentFileUrl.pathname);
58
+ const ursaPackagePath = resolve(currentDir, '..', '..', '..', 'package.json');
59
+
60
+ if (existsSync(ursaPackagePath)) {
61
+ const ursaPackageJson = await readFile(ursaPackagePath, 'utf8');
62
+ const ursaPackage = JSON.parse(ursaPackageJson);
63
+ ursaVersion = ursaPackage.version;
64
+ console.log(`Found ursa package.json at ${ursaPackagePath}, version: ${ursaVersion}`);
65
+ }
66
+ } catch (e) {
67
+ console.error(`Error reading ursa package.json: ${e.message}`);
68
+ }
69
+
70
+ // Build meta line: version, build id, timestamp, "generated by ursa"
71
+ const metaParts = [];
72
+ if (docPackage?.version) {
73
+ metaParts.push(`v${docPackage.version}`);
74
+ }
75
+ metaParts.push(`build ${buildId}`);
76
+
77
+ // Full date/time in a readable format
78
+ const now = new Date();
79
+ const timestamp = now.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
80
+ metaParts.push(timestamp);
81
+
82
+ metaParts.push(`Generated by <a href="https://www.npmjs.com/package/@kenjura/ursa">ursa</a> v${ursaVersion}`);
83
+
84
+ footerParts.push(`<div class="footer-meta">${metaParts.join(' • ')}</div>`);
85
+
86
+ // Copyright line from doc package.json
87
+ if (docPackage?.copyright) {
88
+ footerParts.push(`<div class="footer-copyright">${docPackage.copyright}</div>`);
89
+ } else if (docPackage?.author) {
90
+ const year = new Date().getFullYear();
91
+ const author = typeof docPackage.author === 'string' ? docPackage.author : docPackage.author.name;
92
+ if (author) {
93
+ footerParts.push(`<div class="footer-copyright">© ${year} ${author}</div>`);
94
+ }
95
+ }
96
+
97
+ // Try to get git short hash of doc repo (as HTML comment)
98
+ try {
99
+ const { execSync } = await import('child_process');
100
+ const gitHash = execSync('git rev-parse --short HEAD', {
101
+ cwd: resolve(_source),
102
+ encoding: 'utf8',
103
+ stdio: ['pipe', 'pipe', 'pipe']
104
+ }).trim();
105
+ if (gitHash) {
106
+ footerParts.push(`<!-- git: ${gitHash} -->`);
107
+ }
108
+ } catch (e) {
109
+ // Not a git repo or git not available - silently skip
110
+ }
111
+
112
+ return footerParts.join('\n');
113
+ }
@@ -0,0 +1,13 @@
1
+ // Barrel file for build helpers
2
+ export * from './cacheBust.js';
3
+ export * from './batch.js';
4
+ export * from './progress.js';
5
+ export * from './watchCache.js';
6
+ export * from './titleCase.js';
7
+ export * from './excludeFilter.js';
8
+ export * from './pathUtils.js';
9
+ export * from './templates.js';
10
+ export * from './menu.js';
11
+ export * from './metadata.js';
12
+ export * from './footer.js';
13
+ export * from './autoIndex.js';
@@ -0,0 +1,19 @@
1
+ // Menu helpers for build
2
+ import { getAutomenu } from "../automenu.js";
3
+ import { renderFile } from "../fileRenderer.js";
4
+
5
+ /**
6
+ * Get menu HTML and menu data from source directory
7
+ * @param {string[]} allSourceFilenames - All source file names
8
+ * @param {string} source - Source directory path
9
+ * @param {Set<string>} validPaths - Set of valid internal paths for link validation
10
+ * @returns {Promise<{html: string, menuData: Object}>} Menu HTML and menu data
11
+ */
12
+ export async function getMenu(allSourceFilenames, source, validPaths) {
13
+ const menuResult = await getAutomenu(source, validPaths);
14
+ const menuBody = renderFile({ fileContents: menuResult.html, type: ".md" });
15
+ return {
16
+ html: menuBody,
17
+ menuData: menuResult.menuData
18
+ };
19
+ }
@@ -0,0 +1,30 @@
1
+ // Metadata transformation helpers for build
2
+ import { join } from "path";
3
+
4
+ /**
5
+ * Get transformed metadata using custom or default transform function
6
+ * @param {string} dirname - Directory containing the file
7
+ * @param {Object} metadata - Raw metadata object
8
+ * @returns {Promise<string>} Transformed metadata string
9
+ */
10
+ export async function getTransformedMetadata(dirname, metadata) {
11
+ // custom transform? else, use default
12
+ const customTransformFnFilename = join(dirname, "transformMetadata.js");
13
+ let transformFn = defaultTransformFn;
14
+ try {
15
+ const customTransformFn = (await import(customTransformFnFilename)).default;
16
+ if (typeof customTransformFn === "function")
17
+ transformFn = customTransformFn;
18
+ } catch (e) {
19
+ // No custom transform found, use default
20
+ }
21
+ try {
22
+ return transformFn(metadata);
23
+ } catch (e) {
24
+ return "error transforming metadata";
25
+ }
26
+
27
+ function defaultTransformFn(metadata) {
28
+ return "default transform";
29
+ }
30
+ }
@@ -0,0 +1,13 @@
1
+ // Path utility helpers for build
2
+
3
+ /**
4
+ * Add trailing slash to a path if not present
5
+ * @param {string} somePath - Path to modify
6
+ * @returns {string} Path with trailing slash
7
+ */
8
+ export function addTrailingSlash(somePath) {
9
+ if (typeof somePath !== "string") return somePath;
10
+ if (somePath.length < 1) return somePath;
11
+ if (somePath[somePath.length - 1] === "/") return somePath;
12
+ return `${somePath}/`;
13
+ }
@@ -0,0 +1,35 @@
1
+ // Progress reporter for build
2
+
3
+ export class ProgressReporter {
4
+ constructor() {
5
+ this.lines = {};
6
+ this.isTTY = process.stdout.isTTY;
7
+ }
8
+ status(name, message) {
9
+ if (this.isTTY) {
10
+ const line = `${name}: ${message}`;
11
+ this.lines[name] = line;
12
+ process.stdout.write(`\r\x1b[K${line}`);
13
+ }
14
+ }
15
+ done(name, message) {
16
+ if (this.isTTY) {
17
+ process.stdout.write(`\r\x1b[K${name}: ${message}\n`);
18
+ } else {
19
+ console.log(`${name}: ${message}`);
20
+ }
21
+ delete this.lines[name];
22
+ }
23
+ log(message) {
24
+ if (this.isTTY) {
25
+ process.stdout.write(`\r\x1b[K${message}\n`);
26
+ } else {
27
+ console.log(message);
28
+ }
29
+ }
30
+ clear() {
31
+ if (this.isTTY) {
32
+ process.stdout.write(`\r\x1b[K`);
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,30 @@
1
+ // Template helpers for build
2
+ import { readFile } from "fs/promises";
3
+ import { parse } from "path";
4
+ import { recurse } from "../recursive-readdir.js";
5
+
6
+ /**
7
+ * Get all templates from meta directory
8
+ * @param {string} meta - Full path to meta files directory
9
+ * @returns {Promise<Object>} Map of templateName to templateBody
10
+ */
11
+ export async function getTemplates(meta) {
12
+ const allMetaFilenames = await recurse(meta);
13
+ const allHtmlFilenames = allMetaFilenames.filter((filename) =>
14
+ filename.match(/\.html/)
15
+ );
16
+
17
+ 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
+ );
28
+
29
+ return templates;
30
+ }
@@ -0,0 +1,7 @@
1
+ // Helper to convert filename to title case
2
+ export function toTitleCase(filename) {
3
+ return filename
4
+ .split(/[-_\s]+/)
5
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
6
+ .join(' ');
7
+ }
@@ -0,0 +1,26 @@
1
+ // Watch mode cache and clear function for build
2
+
3
+ export const watchModeCache = {
4
+ templates: null,
5
+ menu: null,
6
+ footer: null,
7
+ validPaths: null,
8
+ source: null,
9
+ meta: null,
10
+ output: null,
11
+ hashCache: null,
12
+ cacheBustTimestamp: null,
13
+ lastFullBuild: 0,
14
+ isInitialized: false,
15
+ };
16
+
17
+ export function clearWatchCache(cssPathCache) {
18
+ watchModeCache.templates = null;
19
+ watchModeCache.menu = null;
20
+ watchModeCache.footer = null;
21
+ watchModeCache.validPaths = null;
22
+ watchModeCache.hashCache = null;
23
+ watchModeCache.isInitialized = false;
24
+ if (cssPathCache) cssPathCache.clear();
25
+ console.log('Watch cache cleared');
26
+ }