@gmickel/gno 1.30.7 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/assets/skill/SKILL.md +25 -0
- package/assets/skill/mcp-reference.md +6 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.30.7.zip → gno-browser-clipper-v1.32.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +19 -0
- package/spec/db/schema.sql +55 -0
- package/spec/mcp.md +176 -11
- package/spec/output-schemas/file-refactor-apply-result.schema.json +305 -0
- package/spec/output-schemas/file-refactor-preview.schema.json +393 -0
- package/spec/output-schemas/section-target-create-result.schema.json +20 -0
- package/spec/output-schemas/section-target-resolve-result.schema.json +194 -0
- package/spec/output-schemas/section-target.schema.json +118 -0
- package/spec/output-schemas/section.schema.json +113 -0
- package/src/core/document-capabilities.ts +13 -0
- package/src/core/file-ops.ts +129 -1
- package/src/core/file-refactor-adapter.ts +329 -0
- package/src/core/file-refactor-apply-edits.ts +61 -0
- package/src/core/file-refactor-apply-fs.ts +512 -0
- package/src/core/file-refactor-apply-safety.ts +340 -0
- package/src/core/file-refactor-apply-validate.ts +401 -0
- package/src/core/file-refactor-contract.ts +486 -0
- package/src/core/file-refactor-destination.ts +123 -0
- package/src/core/file-refactor-from-snapshot.ts +148 -0
- package/src/core/file-refactor-journal-port.ts +150 -0
- package/src/core/file-refactor-journal.ts +347 -0
- package/src/core/file-refactor-paths.ts +60 -0
- package/src/core/file-refactor-plan-classify.ts +208 -0
- package/src/core/file-refactor-plan-validate.ts +169 -0
- package/src/core/file-refactor-planner-types.ts +62 -0
- package/src/core/file-refactor-planner.ts +423 -0
- package/src/core/file-refactor-resolve.ts +280 -0
- package/src/core/file-refactor-service.ts +468 -0
- package/src/core/file-refactors.ts +84 -56
- package/src/core/link-destination-parse.ts +275 -0
- package/src/core/link-inventory-markdown.ts +454 -0
- package/src/core/link-inventory-opaque.ts +244 -0
- package/src/core/link-inventory-types.ts +47 -0
- package/src/core/link-inventory.ts +182 -0
- package/src/core/link-relevance.ts +150 -0
- package/src/core/section-parse.ts +187 -0
- package/src/core/section-target-link.ts +154 -0
- package/src/core/section-target-resolve.ts +351 -0
- package/src/core/section-target-transport.ts +519 -0
- package/src/core/section-target.ts +263 -0
- package/src/core/sections.ts +60 -115
- package/src/mcp/AGENTS.md +1 -0
- package/src/mcp/CLAUDE.md +1 -0
- package/src/mcp/http-egress.ts +1 -0
- package/src/mcp/tools/index.ts +37 -17
- package/src/mcp/tools/sections.ts +512 -0
- package/src/mcp/tools/workspace-write.ts +215 -97
- package/src/sdk/client.ts +238 -116
- package/src/sdk/index.ts +12 -0
- package/src/sdk/types.ts +61 -3
- package/src/serve/file-refactor-http.ts +239 -0
- package/src/serve/public/components/RefactorImpactPreview.tsx +227 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/lib/section-links.ts +189 -0
- package/src/serve/public/pages/DocView.tsx +395 -77
- package/src/serve/routes/api.ts +191 -104
- package/src/serve/routes/section-targets.ts +221 -0
- package/src/serve/server.ts +34 -0
- package/src/store/migrations/026-file-refactor-recovery-journal.ts +72 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +452 -0
- package/src/store/sqlite/file-refactor-journal-store.ts +275 -0
- package/src/store/types.ts +84 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.7.zip.sha256 +0 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared inventory token types and caps for reference-safe refactors.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/link-inventory-types
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
FileRefactorReasonCode,
|
|
9
|
+
FileRefactorReferenceClassification,
|
|
10
|
+
FileRefactorReferenceKind,
|
|
11
|
+
} from "./file-refactor-contract";
|
|
12
|
+
import type { LinkEncodingStyle } from "./link-destination-parse";
|
|
13
|
+
|
|
14
|
+
/** Hard caps — callers must fail closed when truncated. */
|
|
15
|
+
export const LINK_INVENTORY_CAPS = {
|
|
16
|
+
maxContentChars: 1_000_000,
|
|
17
|
+
maxTokensPerDocument: 2_000,
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export interface LinkInventoryToken {
|
|
21
|
+
kind: FileRefactorReferenceKind;
|
|
22
|
+
classification?: FileRefactorReferenceClassification;
|
|
23
|
+
reasonCode?: FileRefactorReasonCode;
|
|
24
|
+
raw: string;
|
|
25
|
+
originalDestination: string;
|
|
26
|
+
destinationStart: number;
|
|
27
|
+
destinationEnd: number;
|
|
28
|
+
startOffset: number;
|
|
29
|
+
endOffset: number;
|
|
30
|
+
startLine: number;
|
|
31
|
+
startCol: number;
|
|
32
|
+
endLine: number;
|
|
33
|
+
endCol: number;
|
|
34
|
+
targetRef: string;
|
|
35
|
+
targetAnchor?: string;
|
|
36
|
+
targetCollection?: string;
|
|
37
|
+
targetQuery?: string;
|
|
38
|
+
hadLeadingDotSlash: boolean;
|
|
39
|
+
encodingStyle: LinkEncodingStyle;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LinkInventoryResult {
|
|
43
|
+
tokens: LinkInventoryToken[];
|
|
44
|
+
truncated: boolean;
|
|
45
|
+
/** True when destination spans overlap after inventory. */
|
|
46
|
+
overlapping: boolean;
|
|
47
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser-backed link inventory for reference-safe file refactors.
|
|
3
|
+
*
|
|
4
|
+
* Produces exact UTF-16 destination token spans plus conservative opaque /
|
|
5
|
+
* malformed detections. Does not mutate disk or change parseLinks behavior.
|
|
6
|
+
*
|
|
7
|
+
* @module src/core/link-inventory
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExcludedRange } from "../ingestion/strip";
|
|
11
|
+
import type {
|
|
12
|
+
LinkInventoryResult,
|
|
13
|
+
LinkInventoryToken,
|
|
14
|
+
} from "./link-inventory-types";
|
|
15
|
+
|
|
16
|
+
import { getExcludedRanges } from "../ingestion/strip";
|
|
17
|
+
import {
|
|
18
|
+
inventoryInlineMarkdown,
|
|
19
|
+
inventoryReferenceDefinitions,
|
|
20
|
+
inventoryWikiLinks,
|
|
21
|
+
} from "./link-inventory-markdown";
|
|
22
|
+
import {
|
|
23
|
+
buildLineOffsets,
|
|
24
|
+
inventoryEmbeds,
|
|
25
|
+
inventoryHtmlHrefs,
|
|
26
|
+
inventoryMalformedWiki,
|
|
27
|
+
} from "./link-inventory-opaque";
|
|
28
|
+
import { LINK_INVENTORY_CAPS } from "./link-inventory-types";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
LINK_INVENTORY_CAPS,
|
|
32
|
+
type LinkInventoryResult,
|
|
33
|
+
type LinkInventoryToken,
|
|
34
|
+
} from "./link-inventory-types";
|
|
35
|
+
export {
|
|
36
|
+
buildContentPrefilterNeedles,
|
|
37
|
+
buildSourceRelevanceKeys,
|
|
38
|
+
isRelevantDestination,
|
|
39
|
+
} from "./link-relevance";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Destination identity for inventory dedupe.
|
|
43
|
+
* Kind is intentionally omitted so identical spans from overlapping scanners
|
|
44
|
+
* collapse to the first (scanner-precedence) token.
|
|
45
|
+
*/
|
|
46
|
+
export function inventoryDestinationKey(token: {
|
|
47
|
+
destinationStart: number;
|
|
48
|
+
destinationEnd: number;
|
|
49
|
+
originalDestination: string;
|
|
50
|
+
}): string {
|
|
51
|
+
return `${token.destinationStart}:${token.destinationEnd}:${token.originalDestination}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Dedupe identical destination spans (regardless of scanner kind) and detect
|
|
56
|
+
* true partial overlaps. First token wins — callers should push in scanner order.
|
|
57
|
+
*/
|
|
58
|
+
export function dedupeInventoryDestinationTokens(
|
|
59
|
+
tokens: LinkInventoryToken[]
|
|
60
|
+
): {
|
|
61
|
+
tokens: LinkInventoryToken[];
|
|
62
|
+
overlapping: boolean;
|
|
63
|
+
} {
|
|
64
|
+
const seen = new Set<string>();
|
|
65
|
+
const unique: LinkInventoryToken[] = [];
|
|
66
|
+
for (const token of tokens) {
|
|
67
|
+
const key = inventoryDestinationKey(token);
|
|
68
|
+
if (seen.has(key)) continue;
|
|
69
|
+
seen.add(key);
|
|
70
|
+
unique.push(token);
|
|
71
|
+
}
|
|
72
|
+
unique.sort((a, b) => {
|
|
73
|
+
if (a.destinationStart !== b.destinationStart) {
|
|
74
|
+
return a.destinationStart - b.destinationStart;
|
|
75
|
+
}
|
|
76
|
+
return a.destinationEnd - b.destinationEnd;
|
|
77
|
+
});
|
|
78
|
+
let overlapping = false;
|
|
79
|
+
for (let i = 1; i < unique.length; i += 1) {
|
|
80
|
+
const prev = unique[i - 1]!;
|
|
81
|
+
const cur = unique[i]!;
|
|
82
|
+
if (cur.destinationStart < prev.destinationEnd) {
|
|
83
|
+
overlapping = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
unique.sort((a, b) => {
|
|
88
|
+
if (a.startLine !== b.startLine) return a.startLine - b.startLine;
|
|
89
|
+
if (a.startCol !== b.startCol) return a.startCol - b.startCol;
|
|
90
|
+
return a.destinationStart - b.destinationStart;
|
|
91
|
+
});
|
|
92
|
+
return { tokens: unique, overlapping };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Inventory all relevant rewrite / opaque reference tokens in one document.
|
|
97
|
+
*/
|
|
98
|
+
export function inventoryDocumentLinks(
|
|
99
|
+
markdown: string,
|
|
100
|
+
options: {
|
|
101
|
+
sourceKeys: ReadonlySet<string>;
|
|
102
|
+
excludedRanges?: ExcludedRange[];
|
|
103
|
+
}
|
|
104
|
+
): LinkInventoryResult {
|
|
105
|
+
const truncated = { value: false };
|
|
106
|
+
if (markdown.length > LINK_INVENTORY_CAPS.maxContentChars) {
|
|
107
|
+
return { tokens: [], truncated: true, overlapping: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const excluded = options.excludedRanges ?? getExcludedRanges(markdown);
|
|
111
|
+
const lineOffsets = buildLineOffsets(markdown);
|
|
112
|
+
const tokens: LinkInventoryToken[] = [];
|
|
113
|
+
const consumed = new Set<number>();
|
|
114
|
+
const sourceKeys = options.sourceKeys;
|
|
115
|
+
|
|
116
|
+
const ok =
|
|
117
|
+
inventoryEmbeds(
|
|
118
|
+
markdown,
|
|
119
|
+
lineOffsets,
|
|
120
|
+
sourceKeys,
|
|
121
|
+
excluded,
|
|
122
|
+
tokens,
|
|
123
|
+
truncated,
|
|
124
|
+
consumed
|
|
125
|
+
) &&
|
|
126
|
+
inventoryHtmlHrefs(
|
|
127
|
+
markdown,
|
|
128
|
+
lineOffsets,
|
|
129
|
+
sourceKeys,
|
|
130
|
+
excluded,
|
|
131
|
+
tokens,
|
|
132
|
+
truncated,
|
|
133
|
+
consumed
|
|
134
|
+
) &&
|
|
135
|
+
inventoryMalformedWiki(
|
|
136
|
+
markdown,
|
|
137
|
+
lineOffsets,
|
|
138
|
+
sourceKeys,
|
|
139
|
+
excluded,
|
|
140
|
+
tokens,
|
|
141
|
+
truncated,
|
|
142
|
+
consumed
|
|
143
|
+
) &&
|
|
144
|
+
inventoryWikiLinks(
|
|
145
|
+
markdown,
|
|
146
|
+
lineOffsets,
|
|
147
|
+
sourceKeys,
|
|
148
|
+
excluded,
|
|
149
|
+
tokens,
|
|
150
|
+
truncated,
|
|
151
|
+
consumed
|
|
152
|
+
) &&
|
|
153
|
+
inventoryReferenceDefinitions(
|
|
154
|
+
markdown,
|
|
155
|
+
lineOffsets,
|
|
156
|
+
sourceKeys,
|
|
157
|
+
excluded,
|
|
158
|
+
tokens,
|
|
159
|
+
truncated,
|
|
160
|
+
consumed
|
|
161
|
+
) &&
|
|
162
|
+
inventoryInlineMarkdown(
|
|
163
|
+
markdown,
|
|
164
|
+
lineOffsets,
|
|
165
|
+
sourceKeys,
|
|
166
|
+
excluded,
|
|
167
|
+
tokens,
|
|
168
|
+
truncated,
|
|
169
|
+
consumed
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (!ok) {
|
|
173
|
+
return { tokens, truncated: true, overlapping: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const finalized = dedupeInventoryDestinationTokens(tokens);
|
|
177
|
+
return {
|
|
178
|
+
tokens: finalized.tokens,
|
|
179
|
+
truncated: truncated.value,
|
|
180
|
+
overlapping: finalized.overlapping,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative destination relevance for reference-safe file refactors.
|
|
3
|
+
*
|
|
4
|
+
* Uses exact token/path/basename equality after stripping query/fragment,
|
|
5
|
+
* safe decoding, NFC normalization, and syntax delimiters. No fuzzy
|
|
6
|
+
* substring matching — unrelated `other-note.md` must not match `note.md`.
|
|
7
|
+
*
|
|
8
|
+
* @module src/core/link-relevance
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// node:path/posix — no Bun path utils
|
|
12
|
+
import { posix as pathPosix } from "node:path";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
stripAngleBracketDestination,
|
|
16
|
+
unescapeCommonMarkDestination,
|
|
17
|
+
} from "./link-destination-parse";
|
|
18
|
+
import { normalizeWikiName, stripWikiMdExt } from "./links";
|
|
19
|
+
|
|
20
|
+
function safeDecodeForRelevance(value: string): string {
|
|
21
|
+
return value
|
|
22
|
+
.replaceAll("%20", " ")
|
|
23
|
+
.replaceAll("%28", "(")
|
|
24
|
+
.replaceAll("%29", ")");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stripQueryAndFragment(value: string): string {
|
|
28
|
+
const hash = value.indexOf("#");
|
|
29
|
+
const withoutHash = hash >= 0 ? value.slice(0, hash) : value;
|
|
30
|
+
const query = withoutHash.indexOf("?");
|
|
31
|
+
return query >= 0 ? withoutHash.slice(0, query) : withoutHash;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function addKey(keys: Set<string>, value: string): void {
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
if (!trimmed) return;
|
|
37
|
+
keys.add(normalizeWikiName(trimmed));
|
|
38
|
+
keys.add(normalizeWikiName(stripWikiMdExt(trimmed)));
|
|
39
|
+
keys.add(trimmed.toLowerCase());
|
|
40
|
+
keys.add(stripWikiMdExt(trimmed).toLowerCase());
|
|
41
|
+
const base = pathPosix.basename(trimmed);
|
|
42
|
+
keys.add(normalizeWikiName(base));
|
|
43
|
+
keys.add(normalizeWikiName(stripWikiMdExt(base)));
|
|
44
|
+
keys.add(base.toLowerCase());
|
|
45
|
+
keys.add(stripWikiMdExt(base).toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build lookup keys used to decide whether a destination is about the source. */
|
|
49
|
+
export function buildSourceRelevanceKeys(input: {
|
|
50
|
+
relPath: string;
|
|
51
|
+
title: string | null | undefined;
|
|
52
|
+
}): Set<string> {
|
|
53
|
+
const keys = new Set<string>();
|
|
54
|
+
addKey(keys, input.relPath);
|
|
55
|
+
addKey(keys, input.relPath.normalize("NFC"));
|
|
56
|
+
addKey(keys, input.relPath.normalize("NFD"));
|
|
57
|
+
if (input.title) {
|
|
58
|
+
addKey(keys, input.title);
|
|
59
|
+
addKey(keys, input.title.normalize("NFC"));
|
|
60
|
+
addKey(keys, input.title.normalize("NFD"));
|
|
61
|
+
}
|
|
62
|
+
return keys;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Normalize a destination token into candidate equality keys (exact only).
|
|
67
|
+
*/
|
|
68
|
+
export function destinationRelevanceCandidates(destination: string): string[] {
|
|
69
|
+
const trimmed = destination.trim();
|
|
70
|
+
if (!trimmed) return [];
|
|
71
|
+
|
|
72
|
+
const { path: withoutAngles } = stripAngleBracketDestination(trimmed);
|
|
73
|
+
const unescaped = unescapeCommonMarkDestination(withoutAngles);
|
|
74
|
+
const withoutQueryFrag = stripQueryAndFragment(unescaped);
|
|
75
|
+
const decoded = safeDecodeForRelevance(withoutQueryFrag);
|
|
76
|
+
|
|
77
|
+
const forms = [
|
|
78
|
+
trimmed,
|
|
79
|
+
withoutAngles,
|
|
80
|
+
unescaped,
|
|
81
|
+
withoutQueryFrag,
|
|
82
|
+
decoded,
|
|
83
|
+
withoutQueryFrag.normalize("NFC"),
|
|
84
|
+
withoutQueryFrag.normalize("NFD"),
|
|
85
|
+
decoded.normalize("NFC"),
|
|
86
|
+
decoded.normalize("NFD"),
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const keys = new Set<string>();
|
|
90
|
+
for (const form of forms) {
|
|
91
|
+
addKey(keys, form);
|
|
92
|
+
}
|
|
93
|
+
return [...keys];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Exact-token relevance: true only when a normalized destination key equals a
|
|
98
|
+
* source key. Malformed prefixes may still match when the prefix token itself
|
|
99
|
+
* equals a source key (bounded exact equality, not substring).
|
|
100
|
+
*/
|
|
101
|
+
export function isRelevantDestination(
|
|
102
|
+
destination: string,
|
|
103
|
+
sourceKeys: ReadonlySet<string>
|
|
104
|
+
): boolean {
|
|
105
|
+
const candidates = destinationRelevanceCandidates(destination);
|
|
106
|
+
return candidates.some((key) => sourceKeys.has(key));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Content-prefilter needle strings for SQL LIKE (caller escapes wildcards).
|
|
111
|
+
* Conservative: source path/title identities and common escape encodings.
|
|
112
|
+
*/
|
|
113
|
+
export function buildContentPrefilterNeedles(input: {
|
|
114
|
+
relPath: string;
|
|
115
|
+
title: string | null | undefined;
|
|
116
|
+
}): string[] {
|
|
117
|
+
const needles = new Set<string>();
|
|
118
|
+
const add = (value: string): void => {
|
|
119
|
+
const trimmed = value.trim();
|
|
120
|
+
if (!trimmed || trimmed.length < 2) return;
|
|
121
|
+
needles.add(trimmed);
|
|
122
|
+
needles.add(trimmed.normalize("NFC"));
|
|
123
|
+
needles.add(trimmed.normalize("NFD"));
|
|
124
|
+
needles.add(trimmed.replaceAll(" ", "%20"));
|
|
125
|
+
needles.add(
|
|
126
|
+
trimmed
|
|
127
|
+
.replaceAll(" ", "%20")
|
|
128
|
+
.replaceAll("(", "%28")
|
|
129
|
+
.replaceAll(")", "%29")
|
|
130
|
+
);
|
|
131
|
+
needles.add(trimmed.replaceAll("(", "%28").replaceAll(")", "%29"));
|
|
132
|
+
needles.add(
|
|
133
|
+
trimmed
|
|
134
|
+
.replaceAll(" ", "\\ ")
|
|
135
|
+
.replaceAll("(", "\\(")
|
|
136
|
+
.replaceAll(")", "\\)")
|
|
137
|
+
);
|
|
138
|
+
needles.add(trimmed.replaceAll("(", "\\(").replaceAll(")", "\\)"));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
add(input.relPath);
|
|
142
|
+
add(pathPosix.basename(input.relPath));
|
|
143
|
+
add(stripWikiMdExt(pathPosix.basename(input.relPath)));
|
|
144
|
+
add(stripWikiMdExt(input.relPath));
|
|
145
|
+
if (input.title) {
|
|
146
|
+
add(input.title);
|
|
147
|
+
add(`${input.title}.md`);
|
|
148
|
+
}
|
|
149
|
+
return [...needles].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
150
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Markdown section extraction (ATX headings + fence awareness).
|
|
3
|
+
* Private helper module — import public API from `./sections`.
|
|
4
|
+
*
|
|
5
|
+
* @module src/core/section-parse
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface DocumentSection {
|
|
9
|
+
anchor: string;
|
|
10
|
+
level: number;
|
|
11
|
+
line: number;
|
|
12
|
+
title: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Structural section record used by target create/resolve. */
|
|
16
|
+
export interface SectionRecord {
|
|
17
|
+
section: DocumentSection;
|
|
18
|
+
headingPath: string[];
|
|
19
|
+
occurrence: number;
|
|
20
|
+
/** Inclusive start line through exclusive end line of section body. */
|
|
21
|
+
endLine: number;
|
|
22
|
+
titleStartOffset: number;
|
|
23
|
+
titleEndOffset: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HEADING_REGEX = /^(#{1,6})\s+(.+?)\s*#*\s*$/u;
|
|
27
|
+
const FENCE_REGEX = /^ {0,3}(`{3,}|~{3,})(.*)$/u;
|
|
28
|
+
const FENCE_CLOSE_REGEX = /^ {0,3}(`{3,}|~{3,})[\t ]*$/u;
|
|
29
|
+
|
|
30
|
+
interface OpenFence {
|
|
31
|
+
marker: "`" | "~";
|
|
32
|
+
length: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const fenceOpener = (line: string): OpenFence | null => {
|
|
36
|
+
const match = FENCE_REGEX.exec(line);
|
|
37
|
+
const run = match?.[1];
|
|
38
|
+
const suffix = match?.[2] ?? "";
|
|
39
|
+
if (!run || (run[0] === "`" && suffix.includes("`"))) return null;
|
|
40
|
+
return { marker: run[0] as OpenFence["marker"], length: run.length };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const closesFence = (line: string, fence: OpenFence): boolean => {
|
|
44
|
+
const run = FENCE_CLOSE_REGEX.exec(line)?.[1];
|
|
45
|
+
return Boolean(run && run[0] === fence.marker && run.length >= fence.length);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const normalizeHeadingTitle = (title: string): string =>
|
|
49
|
+
title.normalize("NFC").trim();
|
|
50
|
+
|
|
51
|
+
export const pathKey = (headingPath: readonly string[]): string =>
|
|
52
|
+
headingPath.join("\0");
|
|
53
|
+
|
|
54
|
+
export function slugifySectionTitle(title: string): string {
|
|
55
|
+
return (
|
|
56
|
+
title
|
|
57
|
+
.normalize("NFC")
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.trim()
|
|
60
|
+
.replaceAll(/[^\p{L}\p{N}\s-]/gu, "")
|
|
61
|
+
.replaceAll(/\s+/g, "-")
|
|
62
|
+
.replaceAll(/-+/g, "-")
|
|
63
|
+
.replace(/^-|-$/g, "") || "section"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const lineStartOffsets = (content: string): number[] => {
|
|
68
|
+
const offsets = [0];
|
|
69
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
70
|
+
if (content[index] === "\n") offsets.push(index + 1);
|
|
71
|
+
}
|
|
72
|
+
return offsets;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Extract structural section records (no quote evidence). */
|
|
76
|
+
export function extractSectionRecords(content: string): SectionRecord[] {
|
|
77
|
+
const records: SectionRecord[] = [];
|
|
78
|
+
const counts = new Map<string, number>();
|
|
79
|
+
const pathCounts = new Map<string, number>();
|
|
80
|
+
const lines = content.split("\n");
|
|
81
|
+
const starts = lineStartOffsets(content);
|
|
82
|
+
let openFence: OpenFence | null = null;
|
|
83
|
+
const stack: { level: number; title: string }[] = [];
|
|
84
|
+
|
|
85
|
+
for (const [index, line] of lines.entries()) {
|
|
86
|
+
if (openFence) {
|
|
87
|
+
if (closesFence(line, openFence)) openFence = null;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const opener = fenceOpener(line);
|
|
91
|
+
if (opener) {
|
|
92
|
+
openFence = opener;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const match = HEADING_REGEX.exec(line);
|
|
96
|
+
if (!match) continue;
|
|
97
|
+
|
|
98
|
+
const level = match[1]?.length ?? 0;
|
|
99
|
+
const title = match[2]?.trim() ?? "";
|
|
100
|
+
if (!title) continue;
|
|
101
|
+
|
|
102
|
+
const normalizedTitle = normalizeHeadingTitle(title);
|
|
103
|
+
while (stack.length > 0 && (stack.at(-1)?.level ?? 0) >= level) {
|
|
104
|
+
stack.pop();
|
|
105
|
+
}
|
|
106
|
+
stack.push({ level, title: normalizedTitle });
|
|
107
|
+
const headingPath = stack.map((entry) => entry.title);
|
|
108
|
+
const occurrence = (pathCounts.get(pathKey(headingPath)) ?? 0) + 1;
|
|
109
|
+
pathCounts.set(pathKey(headingPath), occurrence);
|
|
110
|
+
|
|
111
|
+
const baseAnchor = slugifySectionTitle(title);
|
|
112
|
+
const count = (counts.get(baseAnchor) ?? 0) + 1;
|
|
113
|
+
counts.set(baseAnchor, count);
|
|
114
|
+
const anchor = count === 1 ? baseAnchor : `${baseAnchor}-${count}`;
|
|
115
|
+
|
|
116
|
+
const lineStart = starts[index] ?? 0;
|
|
117
|
+
const titleOffsetInLine = line.indexOf(title);
|
|
118
|
+
const titleStartOffset =
|
|
119
|
+
titleOffsetInLine >= 0 ? lineStart + titleOffsetInLine : lineStart;
|
|
120
|
+
const titleEndOffset = titleStartOffset + title.length;
|
|
121
|
+
|
|
122
|
+
records.push({
|
|
123
|
+
section: {
|
|
124
|
+
anchor,
|
|
125
|
+
level,
|
|
126
|
+
line: index + 1,
|
|
127
|
+
title,
|
|
128
|
+
},
|
|
129
|
+
headingPath,
|
|
130
|
+
occurrence,
|
|
131
|
+
endLine: lines.length,
|
|
132
|
+
titleStartOffset,
|
|
133
|
+
titleEndOffset,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const [recordIndex, record] of records.entries()) {
|
|
138
|
+
let endLine = lines.length;
|
|
139
|
+
for (let next = recordIndex + 1; next < records.length; next += 1) {
|
|
140
|
+
const candidate = records[next];
|
|
141
|
+
if (candidate && candidate.section.level <= record.section.level) {
|
|
142
|
+
endLine = candidate.section.line - 1;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
record.endLine = endLine;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return records;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function extractSections(content: string): DocumentSection[] {
|
|
153
|
+
return extractSectionRecords(content).map((record) => record.section);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Extract one inclusive, 1-based line range without normalizing source bytes. */
|
|
157
|
+
export function extractInclusiveLines(
|
|
158
|
+
content: string,
|
|
159
|
+
startLine: number,
|
|
160
|
+
endLine: number
|
|
161
|
+
): string | null {
|
|
162
|
+
if (
|
|
163
|
+
content.includes("\r") ||
|
|
164
|
+
!Number.isSafeInteger(startLine) ||
|
|
165
|
+
!Number.isSafeInteger(endLine) ||
|
|
166
|
+
startLine < 1 ||
|
|
167
|
+
endLine < startLine
|
|
168
|
+
) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const lines = content.split("\n");
|
|
172
|
+
if (endLine > lines.length) return null;
|
|
173
|
+
return lines.slice(startLine - 1, endLine).join("\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Find the nearest Markdown heading governing a 1-based source line. */
|
|
177
|
+
export function headingForLine(
|
|
178
|
+
sections: readonly DocumentSection[],
|
|
179
|
+
line: number
|
|
180
|
+
): string | null {
|
|
181
|
+
let heading: string | null = null;
|
|
182
|
+
for (const section of sections) {
|
|
183
|
+
if (section.line > line) break;
|
|
184
|
+
heading = section.title;
|
|
185
|
+
}
|
|
186
|
+
return heading;
|
|
187
|
+
}
|