@sorane/cli 0.2.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/package.json +43 -0
- package/src/build.ts +18 -0
- package/src/config-load.ts +28 -0
- package/src/index-cmd.ts +52 -0
- package/src/main.ts +52 -0
- package/src/migrate.ts +46 -0
- package/src/search-cmd.ts +104 -0
- package/src/validate.ts +58 -0
- package/src/watch.ts +79 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sorane/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OKF-native static site generator CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/masanork/sorane.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://sorane.dev",
|
|
13
|
+
"bugs": "https://github.com/masanork/sorane/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"ssg",
|
|
16
|
+
"static-site-generator",
|
|
17
|
+
"okf",
|
|
18
|
+
"open-knowledge-format",
|
|
19
|
+
"markdown",
|
|
20
|
+
"blog",
|
|
21
|
+
"ai-disclosure",
|
|
22
|
+
"c2pa"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"bin": {
|
|
28
|
+
"sorane": "./src/main.ts"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=23.6"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"prepublishOnly": "cd ../.. && npm run typecheck && npm test",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@sorane/core": "0.2.0",
|
|
39
|
+
"@sorane/okf": "0.2.0",
|
|
40
|
+
"@sorane/search": "0.2.0",
|
|
41
|
+
"js-yaml": "^4.1.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { runBuild } from "@sorane/core";
|
|
2
|
+
import { loadSoraneConfig, parseCwdFlag } from "./config-load.ts";
|
|
3
|
+
|
|
4
|
+
export async function runBuildCmd(argv: string[]): Promise<void> {
|
|
5
|
+
const cwd = parseCwdFlag(argv);
|
|
6
|
+
const config = loadSoraneConfig(cwd);
|
|
7
|
+
const clean = argv.includes("--clean");
|
|
8
|
+
const result = await runBuild({
|
|
9
|
+
cwd,
|
|
10
|
+
config,
|
|
11
|
+
clean,
|
|
12
|
+
skipC2pa: argv.includes("--skip-c2pa"),
|
|
13
|
+
});
|
|
14
|
+
const secs = (result.durationMs / 1000).toFixed(1);
|
|
15
|
+
process.stdout.write(
|
|
16
|
+
`[sorane] built ${result.pages} page(s) in ${secs}s → ${config.build.out_dir}/\n`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { mergeConfig, type SoraneConfig } from "@sorane/core";
|
|
5
|
+
|
|
6
|
+
export function loadSoraneConfig(cwd: string): SoraneConfig {
|
|
7
|
+
const path = resolve(cwd, "sorane.yaml");
|
|
8
|
+
if (!existsSync(path)) {
|
|
9
|
+
return mergeConfig({});
|
|
10
|
+
}
|
|
11
|
+
const raw = yaml.load(readFileSync(path, "utf8"), { schema: yaml.CORE_SCHEMA });
|
|
12
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
13
|
+
throw new Error("sorane.yaml must be a YAML mapping");
|
|
14
|
+
}
|
|
15
|
+
const doc = raw as Record<string, unknown>;
|
|
16
|
+
return mergeConfig({
|
|
17
|
+
site: doc.site as SoraneConfig["site"] | undefined,
|
|
18
|
+
build: doc.build as SoraneConfig["build"] | undefined,
|
|
19
|
+
fonts: doc.fonts as SoraneConfig["fonts"] | undefined,
|
|
20
|
+
search: doc.search as SoraneConfig["search"] | undefined,
|
|
21
|
+
docs: doc.docs as SoraneConfig["docs"] | undefined,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseCwdFlag(argv: string[]): string {
|
|
26
|
+
const i = argv.indexOf("--cwd");
|
|
27
|
+
return i >= 0 && argv[i + 1] ? resolve(argv[i + 1]!) : process.cwd();
|
|
28
|
+
}
|
package/src/index-cmd.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { buildSearchIndex, RuriEmbeddings } from "@sorane/search";
|
|
4
|
+
import { loadSoraneConfig, parseCwdFlag } from "./config-load.ts";
|
|
5
|
+
|
|
6
|
+
export async function runIndexCmd(argv: string[]): Promise<void> {
|
|
7
|
+
const cwd = parseCwdFlag(argv);
|
|
8
|
+
const config = loadSoraneConfig(cwd);
|
|
9
|
+
const force = argv.includes("--force");
|
|
10
|
+
const configMode = config.search.mode ?? "fts";
|
|
11
|
+
const hybrid =
|
|
12
|
+
argv.includes("--hybrid") || (argv.includes("--fts-only") ? false : configMode === "hybrid");
|
|
13
|
+
const get = (flag: string, def: string) => {
|
|
14
|
+
const i = argv.indexOf(flag);
|
|
15
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1]! : def;
|
|
16
|
+
};
|
|
17
|
+
const outFlag = argv.indexOf("--out");
|
|
18
|
+
const indexPath =
|
|
19
|
+
outFlag >= 0 && argv[outFlag + 1]
|
|
20
|
+
? resolve(cwd, argv[outFlag + 1]!)
|
|
21
|
+
: resolve(cwd, config.search.index);
|
|
22
|
+
const contentDir = resolve(cwd, config.build.content_dir);
|
|
23
|
+
const modelRoot = resolve(cwd, get("--model", config.search.model));
|
|
24
|
+
const modelId = get("--model-id", config.search.model_id);
|
|
25
|
+
|
|
26
|
+
let embeddings = null;
|
|
27
|
+
if (hybrid) {
|
|
28
|
+
const modelDir = resolve(modelRoot, modelId);
|
|
29
|
+
if (!existsSync(modelDir)) {
|
|
30
|
+
process.stderr.write(
|
|
31
|
+
`[sorane] model not found at ${modelDir}; indexing FTS-only\n` +
|
|
32
|
+
` run: npm run fetch-model (or sorane index without --hybrid)\n`,
|
|
33
|
+
);
|
|
34
|
+
} else {
|
|
35
|
+
embeddings = new RuriEmbeddings({ modelRoot, modelId });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const result = await buildSearchIndex({
|
|
40
|
+
contentDir,
|
|
41
|
+
indexPath,
|
|
42
|
+
force,
|
|
43
|
+
embeddings,
|
|
44
|
+
onProgress: (message) => process.stdout.write(`[sorane] ${message}\n`),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
process.stdout.write(
|
|
48
|
+
`[sorane] indexed ${result.chunks} chunk(s) [${result.mode}] → ${indexPath}\n` +
|
|
49
|
+
` added=${result.added} changed=${result.changed} removed=${result.removed} unchanged=${result.unchanged}\n` +
|
|
50
|
+
(result.mode === "hybrid" ? ` vec=${result.vec}\n` : ""),
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runBuildCmd } from "./build.ts";
|
|
3
|
+
import { runWatchCmd } from "./watch.ts";
|
|
4
|
+
import { runIndexCmd } from "./index-cmd.ts";
|
|
5
|
+
import { runMigrateCmd } from "./migrate.ts";
|
|
6
|
+
import { runSearchCmd } from "./search-cmd.ts";
|
|
7
|
+
import { runValidateCmd } from "./validate.ts";
|
|
8
|
+
|
|
9
|
+
const [, , command, ...rest] = process.argv;
|
|
10
|
+
|
|
11
|
+
async function main(): Promise<void> {
|
|
12
|
+
switch (command) {
|
|
13
|
+
case "build":
|
|
14
|
+
if (rest.includes("--watch")) {
|
|
15
|
+
await runWatchCmd(rest.filter((a) => a !== "--watch"));
|
|
16
|
+
} else {
|
|
17
|
+
await runBuildCmd(rest);
|
|
18
|
+
}
|
|
19
|
+
break;
|
|
20
|
+
case "watch":
|
|
21
|
+
await runWatchCmd(rest);
|
|
22
|
+
break;
|
|
23
|
+
case "validate":
|
|
24
|
+
await runValidateCmd(rest);
|
|
25
|
+
break;
|
|
26
|
+
case "migrate":
|
|
27
|
+
await runMigrateCmd(rest);
|
|
28
|
+
break;
|
|
29
|
+
case "index":
|
|
30
|
+
await runIndexCmd(rest);
|
|
31
|
+
break;
|
|
32
|
+
case "search":
|
|
33
|
+
await runSearchCmd(rest);
|
|
34
|
+
break;
|
|
35
|
+
default:
|
|
36
|
+
process.stderr.write(
|
|
37
|
+
"usage: sorane <build|validate|migrate|index|search|watch> [options]\n" +
|
|
38
|
+
" build --cwd <dir> [--clean] [--watch] [--skip-c2pa]\n" +
|
|
39
|
+
" watch --cwd <dir> [--clean]\n" +
|
|
40
|
+
" validate --cwd <dir>\n" +
|
|
41
|
+
" migrate --cwd <dir> [--dry-run] [--bump-profile 0.2]\n" +
|
|
42
|
+
" index --cwd <dir> [--force] [--hybrid] [--fts-only] [--out <path>] [--model <dir>] [--model-id <id>]\n" +
|
|
43
|
+
" search <query> [--cwd <dir>] [--type article] [--tag <slug>] [--k 10] [--json] [--fts-only]\n",
|
|
44
|
+
);
|
|
45
|
+
process.exit(command === undefined ? 0 : 1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main().catch((err) => {
|
|
50
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { migrateToOkf, parseBumpProfileArg } from "@sorane/core";
|
|
4
|
+
import { loadSoraneConfig, parseCwdFlag } from "./config-load.ts";
|
|
5
|
+
|
|
6
|
+
function walkMarkdown(root: string): string[] {
|
|
7
|
+
const out: string[] = [];
|
|
8
|
+
function visit(dir: string): void {
|
|
9
|
+
for (const name of readdirSync(dir).sort()) {
|
|
10
|
+
const abs = join(dir, name);
|
|
11
|
+
if (statSync(abs).isDirectory()) visit(abs);
|
|
12
|
+
else if (name.endsWith(".md")) out.push(abs);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
visit(root);
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function runMigrateCmd(argv: string[]): Promise<void> {
|
|
20
|
+
const cwd = parseCwdFlag(argv);
|
|
21
|
+
const config = loadSoraneConfig(cwd);
|
|
22
|
+
const contentDir = resolve(cwd, config.build.content_dir);
|
|
23
|
+
const dryRun = argv.includes("--dry-run");
|
|
24
|
+
const bumpProfile = parseBumpProfileArg(argv);
|
|
25
|
+
|
|
26
|
+
if (!existsSync(contentDir)) {
|
|
27
|
+
throw new Error(`content directory not found: ${contentDir}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const abs of walkMarkdown(contentDir)) {
|
|
32
|
+
const rel = relative(contentDir, abs);
|
|
33
|
+
const source = readFileSync(abs, "utf8");
|
|
34
|
+
const migrated = migrateToOkf(source, rel, bumpProfile ? { bumpProfile } : undefined);
|
|
35
|
+
if (migrated !== source) {
|
|
36
|
+
count++;
|
|
37
|
+
if (dryRun) {
|
|
38
|
+
process.stdout.write(`[sorane] would migrate: ${rel}\n`);
|
|
39
|
+
} else {
|
|
40
|
+
writeFileSync(abs, migrated, "utf8");
|
|
41
|
+
process.stdout.write(`[sorane] migrated: ${rel}\n`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
process.stdout.write(`[sorane] ${dryRun ? "would migrate" : "migrated"} ${count} file(s)\n`);
|
|
46
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { IndexStore, RuriEmbeddings, search, checkModelMismatch } from "@sorane/search";
|
|
4
|
+
import { loadSoraneConfig, parseCwdFlag } from "./config-load.ts";
|
|
5
|
+
|
|
6
|
+
function parseSearchArgs(argv: string[]): {
|
|
7
|
+
cwd: string;
|
|
8
|
+
query: string;
|
|
9
|
+
indexPath: string;
|
|
10
|
+
modelRoot: string;
|
|
11
|
+
modelId: string;
|
|
12
|
+
k: number;
|
|
13
|
+
docType: string;
|
|
14
|
+
tag: string;
|
|
15
|
+
json: boolean;
|
|
16
|
+
ftsOnly: boolean;
|
|
17
|
+
} {
|
|
18
|
+
const cwd = parseCwdFlag(argv);
|
|
19
|
+
const config = loadSoraneConfig(cwd);
|
|
20
|
+
const get = (flag: string, def: string) => {
|
|
21
|
+
const i = argv.indexOf(flag);
|
|
22
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1]! : def;
|
|
23
|
+
};
|
|
24
|
+
const query = argv.find((t, i) => !t.startsWith("--") && (i === 0 || !argv[i - 1]!.startsWith("--"))) ?? "";
|
|
25
|
+
const outFlag = argv.indexOf("--out");
|
|
26
|
+
const indexPath =
|
|
27
|
+
outFlag >= 0 && argv[outFlag + 1]
|
|
28
|
+
? resolve(cwd, argv[outFlag + 1]!)
|
|
29
|
+
: resolve(cwd, config.search.index);
|
|
30
|
+
return {
|
|
31
|
+
cwd,
|
|
32
|
+
query,
|
|
33
|
+
indexPath,
|
|
34
|
+
modelRoot: resolve(cwd, get("--model", config.search.model)),
|
|
35
|
+
modelId: get("--model-id", config.search.model_id),
|
|
36
|
+
k: Number(get("--k", "10")) || 10,
|
|
37
|
+
docType: get("--type", ""),
|
|
38
|
+
tag: get("--tag", ""),
|
|
39
|
+
json: argv.includes("--json"),
|
|
40
|
+
ftsOnly: argv.includes("--fts-only"),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runSearchCmd(argv: string[]): Promise<void> {
|
|
45
|
+
const args = parseSearchArgs(argv);
|
|
46
|
+
if (!args.query) {
|
|
47
|
+
process.stderr.write(
|
|
48
|
+
"usage: sorane search <query> [--cwd <dir>] [--type article] [--tag <slug>] [--k 10] [--json] [--fts-only]\n",
|
|
49
|
+
);
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const store = new IndexStore(args.indexPath);
|
|
54
|
+
let embeddings = null;
|
|
55
|
+
if (!args.ftsOnly && store.hasVectors()) {
|
|
56
|
+
const modelDir = resolve(args.modelRoot, args.modelId);
|
|
57
|
+
if (!existsSync(modelDir)) {
|
|
58
|
+
process.stderr.write(
|
|
59
|
+
`[sorane] model not found at ${modelDir}; searching FTS-only\n`,
|
|
60
|
+
);
|
|
61
|
+
} else {
|
|
62
|
+
embeddings = new RuriEmbeddings({
|
|
63
|
+
modelRoot: args.modelRoot,
|
|
64
|
+
modelId: args.modelId,
|
|
65
|
+
});
|
|
66
|
+
const mismatch = checkModelMismatch(
|
|
67
|
+
store.readMeta(),
|
|
68
|
+
args.modelId,
|
|
69
|
+
embeddings.dimensions,
|
|
70
|
+
);
|
|
71
|
+
if (mismatch) {
|
|
72
|
+
process.stderr.write(`[sorane] warning: ${mismatch}; consider re-indexing with --force\n`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const results = await search(store, embeddings, args.query, {
|
|
78
|
+
k: args.k,
|
|
79
|
+
filter: {
|
|
80
|
+
docType: args.docType || undefined,
|
|
81
|
+
tag: args.tag || undefined,
|
|
82
|
+
},
|
|
83
|
+
ftsOnly: args.ftsOnly,
|
|
84
|
+
});
|
|
85
|
+
store.close();
|
|
86
|
+
|
|
87
|
+
if (args.json) {
|
|
88
|
+
process.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (results.length === 0) {
|
|
93
|
+
process.stdout.write("(no results)\n");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const [i, row] of results.entries()) {
|
|
98
|
+
process.stdout.write(
|
|
99
|
+
`${i + 1}. ${row.title || row.source} (${row.source}#${row.headingSlug || row.chunkIndex}) [${row.score.toFixed(4)}]\n` +
|
|
100
|
+
` ${row.headingPath}\n` +
|
|
101
|
+
` ${row.snippet}\n\n`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { validateDiagramAltWarnings, validateHeadingWarnings } from "@sorane/core";
|
|
4
|
+
import { extract } from "@sorane/okf";
|
|
5
|
+
import { validateSource } from "@sorane/okf";
|
|
6
|
+
import { loadSoraneConfig, parseCwdFlag } from "./config-load.ts";
|
|
7
|
+
|
|
8
|
+
function walkMarkdown(root: string): string[] {
|
|
9
|
+
const out: string[] = [];
|
|
10
|
+
function visit(dir: string): void {
|
|
11
|
+
for (const name of readdirSync(dir).sort()) {
|
|
12
|
+
const abs = join(dir, name);
|
|
13
|
+
if (statSync(abs).isDirectory()) visit(abs);
|
|
14
|
+
else if (name.endsWith(".md")) out.push(abs);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
visit(root);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runValidateCmd(argv: string[]): Promise<void> {
|
|
22
|
+
const cwd = parseCwdFlag(argv);
|
|
23
|
+
const config = loadSoraneConfig(cwd);
|
|
24
|
+
const contentDir = resolve(cwd, config.build.content_dir);
|
|
25
|
+
if (!existsSync(contentDir)) {
|
|
26
|
+
throw new Error(`content directory not found: ${contentDir}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let errors = 0;
|
|
30
|
+
for (const abs of walkMarkdown(contentDir)) {
|
|
31
|
+
const rel = relative(contentDir, abs);
|
|
32
|
+
const source = readFileSync(abs, "utf8");
|
|
33
|
+
const result = validateSource(rel, source);
|
|
34
|
+
for (const w of result.warnings) {
|
|
35
|
+
process.stderr.write(`[sorane] ${rel}: warning: ${w}\n`);
|
|
36
|
+
}
|
|
37
|
+
const { body } = extract(source);
|
|
38
|
+
if (body !== null) {
|
|
39
|
+
for (const w of validateDiagramAltWarnings(body, config.build.diagrams ?? {})) {
|
|
40
|
+
process.stderr.write(`[sorane] ${rel}: warning: ${w}\n`);
|
|
41
|
+
}
|
|
42
|
+
for (const w of validateHeadingWarnings(body)) {
|
|
43
|
+
process.stderr.write(`[sorane] ${rel}: warning: ${w}\n`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
for (const issue of result.issues) {
|
|
48
|
+
process.stderr.write(`[sorane] ${rel}: ${issue.message}\n`);
|
|
49
|
+
}
|
|
50
|
+
errors += result.issues.length;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (errors > 0) {
|
|
55
|
+
throw new Error(`${errors} validation error(s)`);
|
|
56
|
+
}
|
|
57
|
+
process.stdout.write("[sorane] all concepts valid\n");
|
|
58
|
+
}
|
package/src/watch.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, watch } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { runBuildCmd } from "./build.ts";
|
|
4
|
+
import { loadSoraneConfig, parseCwdFlag } from "./config-load.ts";
|
|
5
|
+
|
|
6
|
+
const DEBOUNCE_MS = 350;
|
|
7
|
+
|
|
8
|
+
function parseWatchArgv(argv: string[]): { cwd: string; clean: boolean; buildArgv: string[] } {
|
|
9
|
+
const cwd = parseCwdFlag(argv);
|
|
10
|
+
const clean = argv.includes("--clean");
|
|
11
|
+
const buildArgv = ["--cwd", cwd];
|
|
12
|
+
if (clean) buildArgv.push("--clean");
|
|
13
|
+
return { cwd, clean, buildArgv };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function watchPaths(cwd: string, contentDir: string): string[] {
|
|
17
|
+
const paths = [resolve(cwd, contentDir), resolve(cwd, "sorane.yaml")];
|
|
18
|
+
const staticDir = resolve(cwd, "static");
|
|
19
|
+
if (existsSync(staticDir)) paths.push(staticDir);
|
|
20
|
+
return paths;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runWatchCmd(argv: string[]): Promise<void> {
|
|
24
|
+
const { cwd, clean, buildArgv } = parseWatchArgv(argv);
|
|
25
|
+
const config = loadSoraneConfig(cwd);
|
|
26
|
+
const contentDir = config.build.content_dir;
|
|
27
|
+
|
|
28
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
29
|
+
let building = false;
|
|
30
|
+
let pending = false;
|
|
31
|
+
|
|
32
|
+
const runOnce = async (initial: boolean): Promise<void> => {
|
|
33
|
+
if (building) {
|
|
34
|
+
pending = true;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
building = true;
|
|
38
|
+
try {
|
|
39
|
+
if (initial && !clean) {
|
|
40
|
+
await runBuildCmd(buildArgv);
|
|
41
|
+
} else {
|
|
42
|
+
await runBuildCmd([...buildArgv.filter((a) => a !== "--clean"), "--clean"]);
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
process.stderr.write(
|
|
46
|
+
`[sorane] watch build failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
47
|
+
);
|
|
48
|
+
} finally {
|
|
49
|
+
building = false;
|
|
50
|
+
if (pending) {
|
|
51
|
+
pending = false;
|
|
52
|
+
void runOnce(false);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
process.stdout.write("[sorane] watching for changes (Ctrl+C to stop)\n");
|
|
58
|
+
await runOnce(true);
|
|
59
|
+
|
|
60
|
+
const schedule = (): void => {
|
|
61
|
+
if (timer !== null) clearTimeout(timer);
|
|
62
|
+
timer = setTimeout(() => {
|
|
63
|
+
timer = null;
|
|
64
|
+
void runOnce(false);
|
|
65
|
+
}, DEBOUNCE_MS);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (const target of watchPaths(cwd, contentDir)) {
|
|
69
|
+
if (!existsSync(target)) continue;
|
|
70
|
+
try {
|
|
71
|
+
watch(target, { recursive: true }, (_event, filename) => {
|
|
72
|
+
if (filename?.includes(".sorane/")) return;
|
|
73
|
+
schedule();
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
watch(target, () => schedule());
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|