@oa-sdk/spec-bundler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,777 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ /**
4
+ * Temporarily masks code blocks and inline code to prevent false-positive link mutations.
5
+ * Provides character-accurate original line and column mapping.
6
+ */
7
+ export function maskCodeBlocks(markdown) {
8
+ const spans = [];
9
+ function addSpans(regex, type) {
10
+ let m;
11
+ while ((m = regex.exec(markdown)) !== null) {
12
+ const start = m.index;
13
+ const end = start + m[0].length;
14
+ const overlaps = spans.some((s) => Math.max(s.start, start) < Math.min(s.end, end));
15
+ if (!overlaps) {
16
+ spans.push({ start, end, original: m[0], type });
17
+ }
18
+ }
19
+ }
20
+ // 1. HTML comments
21
+ addSpans(/<!--[\s\S]*?-->/g, "code_block");
22
+ // 2. HTML pre, code, script, and style blocks
23
+ addSpans(/(?:<pre\b[^>]*>[\s\S]*?<\/pre>|<code\b[^>]*>[\s\S]*?<\/code>|<script\b[^>]*>[\s\S]*?<\/script>|<style\b[^>]*>[\s\S]*?<\/style>)/gi, "code_block");
24
+ // 3. Fenced code blocks (3+ backticks or tildes, multiline or single line, with optional blockquote prefixes)
25
+ addSpans(/^[ \t>]*(?:(`{3,}|~{3,}))[^\r\n]*\r?\n[\s\S]*?^[ \t>]*\1[`~]*[ \t]*(?:\r?\n|$)/gm, "code_block");
26
+ addSpans(/(?:(`{3,}|~{3,})[\s\S]*?\1[`~]*)/g, "code_block");
27
+ // 4. Inline code (`...` single line, or ``...`` multiline)
28
+ addSpans(/(?:(`{2,})[\s\S]*?\1|`[^`\r\n]+`)/g, "inline_code");
29
+ // 5. Escaped brackets (\[ and \])
30
+ addSpans(/\\[\[\]]/g, "escaped");
31
+ // Sort spans in document order
32
+ spans.sort((a, b) => a.start - b.start);
33
+ const replacements = [];
34
+ const segments = [];
35
+ let masked = "";
36
+ let tokenCounter = 0;
37
+ let lastEnd = 0;
38
+ for (const span of spans) {
39
+ if (span.start > lastEnd) {
40
+ const origStart = lastEnd;
41
+ const origEnd = span.start;
42
+ const maskedStart = masked.length;
43
+ const text = markdown.slice(origStart, origEnd);
44
+ masked += text;
45
+ segments.push({
46
+ origStart,
47
+ origEnd,
48
+ maskedStart,
49
+ maskedEnd: masked.length,
50
+ isToken: false,
51
+ });
52
+ }
53
+ let placeholder = "";
54
+ if (span.type === "escaped") {
55
+ placeholder = `__META_BUNDLER_ESCAPED_BRACKET_${tokenCounter++}__`;
56
+ }
57
+ else if (span.type === "code_block") {
58
+ placeholder = `__META_BUNDLER_CODE_BLOCK_${tokenCounter++}__`;
59
+ }
60
+ else {
61
+ placeholder = `__META_BUNDLER_INLINE_CODE_${tokenCounter++}__`;
62
+ }
63
+ replacements.push({ placeholder, original: span.original });
64
+ const maskedStart = masked.length;
65
+ masked += placeholder;
66
+ segments.push({
67
+ origStart: span.start,
68
+ origEnd: span.end,
69
+ maskedStart,
70
+ maskedEnd: masked.length,
71
+ isToken: true,
72
+ });
73
+ lastEnd = span.end;
74
+ }
75
+ if (lastEnd < markdown.length) {
76
+ const origStart = lastEnd;
77
+ const origEnd = markdown.length;
78
+ const maskedStart = masked.length;
79
+ const text = markdown.slice(origStart, origEnd);
80
+ masked += text;
81
+ segments.push({
82
+ origStart,
83
+ origEnd,
84
+ maskedStart,
85
+ maskedEnd: masked.length,
86
+ isToken: false,
87
+ });
88
+ }
89
+ const getOriginalPosition = (maskedOffset) => {
90
+ let origOffset = maskedOffset;
91
+ for (const seg of segments) {
92
+ if (maskedOffset >= seg.maskedStart && maskedOffset < seg.maskedEnd) {
93
+ if (seg.isToken) {
94
+ origOffset = seg.origStart;
95
+ }
96
+ else {
97
+ origOffset = seg.origStart + (maskedOffset - seg.maskedStart);
98
+ }
99
+ break;
100
+ }
101
+ }
102
+ if (maskedOffset >= masked.length) {
103
+ origOffset = markdown.length;
104
+ }
105
+ const slice = markdown.slice(0, origOffset);
106
+ const line = slice.split("\n").length;
107
+ const lastNl = slice.lastIndexOf("\n");
108
+ const column = lastNl === -1 ? origOffset + 1 : origOffset - lastNl;
109
+ return { line, column, offset: origOffset };
110
+ };
111
+ const restore = (text) => {
112
+ let restored = text;
113
+ for (let i = replacements.length - 1; i >= 0; i--) {
114
+ const r = replacements[i];
115
+ restored = restored.replace(r.placeholder, () => r.original);
116
+ }
117
+ return restored;
118
+ };
119
+ return { masked, restore, getOriginalPosition };
120
+ }
121
+ /**
122
+ * Resolves a raw href from markdown to an absolute local filesystem path.
123
+ */
124
+ export function resolveLocalPath(rawPath, sourceFileAbs, workspaceRoot) {
125
+ const isWindows = sourceFileAbs.includes("\\") ||
126
+ /^[a-zA-Z]:/.test(sourceFileAbs) ||
127
+ workspaceRoot.includes("\\") ||
128
+ /^[a-zA-Z]:/.test(workspaceRoot);
129
+ const pathModule = isWindows ? path.win32 : path.posix;
130
+ const normalized = rawPath.replace(/\\/g, "/");
131
+ if (normalized.startsWith("file://")) {
132
+ try {
133
+ return fileURLToPath(normalized);
134
+ }
135
+ catch {
136
+ // Fallback manual decode if URL parsing fails
137
+ const stripped = normalized.replace(/^file:\/\//, "");
138
+ return pathModule.resolve(decodeURIComponent(stripped));
139
+ }
140
+ }
141
+ let decodedPath = normalized;
142
+ if (normalized.includes("%")) {
143
+ try {
144
+ decodedPath = decodeURIComponent(normalized);
145
+ }
146
+ catch {
147
+ // ignore malformed URI
148
+ }
149
+ }
150
+ if (decodedPath.startsWith("/")) {
151
+ // Root-relative to workspace
152
+ return pathModule.resolve(workspaceRoot, decodedPath.replace(/^\/+/, ""));
153
+ }
154
+ // Relative to current document source directory
155
+ return pathModule.resolve(pathModule.dirname(sourceFileAbs), decodedPath);
156
+ }
157
+ /**
158
+ * Common link target rewriting logic shared between inline links and reference definitions.
159
+ */
160
+ function transformHref(rawHrefWithAngle, sourceFileAbs, bundleDestPath, sourceAbsToBundleDest, workspaceRoot, config, line, column, linkKind = "inline") {
161
+ const hadAngleBrackets = rawHrefWithAngle.startsWith("<") && rawHrefWithAngle.endsWith(">");
162
+ let rawHref = hadAngleBrackets ? rawHrefWithAngle.slice(1, -1) : rawHrefWithAngle;
163
+ // Skip in-page hash anchors
164
+ if (rawHref.startsWith("#")) {
165
+ return { newHrefFormatted: rawHrefWithAngle, skipped: true };
166
+ }
167
+ // Skip external protocols (any protocol scheme other than file:)
168
+ if (!rawHref.startsWith("file:") && /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(rawHref)) {
169
+ return { newHrefFormatted: rawHrefWithAngle, skipped: true };
170
+ }
171
+ const normalizedRawHref = rawHref.replace(/\\/g, "/");
172
+ // Split base path and query/hash
173
+ const hashIndex = normalizedRawHref.indexOf("#");
174
+ const queryIndex = normalizedRawHref.indexOf("?");
175
+ let splitIndex = -1;
176
+ if (hashIndex !== -1 && queryIndex !== -1) {
177
+ splitIndex = Math.min(hashIndex, queryIndex);
178
+ }
179
+ else if (hashIndex !== -1) {
180
+ splitIndex = hashIndex;
181
+ }
182
+ else if (queryIndex !== -1) {
183
+ splitIndex = queryIndex;
184
+ }
185
+ const basePath = splitIndex === -1 ? normalizedRawHref : normalizedRawHref.slice(0, splitIndex);
186
+ const suffix = splitIndex === -1 ? "" : normalizedRawHref.slice(splitIndex);
187
+ if (!basePath) {
188
+ return { newHrefFormatted: rawHrefWithAngle, skipped: true };
189
+ }
190
+ const resolvedAbs = resolveLocalPath(basePath, sourceFileAbs, workspaceRoot);
191
+ if (!resolvedAbs) {
192
+ return { newHrefFormatted: rawHrefWithAngle, skipped: true };
193
+ }
194
+ const normalizedBundleDest = bundleDestPath.replace(/\\/g, "/");
195
+ const currentBundleDir = path.posix.dirname(normalizedBundleDest);
196
+ const unbundledPolicy = config.unbundledPolicy ?? "warn";
197
+ const gitUrl = config.gitRepositoryUrl?.replace(/\/$/, "");
198
+ let targetBundleDest = sourceAbsToBundleDest.get(resolvedAbs) ??
199
+ sourceAbsToBundleDest.get(resolvedAbs.replace(/\\/g, "/")) ??
200
+ sourceAbsToBundleDest.get(resolvedAbs.replace(/\//g, "\\"));
201
+ // Fallback: if basePath has encoded characters, also try raw path
202
+ if (!targetBundleDest && basePath.includes("%")) {
203
+ const rawAbs = path.resolve(path.dirname(sourceFileAbs), basePath);
204
+ targetBundleDest =
205
+ sourceAbsToBundleDest.get(rawAbs) ??
206
+ sourceAbsToBundleDest.get(rawAbs.replace(/\\/g, "/")) ??
207
+ sourceAbsToBundleDest.get(rawAbs.replace(/\//g, "\\"));
208
+ }
209
+ // Fallback: if basePath is workspace-relative (common in TypeSpec @evidence)
210
+ if (!targetBundleDest) {
211
+ const fromWorkspace = path.resolve(workspaceRoot, basePath.replace(/^\/+/, ""));
212
+ targetBundleDest =
213
+ sourceAbsToBundleDest.get(fromWorkspace) ??
214
+ sourceAbsToBundleDest.get(fromWorkspace.replace(/\\/g, "/")) ??
215
+ sourceAbsToBundleDest.get(fromWorkspace.replace(/\//g, "\\"));
216
+ }
217
+ let newHref = normalizedRawHref;
218
+ let diagnostic;
219
+ const kindLabel = linkKind === "reference-def" ? "reference definition" : "internal link";
220
+ if (targetBundleDest) {
221
+ const normalizedTargetBundleDest = targetBundleDest.replace(/\\/g, "/");
222
+ let relInBundle = path.posix.relative(currentBundleDir, normalizedTargetBundleDest);
223
+ if (!relInBundle.startsWith(".")) {
224
+ relInBundle = `./${relInBundle}`;
225
+ }
226
+ // Preserve %20 if original link used %20 and didn't use angle brackets
227
+ if (!hadAngleBrackets && rawHref.includes("%20") && !relInBundle.includes("%20")) {
228
+ relInBundle = relInBundle.replace(/ /g, "%20");
229
+ }
230
+ newHref = `${relInBundle}${suffix}`;
231
+ diagnostic = {
232
+ file: normalizedBundleDest,
233
+ line,
234
+ column,
235
+ originalHref: rawHref,
236
+ rewrittenHref: newHref,
237
+ targetBundleDest: normalizedTargetBundleDest,
238
+ severity: "info",
239
+ linkKind,
240
+ message: `Rewrote ${kindLabel}: '${rawHref}' -> '${newHref}'`,
241
+ };
242
+ }
243
+ else {
244
+ // Target is outside the bundle
245
+ if (unbundledPolicy === "github-fallback") {
246
+ if (gitUrl) {
247
+ const relToRoot = path.relative(workspaceRoot, resolvedAbs).replace(/\\/g, "/");
248
+ newHref = `${gitUrl}/${relToRoot}${suffix}`;
249
+ diagnostic = {
250
+ file: normalizedBundleDest,
251
+ line,
252
+ column,
253
+ originalHref: rawHref,
254
+ rewrittenHref: newHref,
255
+ severity: "info",
256
+ linkKind,
257
+ message: `Converted unbundled ${kindLabel} to remote git URL: '${rawHref}' -> '${newHref}'`,
258
+ };
259
+ }
260
+ else {
261
+ diagnostic = {
262
+ file: normalizedBundleDest,
263
+ line,
264
+ column,
265
+ originalHref: rawHref,
266
+ severity: "warning",
267
+ linkKind,
268
+ message: `Unbundled ${kindLabel} target '${rawHref}' cannot be converted to GitHub URL because 'gitRepositoryUrl' is not configured.`,
269
+ };
270
+ }
271
+ }
272
+ else if (unbundledPolicy === "warn") {
273
+ if (config.normalizeFileSchemes && rawHref.startsWith("file://")) {
274
+ const relToRoot = path.relative(workspaceRoot, resolvedAbs).replace(/\\/g, "/");
275
+ newHref = `./${relToRoot}${suffix}`;
276
+ }
277
+ diagnostic = {
278
+ file: normalizedBundleDest,
279
+ line,
280
+ column,
281
+ originalHref: rawHref,
282
+ severity: "warning",
283
+ linkKind,
284
+ message: `Unbundled ${kindLabel} target '${rawHref}' (${resolvedAbs}) is not part of this bundle.`,
285
+ };
286
+ }
287
+ else if (unbundledPolicy === "error") {
288
+ diagnostic = {
289
+ file: normalizedBundleDest,
290
+ line,
291
+ column,
292
+ originalHref: rawHref,
293
+ severity: "error",
294
+ linkKind,
295
+ message: `Unbundled ${kindLabel} target '${rawHref}' is not allowed by policy 'error'.`,
296
+ };
297
+ }
298
+ }
299
+ const newHrefFormatted = hadAngleBrackets || newHref.includes(" ") ? `<${newHref}>` : newHref;
300
+ return { newHrefFormatted, diagnostic, skipped: false };
301
+ }
302
+ /**
303
+ * Rewrites markdown links and reference definitions in a file so that they point to bundle-relative locations.
304
+ */
305
+ export function rewriteMarkdownContent(content, sourceFileAbs, bundleDestPath, sourceAbsToBundleDest, workspaceRoot, config = {}) {
306
+ if (config.enabled === false) {
307
+ return { rewritten: content, diagnostics: [] };
308
+ }
309
+ const diagnostics = [];
310
+ const { masked, restore, getOriginalPosition } = maskCodeBlocks(content);
311
+ // 1. Match and rewrite Markdown inline links: [text](target "optional title") and ![alt](src "title")
312
+ // Allows nested image in link label: [![alt](src)](target)
313
+ // Supports balanced parentheses in destinations and titles in "", '', or ()
314
+ const linkRegex = /(!?\[)((?:!\[[^\]]*\]\([^)]*\)|[^\]])*)\]\((<[^>]+>|(?:\\.|[^()\s]|\((?:\\.|[^()\s])*\))+)([ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\)/g;
315
+ let transformed = masked.replace(linkRegex, (fullMatch, prefix, label, rawHrefWithAngle, titlePart, offset) => {
316
+ const { line, column } = getOriginalPosition(offset);
317
+ let processedLabel = label;
318
+ if (label.includes("![")) {
319
+ const innerImgRegex = /(!?\[)([^\]]*)\]\((<[^>]+>|(?:\\.|[^()\s]|\((?:\\.|[^()\s])*\))+)([ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\)/g;
320
+ processedLabel = label.replace(innerImgRegex, (innerFull, innerPrefix, innerAlt, innerRawHref, innerTitlePart, innerOffset) => {
321
+ const innerMaskedOffset = offset + prefix.length + innerOffset;
322
+ const { line: innerLine, column: innerCol } = getOriginalPosition(innerMaskedOffset);
323
+ const { newHrefFormatted, diagnostic } = transformHref(innerRawHref, sourceFileAbs, bundleDestPath, sourceAbsToBundleDest, workspaceRoot, config, innerLine, innerCol, "inline");
324
+ if (diagnostic) {
325
+ diagnostics.push(diagnostic);
326
+ }
327
+ const innerTitle = innerTitlePart ?? "";
328
+ return `${innerPrefix}${innerAlt}](${newHrefFormatted}${innerTitle})`;
329
+ });
330
+ }
331
+ const { newHrefFormatted, diagnostic, skipped } = transformHref(rawHrefWithAngle, sourceFileAbs, bundleDestPath, sourceAbsToBundleDest, workspaceRoot, config, line, column, "inline");
332
+ if (diagnostic) {
333
+ diagnostics.push(diagnostic);
334
+ }
335
+ const title = titlePart ?? "";
336
+ if (skipped) {
337
+ return `${prefix}${processedLabel}](${rawHrefWithAngle}${title})`;
338
+ }
339
+ return `${prefix}${processedLabel}](${newHrefFormatted}${title})`;
340
+ });
341
+ // 2. Match and rewrite CommonMark link reference definitions (supports blockquotes, list markers, and destination on next line):
342
+ // ^[ \t>]*(?:(?:[-*+]|\d+[.)])[ \t>]+)?\[label\]:[ \t]*(?:\r?\n[ \t>]+)?(<url>|\S+)(?:[ \t]+(?:"title"|'title'|\(title\)))?[ \t]*$
343
+ const refDefRegex = /^([ \t>]*(?:(?:[-*+]|\d+[.)])[ \t>]+)?\[)([^\]]+)(\]:[ \t]*(?:\r?\n[ \t>]+)?)(<[^>]+>|\S+)((?:[ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?)[ \t]*$/gm;
344
+ transformed = transformed.replace(refDefRegex, (fullMatch, prefix, label, colon, rawHrefWithAngle, titlePart, offset) => {
345
+ const bracketIndex = offset + prefix.indexOf("[");
346
+ const { line, column } = getOriginalPosition(bracketIndex);
347
+ const { newHrefFormatted, diagnostic, skipped } = transformHref(rawHrefWithAngle, sourceFileAbs, bundleDestPath, sourceAbsToBundleDest, workspaceRoot, config, line, column, "reference-def");
348
+ if (diagnostic) {
349
+ diagnostics.push(diagnostic);
350
+ }
351
+ if (skipped) {
352
+ return fullMatch;
353
+ }
354
+ return `${prefix}${label}${colon}${newHrefFormatted}${titlePart}`;
355
+ });
356
+ return {
357
+ rewritten: restore(transformed),
358
+ diagnostics,
359
+ };
360
+ }
361
+ /**
362
+ * Rewrites TypeSpec (@evidence, relative import, and @doc markdown) references in a file
363
+ * so that they point to bundle-relative locations.
364
+ */
365
+ export function rewriteTypeSpecContent(content, sourceFileAbs, bundleDestPath, sourceAbsToBundleDest, workspaceRoot, config = {}) {
366
+ if (config.enabled === false) {
367
+ return { rewritten: content, diagnostics: [] };
368
+ }
369
+ const diagnostics = [];
370
+ const getPosition = (offset) => {
371
+ const slice = content.slice(0, offset);
372
+ const line = slice.split("\n").length;
373
+ const lastNl = slice.lastIndexOf("\n");
374
+ const column = lastNl === -1 ? offset + 1 : offset - lastNl;
375
+ return { line, column };
376
+ };
377
+ let transformed = content;
378
+ // 1. Match and rewrite @evidence("path/to/doc.md#anchor", "reason")
379
+ const evidenceRegex = /(@evidence\s*\(\s*)(["'])([^"']+)\2/g;
380
+ transformed = transformed.replace(evidenceRegex, (fullMatch, prefix, quote, rawTarget, offset) => {
381
+ const targetOffset = offset + prefix.length + quote.length;
382
+ const { line, column } = getPosition(targetOffset);
383
+ const { newHrefFormatted, diagnostic, skipped } = transformHref(rawTarget, sourceFileAbs, bundleDestPath, sourceAbsToBundleDest, workspaceRoot, config, line, column, "inline");
384
+ if (diagnostic) {
385
+ diagnostics.push({
386
+ ...diagnostic,
387
+ message: diagnostic.message.replace(/internal link/g, "@evidence citation"),
388
+ });
389
+ }
390
+ if (skipped) {
391
+ return fullMatch;
392
+ }
393
+ const unwrapped = newHrefFormatted.replace(/^<|>$/g, "");
394
+ return `${prefix}${quote}${unwrapped}${quote}`;
395
+ });
396
+ // 2. Match and rewrite relative imports: import "./model.tsp"; or import "../common.tsp";
397
+ const importRegex = /(\bimport\s+)(["'])(\.[^"']+)\2/g;
398
+ transformed = transformed.replace(importRegex, (fullMatch, prefix, quote, rawTarget, offset) => {
399
+ const targetOffset = offset + prefix.length + quote.length;
400
+ const { line, column } = getPosition(targetOffset);
401
+ const { newHrefFormatted, diagnostic, skipped } = transformHref(rawTarget, sourceFileAbs, bundleDestPath, sourceAbsToBundleDest, workspaceRoot, config, line, column, "inline");
402
+ if (diagnostic) {
403
+ diagnostics.push({
404
+ ...diagnostic,
405
+ message: diagnostic.message.replace(/internal link/g, "TypeSpec import"),
406
+ });
407
+ }
408
+ if (skipped) {
409
+ return fullMatch;
410
+ }
411
+ const unwrapped = newHrefFormatted.replace(/^<|>$/g, "");
412
+ return `${prefix}${quote}${unwrapped}${quote}`;
413
+ });
414
+ // 3. Match and rewrite Markdown links inside @doc("... [text](target) ...") or @doc("""...""")
415
+ const docRegex = /(@doc\s*\(\s*)("""|"|')([\s\S]*?)\2(\s*\))/g;
416
+ transformed = transformed.replace(docRegex, (fullMatch, prefix, quote, docBody, suffix, offset) => {
417
+ const mdLinkRegex = /(!?\[)((?:!\[[^\]]*\]\([^)]*\)|[^\]])*)\]\((<[^>]+>|(?:\\.|[^()\s]|\((?:\\.|[^()\s])*\))+)([ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\)/g;
418
+ const newDocBody = docBody.replace(mdLinkRegex, (linkMatch, lPrefix, lLabel, rawHrefWithAngle, lTitlePart, lOffset) => {
419
+ const linkTargetOffset = offset + prefix.length + quote.length + lOffset;
420
+ const { line, column } = getPosition(linkTargetOffset);
421
+ const { newHrefFormatted, diagnostic, skipped } = transformHref(rawHrefWithAngle, sourceFileAbs, bundleDestPath, sourceAbsToBundleDest, workspaceRoot, config, line, column, "inline");
422
+ if (diagnostic) {
423
+ diagnostics.push({
424
+ ...diagnostic,
425
+ message: diagnostic.message.replace(/internal link/g, "@doc markdown link"),
426
+ });
427
+ }
428
+ const title = lTitlePart ?? "";
429
+ if (skipped) {
430
+ return `${lPrefix}${lLabel}](${rawHrefWithAngle}${title})`;
431
+ }
432
+ return `${lPrefix}${lLabel}](${newHrefFormatted}${title})`;
433
+ });
434
+ return `${prefix}${quote}${newDocBody}${quote}${suffix}`;
435
+ });
436
+ return {
437
+ rewritten: transformed,
438
+ diagnostics,
439
+ };
440
+ }
441
+ /**
442
+ * Normalizes heading text into a GitHub CommonMark-compliant anchor slug.
443
+ * Supports Unicode (Korean, CJK, etc.) and alphanumeric characters.
444
+ */
445
+ export function normalizeSlug(text) {
446
+ return text
447
+ .trim()
448
+ .toLowerCase()
449
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
450
+ .replace(/\s+/g, "-");
451
+ }
452
+ export function extractDocumentAnchors(content) {
453
+ const slugs = new Set();
454
+ const explicit = new Set();
455
+ const all = new Set();
456
+ const headingRegex = /^#{1,6}\s+(.+)$/gm;
457
+ let match;
458
+ while ((match = headingRegex.exec(content)) !== null) {
459
+ const rawHeading = match[1].trim();
460
+ const customMatch = rawHeading.match(/\{#([^}]+)\}/);
461
+ if (customMatch) {
462
+ const customId = customMatch[1].trim().toLowerCase();
463
+ explicit.add(customId);
464
+ all.add(customId);
465
+ }
466
+ const cleanTitle = rawHeading.replace(/\{#([^}]+)\}/, "").trim();
467
+ const slug = normalizeSlug(cleanTitle);
468
+ slugs.add(slug);
469
+ all.add(slug);
470
+ all.add(slug.replace(/-+/g, "-"));
471
+ }
472
+ // Explicit HTML anchors: <a id="..."> or <a name="..."> or any id/name attribute on HTML tags
473
+ const htmlAnchorRegex = /<[a-zA-Z0-9_-]+\s+[^>]*?(?:id|name)=["']([^"']+)["']/gi;
474
+ while ((match = htmlAnchorRegex.exec(content)) !== null) {
475
+ const id = match[1].trim().toLowerCase();
476
+ explicit.add(id);
477
+ all.add(id);
478
+ }
479
+ return { slugs, explicit, all };
480
+ }
481
+ /**
482
+ * Finds high-confidence closest candidate anchor in existingAnchors using Levenshtein distance.
483
+ */
484
+ export function findClosestAnchor(missingAnchor, existingAnchors) {
485
+ const cleanMissing = missingAnchor.replace(/^#/, "").toLowerCase();
486
+ let bestCandidate = null;
487
+ let minDistance = Infinity;
488
+ for (const candidate of existingAnchors) {
489
+ const cleanCandidate = candidate.replace(/^#/, "").toLowerCase();
490
+ const dist = levenshteinDistance(cleanMissing, cleanCandidate);
491
+ if (dist <= 3 && dist < minDistance) {
492
+ minDistance = dist;
493
+ bestCandidate = `#${cleanCandidate}`;
494
+ }
495
+ }
496
+ return bestCandidate;
497
+ }
498
+ /**
499
+ * Verifies that all relative markdown and TypeSpec links point to files that actually exist within the bundle.
500
+ */
501
+ export function verifyBundleLinkIntegrity(bundleEntries, options = {}) {
502
+ const diagnostics = [];
503
+ const existingFiles = new Set(Array.from(bundleEntries.keys()).map((k) => k.replace(/\\/g, "/")));
504
+ const severity = options.severity ?? "error";
505
+ const anchorsCache = new Map();
506
+ const getDocumentAnchors = (filePath) => {
507
+ if (anchorsCache.has(filePath))
508
+ return anchorsCache.get(filePath);
509
+ let entryContent = bundleEntries.get(filePath);
510
+ if (entryContent === undefined) {
511
+ for (const [k, v] of bundleEntries.entries()) {
512
+ if (k.replace(/\\/g, "/") === filePath) {
513
+ entryContent = v;
514
+ break;
515
+ }
516
+ }
517
+ }
518
+ if (entryContent === undefined)
519
+ return null;
520
+ const text = typeof entryContent === "string" ? entryContent : entryContent.toString("utf8");
521
+ const anchors = extractDocumentAnchors(text);
522
+ anchorsCache.set(filePath, anchors);
523
+ return anchors;
524
+ };
525
+ for (const [rawDestPath, content] of bundleEntries.entries()) {
526
+ const destPath = rawDestPath.replace(/\\/g, "/");
527
+ const isMarkdown = destPath.endsWith(".md") || destPath.endsWith(".mdx");
528
+ const isTypeSpec = destPath.endsWith(".tsp");
529
+ if (!isMarkdown && !isTypeSpec)
530
+ continue;
531
+ const text = typeof content === "string" ? content : content.toString("utf8");
532
+ const currentDir = path.posix.dirname(destPath);
533
+ let getOriginalPosition;
534
+ let masked = text;
535
+ if (isMarkdown) {
536
+ const maskResult = maskCodeBlocks(text);
537
+ masked = maskResult.masked;
538
+ getOriginalPosition = maskResult.getOriginalPosition;
539
+ }
540
+ else {
541
+ getOriginalPosition = (offset) => {
542
+ const slice = text.slice(0, offset);
543
+ const line = slice.split("\n").length;
544
+ const lastNl = slice.lastIndexOf("\n");
545
+ const column = lastNl === -1 ? offset + 1 : offset - lastNl;
546
+ return { line, column, offset };
547
+ };
548
+ }
549
+ const checkHref = (rawWithAngle, offset, linkKind) => {
550
+ let rawHref = rawWithAngle;
551
+ if (rawHref.startsWith("<") && rawHref.endsWith(">")) {
552
+ rawHref = rawHref.slice(1, -1);
553
+ }
554
+ // Skip external protocols
555
+ if (!rawHref.startsWith("file:") && /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(rawHref))
556
+ return;
557
+ const hashIndex = rawHref.indexOf("#");
558
+ const hasHash = hashIndex !== -1;
559
+ const rawAnchor = hasHash ? rawHref.slice(hashIndex + 1) : "";
560
+ const splitIndex = rawHref.search(/[#?]/);
561
+ const targetPath = splitIndex === -1 ? rawHref : rawHref.slice(0, splitIndex);
562
+ // Intra-file hash anchors: [Section](#heading)
563
+ if (!targetPath && hasHash) {
564
+ if (!options.checkAnchors || !rawAnchor)
565
+ return;
566
+ const anchors = getDocumentAnchors(destPath);
567
+ if (anchors) {
568
+ const anchorLower = rawAnchor.toLowerCase();
569
+ const collapsedAnchor = anchorLower.replace(/-+/g, "-");
570
+ if (!anchors.all.has(anchorLower) && !anchors.all.has(collapsedAnchor)) {
571
+ const { line, column } = getOriginalPosition(offset);
572
+ const suggestion = findClosestAnchor(rawAnchor, anchors.all);
573
+ const hint = suggestion ? ` (Did you mean '${suggestion}'?)` : "";
574
+ diagnostics.push({
575
+ file: destPath,
576
+ line,
577
+ column,
578
+ originalHref: rawHref,
579
+ severity,
580
+ suggestedTarget: suggestion ?? undefined,
581
+ linkKind,
582
+ message: `Dead anchor link detected: '${rawHref}' not found in '${destPath}'.${hint}`,
583
+ });
584
+ }
585
+ }
586
+ return;
587
+ }
588
+ if (!targetPath)
589
+ return;
590
+ const normalizedTargetPath = targetPath.replace(/\\/g, "/");
591
+ // Calculate destination inside bundle
592
+ let resolvedDest;
593
+ if (normalizedTargetPath.startsWith("/")) {
594
+ resolvedDest = path.posix
595
+ .normalize(normalizedTargetPath)
596
+ .replace(/^\/+/, "");
597
+ }
598
+ else {
599
+ resolvedDest = path.posix
600
+ .normalize(path.posix.join(currentDir, normalizedTargetPath))
601
+ .replace(/^\/+/, "");
602
+ }
603
+ // Fallback for workspace-relative targets without leading slash
604
+ if (!existingFiles.has(resolvedDest) && existingFiles.has(normalizedTargetPath.replace(/^\/+/, ""))) {
605
+ resolvedDest = normalizedTargetPath.replace(/^\/+/, "");
606
+ }
607
+ // If policy is preserve and destination escapes bundle, skip
608
+ if (options.unbundledPolicy === "preserve" &&
609
+ (resolvedDest.startsWith("..") ||
610
+ path.posix.isAbsolute(normalizedTargetPath) ||
611
+ normalizedTargetPath.startsWith("../"))) {
612
+ return;
613
+ }
614
+ let decodedDest = resolvedDest;
615
+ if (resolvedDest.includes("%")) {
616
+ try {
617
+ decodedDest = decodeURIComponent(resolvedDest);
618
+ }
619
+ catch {
620
+ // ignore malformed URI
621
+ }
622
+ }
623
+ if (!existingFiles.has(resolvedDest) && !existingFiles.has(decodedDest)) {
624
+ const { line, column } = getOriginalPosition(offset);
625
+ const suggestion = findClosestFile(resolvedDest, existingFiles) ??
626
+ (decodedDest !== resolvedDest ? findClosestFile(decodedDest, existingFiles) : null);
627
+ let hint = "";
628
+ let suggestedRelative;
629
+ if (suggestion) {
630
+ let relSuggestion = path.posix.relative(currentDir, suggestion);
631
+ if (!relSuggestion.startsWith(".")) {
632
+ relSuggestion = `./${relSuggestion}`;
633
+ }
634
+ suggestedRelative = relSuggestion;
635
+ hint = ` (Did you mean '${relSuggestion}'?)`;
636
+ }
637
+ diagnostics.push({
638
+ file: destPath,
639
+ line,
640
+ column,
641
+ originalHref: rawHref,
642
+ severity,
643
+ suggestedTarget: suggestedRelative,
644
+ linkKind,
645
+ message: `Dead link detected: '${rawHref}' resolves to '${resolvedDest}' which does not exist in the bundle.${hint}`,
646
+ });
647
+ }
648
+ else if (options.checkAnchors && hasHash && rawAnchor) {
649
+ const targetFile = existingFiles.has(resolvedDest) ? resolvedDest : decodedDest;
650
+ if (targetFile.endsWith(".md") || targetFile.endsWith(".mdx")) {
651
+ const anchors = getDocumentAnchors(targetFile);
652
+ if (anchors) {
653
+ const anchorLower = rawAnchor.toLowerCase();
654
+ const collapsedAnchor = anchorLower.replace(/-+/g, "-");
655
+ if (!anchors.all.has(anchorLower) && !anchors.all.has(collapsedAnchor)) {
656
+ const { line, column } = getOriginalPosition(offset);
657
+ const suggestion = findClosestAnchor(rawAnchor, anchors.all);
658
+ const targetPrefix = rawHref.slice(0, hashIndex);
659
+ const suggestedTarget = suggestion ? `${targetPrefix}${suggestion}` : undefined;
660
+ const hint = suggestion ? ` (Did you mean '${suggestedTarget}'?)` : "";
661
+ diagnostics.push({
662
+ file: destPath,
663
+ line,
664
+ column,
665
+ originalHref: rawHref,
666
+ severity,
667
+ suggestedTarget,
668
+ linkKind,
669
+ message: `Dead anchor link detected: '${rawHref}' target anchor '#${rawAnchor}' not found in '${targetFile}'.${hint}`,
670
+ });
671
+ }
672
+ }
673
+ }
674
+ }
675
+ };
676
+ if (isMarkdown) {
677
+ // 1. Inline links (including nested images in labels)
678
+ const linkRegex = /(!?\[)((?:!\[[^\]]*\]\([^)]*\)|[^\]])*)\]\((<[^>]+>|(?:\\.|[^()\s]|\((?:\\.|[^()\s])*\))+)([ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\)/g;
679
+ let match;
680
+ while ((match = linkRegex.exec(masked)) !== null) {
681
+ const prefix = match[1];
682
+ const label = match[2];
683
+ const href = match[3];
684
+ checkHref(href, match.index, "inline");
685
+ if (label.includes("![")) {
686
+ const innerImgRegex = /(!?\[)([^\]]*)\]\((<[^>]+>|(?:\\.|[^()\s]|\((?:\\.|[^()\s])*\))+)([ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\)/g;
687
+ let innerMatch;
688
+ while ((innerMatch = innerImgRegex.exec(label)) !== null) {
689
+ const innerOffset = match.index + prefix.length + innerMatch.index;
690
+ checkHref(innerMatch[3], innerOffset, "inline");
691
+ }
692
+ }
693
+ }
694
+ // 2. Reference definitions
695
+ const refDefRegex = /^[ \t>]*(?:(?:[-*+]|\d+[.)])[ \t>]+)?\[([^\]]+)\]:[ \t]*(?:\r?\n[ \t>]+)?(<[^>]+>|\S+)/gm;
696
+ let refMatch;
697
+ while ((refMatch = refDefRegex.exec(masked)) !== null) {
698
+ const bracketIndex = refMatch.index + refMatch[0].indexOf("[");
699
+ checkHref(refMatch[2], bracketIndex, "reference-def");
700
+ }
701
+ }
702
+ else if (isTypeSpec) {
703
+ // 1. @evidence citations
704
+ const evRegex = /@evidence\s*\(\s*["']([^"']+)["']/g;
705
+ let evMatch;
706
+ while ((evMatch = evRegex.exec(text)) !== null) {
707
+ checkHref(evMatch[1], evMatch.index, "inline");
708
+ }
709
+ // 2. Relative imports
710
+ const impRegex = /\bimport\s+["'](\.[^"']+)["']/g;
711
+ let impMatch;
712
+ while ((impMatch = impRegex.exec(text)) !== null) {
713
+ checkHref(impMatch[1], impMatch.index, "inline");
714
+ }
715
+ // 3. @doc markdown links
716
+ const docRegex = /@doc\s*\(\s*(?:"""([\s\S]*?)"""|"((?:\\.|[^"\\])*)")\s*\)/g;
717
+ let docMatch;
718
+ while ((docMatch = docRegex.exec(text)) !== null) {
719
+ const docText = docMatch[1] ?? docMatch[2] ?? "";
720
+ const mdLinkRegex = /(!?\[)((?:!\[[^\]]*\]\([^)]*\)|[^\]])*)\]\((<[^>]+>|(?:\\.|[^()\s]|\((?:\\.|[^()\s])*\))+)([ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\)/g;
721
+ let lMatch;
722
+ while ((lMatch = mdLinkRegex.exec(docText)) !== null) {
723
+ checkHref(lMatch[3], docMatch.index + lMatch.index, "inline");
724
+ }
725
+ }
726
+ }
727
+ }
728
+ return diagnostics;
729
+ }
730
+ /**
731
+ * Calculates Levenshtein edit distance between two strings.
732
+ */
733
+ export function levenshteinDistance(a, b) {
734
+ const m = a.length;
735
+ const n = b.length;
736
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
737
+ for (let i = 0; i <= m; i++)
738
+ dp[i][0] = i;
739
+ for (let j = 0; j <= n; j++)
740
+ dp[0][j] = j;
741
+ for (let i = 1; i <= m; i++) {
742
+ for (let j = 1; j <= n; j++) {
743
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
744
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
745
+ }
746
+ }
747
+ return dp[m][n];
748
+ }
749
+ /**
750
+ * Finds high-confidence closest candidate file path in existingFiles for a missing target path.
751
+ */
752
+ export function findClosestFile(missingPath, existingFiles) {
753
+ const missingBase = path.posix.basename(missingPath).toLowerCase();
754
+ let bestCandidate = null;
755
+ let minDistance = Infinity;
756
+ for (const candidate of existingFiles) {
757
+ const candidateBase = path.posix.basename(candidate).toLowerCase();
758
+ // 1. Exact base filename match (e.g. user specified wrong relative folder)
759
+ if (candidateBase === missingBase) {
760
+ return candidate;
761
+ }
762
+ // 2. High-confidence basename typo (edit distance <= 2)
763
+ const baseDist = levenshteinDistance(missingBase, candidateBase);
764
+ if (baseDist <= 2 && baseDist < minDistance) {
765
+ minDistance = baseDist;
766
+ bestCandidate = candidate;
767
+ }
768
+ // 3. High-confidence path typo (edit distance <= 3)
769
+ const fullDist = levenshteinDistance(missingPath.toLowerCase(), candidate.toLowerCase());
770
+ if (fullDist <= 3 && fullDist < minDistance) {
771
+ minDistance = fullDist;
772
+ bestCandidate = candidate;
773
+ }
774
+ }
775
+ return bestCandidate;
776
+ }
777
+ //# sourceMappingURL=link-rewriter.js.map