@kenjura/ursa 0.93.0 → 0.96.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 +54 -0
- package/README.md +109 -0
- package/bin/ursa.js +14 -3
- package/meta/templates/default-template/content-hooks.js +45 -0
- package/meta/templates/default-template/index.html +1 -0
- package/meta/templates/default-template/sticky.js +7 -1
- package/meta/templates/default-template/toc-generator.js +58 -38
- package/package.json +1 -1
- package/src/dev.js +29 -0
- package/src/helper/__test__/folderConfig.test.js +89 -0
- package/src/helper/__test__/mdxRenderer.test.js +159 -0
- package/src/helper/__test__/sourceTimestamps.test.js +0 -0
- package/src/helper/automenu.js +4 -2
- package/src/helper/build/__test__/autoIndex.test.js +67 -0
- package/src/helper/build/autoIndex.js +22 -5
- package/src/helper/folderConfig.js +34 -4
- package/src/helper/mdxRenderer.js +199 -22
- package/src/helper/sourceTimestamps.js +139 -0
- package/src/helper/ursaConfig.js +3 -49
- package/src/jobs/__test__/generateJsonOnly.test.js +154 -0
- package/src/jobs/generate.js +297 -220
|
@@ -3,9 +3,9 @@ import { getMDXComponent } from "mdx-bundler/client/index.js";
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
5
|
import * as esbuild from "esbuild";
|
|
6
|
-
import { dirname, join, resolve } from "path";
|
|
6
|
+
import { dirname, extname, join, resolve, sep } from "path";
|
|
7
7
|
import { existsSync } from "fs";
|
|
8
|
-
import { writeFile, mkdir } from "fs/promises";
|
|
8
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
9
9
|
import remarkDirective from "remark-directive";
|
|
10
10
|
import { remarkDefinitionList, defListHastHandlers } from "remark-definition-list";
|
|
11
11
|
import remarkSupersub from "remark-supersub";
|
|
@@ -47,6 +47,160 @@ function remarkAsideContainers() {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Island hydration
|
|
52
|
+
//
|
|
53
|
+
// Components imported directly into an .mdx file are wrapped in an island: the
|
|
54
|
+
// server renders each inside <ursa-island data-island="N">, and the client
|
|
55
|
+
// hydrates each of those elements as its own React root. The markdown around
|
|
56
|
+
// them is never handed to React, so the template is free to restructure it
|
|
57
|
+
// (sectionify.js wraps H1 sections, breadcrumbs are injected, etc.) without
|
|
58
|
+
// causing a hydration mismatch. See docs/changes/island-hydration.md.
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
const ISLAND_MODULE = "ursa:island";
|
|
62
|
+
const ISLAND_NAMESPACE = "ursa-island";
|
|
63
|
+
const ISLAND_RUNTIME_NAMESPACE = "ursa-island-runtime";
|
|
64
|
+
const ISLAND_TAG = "ursa-island";
|
|
65
|
+
|
|
66
|
+
// Extensions that are wrapped when imported from the .mdx entry. .js/.ts are
|
|
67
|
+
// only wrapped when they live in a _components/ directory, since a plain .js
|
|
68
|
+
// import from MDX is as likely to be data or a helper as a component.
|
|
69
|
+
const ISLAND_COMPONENT_EXTS = new Set([".jsx", ".tsx"]);
|
|
70
|
+
const ISLAND_COMPONENT_DIR_EXTS = new Set([".js", ".ts"]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Source of the `ursa:island` virtual module. Both variants assign island ids
|
|
74
|
+
* with a lazy useState initializer so each mount takes exactly one id, in tree
|
|
75
|
+
* order — which is what lets the client match the server's numbering.
|
|
76
|
+
*/
|
|
77
|
+
function islandRuntimeSource(platform) {
|
|
78
|
+
const shared = `
|
|
79
|
+
import React from 'react';
|
|
80
|
+
let counter = 0;
|
|
81
|
+
function useIslandId() {
|
|
82
|
+
const [id] = React.useState(() => counter++);
|
|
83
|
+
return id;
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
if (platform === "node") {
|
|
87
|
+
return `${shared}
|
|
88
|
+
export function island(Component, name) {
|
|
89
|
+
if (typeof Component !== 'function') return Component;
|
|
90
|
+
function Island(props) {
|
|
91
|
+
const id = useIslandId();
|
|
92
|
+
return React.createElement(
|
|
93
|
+
${JSON.stringify(ISLAND_TAG)},
|
|
94
|
+
{ 'data-island': String(id), 'data-component': name, style: { display: 'contents' } },
|
|
95
|
+
React.createElement(Component, props)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
Island.displayName = 'Island(' + name + ')';
|
|
99
|
+
return Island;
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
return `${shared}
|
|
104
|
+
import * as ReactDOM from 'react-dom';
|
|
105
|
+
export function island(Component, name) {
|
|
106
|
+
if (typeof Component !== 'function') return Component;
|
|
107
|
+
function Island(props) {
|
|
108
|
+
const id = useIslandId();
|
|
109
|
+
React.useEffect(() => {
|
|
110
|
+
const target = document.querySelector(${JSON.stringify(ISLAND_TAG)} + '[data-island="' + id + '"]');
|
|
111
|
+
if (!target) {
|
|
112
|
+
console.warn('[ursa] island #' + id + ' (' + name + ') not found in document; skipping hydration');
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const root = ReactDOM.hydrateRoot(target, React.createElement(Component, props));
|
|
116
|
+
return () => root.unmount();
|
|
117
|
+
}, []);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
Island.displayName = 'Island(' + name + ')';
|
|
121
|
+
return Island;
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isIslandCandidate(resolvedPath) {
|
|
127
|
+
const ext = extname(resolvedPath);
|
|
128
|
+
if (ISLAND_COMPONENT_EXTS.has(ext)) return true;
|
|
129
|
+
if (ISLAND_COMPONENT_DIR_EXTS.has(ext)) {
|
|
130
|
+
return resolvedPath.split(sep).includes("_components");
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* esbuild plugin that turns direct component imports of the .mdx entry into
|
|
137
|
+
* islands. Must be registered ahead of mdx-bundler's own resolvers so it sees
|
|
138
|
+
* the import first; it defers to them (via build.resolve) for the actual lookup.
|
|
139
|
+
*/
|
|
140
|
+
function islandPlugin(platform) {
|
|
141
|
+
return {
|
|
142
|
+
name: "ursa-island",
|
|
143
|
+
setup(build) {
|
|
144
|
+
// The runtime module itself
|
|
145
|
+
build.onResolve({ filter: /^ursa:island$/ }, () => ({
|
|
146
|
+
path: ISLAND_MODULE,
|
|
147
|
+
namespace: ISLAND_RUNTIME_NAMESPACE,
|
|
148
|
+
}));
|
|
149
|
+
build.onLoad({ filter: /.*/, namespace: ISLAND_RUNTIME_NAMESPACE }, () => ({
|
|
150
|
+
contents: islandRuntimeSource(platform),
|
|
151
|
+
loader: "js",
|
|
152
|
+
resolveDir: process.cwd(),
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
// Imports from the .mdx entry
|
|
156
|
+
build.onResolve({ filter: /.*/ }, async (args) => {
|
|
157
|
+
if (args.pluginData?.ursaIsland) return undefined; // our own build.resolve
|
|
158
|
+
if (args.namespace !== "file" || !args.importer.endsWith(".mdx")) return undefined;
|
|
159
|
+
if (args.path === ISLAND_MODULE || /^react(\/|$)|^react-dom(\/|$)/.test(args.path)) return undefined;
|
|
160
|
+
|
|
161
|
+
const resolved = await build.resolve(args.path, {
|
|
162
|
+
importer: args.importer,
|
|
163
|
+
resolveDir: args.resolveDir,
|
|
164
|
+
kind: args.kind,
|
|
165
|
+
pluginData: { ursaIsland: true },
|
|
166
|
+
});
|
|
167
|
+
if (resolved.errors.length > 0 || resolved.external || resolved.namespace !== "file") return undefined;
|
|
168
|
+
if (!isIslandCandidate(resolved.path)) return undefined;
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
path: resolved.path,
|
|
172
|
+
namespace: ISLAND_NAMESPACE,
|
|
173
|
+
pluginData: { name: args.path.split("/").pop().replace(/\.[jt]sx?$/, "") },
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
build.onLoad({ filter: /.*/, namespace: ISLAND_NAMESPACE }, (args) => {
|
|
177
|
+
const real = JSON.stringify(args.path);
|
|
178
|
+
const name = JSON.stringify(args.pluginData?.name || "Component");
|
|
179
|
+
return {
|
|
180
|
+
contents: `
|
|
181
|
+
import __ursaInner from ${real};
|
|
182
|
+
export * from ${real};
|
|
183
|
+
import { island } from ${JSON.stringify(ISLAND_MODULE)};
|
|
184
|
+
export default island(__ursaInner, ${name});
|
|
185
|
+
`,
|
|
186
|
+
loader: "js",
|
|
187
|
+
resolveDir: dirname(args.path),
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* React 19's renderToString hoists a <link rel="preload"> for images ahead of
|
|
196
|
+
* the content. It carries the un-rewritten relative href (so it preloads
|
|
197
|
+
* nothing useful) and, sitting before the first <h1>, defeats the template's
|
|
198
|
+
* "body starts with a heading" check, which then injects a duplicate title.
|
|
199
|
+
*/
|
|
200
|
+
function stripHoistedPreloads(html) {
|
|
201
|
+
return html.replace(/<link rel="preload"[^>]*>/g, "");
|
|
202
|
+
}
|
|
203
|
+
|
|
50
204
|
/**
|
|
51
205
|
* Find _components directories by walking up from the MDX file to the source root.
|
|
52
206
|
* Returns paths from most specific (nearest) to most general (root).
|
|
@@ -121,7 +275,10 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
|
|
|
121
275
|
const parentDirs = componentDirs.map(d => dirname(d));
|
|
122
276
|
options.nodePaths = [...(options.nodePaths || []), ...parentDirs];
|
|
123
277
|
}
|
|
124
|
-
|
|
278
|
+
|
|
279
|
+
// Island plugin goes first so it sees component imports before mdx-bundler's resolvers
|
|
280
|
+
options.plugins = [islandPlugin(platform), ...(options.plugins || [])];
|
|
281
|
+
|
|
125
282
|
return options;
|
|
126
283
|
};
|
|
127
284
|
|
|
@@ -170,7 +327,7 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
|
|
|
170
327
|
const Component = getMDXComponent(serverCode);
|
|
171
328
|
|
|
172
329
|
// Render to HTML with hydration markers (renderToString vs renderToStaticMarkup)
|
|
173
|
-
const html = renderToString(React.createElement(Component));
|
|
330
|
+
const html = stripHoistedPreloads(renderToString(React.createElement(Component)));
|
|
174
331
|
|
|
175
332
|
// If hydration is not requested, return without client code
|
|
176
333
|
if (!hydrate) {
|
|
@@ -279,11 +436,18 @@ function formatMDXError(error, filePath) {
|
|
|
279
436
|
* @param {string} publicDir - Absolute path to the output public/ directory
|
|
280
437
|
* @returns {Promise<void>}
|
|
281
438
|
*/
|
|
439
|
+
// Bump when the runtime's contents change; an older runtime left in
|
|
440
|
+
// output/public/ is then rebuilt instead of reused.
|
|
441
|
+
const REACT_RUNTIME_MARKER = 'ursa-react-runtime/2';
|
|
442
|
+
|
|
282
443
|
export async function buildReactRuntime(publicDir) {
|
|
283
444
|
const outfile = join(publicDir, 'react-runtime.js');
|
|
284
445
|
|
|
285
|
-
// Skip rebuild if already exists (for incremental builds)
|
|
286
|
-
if (existsSync(outfile))
|
|
446
|
+
// Skip rebuild if an up-to-date runtime already exists (for incremental builds)
|
|
447
|
+
if (existsSync(outfile)) {
|
|
448
|
+
const existing = await readFile(outfile, 'utf8');
|
|
449
|
+
if (existing.includes(REACT_RUNTIME_MARKER)) return;
|
|
450
|
+
}
|
|
287
451
|
|
|
288
452
|
await mkdir(publicDir, { recursive: true });
|
|
289
453
|
|
|
@@ -292,11 +456,12 @@ export async function buildReactRuntime(publicDir) {
|
|
|
292
456
|
contents: `
|
|
293
457
|
import React from 'react';
|
|
294
458
|
import * as ReactDOM from 'react-dom';
|
|
295
|
-
import { hydrateRoot } from 'react-dom/client';
|
|
459
|
+
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
296
460
|
import * as _jsx_runtime from 'react/jsx-runtime';
|
|
297
461
|
window.React = React;
|
|
298
|
-
window.ReactDOM = { ...ReactDOM, hydrateRoot };
|
|
462
|
+
window.ReactDOM = { ...ReactDOM, hydrateRoot, createRoot };
|
|
299
463
|
window._jsx_runtime = _jsx_runtime;
|
|
464
|
+
window.__ursaReactRuntime = ${JSON.stringify(REACT_RUNTIME_MARKER)};
|
|
300
465
|
`,
|
|
301
466
|
resolveDir: dirname(new URL(import.meta.url).pathname),
|
|
302
467
|
loader: 'js',
|
|
@@ -313,12 +478,17 @@ export async function buildReactRuntime(publicDir) {
|
|
|
313
478
|
/**
|
|
314
479
|
* Generate the hydration script tags for an MDX page.
|
|
315
480
|
* References the locally-built React runtime instead of CDN.
|
|
316
|
-
*
|
|
481
|
+
*
|
|
482
|
+
* The bundled MDX module is rendered into a detached root purely to run the
|
|
483
|
+
* component tree: every island in it hydrates its own <ursa-island> element in
|
|
484
|
+
* the live document (see islandRuntimeSource). Nothing outside those elements
|
|
485
|
+
* is handed to React, so the template's DOM post-processing (sectionify,
|
|
486
|
+
* breadcrumbs, TOC) cannot cause a hydration mismatch.
|
|
487
|
+
*
|
|
317
488
|
* @param {string} clientCode - The bundled MDX client code from renderMDX
|
|
318
|
-
* @param {string} [containerId='main-content'] - The ID of the container element to hydrate
|
|
319
489
|
* @returns {string} HTML script tags to include in the page
|
|
320
490
|
*/
|
|
321
|
-
export function generateHydrationScript(clientCode
|
|
491
|
+
export function generateHydrationScript(clientCode) {
|
|
322
492
|
// Escape the code for embedding in a script tag
|
|
323
493
|
const escapedCode = clientCode
|
|
324
494
|
.replace(/\\/g, '\\\\')
|
|
@@ -330,7 +500,7 @@ export function generateHydrationScript(clientCode, containerId = 'main-content'
|
|
|
330
500
|
<!-- React runtime for MDX hydration (built from node_modules) -->
|
|
331
501
|
<script src="/public/react-runtime.js"></script>
|
|
332
502
|
|
|
333
|
-
<!-- MDX
|
|
503
|
+
<!-- MDX island hydration -->
|
|
334
504
|
<script>
|
|
335
505
|
(function() {
|
|
336
506
|
// getMDXComponent: matches mdx-bundler/client calling convention.
|
|
@@ -344,23 +514,30 @@ export function generateHydrationScript(clientCode, containerId = 'main-content'
|
|
|
344
514
|
var mdxExport = fn(React, ReactDOM, _jsx_runtime);
|
|
345
515
|
return mdxExport.default;
|
|
346
516
|
}
|
|
347
|
-
|
|
348
|
-
// Hydrate when DOM is ready
|
|
517
|
+
|
|
518
|
+
// Hydrate when DOM is ready. Running after the template's own
|
|
519
|
+
// DOMContentLoaded handlers is fine: islands are found by id, so it
|
|
520
|
+
// does not matter where sectionify has moved them.
|
|
349
521
|
function hydrate() {
|
|
350
522
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
523
|
+
if (!window.ReactDOM || !window.ReactDOM.createRoot) {
|
|
524
|
+
console.error('MDX hydration: React runtime not loaded');
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
var islands = document.querySelectorAll('${ISLAND_TAG}[data-island]');
|
|
528
|
+
if (islands.length === 0) {
|
|
529
|
+
console.log('MDX hydration: no islands on this page');
|
|
354
530
|
return;
|
|
355
531
|
}
|
|
356
|
-
|
|
532
|
+
|
|
357
533
|
// MDX bundled code (compiled by mdx-bundler)
|
|
358
534
|
var mdxCode = \`${escapedCode}\`;
|
|
359
535
|
var Component = getMDXComponent(mdxCode);
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
536
|
+
|
|
537
|
+
// Render into a detached root; each island hydrates itself in place.
|
|
538
|
+
var detached = document.createElement('div');
|
|
539
|
+
window.ReactDOM.createRoot(detached).render(window.React.createElement(Component));
|
|
540
|
+
console.log('MDX hydration: ' + islands.length + ' island(s)');
|
|
364
541
|
} catch (err) {
|
|
365
542
|
console.error('MDX hydration error:', err);
|
|
366
543
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { realpath, stat } from "fs/promises";
|
|
4
|
+
import { relative, resolve, sep } from "path";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// When was a source document last edited?
|
|
10
|
+
//
|
|
11
|
+
// The recent-activity feed needs a per-document "last edited" time that does
|
|
12
|
+
// not depend on when ursa happened to run. Two sources, in order:
|
|
13
|
+
//
|
|
14
|
+
// 1. git: the commit date of the last commit that touched the file. One
|
|
15
|
+
// `git log --name-only` pass over the source directory yields every file's
|
|
16
|
+
// latest commit in a single process, which is far cheaper than a `git log`
|
|
17
|
+
// per file. Files with uncommitted changes (per `git status`) are taken
|
|
18
|
+
// from the working tree instead, since the commit time predates the edit.
|
|
19
|
+
// 2. filesystem mtime, for sources that are not in a git work tree, files git
|
|
20
|
+
// does not know about, and the uncommitted files above.
|
|
21
|
+
//
|
|
22
|
+
// A shallow clone (CI checkouts default to depth 1) truncates history, so
|
|
23
|
+
// every file appears to have been edited in the one fetched commit. That is
|
|
24
|
+
// detected and warned about; the fix is on the checkout side (fetch-depth: 0).
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
async function git(cwd, args) {
|
|
28
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
29
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
30
|
+
});
|
|
31
|
+
return stdout;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse `git log --format=%x00%ct --name-only` output into a map of
|
|
36
|
+
* repo-relative path → last-commit time in ms. The log is newest-first, so the
|
|
37
|
+
* first commit a path appears under is its latest.
|
|
38
|
+
* @param {string} log
|
|
39
|
+
* @returns {Map<string, number>}
|
|
40
|
+
*/
|
|
41
|
+
export function parseGitLog(log) {
|
|
42
|
+
const times = new Map();
|
|
43
|
+
let current = 0;
|
|
44
|
+
for (const line of log.split("\n")) {
|
|
45
|
+
if (line.startsWith("\0")) {
|
|
46
|
+
current = Number(line.slice(1)) * 1000;
|
|
47
|
+
} else if (line && !times.has(line)) {
|
|
48
|
+
times.set(line, current);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return times;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse `git status --porcelain -z` output into the set of repo-relative paths
|
|
56
|
+
* with uncommitted changes (modified, added, untracked, renamed — any status).
|
|
57
|
+
* @param {string} status
|
|
58
|
+
* @returns {Set<string>}
|
|
59
|
+
*/
|
|
60
|
+
export function parseGitStatus(status) {
|
|
61
|
+
const dirty = new Set();
|
|
62
|
+
const entries = status.split("\0");
|
|
63
|
+
for (let i = 0; i < entries.length; i++) {
|
|
64
|
+
const entry = entries[i];
|
|
65
|
+
if (entry.length < 4) continue;
|
|
66
|
+
const code = entry.slice(0, 2);
|
|
67
|
+
dirty.add(entry.slice(3));
|
|
68
|
+
// A rename entry ("R new\0old") is followed by the original path
|
|
69
|
+
if (code[0] === "R" || code[0] === "C") i++;
|
|
70
|
+
}
|
|
71
|
+
return dirty;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a lookup of last-edited times for files under `sourceDir`.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} sourceDir - Absolute path to the source directory
|
|
78
|
+
* @param {object} [options]
|
|
79
|
+
* @param {(msg: string) => void} [options.log] - Receives one-line status/warnings
|
|
80
|
+
* @returns {Promise<{ get: (file: string) => Promise<number>, source: 'git'|'mtime' }>}
|
|
81
|
+
*/
|
|
82
|
+
export async function buildSourceTimestampIndex(sourceDir, { log = () => {} } = {}) {
|
|
83
|
+
const dir = resolve(sourceDir);
|
|
84
|
+
|
|
85
|
+
async function mtime(file) {
|
|
86
|
+
try {
|
|
87
|
+
return (await stat(file)).mtimeMs;
|
|
88
|
+
} catch {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// git reports the real path of the work tree; resolve symlinks (macOS
|
|
94
|
+
// /var → /private/var, for one) so paths line up with what git prints.
|
|
95
|
+
let realDir = dir;
|
|
96
|
+
let toplevel = null;
|
|
97
|
+
try {
|
|
98
|
+
realDir = await realpath(dir);
|
|
99
|
+
toplevel = (await git(realDir, ["rev-parse", "--show-toplevel"])).trim();
|
|
100
|
+
} catch {
|
|
101
|
+
// Not a git work tree, or git is not installed
|
|
102
|
+
}
|
|
103
|
+
if (!toplevel) {
|
|
104
|
+
return { get: mtime, source: "mtime" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let committed;
|
|
108
|
+
let dirty;
|
|
109
|
+
const relSource = relative(toplevel, realDir) || ".";
|
|
110
|
+
try {
|
|
111
|
+
const [logOut, statusOut, shallow] = await Promise.all([
|
|
112
|
+
git(toplevel, ["log", "--format=%x00%ct", "--name-only", "--", relSource]),
|
|
113
|
+
git(toplevel, ["status", "--porcelain", "-z", "--untracked-files=all", "--", relSource]),
|
|
114
|
+
git(toplevel, ["rev-parse", "--is-shallow-repository"]),
|
|
115
|
+
]);
|
|
116
|
+
committed = parseGitLog(logOut);
|
|
117
|
+
dirty = parseGitStatus(statusOut);
|
|
118
|
+
if (shallow.trim() === "true") {
|
|
119
|
+
log(
|
|
120
|
+
"⚠️ Source is a shallow git clone: every document appears last edited in the one fetched commit, " +
|
|
121
|
+
"so recent activity will be inaccurate. Fetch full history (e.g. actions/checkout fetch-depth: 0)."
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
log(`⚠️ git history unavailable (${e.message.split("\n")[0]}); using file mtimes for recent activity`);
|
|
126
|
+
return { get: mtime, source: "mtime" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
source: "git",
|
|
131
|
+
async get(file) {
|
|
132
|
+
// Repo-relative path as git prints it: forward slashes on every platform
|
|
133
|
+
const fromSource = relative(dir, resolve(file));
|
|
134
|
+
const rel = (relSource === "." ? fromSource : `${relSource}/${fromSource}`).split(sep).join("/");
|
|
135
|
+
if (dirty.has(rel) || !committed.has(rel)) return mtime(file);
|
|
136
|
+
return committed.get(rel);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
package/src/helper/ursaConfig.js
CHANGED
|
@@ -56,56 +56,10 @@ export function getAndIncrementBuildId(sourceDir) {
|
|
|
56
56
|
const newBuildId = currentBuildId + 1;
|
|
57
57
|
|
|
58
58
|
config.buildId = newBuildId;
|
|
59
|
+
// Dropped in 0.96.0: last-edited times now come from git/mtime, not the
|
|
60
|
+
// build. Remove the stale map so it stops taking up the file.
|
|
61
|
+
delete config.contentTimestamps;
|
|
59
62
|
saveUrsaConfig(sourceDir, config);
|
|
60
63
|
|
|
61
64
|
return newBuildId;
|
|
62
65
|
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Load content timestamps from .ursa.json
|
|
66
|
-
* These track when each file's content actually changed (not filesystem mtime)
|
|
67
|
-
* @param {string} sourceDir - The source directory path
|
|
68
|
-
* @returns {Map<string, number>} Map of relative file paths to timestamps
|
|
69
|
-
*/
|
|
70
|
-
export function loadContentTimestamps(sourceDir) {
|
|
71
|
-
const config = loadUrsaConfig(sourceDir);
|
|
72
|
-
const timestamps = config.contentTimestamps || {};
|
|
73
|
-
return new Map(Object.entries(timestamps));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Save content timestamps to .ursa.json
|
|
78
|
-
* @param {string} sourceDir - The source directory path
|
|
79
|
-
* @param {Map<string, number>} timestampMap - Map of relative file paths to timestamps
|
|
80
|
-
*/
|
|
81
|
-
export function saveContentTimestamps(sourceDir, timestampMap) {
|
|
82
|
-
const config = loadUrsaConfig(sourceDir);
|
|
83
|
-
config.contentTimestamps = Object.fromEntries(timestampMap);
|
|
84
|
-
saveUrsaConfig(sourceDir, config);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Update the content timestamp for a single file
|
|
89
|
-
* @param {string} sourceDir - The source directory path
|
|
90
|
-
* @param {string} relativePath - The relative file path
|
|
91
|
-
* @param {number} timestamp - The timestamp when content changed
|
|
92
|
-
*/
|
|
93
|
-
export function updateContentTimestamp(sourceDir, relativePath, timestamp) {
|
|
94
|
-
const config = loadUrsaConfig(sourceDir);
|
|
95
|
-
if (!config.contentTimestamps) {
|
|
96
|
-
config.contentTimestamps = {};
|
|
97
|
-
}
|
|
98
|
-
config.contentTimestamps[relativePath] = timestamp;
|
|
99
|
-
saveUrsaConfig(sourceDir, config);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Get the content timestamp for a file, or null if not tracked
|
|
104
|
-
* @param {string} sourceDir - The source directory path
|
|
105
|
-
* @param {string} relativePath - The relative file path
|
|
106
|
-
* @returns {number|null} The timestamp or null
|
|
107
|
-
*/
|
|
108
|
-
export function getContentTimestamp(sourceDir, relativePath) {
|
|
109
|
-
const config = loadUrsaConfig(sourceDir);
|
|
110
|
-
return config.contentTimestamps?.[relativePath] || null;
|
|
111
|
-
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `generate({ _jsonOnly: true })` — emit the data files and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing claim is not "fewer files": it is that the .json a JSON-only
|
|
5
|
+
* build writes is byte-identical to the one a full build writes. Every step the
|
|
6
|
+
* mode skips operates on the assembled page, never on the JSON. If that ever
|
|
7
|
+
* stops being true, `identical to a full build's JSON` fails here rather than
|
|
8
|
+
* silently shipping different data to a consumer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm } from "fs/promises";
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { generate } from "../generate.js";
|
|
16
|
+
import { clearConfigCache } from "../../helper/folderConfig.js";
|
|
17
|
+
|
|
18
|
+
const META = join(process.cwd(), "meta");
|
|
19
|
+
|
|
20
|
+
let source;
|
|
21
|
+
let output;
|
|
22
|
+
|
|
23
|
+
async function doc(relPath, contents) {
|
|
24
|
+
const full = join(source, relPath);
|
|
25
|
+
await mkdir(join(full, ".."), { recursive: true });
|
|
26
|
+
await writeFile(full, contents);
|
|
27
|
+
return full;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
source = await mkdtemp(join(tmpdir(), "ursa-jsononly-src-"));
|
|
32
|
+
output = await mkdtemp(join(tmpdir(), "ursa-jsononly-out-"));
|
|
33
|
+
clearConfigCache();
|
|
34
|
+
|
|
35
|
+
await doc("index.md", "# Home\n\nWelcome.\n");
|
|
36
|
+
await doc(
|
|
37
|
+
"character/powers/absorb-magic.md",
|
|
38
|
+
[
|
|
39
|
+
"---",
|
|
40
|
+
"class: Witch",
|
|
41
|
+
"name: Absorb Magic",
|
|
42
|
+
"school: Antimagic",
|
|
43
|
+
"brief: Absorb energy from a touched spell",
|
|
44
|
+
"---",
|
|
45
|
+
"",
|
|
46
|
+
"# Absorb Magic",
|
|
47
|
+
"",
|
|
48
|
+
"Touch a spell and take it apart.",
|
|
49
|
+
"",
|
|
50
|
+
"## Range",
|
|
51
|
+
"",
|
|
52
|
+
"Touch.",
|
|
53
|
+
"",
|
|
54
|
+
].join("\n")
|
|
55
|
+
);
|
|
56
|
+
await doc("character/powers/mind-blast.md", "# Mind Blast\n\nA blast, of the mind.\n");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await rm(source, { recursive: true, force: true });
|
|
61
|
+
await rm(output, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const run = (opts) =>
|
|
65
|
+
generate({ _source: source, _meta: META, _output: output, _clean: true, ...opts });
|
|
66
|
+
|
|
67
|
+
describe("generate --json-only", () => {
|
|
68
|
+
it("writes the document JSON", async () => {
|
|
69
|
+
await run({ _jsonOnly: true });
|
|
70
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.json"))).toBe(true);
|
|
71
|
+
expect(existsSync(join(output, "index.json"))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("writes the directory record lists, which are the point of the mode", async () => {
|
|
75
|
+
await run({ _jsonOnly: true });
|
|
76
|
+
const listPath = join(output, "character/powers.json");
|
|
77
|
+
expect(existsSync(listPath)).toBe(true);
|
|
78
|
+
|
|
79
|
+
const records = JSON.parse(await readFile(listPath, "utf8"));
|
|
80
|
+
const absorb = records.find((r) => r.name === "absorb-magic");
|
|
81
|
+
expect(absorb).toBeDefined();
|
|
82
|
+
expect(absorb.url).toBe("/character/powers/absorb-magic.html");
|
|
83
|
+
expect(absorb.metadata.school).toBe("Antimagic");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("writes no HTML and no XML", async () => {
|
|
87
|
+
await run({ _jsonOnly: true });
|
|
88
|
+
expect(existsSync(join(output, "index.html"))).toBe(false);
|
|
89
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.html"))).toBe(false);
|
|
90
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.xml"))).toBe(false);
|
|
91
|
+
// The directory listing page, distinct from the record list above.
|
|
92
|
+
expect(existsSync(join(output, "character/powers.html"))).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("writes no meta assets, search index, menu data or recent activity", async () => {
|
|
96
|
+
await run({ _jsonOnly: true });
|
|
97
|
+
expect(existsSync(join(output, "public", "search-index.json"))).toBe(false);
|
|
98
|
+
expect(existsSync(join(output, "public", "fulltext-index.json"))).toBe(false);
|
|
99
|
+
expect(existsSync(join(output, "public", "menu-data.json"))).toBe(false);
|
|
100
|
+
expect(existsSync(join(output, "public", "recent-activity.json"))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("produces JSON identical to a full build's", async () => {
|
|
104
|
+
await run({ _jsonOnly: true });
|
|
105
|
+
const jsonOnly = await readFile(
|
|
106
|
+
join(output, "character/powers/absorb-magic.json"),
|
|
107
|
+
"utf8"
|
|
108
|
+
);
|
|
109
|
+
const jsonOnlyList = await readFile(join(output, "character/powers.json"), "utf8");
|
|
110
|
+
|
|
111
|
+
await rm(output, { recursive: true, force: true });
|
|
112
|
+
await mkdir(output, { recursive: true });
|
|
113
|
+
await run({ _jsonOnly: false });
|
|
114
|
+
|
|
115
|
+
const full = await readFile(join(output, "character/powers/absorb-magic.json"), "utf8");
|
|
116
|
+
const fullList = await readFile(join(output, "character/powers.json"), "utf8");
|
|
117
|
+
|
|
118
|
+
expect(jsonOnly).toBe(full);
|
|
119
|
+
expect(jsonOnlyList).toBe(fullList);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("still emits everything on a normal build", async () => {
|
|
123
|
+
await run({ _jsonOnly: false });
|
|
124
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.html"))).toBe(true);
|
|
125
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.xml"))).toBe(true);
|
|
126
|
+
expect(existsSync(join(output, "public", "menu-data.json"))).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("mixing modes against one source tree", () => {
|
|
131
|
+
// The hash cache lives in the SOURCE tree and is shared by both modes, so the
|
|
132
|
+
// per-document output check has to be mode-aware or one mode's cache entries
|
|
133
|
+
// would convince the other that its own missing outputs are up to date.
|
|
134
|
+
|
|
135
|
+
it("a full build after a JSON-only build still writes the HTML", async () => {
|
|
136
|
+
await run({ _jsonOnly: true });
|
|
137
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.html"))).toBe(false);
|
|
138
|
+
|
|
139
|
+
// Warm: no --clean, so the hash cache from the JSON-only run is in play.
|
|
140
|
+
await generate({ _source: source, _meta: META, _output: output, _jsonOnly: false });
|
|
141
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.html"))).toBe(true);
|
|
142
|
+
expect(existsSync(join(output, "character/powers/absorb-magic.xml"))).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("a JSON-only build after a full build leaves the JSON in place", async () => {
|
|
146
|
+
await run({ _jsonOnly: false });
|
|
147
|
+
const before = await readFile(join(output, "character/powers/absorb-magic.json"), "utf8");
|
|
148
|
+
|
|
149
|
+
await generate({ _source: source, _meta: META, _output: output, _jsonOnly: true });
|
|
150
|
+
const after = await readFile(join(output, "character/powers/absorb-magic.json"), "utf8");
|
|
151
|
+
|
|
152
|
+
expect(after).toBe(before);
|
|
153
|
+
});
|
|
154
|
+
});
|