@fedify/vocab 2.5.0-dev.2496 → 2.5.0-dev.2504

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.
@@ -2,7 +2,7 @@ import { Temporal } from "temporal-polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { i as __toESM } from "./rolldown-runtime-BWJg4WE8.mjs";
4
4
  import { t as esm_default } from "./esm-BIqRhjGA.mjs";
5
- import { A as Object$1, B as QuoteAuthorization, C as Intent, D as Measure, E as Link, F as Person, G as Tombstone, H as Reject, I as Place, L as PropertyValue, N as OrderedCollectionPage, O as Multikey, P as Organization, R as Proposal, S as Image, T as InteractionRule, U as Service, V as QuoteRequest, W as Source, Z as vocab_exports, _ as FeaturedCollection, a as Application, b as Group, d as CryptographicKey, f as Delete, g as FeatureRequest, h as FeatureAuthorization, i as Announce, j as Offer, k as Note, l as Commitment, m as Endpoints, n as Activity, p as Document, r as Agreement, s as Collection, t as Accept, u as Create, v as FeaturedItem, w as InteractionPolicy, x as Hashtag, y as Follow, z as Question } from "./vocab-BMEyHiOV.mjs";
5
+ import { A as Object$1, B as QuoteAuthorization, C as Intent, D as Measure, E as Link, F as Person, G as Tombstone, H as Reject, I as Place, L as PropertyValue, N as OrderedCollectionPage, O as Multikey, P as Organization, R as Proposal, S as Image, T as InteractionRule, U as Service, V as QuoteRequest, W as Source, Z as vocab_exports, _ as FeaturedCollection, a as Application, b as Group, d as CryptographicKey, f as Delete, g as FeatureRequest, h as FeatureAuthorization, i as Announce, j as Offer, k as Note, l as Commitment, m as Endpoints, n as Activity, p as Document, r as Agreement, s as Collection, t as Accept, u as Create, v as FeaturedItem, w as InteractionPolicy, x as Hashtag, y as Follow, z as Question } from "./vocab-BmIsd6gR.mjs";
6
6
  import { t as assertInstanceOf } from "./utils-Df2odC27.mjs";
7
7
  import { mockDocumentLoader, test } from "@fedify/fixture";
8
8
  import { deepStrictEqual, notDeepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert/strict";
@@ -17,7 +17,7 @@ import { areAllScalarTypes, loadSchemaFiles } from "@fedify/vocab-tools";
17
17
  import { inspect } from "node:util";
18
18
  //#region ../vocab-runtime/deno.json
19
19
  var name = "@fedify/vocab-runtime";
20
- var version = "2.5.0-dev.2496+e18c05f8";
20
+ var version = "2.5.0-dev.2504+fdeb138d";
21
21
  //#endregion
22
22
  //#region ../vocab-runtime/src/url.ts
23
23
  var UrlError = class extends Error {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab",
3
- "version": "2.5.0-dev.2496+e18c05f8",
3
+ "version": "2.5.0-dev.2504+fdeb138d",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,11 +43,13 @@
43
43
  "jsonld": "^9.0.0",
44
44
  "pkijs": "^3.3.3",
45
45
  "temporal-polyfill": "^1.0.1",
46
- "@fedify/vocab-tools": "2.5.0-dev.2496+e18c05f8",
47
- "@fedify/webfinger": "2.5.0-dev.2496+e18c05f8",
48
- "@fedify/vocab-runtime": "2.5.0-dev.2496+e18c05f8"
46
+ "@fedify/vocab-tools": "2.5.0-dev.2504+fdeb138d",
47
+ "@fedify/vocab-runtime": "2.5.0-dev.2504+fdeb138d",
48
+ "@fedify/webfinger": "2.5.0-dev.2504+fdeb138d"
49
49
  },
50
50
  "devDependencies": {
51
+ "@dprint/formatter": "0.6.0",
52
+ "@dprint/typescript": "0.96.1",
51
53
  "@types/node": "^24.12.2",
52
54
  "fast-check": "^3.22.0",
53
55
  "fetch-mock": "^12.5.4",
@@ -67,7 +69,7 @@
67
69
  },
68
70
  "license": "MIT",
69
71
  "scripts": {
70
- "build:self": "deno task compile && tsdown",
72
+ "build:self": "node --experimental-strip-types scripts/codegen.ts && tsdown",
71
73
  "build": "pnpm --filter @fedify/vocab... run build:self",
72
74
  "prepublish": "pnpm build",
73
75
  "pretest": "pnpm build",
@@ -0,0 +1,234 @@
1
+ import { deepStrictEqual, equal, rejects } from "node:assert/strict";
2
+ import {
3
+ mkdir,
4
+ mkdtemp,
5
+ readdir,
6
+ readFile,
7
+ rm,
8
+ stat,
9
+ utimes,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { it } from "node:test";
15
+ import { setTimeout } from "node:timers/promises";
16
+ import {
17
+ acquireLock,
18
+ codegen,
19
+ formatGenerated,
20
+ getLatestMtimeUnder,
21
+ isUpToDate,
22
+ } from "./codegen_lib.ts";
23
+
24
+ async function fixture() {
25
+ const root = await mkdtemp(join(tmpdir(), "fedify-codegen-"));
26
+ const packageDir = join(root, "vocab");
27
+ const src = join(packageDir, "src");
28
+ const tools = join(root, "vocab-tools");
29
+ await mkdir(src, { recursive: true });
30
+ await mkdir(join(packageDir, "scripts"));
31
+ await mkdir(join(tools, "src"), { recursive: true });
32
+ const inputs = [
33
+ join(src, "object.yaml"),
34
+ join(src, "extension.YmL"),
35
+ join(tools, "src", "generate.ts"),
36
+ join(tools, "src", "schema.yaml"),
37
+ join(packageDir, "scripts", "codegen_lib.ts"),
38
+ join(tools, "deno.json"),
39
+ join(packageDir, "package.json"),
40
+ ];
41
+ const old = new Date(Date.now() - 60_000);
42
+ for (const file of inputs) {
43
+ await writeFile(file, "input");
44
+ await utimes(file, old, old);
45
+ }
46
+ return {
47
+ root,
48
+ packageDir,
49
+ src,
50
+ inputs,
51
+ output: join(src, "vocab.ts"),
52
+ lock: join(packageDir, ".vocab-codegen.lock"),
53
+ async cleanup() {
54
+ await rm(root, { recursive: true, force: true });
55
+ },
56
+ };
57
+ }
58
+
59
+ it("regenerates for every schema, generator, helper and metadata input", async () => {
60
+ const f = await fixture();
61
+ let generations = 0;
62
+ const options = {
63
+ packageDir: f.packageDir,
64
+ async generate(_schema: string, destination: string) {
65
+ generations++;
66
+ await writeFile(
67
+ destination,
68
+ `export const generation = ${generations};\n`,
69
+ );
70
+ },
71
+ format: (source: string) => source,
72
+ };
73
+ try {
74
+ await codegen(options);
75
+ equal(generations, 1);
76
+ const mtime = (await stat(f.output)).mtimeMs;
77
+ await codegen(options);
78
+ equal(generations, 1);
79
+ equal((await stat(f.output)).mtimeMs, mtime);
80
+ for (const input of f.inputs) {
81
+ const original = new Date(Date.now() - 60_000);
82
+ for (const other of f.inputs) await utimes(other, original, original);
83
+ // Put the output in the past rather than depending on clock precision.
84
+ const old = new Date(Date.now() - 30_000);
85
+ await utimes(f.output, old, old);
86
+ const changed = new Date(Date.now() - 10_000);
87
+ await utimes(input, changed, changed);
88
+ const before: number = generations;
89
+ await codegen(options);
90
+ equal(generations, before + 1, input);
91
+ }
92
+ await rm(f.output);
93
+ await codegen(options);
94
+ equal(await isUpToDate(f.src, f.output, 0), true);
95
+ equal(await getLatestMtimeUnder(join(f.root, "missing"), [".ts"]), 0);
96
+ await rejects(stat(f.lock), { code: "ENOENT" });
97
+ } finally {
98
+ await f.cleanup();
99
+ }
100
+ });
101
+
102
+ it("formats before publishing and preserves the old output on failure", async () => {
103
+ const f = await fixture();
104
+ try {
105
+ for (const stage of ["generate", "format"]) {
106
+ await writeFile(f.output, "old output");
107
+ const old = new Date(Date.now() - 120_000);
108
+ await utimes(f.output, old, old);
109
+ await rejects(
110
+ codegen({
111
+ packageDir: f.packageDir,
112
+ async generate(_schema, destination) {
113
+ await writeFile(destination, "partial output");
114
+ if (stage === "generate") throw new Error("generation failed");
115
+ },
116
+ format() {
117
+ throw new Error("formatting failed");
118
+ },
119
+ }),
120
+ /failed/,
121
+ );
122
+ equal(await readFile(f.output, "utf8"), "old output");
123
+ deepStrictEqual((await readdir(f.src)).sort(), [
124
+ "extension.YmL",
125
+ "object.yaml",
126
+ "vocab.ts",
127
+ ]);
128
+ await rejects(stat(f.lock), { code: "ENOENT" });
129
+ }
130
+ await codegen({
131
+ packageDir: f.packageDir,
132
+ async generate(_schema, destination) {
133
+ await writeFile(destination, "const value=1");
134
+ },
135
+ format: formatGenerated,
136
+ });
137
+ const output = await readFile(f.output, "utf8");
138
+ equal(output, "const value = 1;\n");
139
+ equal(await formatGenerated(output), output);
140
+ } finally {
141
+ await f.cleanup();
142
+ }
143
+ });
144
+
145
+ it("concurrent generation rechecks freshness inside the lock", async () => {
146
+ const f = await fixture();
147
+ let generations = 0;
148
+ try {
149
+ const options = {
150
+ packageDir: f.packageDir,
151
+ lockOptions: { retryMs: 5 },
152
+ async generate(_schema: string, destination: string) {
153
+ generations++;
154
+ await setTimeout(20);
155
+ await writeFile(destination, "complete output");
156
+ },
157
+ format: (source: string) => source,
158
+ };
159
+ await Promise.all([codegen(options), codegen(options), codegen(options)]);
160
+ equal(generations, 1);
161
+ equal(await readFile(f.output, "utf8"), "complete output");
162
+ await rejects(stat(f.lock), { code: "ENOENT" });
163
+ } finally {
164
+ await f.cleanup();
165
+ }
166
+ });
167
+
168
+ it("waits for held and initializing locks, and bounds the wait", async () => {
169
+ const f = await fixture();
170
+ try {
171
+ const held = await acquireLock(f.lock);
172
+ try {
173
+ await rejects(
174
+ acquireLock(f.lock, { retryMs: 5, timeoutMs: 20 }),
175
+ /Timeout waiting for lock/,
176
+ );
177
+ await stat(join(f.lock, "info"));
178
+ } finally {
179
+ await held.release();
180
+ }
181
+ await mkdir(f.lock); // The creator hasn't written info yet.
182
+ await rejects(
183
+ acquireLock(f.lock, {
184
+ retryMs: 5,
185
+ timeoutMs: 20,
186
+ initializationMs: 10_000,
187
+ }),
188
+ /Timeout waiting for lock/,
189
+ );
190
+ await stat(f.lock);
191
+ } finally {
192
+ await f.cleanup();
193
+ }
194
+ });
195
+
196
+ it("recovers abandoned complete and incomplete locks", async () => {
197
+ const f = await fixture();
198
+ try {
199
+ for (const withInfo of [false, true]) {
200
+ await mkdir(f.lock);
201
+ const path = withInfo ? join(f.lock, "info") : f.lock;
202
+ if (withInfo) await writeFile(path, "abandoned");
203
+ const old = new Date(Date.now() - 600_000);
204
+ await utimes(path, old, old);
205
+ const lock = await acquireLock(f.lock, { retryMs: 5, timeoutMs: 50 });
206
+ await stat(join(f.lock, "info"));
207
+ await lock.release();
208
+ await rejects(stat(f.lock), { code: "ENOENT" });
209
+ }
210
+ } finally {
211
+ await f.cleanup();
212
+ }
213
+ });
214
+
215
+ it("stabilizes long generated expressions with Deno's formatting", async () => {
216
+ const source = `class Sample {
217
+ method() {
218
+ let _27fgyFbosTtMAhuepJH8K3ZGURT6__array = values["http://www.ontology-of-units-of-measure.org/resource/om-2/hasUnit"];
219
+ }
220
+ }`;
221
+ const formatted = await formatGenerated(source);
222
+ equal(await formatGenerated(formatted), formatted);
223
+ equal(
224
+ formatted,
225
+ `class Sample {
226
+ method() {
227
+ let _27fgyFbosTtMAhuepJH8K3ZGURT6__array = values[
228
+ "http://www.ontology-of-units-of-measure.org/resource/om-2/hasUnit"
229
+ ];
230
+ }
231
+ }
232
+ `,
233
+ );
234
+ });
@@ -1,181 +1,3 @@
1
- import $ from "@david/dax";
2
- import type { Path } from "@david/dax";
3
- import { generateVocab } from "@fedify/vocab-tools";
1
+ import { codegen } from "./codegen_lib.ts";
4
2
 
5
- const LOCK_STALE_MS = 5 * 60 * 1000; // 5 minutes
6
- const LOCK_RETRY_MS = 100;
7
- const LOCK_TIMEOUT_MS = 60 * 1000; // 1 minute
8
-
9
- /**
10
- * Get the latest mtime among files under a directory tree (recursively),
11
- * limited to the given file extensions. Returns 0 if the directory is absent.
12
- */
13
- async function getLatestMtimeUnder(
14
- dir: Path,
15
- exts: readonly string[],
16
- ): Promise<number> {
17
- if (!(await dir.exists())) return 0;
18
- let latestMtime = 0;
19
- for await (const entry of Deno.readDir(dir.toString())) {
20
- const child = dir.join(entry.name);
21
- if (entry.isDirectory) {
22
- const mtime = await getLatestMtimeUnder(child, exts);
23
- if (mtime > latestMtime) latestMtime = mtime;
24
- } else if (entry.isFile && exts.some((ext) => entry.name.endsWith(ext))) {
25
- const fileStat = await child.stat();
26
- const mtime = fileStat?.mtime?.getTime() ?? 0;
27
- if (mtime > latestMtime) latestMtime = mtime;
28
- }
29
- }
30
- return latestMtime;
31
- }
32
-
33
- /**
34
- * Check if the generated file is up to date compared to its source files,
35
- * including the generator itself (`@fedify/vocab-tools` and this codegen
36
- * script): changing the generator changes the generated output, so the
37
- * generated file must be at least as new as those too.
38
- */
39
- async function isUpToDate(
40
- schemaDir: Path,
41
- generatedPath: Path,
42
- generatorMtime: number,
43
- ): Promise<boolean> {
44
- try {
45
- const [sourceMtime, generatedStat] = await Promise.all([
46
- // Match loadSchemaFiles()'s /\.ya?ml$/i: a .yml schema is a real input.
47
- getLatestMtimeUnder(schemaDir, [".yaml", ".yml"]),
48
- generatedPath.stat(),
49
- ]);
50
- if (!generatedStat?.mtime) return false;
51
- return generatedStat.mtime.getTime() >=
52
- Math.max(sourceMtime, generatorMtime);
53
- } catch {
54
- // If generated file doesn't exist, it's not up to date
55
- return false;
56
- }
57
- }
58
-
59
- interface Lock {
60
- release(): Promise<void>;
61
- }
62
-
63
- /**
64
- * Acquire a directory-based lock. mkdir is atomic on POSIX systems.
65
- */
66
- async function acquireLock(lockPath: Path): Promise<Lock> {
67
- const startTime = Date.now();
68
-
69
- while (true) {
70
- try {
71
- // Use Deno.mkdir directly because dax's mkdir() is recursive by default
72
- await Deno.mkdir(lockPath.toString());
73
- // Write PID and timestamp for stale lock detection
74
- const infoPath = lockPath.join("info");
75
- await infoPath.writeJsonPretty({ pid: Deno.pid, timestamp: Date.now() });
76
- return {
77
- async release() {
78
- try {
79
- await lockPath.remove({ recursive: true });
80
- } catch {
81
- // Ignore errors during cleanup
82
- }
83
- },
84
- };
85
- } catch (e) {
86
- if (!(e instanceof Deno.errors.AlreadyExists)) {
87
- throw e;
88
- }
89
-
90
- // Check if lock is stale
91
- try {
92
- const infoPath = lockPath.join("info");
93
- const infoStat = await infoPath.stat();
94
- if (
95
- infoStat?.mtime &&
96
- Date.now() - infoStat.mtime.getTime() > LOCK_STALE_MS
97
- ) {
98
- console.warn("Removing stale lock:", lockPath.toString());
99
- await lockPath.remove({ recursive: true });
100
- continue;
101
- }
102
- } catch {
103
- // If we can't read the info file, try to remove the lock
104
- try {
105
- await lockPath.remove({ recursive: true });
106
- continue;
107
- } catch {
108
- // Ignore
109
- }
110
- }
111
-
112
- // Check timeout
113
- if (Date.now() - startTime > LOCK_TIMEOUT_MS) {
114
- throw new Error(`Timeout waiting for lock: ${lockPath}`);
115
- }
116
-
117
- // Wait and retry
118
- await $.sleep(LOCK_RETRY_MS);
119
- }
120
- }
121
- }
122
-
123
- async function codegen() {
124
- const scriptsDir = $.path(import.meta.dirname!);
125
- const packageDir = scriptsDir.parent()!;
126
- const schemaDir = packageDir.join("src");
127
- const realPath = schemaDir.join("vocab.ts");
128
- const lockPath = packageDir.join(".vocab-codegen.lock");
129
-
130
- // Acquire lock to prevent concurrent codegen
131
- const lock = await acquireLock(lockPath);
132
- try {
133
- // The generated vocab.ts depends not only on the YAML schemas but on the
134
- // generator itself: @fedify/vocab-tools and this codegen script. Sample
135
- // its mtime inside the lock so that a generator edit made while we were
136
- // waiting for the lock is not missed by the freshness check below.
137
- const generatorMtime = Math.max(
138
- await getLatestMtimeUnder(
139
- packageDir.parent()!.join("vocab-tools", "src"),
140
- [".ts", ".yaml", ".yml"],
141
- ),
142
- await getLatestMtimeUnder(scriptsDir, [".ts"]),
143
- );
144
-
145
- // Check if regeneration is needed (after acquiring lock)
146
- if (await isUpToDate(schemaDir, realPath, generatorMtime)) {
147
- $.log("vocab.ts is up to date, skipping codegen");
148
- return;
149
- }
150
-
151
- $.logStep("Generating", "vocab.ts...");
152
-
153
- // Generate to a temporary file first
154
- const generatedPath = schemaDir.join(`vocab-${crypto.randomUUID()}.ts`);
155
- try {
156
- await generateVocab(schemaDir.toString(), generatedPath.toString());
157
- await generatedPath.rename(realPath);
158
- } catch (e) {
159
- // Clean up temp file on error
160
- await generatedPath.remove().catch(() => {});
161
- throw e;
162
- }
163
-
164
- $.logStep("Formatting", "vocab.ts...");
165
- await $`deno fmt ${realPath}`;
166
-
167
- $.logStep("Caching", "vocab.ts...");
168
- await $`deno cache ${realPath}`;
169
-
170
- $.logStep("Type checking", "vocab.ts...");
171
- await $`deno check ${realPath}`;
172
-
173
- $.logStep("Codegen", "completed successfully");
174
- } finally {
175
- await lock.release();
176
- }
177
- }
178
-
179
- if (import.meta.main) {
180
- await codegen();
181
- }
3
+ await codegen();
@@ -0,0 +1,228 @@
1
+ import { createContext } from "@dprint/formatter";
2
+ import { getPath } from "@dprint/typescript";
3
+ import { randomUUID } from "node:crypto";
4
+ import {
5
+ mkdir,
6
+ readdir,
7
+ readFile,
8
+ rename,
9
+ rm,
10
+ stat,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { join, resolve } from "node:path";
14
+ import process from "node:process";
15
+ import { setTimeout } from "node:timers/promises";
16
+ // Use source before any workspace package has been built on a fresh checkout.
17
+ import { generateVocab } from "../../vocab-tools/src/mod.ts";
18
+
19
+ function hasCode(error: unknown, code: string): boolean {
20
+ return error instanceof Error && "code" in error && error.code === code;
21
+ }
22
+
23
+ /** Find the newest matching input, including nested directories. */
24
+ export async function getLatestMtimeUnder(
25
+ dir: string,
26
+ exts: readonly string[],
27
+ ): Promise<number> {
28
+ let entries;
29
+ try {
30
+ entries = await readdir(dir, { withFileTypes: true });
31
+ } catch (error) {
32
+ if (hasCode(error, "ENOENT")) return 0;
33
+ throw error;
34
+ }
35
+ let latest = 0;
36
+ for (const entry of entries) {
37
+ const child = join(dir, entry.name);
38
+ if (entry.isDirectory()) {
39
+ latest = Math.max(latest, await getLatestMtimeUnder(child, exts));
40
+ } else if (
41
+ entry.isFile() &&
42
+ exts.some((ext) => entry.name.toLowerCase().endsWith(ext))
43
+ ) {
44
+ latest = Math.max(latest, (await stat(child)).mtimeMs);
45
+ }
46
+ }
47
+ return latest;
48
+ }
49
+
50
+ /** Check freshness after taking the generation lock. */
51
+ export async function isUpToDate(
52
+ schemaDir: string,
53
+ generatedPath: string,
54
+ generatorMtime: number,
55
+ ): Promise<boolean> {
56
+ try {
57
+ const [sourceMtime, generatedStat] = await Promise.all([
58
+ getLatestMtimeUnder(schemaDir, [".yaml", ".yml"]),
59
+ stat(generatedPath),
60
+ ]);
61
+ return generatedStat.mtimeMs >= Math.max(sourceMtime, generatorMtime);
62
+ } catch (error) {
63
+ if (hasCode(error, "ENOENT")) return false;
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ interface LockOptions {
69
+ staleMs: number;
70
+ initializationMs: number;
71
+ retryMs: number;
72
+ timeoutMs: number;
73
+ }
74
+
75
+ /** Acquire the same directory lock used by Node and Deno generation. */
76
+ export async function acquireLock(
77
+ lockPath: string,
78
+ options: Partial<LockOptions> = {},
79
+ ): Promise<{ release(): Promise<void> }> {
80
+ const {
81
+ staleMs = 5 * 60 * 1000,
82
+ initializationMs = 10 * 1000,
83
+ retryMs = 100,
84
+ timeoutMs = 60 * 1000,
85
+ } = options;
86
+ const startTime = Date.now();
87
+ while (true) {
88
+ try {
89
+ // Non-recursive mkdir is atomic, including on Windows.
90
+ await mkdir(lockPath);
91
+ } catch (error) {
92
+ if (!hasCode(error, "EEXIST")) throw error;
93
+ try {
94
+ let age: number;
95
+ let threshold: number;
96
+ try {
97
+ age = Date.now() - (await stat(join(lockPath, "info"))).mtimeMs;
98
+ threshold = staleMs;
99
+ } catch (infoError) {
100
+ if (!hasCode(infoError, "ENOENT")) throw infoError;
101
+ // Another process may still be writing info after its mkdir.
102
+ age = Date.now() - (await stat(lockPath)).mtimeMs;
103
+ threshold = initializationMs;
104
+ }
105
+ if (age > threshold) {
106
+ console.warn("Removing stale lock:", lockPath);
107
+ await rm(lockPath, { recursive: true, force: true });
108
+ continue;
109
+ }
110
+ } catch (statError) {
111
+ if (!hasCode(statError, "ENOENT")) throw statError;
112
+ continue; // The holder released the lock while we inspected it.
113
+ }
114
+ if (Date.now() - startTime > timeoutMs) {
115
+ throw new Error(`Timeout waiting for lock: ${lockPath}`);
116
+ }
117
+ await setTimeout(retryMs);
118
+ continue;
119
+ }
120
+
121
+ try {
122
+ await writeFile(
123
+ join(lockPath, "info"),
124
+ JSON.stringify({ pid: process.pid, timestamp: Date.now() }) + "\n",
125
+ );
126
+ } catch (error) {
127
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {});
128
+ throw error;
129
+ }
130
+ return {
131
+ async release() {
132
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {});
133
+ },
134
+ };
135
+ }
136
+ }
137
+
138
+ let formatter: Promise<ReturnType<typeof createContext>> | undefined;
139
+
140
+ /**
141
+ * Preserve Deno's generated-code formatting without requiring Deno at bootstrap.
142
+ *
143
+ * TODO(#1285): Replace dprint with Oxfmt when migrating repository formatting.
144
+ * Switch the generated-code checks and formatting exclusions together so Deno
145
+ * and Oxfmt never compete over vocab.ts, then remove the dprint dependencies
146
+ * and Deno-version coupling in mise.toml. The vocab-tools code snapshots record
147
+ * unformatted generator output, so a formatter change alone need not alter them.
148
+ * https://github.com/fedify-dev/fedify/issues/1285
149
+ */
150
+ export async function formatGenerated(source: string): Promise<string> {
151
+ formatter ??= (async () => {
152
+ const context = createContext({
153
+ lineWidth: 80,
154
+ indentWidth: 2,
155
+ useTabs: false,
156
+ newLineKind: "lf",
157
+ });
158
+ context.addPlugin(await readFile(getPath()), { deno: true });
159
+ return context;
160
+ })();
161
+ const context = await formatter;
162
+ // Like Deno's format_ensure_stable(), keep formatting until the result is
163
+ // stable. Some long generated expressions need more than one dprint pass.
164
+ let current = source;
165
+ for (let pass = 0; pass < 6; pass++) {
166
+ const next = context.formatText({
167
+ filePath: "vocab.ts",
168
+ fileText: current,
169
+ });
170
+ if (next === current) return current;
171
+ current = next;
172
+ }
173
+ throw new Error("Generated vocabulary formatting did not stabilize");
174
+ }
175
+
176
+ interface CodegenOptions {
177
+ packageDir?: string;
178
+ generate?: typeof generateVocab;
179
+ format?: (source: string) => string | Promise<string>;
180
+ lockOptions?: Partial<LockOptions>;
181
+ }
182
+
183
+ /** Generate and format vocabulary atomically, or leave a fresh output alone. */
184
+ export async function codegen(options: CodegenOptions = {}): Promise<void> {
185
+ const packageDir = options.packageDir ?? resolve(import.meta.dirname!, "..");
186
+ const scriptsDir = join(packageDir, "scripts");
187
+ const schemaDir = join(packageDir, "src");
188
+ const generatedPath = join(schemaDir, "vocab.ts");
189
+ const toolsDir = resolve(packageDir, "../vocab-tools");
190
+ const lock = await acquireLock(
191
+ join(packageDir, ".vocab-codegen.lock"),
192
+ options.lockOptions,
193
+ );
194
+ try {
195
+ // Keep these inputs aligned with [deps.codegen].sources in mise.toml.
196
+ const generatorMtime = Math.max(
197
+ await getLatestMtimeUnder(join(toolsDir, "src"), [
198
+ ".ts",
199
+ ".yaml",
200
+ ".yml",
201
+ ]),
202
+ await getLatestMtimeUnder(scriptsDir, [".ts"]),
203
+ (await stat(join(toolsDir, "deno.json"))).mtimeMs,
204
+ (await stat(join(packageDir, "package.json"))).mtimeMs,
205
+ );
206
+ if (await isUpToDate(schemaDir, generatedPath, generatorMtime)) {
207
+ console.log("vocab.ts is up to date, skipping codegen");
208
+ return;
209
+ }
210
+
211
+ console.log("Generating vocab.ts...");
212
+ const temporaryPath = join(schemaDir, `vocab-${randomUUID()}.ts`);
213
+ try {
214
+ await (options.generate ?? generateVocab)(schemaDir, temporaryPath);
215
+ const formatted = await (options.format ?? formatGenerated)(
216
+ await readFile(temporaryPath, "utf8"),
217
+ );
218
+ await writeFile(temporaryPath, formatted);
219
+ // Publish only a complete, formatted file. Failed runs preserve vocab.ts.
220
+ await rename(temporaryPath, generatedPath);
221
+ } finally {
222
+ await rm(temporaryPath, { force: true }).catch(() => {});
223
+ }
224
+ console.log("Codegen completed successfully");
225
+ } finally {
226
+ await lock.release();
227
+ }
228
+ }