rspress-plugin-api-extractor 0.13.2 → 0.14.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.
@@ -1,471 +0,0 @@
1
- import { ApiItemKind, ApiModel, ExcerptTokenKind } from "@microsoft/api-extractor-model";
2
- import { VirtualPackage } from "@tsdoctor/registry";
3
-
4
- //#region src/api-extracted-package.ts
5
- /**
6
- * Reconstructs TypeScript declaration files from an API Extractor model.
7
- *
8
- * Extends {@link VirtualPackage} with the ability to generate high-fidelity
9
- * `.d.ts` output from API Extractor's `ApiPackage` — including enum values,
10
- * full JSDoc, namespace members, and all interface member kinds.
11
- *
12
- * Use the factory methods {@link fromApiModel} or {@link fromPackage} to create instances.
13
- */
14
- var ApiExtractedPackage = class ApiExtractedPackage extends VirtualPackage {
15
- apiPackage;
16
- constructor(apiPackage, packageName, entries) {
17
- super({
18
- name: packageName,
19
- version: "1.0.0",
20
- entries
21
- });
22
- this.apiPackage = apiPackage;
23
- }
24
- /**
25
- * Create an ApiExtractedPackage from an API model JSON file path.
26
- */
27
- static fromApiModel(modelPath) {
28
- const apiPackage = new ApiModel().loadPackage(modelPath);
29
- return ApiExtractedPackage.fromPackage(apiPackage, apiPackage.name);
30
- }
31
- /**
32
- * Create an ApiExtractedPackage from an existing ApiPackage instance.
33
- */
34
- static fromPackage(apiPackage, packageName) {
35
- const scratch = new ApiExtractedPackage(apiPackage, packageName, /* @__PURE__ */ new Map([["index.d.ts", ""]]));
36
- const entries = /* @__PURE__ */ new Map();
37
- for (const ep of apiPackage.entryPoints) {
38
- const entryName = scratch.getEntryPointName(ep);
39
- const fileName = entryName ? `${entryName}.d.ts` : "index.d.ts";
40
- entries.set(fileName, scratch.generateDeclarations(ep));
41
- }
42
- return new ApiExtractedPackage(apiPackage, packageName, entries);
43
- }
44
- /**
45
- * Generate the VFS map for this package (`node_modules/<name>/...`).
46
- *
47
- * Delegates to the v2 {@link VirtualPackage.toVfs}; kept under the v1 name
48
- * because the config layer and tests consume it as `generateVfs()`.
49
- */
50
- generateVfs() {
51
- return this.toVfs();
52
- }
53
- /**
54
- * Generate the .d.ts content for a specific entry point.
55
- */
56
- generateDeclarations(entryPoint) {
57
- const ep = entryPoint ?? this.apiPackage.entryPoints[0];
58
- if (!ep) return "";
59
- const parts = [];
60
- const packageDoc = this.extractPackageDocumentation();
61
- if (packageDoc) {
62
- parts.push(packageDoc);
63
- parts.push("");
64
- }
65
- for (const member of ep.members) {
66
- const decl = this.generateDeclaration(member);
67
- if (decl) {
68
- parts.push(decl);
69
- parts.push("");
70
- }
71
- }
72
- parts.push("export { }");
73
- parts.push("");
74
- return parts.join("\n");
75
- }
76
- /**
77
- * Generate a TypeScript declaration for a single API item.
78
- */
79
- generateDeclaration(apiItem) {
80
- switch (apiItem.kind) {
81
- case ApiItemKind.Class: return this.generateClassDeclaration(apiItem);
82
- case ApiItemKind.Interface: return this.generateInterfaceDeclaration(apiItem);
83
- case ApiItemKind.TypeAlias: return this.generateTypeAliasDeclaration(apiItem);
84
- case ApiItemKind.Function: return this.generateFunctionDeclaration(apiItem);
85
- case ApiItemKind.Enum: return this.generateEnumDeclaration(apiItem);
86
- case ApiItemKind.Variable: return this.generateVariableDeclaration(apiItem);
87
- case ApiItemKind.Namespace: return this.generateNamespaceDeclaration(apiItem);
88
- default: return null;
89
- }
90
- }
91
- generateClassDeclaration(apiClass) {
92
- const lines = [];
93
- const jsDoc = this.formatJSDoc(apiClass);
94
- if (jsDoc) lines.push(jsDoc);
95
- let name = apiClass.displayName;
96
- if (apiClass.typeParameters?.length) name += this.formatTypeParameters(apiClass.typeParameters);
97
- const headerParts = apiClass.isAbstract ? ["export declare abstract class", name] : ["export declare class", name];
98
- if (apiClass.extendsType) headerParts.push(`extends ${this.renderExcerpt(apiClass.extendsType.excerpt)}`);
99
- if (apiClass.implementsTypes?.length) {
100
- const impl = apiClass.implementsTypes.map((t) => this.renderExcerpt(t.excerpt)).join(", ");
101
- headerParts.push(`implements ${impl}`);
102
- }
103
- lines.push(`${headerParts.join(" ")} {`);
104
- for (const member of apiClass.members) {
105
- const memberDecl = this.generateClassMember(member);
106
- if (memberDecl) lines.push(memberDecl);
107
- }
108
- lines.push("}");
109
- return lines.join("\n");
110
- }
111
- generateInterfaceDeclaration(apiInterface) {
112
- const lines = [];
113
- const jsDoc = this.formatJSDoc(apiInterface);
114
- if (jsDoc) lines.push(jsDoc);
115
- let name = apiInterface.displayName;
116
- if (apiInterface.typeParameters?.length) name += this.formatTypeParameters(apiInterface.typeParameters);
117
- const headerParts = ["export declare interface", name];
118
- if (apiInterface.extendsTypes?.length) {
119
- const ext = apiInterface.extendsTypes.map((t) => this.renderExcerpt(t.excerpt)).join(", ");
120
- headerParts.push(`extends ${ext}`);
121
- }
122
- lines.push(`${headerParts.join(" ")} {`);
123
- for (const member of apiInterface.members) {
124
- const memberDecl = this.generateInterfaceMember(member);
125
- if (memberDecl) lines.push(memberDecl);
126
- }
127
- lines.push("}");
128
- return lines.join("\n");
129
- }
130
- generateTypeAliasDeclaration(typeAlias) {
131
- const lines = [];
132
- const jsDoc = this.formatJSDoc(typeAlias);
133
- if (jsDoc) lines.push(jsDoc);
134
- let name = typeAlias.displayName;
135
- if (typeAlias.typeParameters?.length) name += this.formatTypeParameters(typeAlias.typeParameters);
136
- lines.push(`export declare type ${name} = ${this.renderExcerpt(typeAlias.typeExcerpt)};`);
137
- return lines.join("\n");
138
- }
139
- generateFunctionDeclaration(apiFunction) {
140
- const lines = [];
141
- const jsDoc = this.formatJSDoc(apiFunction);
142
- if (jsDoc) lines.push(jsDoc);
143
- const cleaned = this.cleanExcerpt(this.renderExcerpt(apiFunction.excerpt));
144
- const decl = cleaned.startsWith("function ") ? cleaned : `const ${cleaned}`;
145
- lines.push(`export declare ${decl};`);
146
- return lines.join("\n");
147
- }
148
- generateEnumDeclaration(apiEnum) {
149
- const lines = [];
150
- const jsDoc = this.formatJSDoc(apiEnum);
151
- if (jsDoc) lines.push(jsDoc);
152
- lines.push(`export declare enum ${apiEnum.displayName} {`);
153
- const enumMembers = apiEnum.members.filter((m) => m.kind === ApiItemKind.EnumMember);
154
- for (let i = 0; i < enumMembers.length; i++) {
155
- const enumMember = enumMembers[i];
156
- const memberJsDoc = this.formatJSDoc(enumMember, " ");
157
- if (memberJsDoc) lines.push(memberJsDoc);
158
- const suffix = i === enumMembers.length - 1 ? "" : ",";
159
- const initExcerpt = enumMember.initializerExcerpt;
160
- if (initExcerpt?.text.trim()) lines.push(` ${enumMember.displayName} = ${initExcerpt.text.trim()}${suffix}`);
161
- else lines.push(` ${enumMember.displayName}${suffix}`);
162
- }
163
- lines.push("}");
164
- return lines.join("\n");
165
- }
166
- generateVariableDeclaration(apiVariable) {
167
- const lines = [];
168
- const jsDoc = this.formatJSDoc(apiVariable);
169
- if (jsDoc) lines.push(jsDoc);
170
- let cleaned = this.cleanExcerpt(this.renderExcerpt(apiVariable.excerpt));
171
- if (!cleaned.startsWith("const ") && !cleaned.startsWith("let ") && !cleaned.startsWith("var ")) cleaned = `const ${cleaned}`;
172
- lines.push(`export declare ${cleaned};`);
173
- return lines.join("\n");
174
- }
175
- generateNamespaceDeclaration(apiNamespace) {
176
- const lines = [];
177
- const jsDoc = this.formatJSDoc(apiNamespace);
178
- if (jsDoc) lines.push(jsDoc);
179
- lines.push(`export declare namespace ${apiNamespace.displayName} {`);
180
- for (const member of apiNamespace.members) {
181
- const memberDecl = this.generateNamespaceMember(member);
182
- if (memberDecl) lines.push(memberDecl);
183
- }
184
- lines.push("}");
185
- return lines.join("\n");
186
- }
187
- generateNamespaceMember(apiItem) {
188
- switch (apiItem.kind) {
189
- case ApiItemKind.Function: return this.generateNamespaceFunction(apiItem);
190
- case ApiItemKind.Interface: return this.generateNamespaceInterface(apiItem);
191
- case ApiItemKind.Enum: return this.generateNamespaceEnum(apiItem);
192
- case ApiItemKind.TypeAlias: return this.generateNamespaceTypeAlias(apiItem);
193
- case ApiItemKind.Variable: return this.generateNamespaceVariable(apiItem);
194
- case ApiItemKind.Class: return this.generateNamespaceClass(apiItem);
195
- default: return null;
196
- }
197
- }
198
- generateNamespaceFunction(apiFunction) {
199
- const lines = [];
200
- const jsDoc = this.formatJSDoc(apiFunction, " ");
201
- if (jsDoc) lines.push(jsDoc);
202
- const cleaned = this.cleanExcerpt(this.renderExcerpt(apiFunction.excerpt));
203
- lines.push(` export ${cleaned};`);
204
- return lines.join("\n");
205
- }
206
- generateNamespaceInterface(apiInterface) {
207
- const lines = [];
208
- const jsDoc = this.formatJSDoc(apiInterface, " ");
209
- if (jsDoc) lines.push(jsDoc);
210
- let name = apiInterface.displayName;
211
- if (apiInterface.typeParameters?.length) name += this.formatTypeParameters(apiInterface.typeParameters);
212
- const headerParts = ["export interface", name];
213
- if (apiInterface.extendsTypes?.length) {
214
- const ext = apiInterface.extendsTypes.map((t) => this.renderExcerpt(t.excerpt)).join(", ");
215
- headerParts.push(`extends ${ext}`);
216
- }
217
- lines.push(` ${headerParts.join(" ")} {`);
218
- for (const member of apiInterface.members) {
219
- const memberDecl = this.generateInterfaceMember(member, " ");
220
- if (memberDecl) lines.push(memberDecl);
221
- }
222
- lines.push(" }");
223
- return lines.join("\n");
224
- }
225
- generateNamespaceEnum(apiEnum) {
226
- const lines = [];
227
- const jsDoc = this.formatJSDoc(apiEnum, " ");
228
- if (jsDoc) lines.push(jsDoc);
229
- lines.push(` export enum ${apiEnum.displayName} {`);
230
- const enumMembers = apiEnum.members.filter((m) => m.kind === ApiItemKind.EnumMember);
231
- for (let i = 0; i < enumMembers.length; i++) {
232
- const enumMember = enumMembers[i];
233
- const memberJsDoc = this.formatJSDoc(enumMember, " ");
234
- if (memberJsDoc) lines.push(memberJsDoc);
235
- const suffix = i === enumMembers.length - 1 ? "" : ",";
236
- const initExcerpt = enumMember.initializerExcerpt;
237
- if (initExcerpt?.text.trim()) lines.push(` ${enumMember.displayName} = ${initExcerpt.text.trim()}${suffix}`);
238
- else lines.push(` ${enumMember.displayName}${suffix}`);
239
- }
240
- lines.push(" }");
241
- return lines.join("\n");
242
- }
243
- generateNamespaceTypeAlias(typeAlias) {
244
- const lines = [];
245
- const jsDoc = this.formatJSDoc(typeAlias, " ");
246
- if (jsDoc) lines.push(jsDoc);
247
- let name = typeAlias.displayName;
248
- if (typeAlias.typeParameters?.length) name += this.formatTypeParameters(typeAlias.typeParameters);
249
- lines.push(` export type ${name} = ${this.renderExcerpt(typeAlias.typeExcerpt)};`);
250
- return lines.join("\n");
251
- }
252
- generateNamespaceVariable(apiVariable) {
253
- const lines = [];
254
- const jsDoc = this.formatJSDoc(apiVariable, " ");
255
- if (jsDoc) lines.push(jsDoc);
256
- let cleaned = this.cleanExcerpt(this.renderExcerpt(apiVariable.excerpt));
257
- if (!cleaned.startsWith("const ") && !cleaned.startsWith("let ") && !cleaned.startsWith("var ")) cleaned = `const ${cleaned}`;
258
- lines.push(` export ${cleaned};`);
259
- return lines.join("\n");
260
- }
261
- generateNamespaceClass(apiClass) {
262
- const decl = this.generateClassDeclaration(apiClass);
263
- if (!decl) return "";
264
- return decl.replace(/\bexport declare (abstract )?class\b/, "export $1class").split("\n").map((line) => line.trim() ? ` ${line}` : line).join("\n");
265
- }
266
- generateClassMember(member, indent = " ") {
267
- switch (member.kind) {
268
- case ApiItemKind.Constructor: return this.generateMemberFromExcerpt(member, indent);
269
- case ApiItemKind.Method: return this.generateMemberFromExcerpt(member, indent);
270
- case ApiItemKind.Property: return this.generateMemberFromExcerpt(member, indent);
271
- default: return null;
272
- }
273
- }
274
- generateInterfaceMember(member, indent = " ") {
275
- switch (member.kind) {
276
- case ApiItemKind.MethodSignature: return this.generateMemberFromExcerpt(member, indent);
277
- case ApiItemKind.PropertySignature: return this.generateMemberFromExcerpt(member, indent);
278
- case ApiItemKind.CallSignature: return this.generateMemberFromExcerpt(member, indent);
279
- case ApiItemKind.ConstructSignature: return this.generateMemberFromExcerpt(member, indent);
280
- case ApiItemKind.IndexSignature: return this.generateMemberFromExcerpt(member, indent);
281
- default: return null;
282
- }
283
- }
284
- generateMemberFromExcerpt(member, indent) {
285
- const lines = [];
286
- const jsDoc = this.formatJSDoc(member, indent);
287
- if (jsDoc) lines.push(jsDoc);
288
- const cleaned = this.cleanExcerpt(this.renderExcerpt(member.excerpt));
289
- lines.push(`${indent}${cleaned};`);
290
- return lines.join("\n");
291
- }
292
- /**
293
- * Render an excerpt to source text, normalizing dts-rollup disambiguation
294
- * aliases. The dts rollup renames a re-imported symbol as `Name$1`, but its
295
- * canonical reference is the un-suffixed `Name` (the same symbol). The import
296
- * prepender ({@link TypeReferenceExtractor}) imports the canonical name, so
297
- * emitting the suffixed text would leave `Name$1` undefined (TS2304). Emit the
298
- * canonical name so the body and the prepended import agree.
299
- *
300
- * Equivalent to `excerpt.text` for excerpts without rollup aliases (the text
301
- * is the concatenation of the spanned tokens), so unaliased output is unchanged.
302
- */
303
- renderExcerpt(excerpt) {
304
- return excerpt.spannedTokens.map((token) => this.normalizeTokenText(token)).join("");
305
- }
306
- /**
307
- * Strip a dts-rollup `$N` suffix from a reference token when the de-suffixed
308
- * text matches the token's canonical symbol. Never touches a non-reference
309
- * token or a legitimate identifier that genuinely ends in `$N` (its canonical
310
- * name would carry the suffix too).
311
- */
312
- normalizeTokenText(token) {
313
- if (token.kind !== ExcerptTokenKind.Reference) return token.text;
314
- const match = /^(.+)\$\d+$/.exec(token.text);
315
- if (!match) return token.text;
316
- const canonical = token.canonicalReference?.toString();
317
- if (!canonical) return token.text;
318
- const afterBang = canonical.slice(canonical.indexOf("!") + 1);
319
- const colon = afterBang.indexOf(":");
320
- const symbol = colon === -1 ? afterBang : afterBang.slice(0, colon);
321
- const leaf = symbol.includes(".") ? symbol.slice(symbol.lastIndexOf(".") + 1) : symbol;
322
- return match[1] === symbol || match[1] === leaf ? match[1] : token.text;
323
- }
324
- /**
325
- * Clean an excerpt text: strip export/declare keywords and trailing semicolons/whitespace.
326
- */
327
- cleanExcerpt(text) {
328
- return text.replace(/^export\s+/, "").replace(/^declare\s+/, "").replace(/;+\s*$/, "").trim();
329
- }
330
- formatTypeParameters(typeParameters) {
331
- if (!typeParameters.length) return "";
332
- return `<${typeParameters.map((tp) => {
333
- const parts = [tp.name];
334
- if (tp.constraintExcerpt && this.renderExcerpt(tp.constraintExcerpt).trim()) parts.push(`extends ${this.renderExcerpt(tp.constraintExcerpt).trim()}`);
335
- if (tp.defaultTypeExcerpt && this.renderExcerpt(tp.defaultTypeExcerpt).trim()) parts.push(`= ${this.renderExcerpt(tp.defaultTypeExcerpt).trim()}`);
336
- return parts.join(" ");
337
- }).join(", ")}>`;
338
- }
339
- extractPackageDocumentation() {
340
- const pkg = this.apiPackage;
341
- if (!pkg.tsdocComment?.summarySection) return null;
342
- const summary = this.extractPlainText(pkg.tsdocComment.summarySection).trim();
343
- if (!summary) return null;
344
- const lines = [];
345
- for (const line of summary.split("\n")) lines.push(line);
346
- lines.push("");
347
- lines.push("@packageDocumentation");
348
- return `/**\n${lines.map((line) => line ? ` * ${line}` : " *").join("\n")}\n */`;
349
- }
350
- /**
351
- * Format JSDoc comment from an API item's TSDoc.
352
- * Produces output matching the TypeScript compiler's JSDoc style.
353
- */
354
- formatJSDoc(apiItem, indent = "") {
355
- const item = apiItem;
356
- if (!item.tsdocComment) return null;
357
- const tsdoc = item.tsdocComment;
358
- const lines = [];
359
- if (tsdoc.summarySection) {
360
- const summary = this.extractPlainText(tsdoc.summarySection).trim();
361
- if (summary) for (const line of summary.split("\n")) lines.push(line);
362
- }
363
- const typeParamLines = [];
364
- if (tsdoc.typeParams?.blocks) for (const block of tsdoc.typeParams.blocks) {
365
- const blockAny = block;
366
- const name = blockAny.parameterName || "";
367
- const desc = this.extractPlainText(blockAny.content).replace(/\s+/g, " ").trim();
368
- if (name && desc) typeParamLines.push(`@typeParam ${name} - ${desc}`);
369
- }
370
- const paramLines = [];
371
- if (tsdoc.params?.blocks) for (const paramBlock of tsdoc.params.blocks) {
372
- const param = paramBlock;
373
- const name = param.parameterName || "";
374
- const desc = this.extractPlainText(param.content).replace(/\s+/g, " ").trim();
375
- if (name && desc) paramLines.push(`@param ${name} - ${desc}`);
376
- }
377
- let returnsLine = null;
378
- if (tsdoc.returnsBlock) {
379
- const desc = this.extractPlainText(tsdoc.returnsBlock.content).replace(/\s+/g, " ").trim();
380
- if (desc) returnsLine = `@returns ${desc}`;
381
- }
382
- if (typeParamLines.length || paramLines.length || returnsLine) {
383
- if (lines.length > 0) lines.push("");
384
- lines.push(...typeParamLines);
385
- lines.push(...paramLines);
386
- if (returnsLine) lines.push(returnsLine);
387
- }
388
- if (tsdoc.deprecatedBlock) {
389
- const msg = this.extractPlainText(tsdoc.deprecatedBlock.content).replace(/\s+/g, " ").trim();
390
- if (msg) {
391
- if (lines.length > 0) lines.push("");
392
- lines.push(`@deprecated ${msg}`);
393
- }
394
- }
395
- if (tsdoc.remarksBlock) {
396
- const remarks = this.extractPlainText(tsdoc.remarksBlock.content).trim();
397
- if (remarks) {
398
- if (lines.length > 0) lines.push("");
399
- lines.push("@remarks");
400
- for (const line of remarks.split("\n")) lines.push(line);
401
- }
402
- }
403
- if (tsdoc.customBlocks) for (const block of tsdoc.customBlocks) {
404
- const blockAny = block;
405
- if (blockAny.blockTag?.tagName === "@example") {
406
- const exampleText = this.extractPlainText(blockAny.content).trim();
407
- if (exampleText) {
408
- if (lines.length > 0) lines.push("");
409
- lines.push("@example");
410
- for (const line of exampleText.split("\n")) lines.push(line);
411
- }
412
- }
413
- }
414
- try {
415
- if (tsdoc.modifierTagSet?.isPublic?.()) {
416
- if (lines.length > 0) lines.push("");
417
- lines.push("@public");
418
- }
419
- } catch {}
420
- if (lines.length === 0) return null;
421
- if (lines.length === 1 && !lines[0].includes("\n")) return `${indent}/** ${lines[0]} */`;
422
- return `${indent}/**\n${lines.map((line) => line ? `${indent} * ${line}` : `${indent} *`).join("\n")}\n${indent} */`;
423
- }
424
- /**
425
- * Recursively extract plain text from a TSDoc DocNode tree.
426
- */
427
- extractPlainText(node) {
428
- const n = node;
429
- if (n.kind === "PlainText") return n.text || "";
430
- if (n.kind === "SoftBreak") return "\n";
431
- if (n.kind === "CodeSpan") return `\`${n.code || ""}\``;
432
- if (n.kind === "EscapedText") return n.encodedText || n.decodedText || "";
433
- if (n.kind === "ErrorText") return n.text || "";
434
- if (n.kind === "FencedCode") return `\`\`\`${n.language || ""}\n${(n.code || "").replace(/\n+$/, "")}\n\`\`\``;
435
- if (n.kind === "LinkTag") {
436
- let target = "";
437
- if (n.codeDestination?.memberReferences) {
438
- const identifiers = [];
439
- for (const ref of n.codeDestination.memberReferences) if (ref.memberIdentifier?.identifier) identifiers.push(ref.memberIdentifier.identifier);
440
- target = identifiers.join(".");
441
- }
442
- const displayText = typeof n.linkText === "string" ? n.linkText : "";
443
- if (target && displayText) return `{@link ${target} | ${displayText}}`;
444
- if (target) return `{@link ${target}}`;
445
- if (displayText) return displayText;
446
- return "";
447
- }
448
- if (n.kind === "Section") {
449
- const children = n.getChildNodes?.() || [];
450
- const paragraphs = [];
451
- for (const child of children) {
452
- const trimmed = this.extractPlainText(child).trim();
453
- if (trimmed) paragraphs.push(trimmed);
454
- }
455
- return paragraphs.join("\n\n");
456
- }
457
- const parts = [];
458
- if (n.getChildNodes && typeof n.getChildNodes === "function") for (const child of n.getChildNodes()) {
459
- const text = this.extractPlainText(child);
460
- if (text) parts.push(text);
461
- }
462
- return parts.join("");
463
- }
464
- getEntryPointName(entryPoint) {
465
- if (entryPoint.displayName === "") return void 0;
466
- return entryPoint.displayName;
467
- }
468
- };
469
-
470
- //#endregion
471
- export { ApiExtractedPackage };
package/frontmatter.js DELETED
@@ -1,176 +0,0 @@
1
- import { Effect } from "effect";
2
- import { FrontmatterSource, FrontmatterSourceBlock, FrontmatterSourceSplit } from "@effected/markdown";
3
- import { Yaml, YamlStringifyOptions } from "@effected/yaml";
4
-
5
- //#region src/frontmatter.ts
6
- /**
7
- * Stringify options shared by both emit sites.
8
- *
9
- * `lineWidth: 0` disables wrapping so long titles/descriptions/URLs stay on
10
- * one line. The quoting matters for downstream consumers: RSPress parses the
11
- * emitted frontmatter with js-yaml (YAML 1.1-flavored), where an unquoted
12
- * ISO timestamp such as `2024-01-15T12:00:00.000Z` decodes to a `Date`
13
- * object instead of a string. `quoteCompat: "yaml-1.1"` quotes exactly the
14
- * plain scalars a YAML 1.1 resolver would coerce (timestamps, `yes`/`no`/
15
- * `on`/`off` booleans, legacy octal/sexagesimal numbers), keeping the
16
- * decoded representation identical across YAML 1.1 and 1.2 parsers without
17
- * quoting every value; `quoteStyle: "double"` makes the quotes that do
18
- * appear double quotes.
19
- */
20
- const STRINGIFY_OPTIONS = YamlStringifyOptions.make({
21
- lineWidth: 0,
22
- quoteCompat: "yaml-1.1",
23
- quoteStyle: "double"
24
- });
25
- const OPEN_DELIMITER = "---";
26
- const CLOSE_SEARCH = "\n---";
27
- /**
28
- * Split markdown source into frontmatter data and body content, preserving
29
- * gray-matter's exact boundary semantics.
30
- *
31
- * @remarks
32
- * This is a byte-for-byte port of the `gray-matter` split contract the
33
- * snapshot system's hashes depend on (see `@tsdoctor/snapshot`
34
- * `hashContent`/`hashFrontmatter` and the disk-fallback comparison in
35
- * `build-stages.ts`), with `@effected/yaml` (`Yaml.parse`, YAML 1.2) as the
36
- * YAML engine instead of js-yaml:
37
- *
38
- * - No opening `---` line at offset 0 → `data: {}` and the whole input as
39
- * `content` (a leading BOM is stripped first, as gray-matter does).
40
- * - The closing delimiter is the first `\n---` after the opening line
41
- * (gray-matter uses a plain `indexOf`, so `\n----` also closes and the
42
- * leftover `-` stays in the body — preserved deliberately).
43
- * - Exactly one newline (`\n` or `\r\n`) immediately after the closing `---`
44
- * is consumed; everything else is the body verbatim. A build's generated
45
- * page (`---\n…\n---\n\n# Title`) therefore yields a body starting with a
46
- * single `\n`, exactly as gray-matter returned it.
47
- * - A block with no closing delimiter is all frontmatter and yields an empty
48
- * body; an empty/blank block yields `data: {}`.
49
- * - Invalid YAML throws (a defect), matching gray-matter's js-yaml throw.
50
- *
51
- * One deliberate delta: gray-matter treats text on the opening line
52
- * (`---toml`) as an engine name and throws for unregistered engines; this
53
- * split treats such input as "no frontmatter" instead. The plugin never emits
54
- * or consumes language-tagged frontmatter.
55
- *
56
- * `@effected/markdown`'s `FrontmatterSource.split` was evaluated for this
57
- * path and deliberately NOT adopted: its grammar is strict by design (a
58
- * fence line is exactly `---`, an unterminated block is not frontmatter),
59
- * while this contract pins gray-matter's `indexOf`-based quirks (`\n----`
60
- * closes, trailing-space close lines close, a missing close means
61
- * all-frontmatter). The emission half (`stringifyFrontmatter` /
62
- * `emitFrontmatterBlock`) does use `FrontmatterSource.join`.
63
- *
64
- * Representation parity with js-yaml is verified by characterization tests
65
- * (`__test__/frontmatter.test.ts`) pinning hashes captured under gray-matter.
66
- * The one input where the engines disagree — an *unquoted* ISO timestamp
67
- * (js-yaml: `Date`, YAML 1.2: string) — is unreachable from this plugin's
68
- * emitters, which always quote timestamp values, and hashes identically
69
- * anyway because `hashFrontmatter` JSON-serializes (a `Date` serializes to
70
- * the same ISO string).
71
- *
72
- * @param source - The markdown source, with or without a frontmatter block
73
- * @returns The decoded frontmatter data and the body content
74
- *
75
- * @public
76
- */
77
- function parseFrontmatter(source) {
78
- const text = source.charCodeAt(0) === 65279 ? source.slice(1) : source;
79
- if (!text.startsWith(OPEN_DELIMITER)) return {
80
- data: {},
81
- content: text
82
- };
83
- const afterOpen = text.charAt(3);
84
- if (text === OPEN_DELIMITER) return {
85
- data: {},
86
- content: ""
87
- };
88
- if (afterOpen !== "\n" && !(afterOpen === "\r" && text.charAt(4) === "\n")) return {
89
- data: {},
90
- content: text
91
- };
92
- const fmStart = afterOpen === "\r" ? 5 : 4;
93
- const closeIndex = text.indexOf(CLOSE_SEARCH, fmStart - 1);
94
- let frontmatterText;
95
- let content;
96
- if (closeIndex === -1) {
97
- frontmatterText = text.slice(fmStart);
98
- content = "";
99
- } else {
100
- frontmatterText = closeIndex < fmStart ? "" : text.slice(fmStart, closeIndex);
101
- let bodyStart = closeIndex + 4;
102
- if (text.charAt(bodyStart) === "\r" && text.charAt(bodyStart + 1) === "\n") bodyStart += 2;
103
- else if (text.charAt(bodyStart) === "\n") bodyStart += 1;
104
- content = text.slice(bodyStart);
105
- }
106
- if (frontmatterText.trim() === "") return {
107
- data: {},
108
- content
109
- };
110
- const value = Effect.runSync(Yaml.parse(frontmatterText));
111
- return {
112
- data: value == null ? {} : value,
113
- content
114
- };
115
- }
116
- /**
117
- * Serialize frontmatter data and body content back into a markdown document,
118
- * preserving gray-matter's `matter.stringify` contract.
119
- *
120
- * @remarks
121
- * Emits `---\n<yaml>---\n<content>` with the body's trailing newline ensured,
122
- * and returns the body unchanged (no fences) when `data` has no keys — both
123
- * gray-matter behaviors the write path relied on. The YAML is emitted by
124
- * `@effected/yaml` with every string value double-quoted (see
125
- * `STRINGIFY_OPTIONS` for why); byte output differs from js-yaml's dump, but
126
- * the decoded representation is identical, which is the invariant the
127
- * snapshot hashes depend on. Unchanged pages are never rewritten, so the byte
128
- * difference only ever lands in files that were being rewritten anyway.
129
- *
130
- * @param content - The body content
131
- * @param data - The frontmatter data to serialize
132
- * @returns The combined markdown document
133
- *
134
- * @public
135
- */
136
- function stringifyFrontmatter(content, data) {
137
- const body = content.endsWith("\n") ? content : `${content}\n`;
138
- if (Object.keys(data).length === 0) return body;
139
- const yaml = Effect.runSync(Yaml.stringify(data, STRINGIFY_OPTIONS));
140
- return FrontmatterSource.join(FrontmatterSourceSplit.make({
141
- frontmatter: FrontmatterSourceBlock.make({
142
- format: "yaml",
143
- value: yaml
144
- }),
145
- body
146
- }));
147
- }
148
- /**
149
- * Serialize a data object to a YAML frontmatter block (fences included, plus
150
- * the trailing blank line the page generators emit before the body).
151
- *
152
- * @remarks
153
- * Used by `generateFrontmatter` (`markdown/helpers.ts`) as the emission half
154
- * of the page generators' frontmatter. Every string value is double-quoted
155
- * (see `STRINGIFY_OPTIONS`), so values that a YAML 1.1 consumer would
156
- * otherwise coerce (timestamps, `yes`/`no`, numeric-looking strings) stay
157
- * strings for RSPress's js-yaml parse.
158
- *
159
- * @param data - The frontmatter data to serialize
160
- * @returns A `---`-fenced YAML block ending with a blank line
161
- *
162
- * @public
163
- */
164
- function emitFrontmatterBlock(data) {
165
- const yaml = Effect.runSync(Yaml.stringify(data, STRINGIFY_OPTIONS));
166
- return FrontmatterSource.join(FrontmatterSourceSplit.make({
167
- frontmatter: FrontmatterSourceBlock.make({
168
- format: "yaml",
169
- value: yaml
170
- }),
171
- body: "\n"
172
- }));
173
- }
174
-
175
- //#endregion
176
- export { emitFrontmatterBlock, parseFrontmatter, stringifyFrontmatter };