svelte-docsmith 0.12.0 → 0.12.1

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.
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
  import { assertValidVersionId } from '../../core/version.js';
14
14
  import { freezeLastUpdated, isInheritedRouteFile, rewriteDocsLinks } from '../archive.js';
15
15
  import { ARCHIVE_MARKER, discoverArchives, markerContents } from '../archives.js';
16
- import { lastCommitDate } from '../git.js';
16
+ import { commitDates } from '../git.js';
17
17
  import { docsBaseFrom } from '../paths.js';
18
18
  import { isPageFile } from '../vite/pages.js';
19
19
  import { CliError } from './error.js';
@@ -79,6 +79,14 @@ export function archiveVersion(options, log) {
79
79
  yield { file, source: path.join(contentDir, path.relative(toDir, file)) };
80
80
  }
81
81
  }
82
+ // Read the dates before copying, so the walk sees the docs root as it was
83
+ // rather than a tree half full of untracked copies. Say so if they could not
84
+ // be read: an archive is written once and never edited again, so a page that
85
+ // misses its date here keeps the wrong one for good.
86
+ const dates = commitDates(contentDir, (reason) => {
87
+ log(`\n! Could not read commit dates: ${reason}`);
88
+ log(' Archived pages will keep no last-updated date.\n');
89
+ });
82
90
  copyCurrent(contentDir, toDir);
83
91
  fs.writeFileSync(path.join(toDir, ARCHIVE_MARKER), markerContents(id));
84
92
  let pages = 0;
@@ -87,12 +95,15 @@ export function archiveVersion(options, log) {
87
95
  continue;
88
96
  pages++;
89
97
  const text = fs.readFileSync(file, 'utf-8');
90
- fs.writeFileSync(file, freezeLastUpdated(rewriteDocsLinks(text, { docsBase, versionId: id, archivedIds }), lastCommitDate(source)));
98
+ fs.writeFileSync(file, freezeLastUpdated(rewriteDocsLinks(text, { docsBase, versionId: id, archivedIds }), dates.get(source)));
91
99
  }
92
100
  log(`\n✓ Archived the current docs into ${rel(toDir)} (${pages} pages)\n`);
93
- log('Links were rewritten to stay inside the archive, and each page kept');
94
- log('its real last-updated date. Review the diff, then update');
95
- log('docsmith({ versions }) so the archive is served:\n');
101
+ log('Links were rewritten to stay inside the archive.');
102
+ // Only claim the dates were kept when the walk actually produced some.
103
+ if (dates.size)
104
+ log('Each page kept its real last-updated date.');
105
+ log('Review the diff, then update docsmith({ versions }) so the archive');
106
+ log('is served:\n');
96
107
  log(' versions: {');
97
108
  log(" current: { id: '<new release>', label: '<new release>' },");
98
109
  log(` archived: [{ id: '${id}', label: '${label}' }${archivedIds.size ? ', …' : ''}]`);
@@ -1,5 +1,33 @@
1
- /** Commit day (`YYYY-MM-DD`) a file last changed, or undefined outside a repo. */
2
- export declare function lastCommitDate(file: string): string | undefined;
1
+ /**
2
+ * Commit day (`YYYY-MM-DD`) every tracked file under `dir` last changed, keyed by
3
+ * absolute path. One history walk rather than a `git log` per file: the per-file
4
+ * form costs a process each, which is the build's largest single expense once a
5
+ * site has more than a handful of pages.
6
+ *
7
+ * `--relative` makes git print paths relative to `dir`, so they join straight
8
+ * back onto it with no second call to find the repository root. `--no-renames`
9
+ * makes a rename list both of its paths, which keeps the answer identical to
10
+ * asking about one file at a time.
11
+ *
12
+ * An empty map for a directory outside a repository, or one git could not read.
13
+ * That is the same "no date" every caller already handles, and it is deliberately
14
+ * all-or-nothing: a truncated read would date some pages and not others, which
15
+ * looks like real data and is not.
16
+ *
17
+ * Only a truncated read calls `onFail`, because it is the one failure that would
18
+ * otherwise date some pages and not others. A directory outside a repository, a
19
+ * missing directory, or a machine without git all yield no dates, which every
20
+ * caller already handles and which the plugin already reports where it matters.
21
+ * Reporting is the caller's to do, which is why this takes a callback rather
22
+ * than writing to the console itself.
23
+ */
24
+ export declare function commitDates(dir: string, onFail?: (reason: string) => void,
25
+ /**
26
+ * Cap on the walk's output. The default is far above any real docs root; it is
27
+ * a parameter so the truncation path can be exercised without a repository
28
+ * large enough to produce 256 MB of history.
29
+ */
30
+ maxBuffer?: number): Map<string, string>;
3
31
  /**
4
32
  * Release dates keyed by version, read from the commit that introduced each
5
33
  * `## <version>` heading in a changelog. Changesets does not write dates, and a
@@ -12,14 +12,64 @@
12
12
  */
13
13
  import { spawnSync } from 'node:child_process';
14
14
  import path from 'node:path';
15
- /** Commit day (`YYYY-MM-DD`) a file last changed, or undefined outside a repo. */
16
- export function lastCommitDate(file) {
17
- const res = spawnSync('git', ['log', '-1', '--format=%cs', '--', file], {
18
- cwd: path.dirname(file),
19
- encoding: 'utf-8'
20
- });
21
- const date = res.status === 0 ? res.stdout.trim() : '';
22
- return date || undefined;
15
+ /**
16
+ * Commit day (`YYYY-MM-DD`) every tracked file under `dir` last changed, keyed by
17
+ * absolute path. One history walk rather than a `git log` per file: the per-file
18
+ * form costs a process each, which is the build's largest single expense once a
19
+ * site has more than a handful of pages.
20
+ *
21
+ * `--relative` makes git print paths relative to `dir`, so they join straight
22
+ * back onto it with no second call to find the repository root. `--no-renames`
23
+ * makes a rename list both of its paths, which keeps the answer identical to
24
+ * asking about one file at a time.
25
+ *
26
+ * An empty map for a directory outside a repository, or one git could not read.
27
+ * That is the same "no date" every caller already handles, and it is deliberately
28
+ * all-or-nothing: a truncated read would date some pages and not others, which
29
+ * looks like real data and is not.
30
+ *
31
+ * Only a truncated read calls `onFail`, because it is the one failure that would
32
+ * otherwise date some pages and not others. A directory outside a repository, a
33
+ * missing directory, or a machine without git all yield no dates, which every
34
+ * caller already handles and which the plugin already reports where it matters.
35
+ * Reporting is the caller's to do, which is why this takes a callback rather
36
+ * than writing to the console itself.
37
+ */
38
+ export function commitDates(dir, onFail,
39
+ /**
40
+ * Cap on the walk's output. The default is far above any real docs root; it is
41
+ * a parameter so the truncation path can be exercised without a repository
42
+ * large enough to produce 256 MB of history.
43
+ */
44
+ maxBuffer = 256 * 1024 * 1024) {
45
+ const dates = new Map();
46
+ const res = spawnSync('git', ['log', '--format=%cs', '--name-only', '--relative', '--no-renames', '--', '.'],
47
+ // The 1 MB default would truncate rather than throw on a large history,
48
+ // which is why the cap is raised and the result checked below.
49
+ { cwd: dir, encoding: 'utf-8', maxBuffer });
50
+ // `ENOBUFS` is the output outgrowing maxBuffer, which truncates rather than
51
+ // throws and is the only failure that could leave a half-filled map. `ENOENT`
52
+ // (no such directory, or no git installed) and a non-zero status ("not a
53
+ // repository") both mean no dates at all, which is a state callers expect.
54
+ if (res.error?.code === 'ENOBUFS') {
55
+ onFail?.(res.error.message);
56
+ }
57
+ if (res.error || res.status !== 0)
58
+ return dates;
59
+ let date;
60
+ for (const line of res.stdout.split('\n')) {
61
+ if (!line)
62
+ continue;
63
+ if (/^\d{4}-\d{2}-\d{2}$/.test(line)) {
64
+ date = line;
65
+ continue;
66
+ }
67
+ // Newest commit first, so the first date a file appears under is its latest.
68
+ const file = path.resolve(dir, line);
69
+ if (date && !dates.has(file))
70
+ dates.set(file, date);
71
+ }
72
+ return dates;
23
73
  }
24
74
  /**
25
75
  * Release dates keyed by version, read from the commit that introduced each
@@ -26,6 +26,7 @@ import { docsBaseFrom } from '../paths.js';
26
26
  import { isPageFile, readSourcePages, withCommitDates } from './pages.js';
27
27
  import { contentIndex, llmsIndex, searchIndex } from './collect.js';
28
28
  import { collectReleases } from './releases.js';
29
+ import { commitDates } from '../git.js';
29
30
  /**
30
31
  * The svelte-docsmith Vite plugin. Add it to `plugins` in `vite.config.ts`:
31
32
  *
@@ -70,6 +71,17 @@ function contentIndexPlugin(options) {
70
71
  const changelogRoute = options.changelogPath ?? '/changelog';
71
72
  const changelogOverrides = path.join(routesDir, changelogRoute);
72
73
  const indexOptions = { contentDir, routesDir, versions };
74
+ // One history walk, reused for the life of the plugin. A build loads this
75
+ // module once, so it is always fresh there; `dev` reloads it on every page
76
+ // save, and re-walking each time would cost git a process for dates that
77
+ // cannot have changed without a commit. The trade is that committing during
78
+ // `dev` leaves the dates as they were until the server restarts.
79
+ let dates;
80
+ const trackedDates = () => (dates ??= commitDates(contentDir, (reason) => {
81
+ console.warn(`[svelte-docsmith] could not read commit dates under ${contentDir}\n` +
82
+ ` ${reason}\n` +
83
+ ` Pages will render without a last-updated date, and sitemap entries without \`lastmod\`.`);
84
+ }));
73
85
  return {
74
86
  name: 'docsmith-content',
75
87
  enforce: 'pre',
@@ -101,7 +113,7 @@ function contentIndexPlugin(options) {
101
113
  const { pages, exists } = readSourcePages(contentDir);
102
114
  watch(pages);
103
115
  // Only this index has a date field, so only this one pays for git.
104
- const docs = contentIndex(withCommitDates(pages), indexOptions);
116
+ const docs = contentIndex(withCommitDates(pages, trackedDates()), indexOptions);
105
117
  warnAboutDocsRoot(contentDir, exists, docs.length);
106
118
  const resolved = resolveVersions(versions, docsBase, docs);
107
119
  return (`export const docs = ${JSON.stringify(docs, null, 2)};\n` +
@@ -39,18 +39,18 @@ export declare function readSourcePages(contentDir: string): {
39
39
  */
40
40
  export declare function titleOf(page: SourcePage): string | undefined;
41
41
  /**
42
- * Date each page from the commit that last touched it, skipping the lookup for
43
- * pages that carry their own `lastUpdated`. Archiving copies every page in one
44
- * commit, so an archive's pages are written with the date they last really
45
- * changed and must keep it.
42
+ * Date each page from `dates`, keyed by absolute path, unless it carries its own
43
+ * frontmatter `lastUpdated`. Archiving copies every page in one commit, so an
44
+ * archive's pages are written with the date they last really changed and must
45
+ * keep it.
46
46
  *
47
- * Deliberately a second pass rather than part of {@link readSourcePages}: it
48
- * spawns a git process per page, and only the content index has a date field.
49
- * Folding it into the read would make the search and llms indexes pay for a
50
- * value neither of them has anywhere to put. For the same reason it skips
51
- * untitled pages, which no index carries.
47
+ * Pure: the caller walks git once with {@link commitDates} and hands the result
48
+ * in. Asking per page cost a process each and was the build's largest single
49
+ * expense, and doing the walk here would put it behind a function the search and
50
+ * llms indexes also call, making them pay for a date neither has anywhere to put.
51
+ * For the same reason this skips untitled pages, which no index carries.
52
52
  */
53
- export declare function withCommitDates(pages: SourcePage[]): SourcePage[];
53
+ export declare function withCommitDates(pages: SourcePage[], dates: Map<string, string>): SourcePage[];
54
54
  /**
55
55
  * List the absolute paths of every `+page.md`/`+page.svx` under `contentDir`.
56
56
  */
@@ -7,7 +7,6 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { parseFrontmatter } from './frontmatter.js';
10
- import { lastCommitDate } from '../git.js';
11
10
  const PAGE_NAMES = ['+page.md', '+page.svx'];
12
11
  /** Whether a filename is a doc page the plugin should index. */
13
12
  export function isPageFile(file) {
@@ -40,18 +39,18 @@ export function titleOf(page) {
40
39
  return typeof page.frontmatter.title === 'string' ? page.frontmatter.title : undefined;
41
40
  }
42
41
  /**
43
- * Date each page from the commit that last touched it, skipping the lookup for
44
- * pages that carry their own `lastUpdated`. Archiving copies every page in one
45
- * commit, so an archive's pages are written with the date they last really
46
- * changed and must keep it.
42
+ * Date each page from `dates`, keyed by absolute path, unless it carries its own
43
+ * frontmatter `lastUpdated`. Archiving copies every page in one commit, so an
44
+ * archive's pages are written with the date they last really changed and must
45
+ * keep it.
47
46
  *
48
- * Deliberately a second pass rather than part of {@link readSourcePages}: it
49
- * spawns a git process per page, and only the content index has a date field.
50
- * Folding it into the read would make the search and llms indexes pay for a
51
- * value neither of them has anywhere to put. For the same reason it skips
52
- * untitled pages, which no index carries.
47
+ * Pure: the caller walks git once with {@link commitDates} and hands the result
48
+ * in. Asking per page cost a process each and was the build's largest single
49
+ * expense, and doing the walk here would put it behind a function the search and
50
+ * llms indexes also call, making them pay for a date neither has anywhere to put.
51
+ * For the same reason this skips untitled pages, which no index carries.
53
52
  */
54
- export function withCommitDates(pages) {
53
+ export function withCommitDates(pages, dates) {
55
54
  return pages.map((page) => {
56
55
  if (titleOf(page) === undefined)
57
56
  return page;
@@ -59,7 +58,7 @@ export function withCommitDates(pages) {
59
58
  ...page,
60
59
  lastUpdated: typeof page.frontmatter.lastUpdated === 'string'
61
60
  ? page.frontmatter.lastUpdated
62
- : lastCommitDate(page.file)
61
+ : dates.get(page.file)
63
62
  };
64
63
  });
65
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-docsmith",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "A framework for building beautiful documentation sites with Svelte.",
5
5
  "author": {
6
6
  "name": "George Daskalakis",