@ts-for-gir/generator-json 4.0.0-beta.39 → 4.0.0-beta.41

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.
@@ -0,0 +1,581 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { TypeDefinitionGenerator } from "@ts-for-gir/generator-typescript";
7
+ import { getModuleMetadata } from "@ts-for-gir/gir-module-metadata";
8
+ import type { GirModule, NSRegistry, OptionsGeneration } from "@ts-for-gir/lib";
9
+ import {
10
+ Application,
11
+ Converter,
12
+ type DeclarationReflection,
13
+ type NormalizedPath,
14
+ normalizePath,
15
+ type OptionsReader,
16
+ type ProjectReflection,
17
+ ReflectionCategory,
18
+ Serializer,
19
+ TSConfigReader,
20
+ type TypeDocOptions,
21
+ } from "typedoc";
22
+ import { GirMetadataDeserializer } from "./gir-metadata-deserializer.ts";
23
+ import { buildGirLookupIndex } from "./gir-metadata-index.ts";
24
+ import { GirMetadataSerializer } from "./gir-metadata-serializer.ts";
25
+ import type { GirNamespaceMetadata } from "./gir-metadata-types.ts";
26
+
27
+ /** Disable all TypeDoc validation checks to speed up generation. */
28
+ const NO_VALIDATION = {
29
+ notExported: false,
30
+ invalidLink: false,
31
+ invalidPath: false,
32
+ rewrittenLink: false,
33
+ notDocumented: false,
34
+ unusedMergeModuleWith: false,
35
+ } as const;
36
+
37
+ /** Languages to highlight in TypeDoc code blocks. */
38
+ const HIGHLIGHT_LANGUAGES = ["typescript", "javascript", "c", "cpp", "xml", "bash", "json", "css"] as const;
39
+
40
+ /** Result of bootstrapping a TypeDoc application. */
41
+ export interface TypeDocAppResult {
42
+ app: Application;
43
+ project: ProjectReflection;
44
+ }
45
+
46
+ /**
47
+ * Shared pipeline for TypeDoc-based generators (JSON and HTML doc).
48
+ *
49
+ * Handles the common steps:
50
+ * 1. Generate .d.ts files into a temporary directory
51
+ * 2. Bootstrap TypeDoc and convert to ProjectReflection
52
+ * 3. Enrich with GIR metadata via custom SerializerComponent
53
+ *
54
+ * Consumers call `createTypeDocApp()` per module and then decide
55
+ * what to do with the result (serialize to JSON, generate HTML, etc.).
56
+ */
57
+ export class TypeDocPipeline {
58
+ private tempDir = "";
59
+ private tsGenerator: TypeDefinitionGenerator | undefined;
60
+ private readonly _modules: GirModule[] = [];
61
+ readonly config: OptionsGeneration;
62
+ readonly registry: NSRegistry;
63
+
64
+ constructor(config: OptionsGeneration, registry: NSRegistry) {
65
+ this.config = config;
66
+ this.registry = registry;
67
+ }
68
+
69
+ get modules(): ReadonlyArray<GirModule> {
70
+ return this._modules;
71
+ }
72
+
73
+ async start(): Promise<void> {
74
+ this.tempDir = await mkdtemp(join(tmpdir(), "ts-for-gir-typedoc-"));
75
+
76
+ const tempConfig: OptionsGeneration = {
77
+ ...this.config,
78
+ outdir: this.tempDir,
79
+ package: true,
80
+ };
81
+
82
+ this.tsGenerator = new TypeDefinitionGenerator(tempConfig, this.registry);
83
+ await this.tsGenerator.start();
84
+ }
85
+
86
+ async generate(module: GirModule): Promise<void> {
87
+ if (!this.tsGenerator) {
88
+ throw new Error("TypeDocPipeline.start() must be called before generate()");
89
+ }
90
+ this._modules.push(module);
91
+ await this.tsGenerator.generate(module);
92
+ }
93
+
94
+ async finishTypescriptGeneration(girModules: GirModule[]): Promise<void> {
95
+ if (!this.tsGenerator) {
96
+ throw new Error("TypeDocPipeline.start() must be called before finishTypescriptGeneration()");
97
+ }
98
+ await this.tsGenerator.finish(girModules);
99
+ }
100
+
101
+ /**
102
+ * Bootstrap a TypeDoc Application for the given module,
103
+ * convert to ProjectReflection, and enrich with GIR metadata.
104
+ */
105
+ async createTypeDocApp(module: GirModule): Promise<TypeDocAppResult> {
106
+ const moduleDir = join(this.tempDir, module.importName);
107
+ const entryPoint = join(moduleDir, `${module.importName}.d.ts`);
108
+ const tsconfigPath = join(moduleDir, "tsconfig.json");
109
+ const readmePath = join(moduleDir, "README.md");
110
+
111
+ const result = await this.bootstrapAndConvert(
112
+ {
113
+ entryPoints: [entryPoint],
114
+ tsconfig: normalizePath(tsconfigPath),
115
+ name: module.packageName,
116
+ // Include the generated README.md so it gets embedded in JSON output
117
+ // and is available when using merge mode later.
118
+ readme: readmePath,
119
+ ...this.sourceLinkOptions,
120
+ },
121
+ [new TSConfigReader()],
122
+ );
123
+
124
+ // Set packageVersion so it's serialized into JSON and available in merge mode.
125
+ // In "packages" mode TypeDoc reads this from package.json automatically,
126
+ // but in per-module mode we need to set it explicitly.
127
+ result.project.packageVersion = module.libraryVersion.toString();
128
+
129
+ this.registerGirMetadata(result.app, module);
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * Get the normalized path to a module's temporary directory.
135
+ * Useful for TypeDoc's `projectToObject()` base path.
136
+ */
137
+ getModuleDir(module: GirModule): NormalizedPath {
138
+ return normalizePath(join(this.tempDir, module.importName));
139
+ }
140
+
141
+ /**
142
+ * Get the normalized path to the GJS package temporary directory.
143
+ */
144
+ getGjsDir(): NormalizedPath {
145
+ return normalizePath(join(this.tempDir, "gjs"));
146
+ }
147
+
148
+ /**
149
+ * Bootstrap a TypeDoc Application for the GJS core types package.
150
+ *
151
+ * GJS is a pseudo-package (not a GIR module) that provides core GJS
152
+ * type definitions. It is generated by TypeDefinitionGenerator.exportGjs()
153
+ * during finishTypescriptGeneration(), creating a complete package with
154
+ * gjs.d.ts, typedoc.json, tsconfig.json, etc. in the temp directory.
155
+ *
156
+ * Returns null if the GJS package was not generated (e.g. temp dir missing).
157
+ */
158
+ async createGjsTypeDocApp(): Promise<TypeDocAppResult | null> {
159
+ const gjsDir = join(this.tempDir, "gjs");
160
+ const tsconfigPath = join(gjsDir, "tsconfig.json");
161
+ const readmePath = join(gjsDir, "README.md");
162
+
163
+ // Include all GJS .d.ts files so TypeDoc sees all namespaces.
164
+ // With a single entry point, only gjs.d.ts contents are visible;
165
+ // cairo, gettext, system, console, dom namespaces would be missing.
166
+ const entryPoints = [
167
+ join(gjsDir, "gjs.d.ts"),
168
+ join(gjsDir, "cairo.d.ts"),
169
+ join(gjsDir, "gettext.d.ts"),
170
+ join(gjsDir, "system.d.ts"),
171
+ join(gjsDir, "dom.d.ts"),
172
+ join(gjsDir, "console.d.ts"),
173
+ ];
174
+
175
+ try {
176
+ const result = await this.bootstrapAndConvert(
177
+ {
178
+ entryPoints,
179
+ tsconfig: normalizePath(tsconfigPath),
180
+ name: "Gjs",
181
+ readme: readmePath,
182
+ ...this.sourceLinkOptions,
183
+ },
184
+ [new TSConfigReader()],
185
+ );
186
+
187
+ this.attachNamespaceMetadata(result.app, this.buildGjsNamespaceMetadata());
188
+ return result;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Bootstrap a single TypeDoc Application using the "packages" entry point strategy
196
+ * to generate combined documentation for all modules.
197
+ *
198
+ * TypeDoc discovers each module's package.json, typedoc.json, and tsconfig.json,
199
+ * converts each package separately, then merges them into one ProjectReflection.
200
+ */
201
+ async createCombinedTypeDocApp(): Promise<TypeDocAppResult> {
202
+ return this.bootstrapAndConvert(
203
+ {
204
+ entryPoints: [join(this.tempDir, "*")],
205
+ entryPointStrategy: "packages",
206
+ name: "TypeScript API Documentation for GJS",
207
+ ...this.sourceLinkOptions,
208
+ packageOptions: {
209
+ skipErrorChecking: true,
210
+ validation: NO_VALIDATION,
211
+ ...this.sourceLinkOptions,
212
+ },
213
+ },
214
+ [new TSConfigReader()],
215
+ );
216
+ }
217
+
218
+ /**
219
+ * Bootstrap a TypeDoc Application using the "merge" entry point strategy.
220
+ * Reads pre-generated JSON files and merges them into a single ProjectReflection.
221
+ *
222
+ * This does NOT require start() or generate() to have been called.
223
+ * It works entirely from pre-existing JSON files generated by `ts-for-gir json`.
224
+ */
225
+ async createMergedTypeDocApp(jsonDir: string): Promise<TypeDocAppResult> {
226
+ const app = await this.bootstrapApp(
227
+ {
228
+ entryPoints: [join(jsonDir, "*.json")],
229
+ entryPointStrategy: "merge",
230
+ name: "TypeScript API Documentation for GJS",
231
+ },
232
+ [],
233
+ );
234
+
235
+ // Register deserializer for merge mode — restores GIR metadata from JSON
236
+ app.deserializer.addDeserializer(new GirMetadataDeserializer());
237
+
238
+ return this.convertApp(app, "merged documentation");
239
+ }
240
+
241
+ async cleanup(): Promise<void> {
242
+ if (this.tempDir) {
243
+ await rm(this.tempDir, { recursive: true, force: true });
244
+ this.tempDir = "";
245
+ }
246
+ }
247
+
248
+ /** Source link options derived from config. */
249
+ private get sourceLinkOptions() {
250
+ if (!this.config.sourceLinkTemplate) return {};
251
+ return {
252
+ sourceLinkTemplate: this.config.sourceLinkTemplate,
253
+ basePath: normalizePath(this.tempDir),
254
+ disableGit: true,
255
+ };
256
+ }
257
+
258
+ /** Shared bootstrap options used by all create*TypeDocApp methods. */
259
+ private get sharedBootstrapOptions(): Partial<TypeDocOptions> {
260
+ return {
261
+ skipErrorChecking: true,
262
+ validation: NO_VALIDATION,
263
+ // Disable TypeDoc's built-in output directory cleanup so that git
264
+ // submodule files (e.g. docs/.git) are preserved across builds.
265
+ cleanOutputDir: false,
266
+ // Only set readme if explicitly configured — otherwise let TypeDoc
267
+ // auto-discover README.md files from individual packages.
268
+ ...(this.config.readme ? { readme: this.config.readme } : {}),
269
+ logLevel: this.config.verbose ? ("Verbose" as const) : ("Info" as const),
270
+ highlightLanguages: [...HIGHLIGHT_LANGUAGES],
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Bootstrap a TypeDoc Application with shared defaults and custom tags.
276
+ * Does NOT convert — call {@link convertApp} separately when you need
277
+ * to register additional plugins (e.g. deserializers) before conversion.
278
+ */
279
+ private async bootstrapApp(
280
+ options: Partial<TypeDocOptions>,
281
+ readers: readonly OptionsReader[],
282
+ ): Promise<Application> {
283
+ const app = await Application.bootstrap({ ...this.sharedBootstrapOptions, ...options }, readers);
284
+ this.registerCustomTags(app);
285
+ return app;
286
+ }
287
+
288
+ /** Convert a bootstrapped TypeDoc Application to a ProjectReflection. */
289
+ private async convertApp(app: Application, name = "unknown"): Promise<TypeDocAppResult> {
290
+ const project = await app.convert();
291
+ if (!project) {
292
+ throw new Error(`TypeDoc conversion failed for ${name}`);
293
+ }
294
+ return { app, project };
295
+ }
296
+
297
+ /** Bootstrap and immediately convert — shorthand for the common case. */
298
+ private async bootstrapAndConvert(
299
+ options: Partial<TypeDocOptions>,
300
+ readers: readonly OptionsReader[],
301
+ ): Promise<TypeDocAppResult> {
302
+ const app = await this.bootstrapApp(options, readers);
303
+ return this.convertApp(app, (options.name as string) || "unknown");
304
+ }
305
+
306
+ /** Register custom GIR-specific TSDoc tags so TypeDoc parses and preserves them. */
307
+ private registerCustomTags(app: Application): void {
308
+ const blockTags = app.options.getValue("blockTags") as string[];
309
+ if (!blockTags.includes("@gir-type")) {
310
+ app.options.setValue("blockTags", [...blockTags, "@gir-type"]);
311
+ }
312
+
313
+ const modTags = app.options.getValue("modifierTags") as string[];
314
+ const newModTags = [
315
+ "@signal",
316
+ "@detailed",
317
+ "@action",
318
+ "@run-first",
319
+ "@run-last",
320
+ "@run-cleanup",
321
+ "@construct-only",
322
+ "@read-only",
323
+ "@write-only",
324
+ ];
325
+ const missingModTags = newModTags.filter((t) => !modTags.includes(t));
326
+ if (missingModTags.length > 0) {
327
+ app.options.setValue("modifierTags", [...modTags, ...missingModTags]);
328
+ }
329
+
330
+ // Prevent TypeDoc from rendering these tags in comment output —
331
+ // the theme handles display via badges instead.
332
+ const notRendered = app.options.getValue("notRenderedTags") as string[];
333
+ const tagsToSuppress = [
334
+ "@gir-type",
335
+ "@virtual",
336
+ "@signal",
337
+ "@detailed",
338
+ "@action",
339
+ "@run-first",
340
+ "@run-last",
341
+ "@run-cleanup",
342
+ "@construct-only",
343
+ "@read-only",
344
+ "@write-only",
345
+ ];
346
+ const missing = tagsToSuppress.filter((t) => !notRendered.includes(t));
347
+ if (missing.length > 0) {
348
+ app.options.setValue("notRenderedTags", [...notRendered, ...missing]);
349
+ }
350
+
351
+ // Enable per-group categories so @category "Inherited from X" creates
352
+ // subcategories within their kind group (Properties, Methods, etc.)
353
+ app.options.setValue("categorizeByGroup", true);
354
+ app.options.setValue("defaultCategory", "None");
355
+
356
+ // After TypeDoc's CategoryPlugin has processed categories, move
357
+ // natively inherited members (from TS extends) into "Inherited from X"
358
+ // categories so they display the same as our @category-injected members.
359
+ // Priority -300 ensures this runs AFTER GroupPlugin (-100) creates groups
360
+ // and CategoryPlugin (-200) creates categories.
361
+ app.converter.on(
362
+ Converter.EVENT_RESOLVE_END,
363
+ (context) => {
364
+ this.categorizeInheritedMembers(context.project);
365
+ },
366
+ -300,
367
+ );
368
+ }
369
+
370
+ /**
371
+ * Move natively inherited members from the default "None" category
372
+ * into "Inherited from X" categories based on their `inheritedFrom` source.
373
+ *
374
+ * TypeDoc marks members inherited via TS `extends` with `flags.isInherited`
375
+ * and `inheritedFrom`, but puts them in the default category alongside own
376
+ * members. This method splits them into proper source-based categories to
377
+ * match our @category-injected interface members.
378
+ */
379
+ private categorizeInheritedMembers(project: ProjectReflection): void {
380
+ for (const r of Object.values(project.reflections)) {
381
+ if (!r.isDeclaration()) continue;
382
+ const refl = r as DeclarationReflection;
383
+ if (!refl.groups) continue;
384
+
385
+ for (const group of refl.groups) {
386
+ if (!group.children?.length) continue;
387
+
388
+ // Get the children to process: from "None" category if categories exist,
389
+ // or from group.children directly if no categories were created
390
+ const hasCategories = !!group.categories?.length;
391
+ const noneIdx = hasCategories
392
+ ? (group.categories?.findIndex((c) => c.title.toLowerCase() === "none") ?? -1)
393
+ : -1;
394
+
395
+ // Source of children to split: "None" category or all group children
396
+ const childrenToSplit =
397
+ hasCategories && noneIdx >= 0 ? group.categories?.[noneIdx].children : !hasCategories ? group.children : null;
398
+
399
+ if (!childrenToSplit?.length) continue;
400
+
401
+ // Split: own members stay, inherited move to source categories
402
+ const inheritedBySource = new Map<string, Array<(typeof childrenToSplit)[0]>>();
403
+ const ownMembers: typeof childrenToSplit = [];
404
+
405
+ // Get the owning class name to skip self-references
406
+ const ownerName = refl.getFullName();
407
+
408
+ for (const child of childrenToSplit) {
409
+ if (
410
+ child.isDeclaration() &&
411
+ (child as DeclarationReflection).flags.isInherited &&
412
+ (child as DeclarationReflection).inheritedFrom
413
+ ) {
414
+ const source = this.extractInheritedSourceName(child as DeclarationReflection);
415
+ // Skip if source is the same class (self-inheritance artifact)
416
+ if (source && source !== ownerName && source !== refl.name) {
417
+ let list = inheritedBySource.get(source);
418
+ if (!list) {
419
+ list = [];
420
+ inheritedBySource.set(source, list);
421
+ }
422
+ list.push(child);
423
+ continue;
424
+ }
425
+ }
426
+ ownMembers.push(child);
427
+ }
428
+
429
+ if (inheritedBySource.size === 0) continue;
430
+
431
+ // Ensure categories array exists
432
+ if (!group.categories) {
433
+ group.categories = [];
434
+ }
435
+
436
+ // Update or remove the None category
437
+ if (hasCategories && noneIdx >= 0) {
438
+ if (ownMembers.length > 0) {
439
+ group.categories[noneIdx].children = ownMembers;
440
+ } else {
441
+ group.categories.splice(noneIdx, 1);
442
+ }
443
+ } else if (!hasCategories && ownMembers.length > 0) {
444
+ // No categories existed before — create None for own members
445
+ const noneCat = new ReflectionCategory("None");
446
+ noneCat.children = ownMembers;
447
+ group.categories.unshift(noneCat);
448
+ }
449
+
450
+ // Add inherited members to existing or new "Inherited from X" categories
451
+ const catByTitle = new Map(group.categories.map((c) => [c.title, c]));
452
+ for (const [source, members] of inheritedBySource) {
453
+ const catTitle = `Inherited from ${source}`;
454
+ const existing = catByTitle.get(catTitle);
455
+ if (existing) {
456
+ existing.children.push(...members);
457
+ } else {
458
+ const cat = new ReflectionCategory(catTitle);
459
+ cat.children = members;
460
+ group.categories.push(cat);
461
+ catByTitle.set(catTitle, cat);
462
+ }
463
+ }
464
+ }
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Extract a human-readable source name from an inherited member's `inheritedFrom`.
470
+ * e.g. `"Window.accessible_role"` → looks up parent to get `"Gtk.Window"`.
471
+ */
472
+ private extractInheritedSourceName(child: DeclarationReflection): string | null {
473
+ if (!child.inheritedFrom) return null;
474
+
475
+ // Try to resolve via the referenced reflection's parent
476
+ const target = child.inheritedFrom.reflection;
477
+ if (target?.parent) {
478
+ const parent = target.parent;
479
+ // Get the full qualified name: "Gtk.Window" from the parent's path
480
+ const fullName = parent.getFullName();
481
+ // The full name format is "Module.Class" — keep as-is
482
+ if (fullName) return fullName;
483
+ }
484
+
485
+ // Fallback: extract from the name string (e.g. "Window.method" → "Window")
486
+ const name = child.inheritedFrom.name;
487
+ const dotIdx = name.indexOf(".");
488
+ return dotIdx > 0 ? name.slice(0, dotIdx) : name;
489
+ }
490
+
491
+ /** Register GIR metadata serializer and namespace-level metadata on a TypeDoc app. */
492
+ private registerGirMetadata(app: Application, module: GirModule): void {
493
+ const index = buildGirLookupIndex(module);
494
+ app.serializer.addSerializer(new GirMetadataSerializer(index));
495
+ this.attachNamespaceMetadata(app, this.buildNamespaceMetadata(module));
496
+ }
497
+
498
+ /** Attach namespace-level metadata to the TypeDoc JSON output via a serializer event. */
499
+ private attachNamespaceMetadata(app: Application, nsMeta: GirNamespaceMetadata): void {
500
+ app.serializer.on(Serializer.EVENT_END, (event) => {
501
+ if (event.output) {
502
+ // biome-ignore lint/suspicious/noExplicitAny: extending TypeDoc's JSON schema with custom field
503
+ (event.output as any).girNamespaceMetadata = nsMeta;
504
+ }
505
+ });
506
+ }
507
+
508
+ private buildNamespaceMetadata(module: GirModule): GirNamespaceMetadata {
509
+ const meta = getModuleMetadata(`${module.namespace}-${module.version}`);
510
+ const pkgJson = this.readPackageJson(module.importName);
511
+
512
+ return this.mergeMetadataWithPackageJson(
513
+ {
514
+ namespace: module.namespace,
515
+ version: module.version,
516
+ packageName: module.packageName,
517
+ cPrefixes: module.c_prefixes,
518
+ libraryVersion: module.libraryVersion.toString(),
519
+ dependencies: module.dependencies.map((d) => ({
520
+ namespace: d.namespace,
521
+ version: d.version,
522
+ })),
523
+ },
524
+ meta,
525
+ pkgJson,
526
+ );
527
+ }
528
+
529
+ /** Build namespace metadata for the GJS pseudo-package (no GIR module). */
530
+ private buildGjsNamespaceMetadata(): GirNamespaceMetadata {
531
+ const meta = getModuleMetadata("Gjs");
532
+ const pkgJson = this.readPackageJson("gjs");
533
+
534
+ return this.mergeMetadataWithPackageJson(
535
+ {
536
+ namespace: "Gjs",
537
+ version: "",
538
+ packageName: "Gjs",
539
+ cPrefixes: [],
540
+ libraryVersion: "",
541
+ dependencies: [],
542
+ },
543
+ meta,
544
+ pkgJson,
545
+ );
546
+ }
547
+
548
+ /** Merge curated module metadata and package.json fallbacks into a GirNamespaceMetadata. */
549
+ private mergeMetadataWithPackageJson(
550
+ base: Pick<
551
+ GirNamespaceMetadata,
552
+ "namespace" | "version" | "packageName" | "cPrefixes" | "libraryVersion" | "dependencies"
553
+ >,
554
+ meta: ReturnType<typeof getModuleMetadata>,
555
+ pkgJson: { version?: string; description?: string; license?: string; homepage?: string } | null,
556
+ ): GirNamespaceMetadata {
557
+ return {
558
+ ...base,
559
+ packageVersion: pkgJson?.version,
560
+ displayName: meta?.displayName,
561
+ description: meta?.description ?? pkgJson?.description,
562
+ logoUrl: meta?.logoUrl,
563
+ websiteUrl: meta?.websiteUrl ?? pkgJson?.homepage,
564
+ cDocsUrl: meta?.cDocsUrl,
565
+ license: meta?.license ?? pkgJson?.license,
566
+ category: meta?.category,
567
+ };
568
+ }
569
+
570
+ /** Try to read the generated package.json for a module from the temp directory. */
571
+ private readPackageJson(
572
+ importName: string,
573
+ ): { version?: string; description?: string; license?: string; homepage?: string } | null {
574
+ try {
575
+ const content = readFileSync(join(this.tempDir, importName, "package.json"), "utf8");
576
+ return JSON.parse(content);
577
+ } catch {
578
+ return null;
579
+ }
580
+ }
581
+ }