@takazudo/zudo-doc-history-server 0.1.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/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/chunk-6M66L4CY.js +176 -0
- package/dist/chunk-F5LKLFOU.js +125 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +51 -0
- package/dist/git-history.d.ts +45 -0
- package/dist/git-history.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +97 -0
- package/dist/types.d.ts +24 -0
- package/dist/types.js +0 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Takeshi Takatsudo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @takazudo/zudo-doc-history-server
|
|
2
|
+
|
|
3
|
+
Standalone package for extracting and serving git history of documentation files. Has two modes: an HTTP server for local development and a CLI generator for CI builds.
|
|
4
|
+
|
|
5
|
+
The history extraction logic was extracted from the Astro build pipeline so that expensive `git log --follow` calls do not block the main build, enabling a parallel CI strategy.
|
|
6
|
+
|
|
7
|
+
## Modes
|
|
8
|
+
|
|
9
|
+
### Server mode (local development)
|
|
10
|
+
|
|
11
|
+
Runs an HTTP server that serves history on demand. Used by `pnpm dev` at the repository root, which starts both Astro and this server concurrently via `run-p`.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm dev -- \
|
|
15
|
+
--content-dir src/content/docs \
|
|
16
|
+
--locale ja:src/content/docs-ja \
|
|
17
|
+
--port 4322
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| Flag | Required | Default | Description |
|
|
21
|
+
| ------------------ | -------- | ------- | --------------------------------------------- |
|
|
22
|
+
| `--content-dir` | Yes | — | Primary content directory to scan |
|
|
23
|
+
| `--locale <k>:<d>` | No | — | Extra locale directory (repeatable) |
|
|
24
|
+
| `--port` | No | `4322` | HTTP port |
|
|
25
|
+
| `--max-entries` | No | `50` | Max commits to include per file |
|
|
26
|
+
|
|
27
|
+
#### Endpoints
|
|
28
|
+
|
|
29
|
+
- `GET /doc-history/{slug}.json` — Full history for a document
|
|
30
|
+
- `GET /doc-history/{locale}/{slug}.json` — History for a localized document
|
|
31
|
+
- `GET /health` — Health check
|
|
32
|
+
|
|
33
|
+
The file index is refreshed every 10 seconds so newly added or renamed files are picked up without restarting the server. All responses include CORS headers for cross-origin dev access.
|
|
34
|
+
|
|
35
|
+
### CLI mode (CI builds)
|
|
36
|
+
|
|
37
|
+
Generates static `{slug}.json` files into an output directory. Used by the `build-history` CI job, which runs in parallel with the `build-site` Astro build.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pnpm generate -- \
|
|
41
|
+
--content-dir src/content/docs \
|
|
42
|
+
--locale ja:src/content/docs-ja \
|
|
43
|
+
--out-dir dist/doc-history
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Flag | Required | Default | Description |
|
|
47
|
+
| ------------------ | -------- | ------- | ----------------------------------------- |
|
|
48
|
+
| `--content-dir` | Yes | — | Primary content directory to scan |
|
|
49
|
+
| `--locale <k>:<d>` | No | — | Extra locale directory (repeatable) |
|
|
50
|
+
| `--out-dir` | Yes | — | Output directory for the generated JSONs |
|
|
51
|
+
| `--max-entries` | No | `50` | Max commits to include per file |
|
|
52
|
+
|
|
53
|
+
## Astro integration
|
|
54
|
+
|
|
55
|
+
In dev mode, the Astro integration at `src/integrations/doc-history.ts` proxies `/doc-history/*` requests to this server. In build mode, the integration falls back to inline generation unless `SKIP_DOC_HISTORY=1` is set — which is the case in the CI `build-site` job so that the Astro build completes fast while the CLI `build-history` job generates the JSONs in parallel.
|
|
56
|
+
|
|
57
|
+
## Build
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pnpm build
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Uses `tsup` to emit ESM output + DTS into `dist/`.
|
|
64
|
+
|
|
65
|
+
## Design notes
|
|
66
|
+
|
|
67
|
+
- **Synchronous git** — uses `execFileSync` for `git log` calls. The dev server is single-user and CI is inherently sequential, so async streaming is not needed.
|
|
68
|
+
- **Repo-relative paths** — responses use relative file paths to avoid leaking absolute server paths.
|
|
69
|
+
- **`--follow` for renames** — file history is tracked across renames with multiple fallback strategies.
|
|
70
|
+
- **pnpm --filter CWD** — when run via `pnpm --filter`, the CWD is this package dir, so content paths passed from CI need `../../` prefix for repo-relative resolution.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// src/git-history.ts
|
|
2
|
+
import { execFileSync } from "child_process";
|
|
3
|
+
import { readdirSync } from "fs";
|
|
4
|
+
import { join, relative } from "path";
|
|
5
|
+
var QUIET = {
|
|
6
|
+
encoding: "utf-8",
|
|
7
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
8
|
+
};
|
|
9
|
+
var repoRootCache = null;
|
|
10
|
+
function getRepoRoot() {
|
|
11
|
+
if (repoRootCache) return repoRootCache;
|
|
12
|
+
repoRootCache = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
13
|
+
encoding: "utf-8"
|
|
14
|
+
}).trim();
|
|
15
|
+
return repoRootCache;
|
|
16
|
+
}
|
|
17
|
+
function toRepoRelative(absolutePath) {
|
|
18
|
+
return relative(getRepoRoot(), absolutePath);
|
|
19
|
+
}
|
|
20
|
+
function getFileCommits(filePath, maxEntries = 50) {
|
|
21
|
+
try {
|
|
22
|
+
const output = execFileSync(
|
|
23
|
+
"git",
|
|
24
|
+
[
|
|
25
|
+
"log",
|
|
26
|
+
"--follow",
|
|
27
|
+
"--format=%H",
|
|
28
|
+
"-n",
|
|
29
|
+
String(maxEntries),
|
|
30
|
+
"--",
|
|
31
|
+
filePath
|
|
32
|
+
],
|
|
33
|
+
QUIET
|
|
34
|
+
).trim();
|
|
35
|
+
return output ? [...new Set(output.split("\n"))] : [];
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function getFirstCommit(filePath) {
|
|
41
|
+
try {
|
|
42
|
+
const output = execFileSync(
|
|
43
|
+
"git",
|
|
44
|
+
[
|
|
45
|
+
"log",
|
|
46
|
+
"--follow",
|
|
47
|
+
"--reverse",
|
|
48
|
+
"--format=%H",
|
|
49
|
+
"--max-count=1",
|
|
50
|
+
"--",
|
|
51
|
+
filePath
|
|
52
|
+
],
|
|
53
|
+
QUIET
|
|
54
|
+
).trim();
|
|
55
|
+
if (!output) return null;
|
|
56
|
+
const first = output.split("\n")[0]?.trim();
|
|
57
|
+
return first ? first : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function getCommitInfo(hash, filePath) {
|
|
63
|
+
try {
|
|
64
|
+
const output = execFileSync(
|
|
65
|
+
"git",
|
|
66
|
+
["log", "-1", "--format=%H%n%aI%n%aN%n%s", hash, "--", filePath],
|
|
67
|
+
QUIET
|
|
68
|
+
).trim();
|
|
69
|
+
const lines = output.split("\n");
|
|
70
|
+
return {
|
|
71
|
+
hash: lines[0] ?? hash,
|
|
72
|
+
date: lines[1] ?? "",
|
|
73
|
+
author: lines[2] ?? "",
|
|
74
|
+
message: lines[3] ?? ""
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return { hash, date: "", author: "", message: "" };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function getFileAtCommit(hash, filePath) {
|
|
81
|
+
const isAbsolute = filePath.startsWith("/");
|
|
82
|
+
const relPath = isAbsolute ? toRepoRelative(filePath) : filePath;
|
|
83
|
+
try {
|
|
84
|
+
return execFileSync("git", ["show", `${hash}:${relPath}`], QUIET);
|
|
85
|
+
} catch {
|
|
86
|
+
try {
|
|
87
|
+
const oldPath = execFileSync(
|
|
88
|
+
"git",
|
|
89
|
+
[
|
|
90
|
+
"log",
|
|
91
|
+
"-1",
|
|
92
|
+
"--follow",
|
|
93
|
+
"--diff-filter=R",
|
|
94
|
+
"--format=",
|
|
95
|
+
"--name-only",
|
|
96
|
+
hash,
|
|
97
|
+
"--",
|
|
98
|
+
relPath
|
|
99
|
+
],
|
|
100
|
+
QUIET
|
|
101
|
+
).trim();
|
|
102
|
+
if (oldPath) {
|
|
103
|
+
return execFileSync("git", ["show", `${hash}:${oldPath}`], QUIET);
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const followOutput = execFileSync(
|
|
109
|
+
"git",
|
|
110
|
+
[
|
|
111
|
+
"log",
|
|
112
|
+
"--follow",
|
|
113
|
+
"--format=%H",
|
|
114
|
+
"--name-only",
|
|
115
|
+
"--diff-filter=AMRC",
|
|
116
|
+
"--",
|
|
117
|
+
relPath
|
|
118
|
+
],
|
|
119
|
+
QUIET
|
|
120
|
+
).trim();
|
|
121
|
+
const lines = followOutput.split("\n").filter(Boolean);
|
|
122
|
+
for (let i = 0; i < lines.length - 1; i += 2) {
|
|
123
|
+
if (lines[i] === hash && lines[i + 1]) {
|
|
124
|
+
return execFileSync(
|
|
125
|
+
"git",
|
|
126
|
+
["show", `${hash}:${lines[i + 1]}`],
|
|
127
|
+
QUIET
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
return "";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function getDocHistory(filePath, slug, maxEntries = 50) {
|
|
137
|
+
const commits = getFileCommits(filePath, maxEntries);
|
|
138
|
+
const entries = commits.map((hash) => {
|
|
139
|
+
const info = getCommitInfo(hash, filePath);
|
|
140
|
+
const content = getFileAtCommit(hash, filePath);
|
|
141
|
+
return { ...info, content };
|
|
142
|
+
});
|
|
143
|
+
const relPath = filePath.startsWith("/") ? toRepoRelative(filePath) : filePath;
|
|
144
|
+
return { slug, filePath: relPath, entries };
|
|
145
|
+
}
|
|
146
|
+
function collectContentFiles(dir) {
|
|
147
|
+
const results = [];
|
|
148
|
+
function walk(currentDir, baseDir) {
|
|
149
|
+
let entries;
|
|
150
|
+
try {
|
|
151
|
+
entries = readdirSync(currentDir, { withFileTypes: true });
|
|
152
|
+
} catch {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const fullPath = join(currentDir, entry.name);
|
|
157
|
+
if (entry.isDirectory()) {
|
|
158
|
+
walk(fullPath, baseDir);
|
|
159
|
+
} else if (/\.mdx?$/.test(entry.name) && !entry.name.startsWith("_")) {
|
|
160
|
+
const rel = relative(baseDir, fullPath).replace(/\.mdx?$/, "").replace(/\/index$/, "");
|
|
161
|
+
results.push({ filePath: fullPath, slug: rel });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
walk(dir, dir);
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export {
|
|
170
|
+
getFileCommits,
|
|
171
|
+
getFirstCommit,
|
|
172
|
+
getCommitInfo,
|
|
173
|
+
getFileAtCommit,
|
|
174
|
+
getDocHistory,
|
|
175
|
+
collectContentFiles
|
|
176
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// src/args.ts
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { isAbsolute, resolve } from "path";
|
|
4
|
+
function resolveContentPath(p) {
|
|
5
|
+
if (isAbsolute(p)) return p;
|
|
6
|
+
const initCwd = process.env["INIT_CWD"];
|
|
7
|
+
if (initCwd) {
|
|
8
|
+
const candidate = resolve(initCwd, p);
|
|
9
|
+
if (existsSync(candidate)) return candidate;
|
|
10
|
+
console.warn(
|
|
11
|
+
`doc-history-server: INIT_CWD candidate "${candidate}" does not exist; falling back to process.cwd()`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
return resolve(p);
|
|
15
|
+
}
|
|
16
|
+
function requireNextArg(args, index, flag) {
|
|
17
|
+
if (index >= args.length) {
|
|
18
|
+
console.error(`Missing value for ${flag}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
return args[index];
|
|
22
|
+
}
|
|
23
|
+
function parseLocaleArg(val) {
|
|
24
|
+
const colonIdx = val.indexOf(":");
|
|
25
|
+
if (colonIdx === -1) {
|
|
26
|
+
console.error(`Invalid --locale format: ${val} (expected key:dir)`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
key: val.slice(0, colonIdx),
|
|
31
|
+
dir: resolveContentPath(val.slice(colonIdx + 1))
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function parseCommonArgs(args, extra) {
|
|
35
|
+
let contentDir = "";
|
|
36
|
+
const locales = [];
|
|
37
|
+
let maxEntries = 50;
|
|
38
|
+
for (let i = 0; i < args.length; i++) {
|
|
39
|
+
const flag = args[i];
|
|
40
|
+
const next = () => requireNextArg(args, ++i, flag);
|
|
41
|
+
switch (flag) {
|
|
42
|
+
case "--content-dir":
|
|
43
|
+
contentDir = resolveContentPath(next());
|
|
44
|
+
break;
|
|
45
|
+
case "--locale":
|
|
46
|
+
locales.push(parseLocaleArg(next()));
|
|
47
|
+
break;
|
|
48
|
+
case "--max-entries": {
|
|
49
|
+
const n = Number(next());
|
|
50
|
+
if (Number.isNaN(n) || n < 1) {
|
|
51
|
+
console.error(`Invalid --max-entries value: ${args[i]}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
maxEntries = n;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
default:
|
|
58
|
+
if (flag === "--") break;
|
|
59
|
+
if (!extra.onFlag(flag, next)) {
|
|
60
|
+
if (flag.startsWith("--")) {
|
|
61
|
+
console.error(`Unknown option: ${flag}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!contentDir) {
|
|
68
|
+
console.error("Missing required --content-dir option");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
return { contentDir, locales, maxEntries };
|
|
72
|
+
}
|
|
73
|
+
function parseServerArgs(args) {
|
|
74
|
+
let port = 4322;
|
|
75
|
+
const common = parseCommonArgs(args, {
|
|
76
|
+
onFlag: (flag, next) => {
|
|
77
|
+
if (flag === "--port") {
|
|
78
|
+
const n = Number(next());
|
|
79
|
+
if (Number.isNaN(n) || n < 1) {
|
|
80
|
+
console.error(`Invalid --port value`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
port = n;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return { ...common, port };
|
|
90
|
+
}
|
|
91
|
+
function parseCliArgs(args) {
|
|
92
|
+
let outDir = "";
|
|
93
|
+
const common = parseCommonArgs(args, {
|
|
94
|
+
onFlag: (flag, next) => {
|
|
95
|
+
if (flag === "--out-dir") {
|
|
96
|
+
outDir = next();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
if (!outDir) {
|
|
103
|
+
console.error("Missing required --out-dir option");
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
return { ...common, outDir };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/shared.ts
|
|
110
|
+
import { resolve as resolve2 } from "path";
|
|
111
|
+
function getContentDirEntries(contentDir, locales) {
|
|
112
|
+
const entries = [
|
|
113
|
+
[null, resolve2(contentDir)]
|
|
114
|
+
];
|
|
115
|
+
for (const locale of locales) {
|
|
116
|
+
entries.push([locale.key, resolve2(locale.dir)]);
|
|
117
|
+
}
|
|
118
|
+
return entries;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export {
|
|
122
|
+
parseServerArgs,
|
|
123
|
+
parseCliArgs,
|
|
124
|
+
getContentDirEntries
|
|
125
|
+
};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
getContentDirEntries,
|
|
4
|
+
parseCliArgs
|
|
5
|
+
} from "./chunk-F5LKLFOU.js";
|
|
6
|
+
import {
|
|
7
|
+
collectContentFiles,
|
|
8
|
+
getDocHistory
|
|
9
|
+
} from "./chunk-6M66L4CY.js";
|
|
10
|
+
|
|
11
|
+
// src/cli.ts
|
|
12
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
13
|
+
import { dirname, join } from "path";
|
|
14
|
+
function generate(options2) {
|
|
15
|
+
const { contentDir, locales, outDir, maxEntries } = options2;
|
|
16
|
+
const startTime = performance.now();
|
|
17
|
+
const dirEntries = getContentDirEntries(contentDir, locales);
|
|
18
|
+
mkdirSync(outDir, { recursive: true });
|
|
19
|
+
let totalFiles = 0;
|
|
20
|
+
let errorCount = 0;
|
|
21
|
+
for (const [localeKey, dir] of dirEntries) {
|
|
22
|
+
const files = collectContentFiles(dir);
|
|
23
|
+
const label = localeKey ?? "default";
|
|
24
|
+
console.log(`Processing ${label}: ${files.length} files in ${dir}`);
|
|
25
|
+
for (const { filePath, slug } of files) {
|
|
26
|
+
try {
|
|
27
|
+
const history = getDocHistory(filePath, slug, maxEntries);
|
|
28
|
+
const prefixedSlug = localeKey ? `${localeKey}/${slug}` : slug;
|
|
29
|
+
const jsonPath = join(outDir, `${prefixedSlug}.json`);
|
|
30
|
+
mkdirSync(dirname(jsonPath), { recursive: true });
|
|
31
|
+
writeFileSync(jsonPath, JSON.stringify(history));
|
|
32
|
+
totalFiles++;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
35
|
+
console.warn(` Skipped ${slug}: ${msg}`);
|
|
36
|
+
errorCount++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const elapsed = ((performance.now() - startTime) / 1e3).toFixed(1);
|
|
41
|
+
console.log(
|
|
42
|
+
`
|
|
43
|
+
Generated ${totalFiles} history files in ${elapsed}s${errorCount ? ` (${errorCount} errors)` : ""}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
var options = parseCliArgs(process.argv.slice(2));
|
|
47
|
+
console.log(`doc-history-server: content-dir resolved to ${options.contentDir}`);
|
|
48
|
+
for (const locale of options.locales) {
|
|
49
|
+
console.log(`doc-history-server: locale ${locale.key} resolved to ${locale.dir}`);
|
|
50
|
+
}
|
|
51
|
+
generate(options);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { DocHistoryEntry, DocHistoryData } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get the list of commit hashes that touched a file, newest first.
|
|
5
|
+
* Uses --follow to track renames.
|
|
6
|
+
* Limits to maxEntries commits (default 50).
|
|
7
|
+
*/
|
|
8
|
+
declare function getFileCommits(filePath: string, maxEntries?: number): string[];
|
|
9
|
+
/**
|
|
10
|
+
* Get the oldest commit hash that touched a file (the file's "first" commit).
|
|
11
|
+
*
|
|
12
|
+
* Uses --follow + --reverse + --max-count=1. Note that git still has to walk
|
|
13
|
+
* the file's full history once before applying --reverse, so this is O(history)
|
|
14
|
+
* for that file path, not O(1). Acceptable here because the caller is a
|
|
15
|
+
* build-time helper run once per content file, not a hot path.
|
|
16
|
+
*
|
|
17
|
+
* Returns null when the file has no git history (untracked / not yet committed).
|
|
18
|
+
*/
|
|
19
|
+
declare function getFirstCommit(filePath: string): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Get metadata for a specific commit on a file.
|
|
22
|
+
* Returns { hash, date, author, message } with full hash for unique identification.
|
|
23
|
+
*/
|
|
24
|
+
declare function getCommitInfo(hash: string, filePath: string): Omit<DocHistoryEntry, "content">;
|
|
25
|
+
/**
|
|
26
|
+
* Get the file content at a specific commit.
|
|
27
|
+
* Accepts absolute paths and converts to repo-relative for git show.
|
|
28
|
+
* Handles renamed files by falling back to the old path via git log --follow.
|
|
29
|
+
*/
|
|
30
|
+
declare function getFileAtCommit(hash: string, filePath: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Get the complete history for a document file.
|
|
33
|
+
* Returns DocHistoryData with all entries populated.
|
|
34
|
+
*/
|
|
35
|
+
declare function getDocHistory(filePath: string, slug: string, maxEntries?: number): DocHistoryData;
|
|
36
|
+
/**
|
|
37
|
+
* Collect all MDX/md files in a content directory.
|
|
38
|
+
* Returns array of { filePath, slug } pairs.
|
|
39
|
+
*/
|
|
40
|
+
declare function collectContentFiles(dir: string): Array<{
|
|
41
|
+
filePath: string;
|
|
42
|
+
slug: string;
|
|
43
|
+
}>;
|
|
44
|
+
|
|
45
|
+
export { collectContentFiles, getCommitInfo, getDocHistory, getFileAtCommit, getFileCommits, getFirstCommit };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collectContentFiles,
|
|
3
|
+
getCommitInfo,
|
|
4
|
+
getDocHistory,
|
|
5
|
+
getFileAtCommit,
|
|
6
|
+
getFileCommits,
|
|
7
|
+
getFirstCommit
|
|
8
|
+
} from "./chunk-6M66L4CY.js";
|
|
9
|
+
export {
|
|
10
|
+
collectContentFiles,
|
|
11
|
+
getCommitInfo,
|
|
12
|
+
getDocHistory,
|
|
13
|
+
getFileAtCommit,
|
|
14
|
+
getFileCommits,
|
|
15
|
+
getFirstCommit
|
|
16
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
getContentDirEntries,
|
|
4
|
+
parseServerArgs
|
|
5
|
+
} from "./chunk-F5LKLFOU.js";
|
|
6
|
+
import {
|
|
7
|
+
collectContentFiles,
|
|
8
|
+
getDocHistory
|
|
9
|
+
} from "./chunk-6M66L4CY.js";
|
|
10
|
+
|
|
11
|
+
// src/server.ts
|
|
12
|
+
import { createServer } from "http";
|
|
13
|
+
import { resolve } from "path";
|
|
14
|
+
var FILE_INDEX_REFRESH_MS = 1e4;
|
|
15
|
+
function setCorsHeaders(res) {
|
|
16
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
17
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
18
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
19
|
+
}
|
|
20
|
+
function sendJson(res, status, data) {
|
|
21
|
+
setCorsHeaders(res);
|
|
22
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
23
|
+
res.end(JSON.stringify(data));
|
|
24
|
+
}
|
|
25
|
+
function buildFileIndex(dirEntries) {
|
|
26
|
+
const index = /* @__PURE__ */ new Map();
|
|
27
|
+
for (const [localeKey, contentDir] of dirEntries) {
|
|
28
|
+
const files = collectContentFiles(contentDir);
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
const prefixedSlug = localeKey ? `${localeKey}/${file.slug}` : file.slug;
|
|
31
|
+
index.set(prefixedSlug, file);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return index;
|
|
35
|
+
}
|
|
36
|
+
function handleDocHistory(requestedSlug, fileIndex, maxEntries, res) {
|
|
37
|
+
const found = fileIndex.get(requestedSlug);
|
|
38
|
+
if (found) {
|
|
39
|
+
const history = getDocHistory(found.filePath, found.slug, maxEntries);
|
|
40
|
+
sendJson(res, 200, history);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
sendJson(res, 404, { error: `No doc found for slug: ${requestedSlug}` });
|
|
44
|
+
}
|
|
45
|
+
function startServer(options2) {
|
|
46
|
+
const { port, contentDir, locales, maxEntries } = options2;
|
|
47
|
+
const dirEntries = getContentDirEntries(contentDir, locales);
|
|
48
|
+
let fileIndex = buildFileIndex(dirEntries);
|
|
49
|
+
console.log(`Indexed ${fileIndex.size} documents`);
|
|
50
|
+
setInterval(() => {
|
|
51
|
+
try {
|
|
52
|
+
fileIndex = buildFileIndex(dirEntries);
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
}, FILE_INDEX_REFRESH_MS);
|
|
56
|
+
const server = createServer((req, res) => {
|
|
57
|
+
const url = req.url ?? "";
|
|
58
|
+
if (req.method === "OPTIONS") {
|
|
59
|
+
setCorsHeaders(res);
|
|
60
|
+
res.writeHead(204);
|
|
61
|
+
res.end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (url === "/health") {
|
|
65
|
+
sendJson(res, 200, { status: "ok" });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const match = url.match(/^\/doc-history\/(.+)\.json$/);
|
|
69
|
+
if (match) {
|
|
70
|
+
try {
|
|
71
|
+
const requestedSlug = decodeURIComponent(match[1]);
|
|
72
|
+
handleDocHistory(requestedSlug, fileIndex, maxEntries, res);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
sendJson(res, 500, {
|
|
75
|
+
error: err instanceof Error ? err.message : "Internal error"
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
sendJson(res, 404, { error: "Not found" });
|
|
81
|
+
});
|
|
82
|
+
server.listen(port, () => {
|
|
83
|
+
console.log(`Doc history server listening on http://localhost:${port}`);
|
|
84
|
+
console.log(`Content dir: ${resolve(contentDir)}`);
|
|
85
|
+
for (const locale of locales) {
|
|
86
|
+
console.log(`Locale ${locale.key}: ${resolve(locale.dir)}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/index.ts
|
|
92
|
+
var options = parseServerArgs(process.argv.slice(2));
|
|
93
|
+
console.log(`doc-history-server: content-dir resolved to ${options.contentDir}`);
|
|
94
|
+
for (const locale of options.locales) {
|
|
95
|
+
console.log(`doc-history-server: locale ${locale.key} resolved to ${locale.dir}`);
|
|
96
|
+
}
|
|
97
|
+
startServer(options);
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A single git revision entry for a document */
|
|
2
|
+
interface DocHistoryEntry {
|
|
3
|
+
/** Full commit hash (use .slice(0, 7) for display) */
|
|
4
|
+
hash: string;
|
|
5
|
+
/** ISO 8601 date string */
|
|
6
|
+
date: string;
|
|
7
|
+
/** Commit author name */
|
|
8
|
+
author: string;
|
|
9
|
+
/** First line of commit message */
|
|
10
|
+
message: string;
|
|
11
|
+
/** Full file content at this revision */
|
|
12
|
+
content: string;
|
|
13
|
+
}
|
|
14
|
+
/** Complete history data for a single document */
|
|
15
|
+
interface DocHistoryData {
|
|
16
|
+
/** Document slug (route path) */
|
|
17
|
+
slug: string;
|
|
18
|
+
/** Relative file path in the repository */
|
|
19
|
+
filePath: string;
|
|
20
|
+
/** Git revision entries, newest first */
|
|
21
|
+
entries: DocHistoryEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type { DocHistoryData, DocHistoryEntry };
|
package/dist/types.js
ADDED
|
File without changes
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@takazudo/zudo-doc-history-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Standalone doc git-history service for zudo-doc — HTTP server + CLI batch generator. Decouples expensive `git log --follow` calls from the main build pipeline.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Takeshi Takatsudo",
|
|
8
|
+
"homepage": "https://zudo-doc.takazudomodular.com",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/zudolab/zudo-doc.git",
|
|
12
|
+
"directory": "packages/doc-history-server"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/zudolab/zudo-doc/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"zudo-doc",
|
|
19
|
+
"documentation",
|
|
20
|
+
"git-history",
|
|
21
|
+
"mdx"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"doc-history-server": "./dist/index.js",
|
|
29
|
+
"doc-history-generate": "./dist/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./git-history": {
|
|
37
|
+
"types": "./dist/git-history.d.ts",
|
|
38
|
+
"default": "./dist/git-history.js"
|
|
39
|
+
},
|
|
40
|
+
"./types": {
|
|
41
|
+
"types": "./dist/types.d.ts",
|
|
42
|
+
"default": "./dist/types.js"
|
|
43
|
+
},
|
|
44
|
+
"./package.json": "./package.json"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"README.md"
|
|
49
|
+
],
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"tsup": "^8.0.0",
|
|
53
|
+
"tsx": "^4.0.0",
|
|
54
|
+
"typescript": "^5.9.3",
|
|
55
|
+
"vitest": "^3.1.1"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsup src/index.ts src/cli.ts src/git-history.ts src/types.ts --format esm --dts",
|
|
59
|
+
"dev": "tsx src/index.ts",
|
|
60
|
+
"generate": "tsx src/cli.ts",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"typecheck": "tsc --noEmit"
|
|
63
|
+
}
|
|
64
|
+
}
|