@kenjura/ursa 0.95.0 → 0.97.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 (52) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +114 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/content-hooks.js +45 -0
  5. package/meta/templates/default-template/index.html +1 -0
  6. package/meta/templates/default-template/menu.js +18 -1
  7. package/meta/templates/default-template/search.js +11 -0
  8. package/meta/templates/default-template/sticky.js +7 -1
  9. package/meta/templates/default-template/toc-generator.js +58 -38
  10. package/meta/templates/default-template/widgets.js +4 -0
  11. package/package.json +1 -2
  12. package/src/dev.js +13 -23
  13. package/src/helper/__test__/contentHash.test.js +16 -6
  14. package/src/helper/__test__/mdxRenderer.test.js +159 -0
  15. package/src/helper/__test__/sourceTimestamps.test.js +0 -0
  16. package/src/helper/assetBundler.js +93 -19
  17. package/src/helper/automenu.js +36 -11
  18. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  19. package/src/helper/build/__test__/graph.test.js +259 -3
  20. package/src/helper/build/__test__/pass.test.js +553 -0
  21. package/src/helper/build/autoIndex.js +2 -371
  22. package/src/helper/build/excludeFilter.js +1 -2
  23. package/src/helper/build/footer.js +27 -14
  24. package/src/helper/build/graph.js +575 -152
  25. package/src/helper/build/index.js +0 -2
  26. package/src/helper/build/metadata.js +19 -5
  27. package/src/helper/build/pass.js +497 -0
  28. package/src/helper/build/precedence.js +174 -0
  29. package/src/helper/build/site.js +1270 -0
  30. package/src/helper/build/templates.js +1 -2
  31. package/src/helper/build/tracedFs.js +247 -0
  32. package/src/helper/contentHash.js +0 -78
  33. package/src/helper/customMenu.js +1 -1
  34. package/src/helper/fileRenderer.js +119 -111
  35. package/src/helper/findScriptJs.js +1 -1
  36. package/src/helper/findStyleCss.js +1 -1
  37. package/src/helper/folderConfig.js +7 -18
  38. package/src/helper/fullTextIndex.js +41 -29
  39. package/src/helper/imageProcessor.js +45 -0
  40. package/src/helper/linkValidator.js +118 -127
  41. package/src/helper/mdxRenderer.js +225 -26
  42. package/src/helper/menuLabels.js +30 -5
  43. package/src/helper/sourceTimestamps.js +139 -0
  44. package/src/helper/ursaConfig.js +3 -49
  45. package/src/helper/whitelistFilter.js +1 -2
  46. package/src/jobs/generate.js +67 -1859
  47. package/src/serve.js +317 -697
  48. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  49. package/src/helper/build/cacheBust.js +0 -141
  50. package/src/helper/build/navCache.js +0 -145
  51. package/src/helper/build/watchCache.js +0 -33
  52. package/src/helper/dependencyTracker.js +0 -384
@@ -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";
7
- import { existsSync } from "fs";
8
- import { writeFile, mkdir } from "fs/promises";
6
+ import { dirname, extname, join, resolve, sep } from "path";
7
+ import { existsSync } from "./build/tracedFs.js";
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).
@@ -91,11 +245,29 @@ function findComponentDirs(startDir, sourceRoot) {
91
245
  * @param {string} options.filePath - Absolute path to the MDX file (used for import resolution)
92
246
  * @param {string} [options.sourceRoot] - Root directory of the source files (for absolute imports)
93
247
  * @param {boolean} [options.hydrate=false] - If true, includes client bundle for hydration
94
- * @returns {Promise<{ html: string, frontmatter: Record<string, any>, clientCode?: string }>}
248
+ * @returns {Promise<{ html: string, frontmatter: Record<string, any>, clientCode?: string, inputs: string[] }>}
249
+ * `inputs` is every file esbuild loaded while bundling (components, their
250
+ * imports, anything under `_components`), excluding node_modules — the
251
+ * document's real dependency set, so the build can re-render exactly the
252
+ * pages that import an edited component.
95
253
  */
96
254
  export async function renderMDX({ source, filePath, sourceRoot, hydrate = false }) {
97
255
  const cwd = dirname(filePath);
98
256
  const componentDirs = findComponentDirs(cwd, sourceRoot);
257
+ const inputs = new Set();
258
+
259
+ /** Records every file esbuild loads; returns nothing so the real loaders still run. */
260
+ const inputRecorderPlugin = {
261
+ name: "ursa-input-recorder",
262
+ setup(build) {
263
+ build.onLoad({ filter: /.*/ }, (args) => {
264
+ if (args.namespace === "file" && !args.path.includes(`${sep}node_modules${sep}`)) {
265
+ inputs.add(args.path);
266
+ }
267
+ return undefined;
268
+ });
269
+ },
270
+ };
99
271
 
100
272
  /**
101
273
  * Create esbuild options for the given platform
@@ -121,7 +293,10 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
121
293
  const parentDirs = componentDirs.map(d => dirname(d));
122
294
  options.nodePaths = [...(options.nodePaths || []), ...parentDirs];
123
295
  }
124
-
296
+
297
+ // Island plugin goes first so it sees component imports before mdx-bundler's resolvers
298
+ options.plugins = [inputRecorderPlugin, islandPlugin(platform), ...(options.plugins || [])];
299
+
125
300
  return options;
126
301
  };
127
302
 
@@ -170,11 +345,11 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
170
345
  const Component = getMDXComponent(serverCode);
171
346
 
172
347
  // Render to HTML with hydration markers (renderToString vs renderToStaticMarkup)
173
- const html = renderToString(React.createElement(Component));
348
+ const html = stripHoistedPreloads(renderToString(React.createElement(Component)));
174
349
 
175
350
  // If hydration is not requested, return without client code
176
351
  if (!hydrate) {
177
- return { html, frontmatter: frontmatter || {} };
352
+ return { html, frontmatter: frontmatter || {}, inputs: [...inputs].sort() };
178
353
  }
179
354
 
180
355
  // Client-side bundle (for hydration)
@@ -191,9 +366,13 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
191
366
  html,
192
367
  frontmatter: frontmatter || {},
193
368
  clientCode: clientResult.code,
369
+ inputs: [...inputs].sort(),
194
370
  };
195
371
  } catch (error) {
196
- throw formatMDXError(error, filePath);
372
+ const formatted = formatMDXError(error, filePath);
373
+ formatted.inputs = [...inputs].sort();
374
+ formatted.componentDirs = componentDirs;
375
+ throw formatted;
197
376
  }
198
377
  }
199
378
 
@@ -279,11 +458,18 @@ function formatMDXError(error, filePath) {
279
458
  * @param {string} publicDir - Absolute path to the output public/ directory
280
459
  * @returns {Promise<void>}
281
460
  */
461
+ // Bump when the runtime's contents change; an older runtime left in
462
+ // output/public/ is then rebuilt instead of reused.
463
+ const REACT_RUNTIME_MARKER = 'ursa-react-runtime/2';
464
+
282
465
  export async function buildReactRuntime(publicDir) {
283
466
  const outfile = join(publicDir, 'react-runtime.js');
284
467
 
285
- // Skip rebuild if already exists (for incremental builds)
286
- if (existsSync(outfile)) return;
468
+ // Skip rebuild if an up-to-date runtime already exists (for incremental builds)
469
+ if (existsSync(outfile)) {
470
+ const existing = await readFile(outfile, 'utf8');
471
+ if (existing.includes(REACT_RUNTIME_MARKER)) return;
472
+ }
287
473
 
288
474
  await mkdir(publicDir, { recursive: true });
289
475
 
@@ -292,11 +478,12 @@ export async function buildReactRuntime(publicDir) {
292
478
  contents: `
293
479
  import React from 'react';
294
480
  import * as ReactDOM from 'react-dom';
295
- import { hydrateRoot } from 'react-dom/client';
481
+ import { hydrateRoot, createRoot } from 'react-dom/client';
296
482
  import * as _jsx_runtime from 'react/jsx-runtime';
297
483
  window.React = React;
298
- window.ReactDOM = { ...ReactDOM, hydrateRoot };
484
+ window.ReactDOM = { ...ReactDOM, hydrateRoot, createRoot };
299
485
  window._jsx_runtime = _jsx_runtime;
486
+ window.__ursaReactRuntime = ${JSON.stringify(REACT_RUNTIME_MARKER)};
300
487
  `,
301
488
  resolveDir: dirname(new URL(import.meta.url).pathname),
302
489
  loader: 'js',
@@ -313,12 +500,17 @@ export async function buildReactRuntime(publicDir) {
313
500
  /**
314
501
  * Generate the hydration script tags for an MDX page.
315
502
  * References the locally-built React runtime instead of CDN.
316
- *
503
+ *
504
+ * The bundled MDX module is rendered into a detached root purely to run the
505
+ * component tree: every island in it hydrates its own <ursa-island> element in
506
+ * the live document (see islandRuntimeSource). Nothing outside those elements
507
+ * is handed to React, so the template's DOM post-processing (sectionify,
508
+ * breadcrumbs, TOC) cannot cause a hydration mismatch.
509
+ *
317
510
  * @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
511
  * @returns {string} HTML script tags to include in the page
320
512
  */
321
- export function generateHydrationScript(clientCode, containerId = 'main-content') {
513
+ export function generateHydrationScript(clientCode) {
322
514
  // Escape the code for embedding in a script tag
323
515
  const escapedCode = clientCode
324
516
  .replace(/\\/g, '\\\\')
@@ -330,7 +522,7 @@ export function generateHydrationScript(clientCode, containerId = 'main-content'
330
522
  <!-- React runtime for MDX hydration (built from node_modules) -->
331
523
  <script src="/public/react-runtime.js"></script>
332
524
 
333
- <!-- MDX Hydration -->
525
+ <!-- MDX island hydration -->
334
526
  <script>
335
527
  (function() {
336
528
  // getMDXComponent: matches mdx-bundler/client calling convention.
@@ -344,23 +536,30 @@ export function generateHydrationScript(clientCode, containerId = 'main-content'
344
536
  var mdxExport = fn(React, ReactDOM, _jsx_runtime);
345
537
  return mdxExport.default;
346
538
  }
347
-
348
- // Hydrate when DOM is ready
539
+
540
+ // Hydrate when DOM is ready. Running after the template's own
541
+ // DOMContentLoaded handlers is fine: islands are found by id, so it
542
+ // does not matter where sectionify has moved them.
349
543
  function hydrate() {
350
544
  try {
351
- var container = document.getElementById('${containerId}');
352
- if (!container) {
353
- console.error('MDX hydration: container #${containerId} not found');
545
+ if (!window.ReactDOM || !window.ReactDOM.createRoot) {
546
+ console.error('MDX hydration: React runtime not loaded');
547
+ return;
548
+ }
549
+ var islands = document.querySelectorAll('${ISLAND_TAG}[data-island]');
550
+ if (islands.length === 0) {
551
+ console.log('MDX hydration: no islands on this page');
354
552
  return;
355
553
  }
356
-
554
+
357
555
  // MDX bundled code (compiled by mdx-bundler)
358
556
  var mdxCode = \`${escapedCode}\`;
359
557
  var Component = getMDXComponent(mdxCode);
360
-
361
- // Use hydrateRoot (React 18+)
362
- window.ReactDOM.hydrateRoot(container, window.React.createElement(Component));
363
- console.log('MDX hydration complete');
558
+
559
+ // Render into a detached root; each island hydrates itself in place.
560
+ var detached = document.createElement('div');
561
+ window.ReactDOM.createRoot(detached).render(window.React.createElement(Component));
562
+ console.log('MDX hydration: ' + islands.length + ' island(s)');
364
563
  } catch (err) {
365
564
  console.error('MDX hydration error:', err);
366
565
  }
@@ -7,9 +7,9 @@
7
7
  * resolution rules here is what makes `menu-label: 'BNW - Brave New World'`
8
8
  * show up in both places instead of only in the sidebar.
9
9
  */
10
- import { existsSync, readFileSync } from "fs";
10
+ import { existsSync, readFileSync, currentRecorder } from "./build/tracedFs.js";
11
11
  import { basename, extname, join } from "path";
12
- import { extractMetadata } from "./metadataExtractor.js";
12
+ import { extractMetadata, isMetadataOnly } from "./metadataExtractor.js";
13
13
  import { stripHtml } from "./stripHtml.js";
14
14
 
15
15
  // Index file extensions to check for folder metadata
@@ -29,6 +29,32 @@ export function toDisplayName(filename) {
29
29
  .replace(/\b\w/g, c => c.toUpperCase()); // Capitalize first letter of each word
30
30
  }
31
31
 
32
+ /**
33
+ * A document's frontmatter and whether it is metadata-only, or null when the
34
+ * file does not exist.
35
+ *
36
+ * When a build-graph node is computing, it may have pre-loaded this from the
37
+ * document's `docMeta` node into the active recorder's `frontmatter` map. A
38
+ * hit there records nothing further — the edge to `docMeta` already exists,
39
+ * and it is a projection that a body edit leaves unchanged. That is what
40
+ * keeps a paragraph edit from reaching the menu, the auto-indices and every
41
+ * breadcrumb beneath the folder. A miss reads the file (and records it).
42
+ *
43
+ * @param {string} filePath - Path to the source file
44
+ * @returns {{meta: object|null, isMetadataOnly: boolean}|null}
45
+ */
46
+ export function readFrontmatterInfo(filePath) {
47
+ const cached = currentRecorder()?.frontmatter?.get(filePath);
48
+ if (cached !== undefined) return cached;
49
+ try {
50
+ if (!existsSync(filePath)) return null;
51
+ const content = readFileSync(filePath, 'utf8');
52
+ return { meta: extractMetadata(content), isMetadataOnly: isMetadataOnly(content) };
53
+ } catch (e) {
54
+ return null;
55
+ }
56
+ }
57
+
32
58
  /**
33
59
  * Read a single frontmatter key from a file, with HTML stripped.
34
60
  * @param {string} filePath - Path to the source file
@@ -37,9 +63,8 @@ export function toDisplayName(filename) {
37
63
  */
38
64
  function getFrontmatterString(filePath, key) {
39
65
  try {
40
- if (!existsSync(filePath)) return null;
41
- const content = readFileSync(filePath, 'utf8');
42
- const metadata = extractMetadata(content);
66
+ const info = readFrontmatterInfo(filePath);
67
+ const metadata = info?.meta;
43
68
  if (metadata && metadata[key]) {
44
69
  return stripHtml(String(metadata[key]));
45
70
  }
@@ -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
+ }
@@ -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
- }
@@ -1,6 +1,5 @@
1
- import { readFile } from 'fs/promises';
1
+ import { readFile, existsSync } from './build/tracedFs.js';
2
2
  import { resolve, relative } from 'path';
3
- import { existsSync } from 'fs';
4
3
 
5
4
  /**
6
5
  * Creates a filter function based on a whitelist file