@dsh-gis/dsh-opengis-skill 0.1.0 → 0.1.2

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.
Files changed (37) hide show
  1. package/.editorconfig +19 -0
  2. package/CHANGELOG.md +96 -0
  3. package/LICENSE +21 -21
  4. package/README.md +91 -30
  5. package/THIRD_PARTY_NOTICES.md +37 -10
  6. package/assets/gdal/reference/raster-tools.md +538 -538
  7. package/assets/gdal/reference/vector-tools.md +149 -149
  8. package/assets/gdal-api/reference/raster-api.md +424 -424
  9. package/assets/gdal-api/reference/vector-api.md +724 -724
  10. package/assets/geometry-api-net/reference/geometry-types.md +243 -243
  11. package/assets/geopandas/reference/core-api.md +221 -221
  12. package/assets/geotools/reference/raster-rendering.md +108 -108
  13. package/assets/geotools/reference/vector-io.md +149 -149
  14. package/assets/jts/reference/geometry-operations.md +303 -303
  15. package/assets/mapsui/SKILL.md +283 -283
  16. package/assets/opengis-all/reference/data-stages.md +608 -608
  17. package/assets/opengis-all/reference/workflow-cheatsheets.md +324 -324
  18. package/assets/opengis-utils-for-java/reference/common-tasks.md +384 -384
  19. package/assets/opengis-utils-for-net/reference/code-patterns.md +124 -124
  20. package/assets/opengis-utils-for-net/reference/util-classes.md +291 -291
  21. package/assets/pyqgis/reference/processing-rendering.md +292 -292
  22. package/assets/pyqgis/reference/vector-raster-layers.md +318 -318
  23. package/assets/qgis-process/SKILL.md +414 -414
  24. package/assets/qgis-process/reference/algorithm-cheatsheet.md +194 -194
  25. package/assets/shapely/reference/operations-reference.md +199 -199
  26. package/cordis.patch.yml +6 -3
  27. package/lib/catalog.d.ts +50 -0
  28. package/lib/catalog.js +238 -0
  29. package/lib/index.d.ts +47 -6
  30. package/lib/index.js +32 -44
  31. package/lib/markdown-links.d.ts +11 -0
  32. package/lib/markdown-links.js +103 -0
  33. package/lib/skill-file.js +104 -17
  34. package/locale/en.json +1 -1
  35. package/locale/zh.json +1 -1
  36. package/package.json +11 -5
  37. package/upstream.json +64 -2
package/lib/catalog.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Shared skill-catalog helpers for the runtime provider and the source-repo
3
+ * scripts. The functions are pure Node built-ins: no DeepSeek Harness imports
4
+ * and no third-party dependencies.
5
+ *
6
+ * @module @dsh-gis/dsh-opengis-skill/catalog
7
+ */
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { readdirSync, readFileSync, statSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { parseSkillFile } from "./skill-file.js";
13
+
14
+ /** The instruction file name inside one skill directory. */
15
+ export const SKILL_FILE = "SKILL.md";
16
+
17
+ /** One inline code-span delimiter used by the generated markdown table. */
18
+ const TICK = "`";
19
+
20
+ /** README marker pair around the generated skill count line. */
21
+ const COUNT_START = "<!-- skills:count:start -->";
22
+ const COUNT_END = "<!-- skills:count:end -->";
23
+
24
+ /** README marker pair around the generated skill table. */
25
+ const TABLE_START = "<!-- skills:start -->";
26
+ const TABLE_END = "<!-- skills:end -->";
27
+
28
+ /**
29
+ * Read one skill directory.
30
+ *
31
+ * @param directory - absolute skill directory.
32
+ * @param expectedName - directory name the frontmatter name must match, when known.
33
+ * @returns the validated entry, or undefined when the directory has no SKILL.md.
34
+ * @throws when SKILL.md is malformed or declares a different name.
35
+ */
36
+ export function readSkillEntry(directory, expectedName) {
37
+ const path = join(directory, SKILL_FILE);
38
+ if (statSync(path, { throwIfNoEntry: false })?.isFile() !== true) return undefined;
39
+ const parsed = parseSkillFile(readFileSync(path, "utf8"), path);
40
+ if (expectedName !== undefined && parsed.name !== expectedName) {
41
+ throw new Error(path + ' declares "' + parsed.name + '" but its directory is "' + expectedName + '"');
42
+ }
43
+ return { name: parsed.name, description: parsed.description, directory, path };
44
+ }
45
+
46
+ /**
47
+ * Read every skill directory under one asset root, sorted by name.
48
+ *
49
+ * Directories without SKILL.md are skipped so a stray directory cannot break
50
+ * the runtime; the validation script reports those separately.
51
+ *
52
+ * @param assetRoot - absolute directory holding one subdirectory per skill.
53
+ * @returns the validated skills sorted by name.
54
+ * @throws when the root is missing, two skills share a name, or no skill exists.
55
+ */
56
+ export function readSkillCatalog(assetRoot) {
57
+ if (statSync(assetRoot, { throwIfNoEntry: false })?.isDirectory() !== true) {
58
+ throw new Error("skill asset directory not found: " + assetRoot);
59
+ }
60
+ const skills = [];
61
+ const seen = new Set();
62
+ for (const entry of readdirSync(assetRoot, { withFileTypes: true })) {
63
+ if (!entry.isDirectory()) continue;
64
+ const skill = readSkillEntry(join(assetRoot, entry.name), entry.name);
65
+ if (skill === undefined) continue;
66
+ if (seen.has(skill.name)) throw new Error('duplicate skill name "' + skill.name + '" under ' + assetRoot);
67
+ seen.add(skill.name);
68
+ skills.push(skill);
69
+ }
70
+ if (skills.length === 0) throw new Error("no skills found under " + assetRoot);
71
+ return skills.sort((left, right) => compareCodePoints(left.name, right.name));
72
+ }
73
+
74
+ /**
75
+ * Render the directory resource base with forward slashes for the model.
76
+ *
77
+ * @param directory - absolute skill directory, using the host separator.
78
+ * @returns the resource base value the skill registry expects.
79
+ */
80
+ export function resourceBaseOf(directory) {
81
+ return { kind: "directory", path: directory.replaceAll("\\", "/") };
82
+ }
83
+
84
+ /**
85
+ * Render the generated README skill-count section.
86
+ *
87
+ * @param skills - the catalog entries.
88
+ * @returns the complete marked section, markers included.
89
+ */
90
+ export function renderSkillCount(skills) {
91
+ return [
92
+ COUNT_START,
93
+ "共 **" + skills.length + "** 个技能,描述取自上游 frontmatter。",
94
+ COUNT_END
95
+ ].join("\n");
96
+ }
97
+
98
+ /**
99
+ * Render the generated README skill table.
100
+ *
101
+ * @param skills - the catalog entries carrying name and description.
102
+ * @returns the complete marked table section, markers included.
103
+ */
104
+ export function renderSkillTable(skills) {
105
+ const rows = skills.map((skill) => {
106
+ const cell = "| " + TICK + skill.name + TICK + " | " + skill.description.replaceAll("|", "\\|") + " |";
107
+ return cell;
108
+ });
109
+ return [
110
+ TABLE_START,
111
+ "| Skill | Description (upstream frontmatter) |",
112
+ "| --- | --- |",
113
+ ...rows,
114
+ TABLE_END
115
+ ].join("\n");
116
+ }
117
+
118
+ /**
119
+ * Read the generated README sections including their markers.
120
+ *
121
+ * @param markdown - the complete README text.
122
+ * @returns the exact current count and table sections.
123
+ * @throws when a marker is missing or duplicated.
124
+ */
125
+ export function readGeneratedSections(markdown) {
126
+ return {
127
+ count: extractSection(markdown, COUNT_START, COUNT_END),
128
+ table: extractSection(markdown, TABLE_START, TABLE_END)
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Regenerate both README sections in place.
134
+ *
135
+ * @param markdown - the complete README text.
136
+ * @param skills - the catalog entries to render.
137
+ * @returns the README text with both generated sections replaced.
138
+ * @throws when a marker is missing or duplicated.
139
+ */
140
+ export function replaceGeneratedSections(markdown, skills) {
141
+ const current = readGeneratedSections(markdown);
142
+ const replacements = [
143
+ [current.table, renderSkillTable(skills)],
144
+ [current.count, renderSkillCount(skills)]
145
+ ];
146
+ let next = markdown;
147
+ for (const [from, to] of replacements) {
148
+ const index = next.indexOf(from);
149
+ if (index === -1) throw new Error("cannot regenerate README: generated section not found");
150
+ next = next.slice(0, index) + to + next.slice(index + from.length);
151
+ }
152
+ return next;
153
+ }
154
+
155
+ /**
156
+ * List every regular file under one directory as POSIX-style relative paths.
157
+ *
158
+ * @param root - the directory to walk.
159
+ * @returns sorted relative paths using "/" on every platform.
160
+ * @throws when the root is missing, a symbolic link is present, or an entry is neither a file nor a directory.
161
+ */
162
+ export function listFiles(root) {
163
+ const resolvedRoot = resolve(root);
164
+ const output = [];
165
+ const stack = [""];
166
+ while (stack.length > 0) {
167
+ const prefix = stack.pop();
168
+ const directory = prefix === "" ? resolvedRoot : join(resolvedRoot, prefix);
169
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
170
+ const relativePath = prefix === "" ? entry.name : prefix + "/" + entry.name;
171
+ if (entry.isDirectory()) {
172
+ stack.push(relativePath);
173
+ continue;
174
+ }
175
+ if (entry.isSymbolicLink()) throw new Error("refusing to follow symbolic link: " + join(directory, entry.name));
176
+ if (!entry.isFile()) throw new Error("unsupported file type: " + join(directory, entry.name));
177
+ output.push(relativePath);
178
+ }
179
+ }
180
+ return output.sort(compareCodePoints);
181
+ }
182
+
183
+ /**
184
+ * Hash one file with SHA-256.
185
+ *
186
+ * @param path - the file to read.
187
+ * @returns the lowercase hexadecimal digest.
188
+ */
189
+ export function hashFile(path) {
190
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
191
+ }
192
+
193
+ /**
194
+ * Hash every regular file under one directory.
195
+ *
196
+ * @param root - the directory to walk.
197
+ * @returns a map from POSIX-style relative path to lowercase SHA-256 digest.
198
+ */
199
+ export function hashFiles(root) {
200
+ const digests = {};
201
+ for (const relativePath of listFiles(root)) digests[relativePath] = hashFile(join(root, relativePath));
202
+ return digests;
203
+ }
204
+
205
+ /**
206
+ * Normalize one vendored file to the upstream git blob form.
207
+ *
208
+ * A file carrying a NUL byte is treated as binary and copied unchanged; every
209
+ * other file has its CRLF line endings collapsed to LF. An upstream checkout on
210
+ * Windows can carry CRLF through core.autocrlf while the upstream git blob is
211
+ * LF, so normalizing keeps the vendored bytes equal to the blob independent of
212
+ * the platform.
213
+ *
214
+ * @param buffer - the raw file bytes.
215
+ * @returns the bytes to write or compare.
216
+ */
217
+ export function canonicalVendoredBytes(buffer) {
218
+ if (buffer.includes(0)) return buffer;
219
+ return Buffer.from(buffer.toString("utf8").replaceAll("\r\n", "\n"), "utf8");
220
+ }
221
+
222
+ /** Extract one marked section and reject missing or duplicated markers. */
223
+ function extractSection(markdown, start, end) {
224
+ const first = markdown.indexOf(start);
225
+ if (first === -1) throw new Error('missing generated README marker "' + start + '"');
226
+ if (markdown.indexOf(start, first + start.length) !== -1) throw new Error('duplicate generated README marker "' + start + '"');
227
+ const last = markdown.indexOf(end, first + start.length);
228
+ if (last === -1) throw new Error('missing generated README marker "' + end + '"');
229
+ if (markdown.indexOf(end, last + end.length) !== -1) throw new Error('duplicate generated README marker "' + end + '"');
230
+ return markdown.slice(first, last + end.length);
231
+ }
232
+
233
+ /** Compare two strings by Unicode code point, independent of the locale. */
234
+ function compareCodePoints(left, right) {
235
+ if (left < right) return -1;
236
+ if (left > right) return 1;
237
+ return 0;
238
+ }
package/lib/index.d.ts CHANGED
@@ -1,18 +1,59 @@
1
1
  /** Stable Loader identity. */
2
2
  export declare const name: "opengis-skill";
3
3
 
4
- /** Services that must exist before `apply` runs. */
4
+ /** Services that must exist before "apply" runs. */
5
5
  export declare const inject: readonly ["skills"];
6
6
 
7
+ /** Invocation controls shared by the model-facing and user-facing surfaces. */
8
+ export interface OpenGisInvocation {
9
+ modelInvocable: boolean;
10
+ userInvocable: boolean;
11
+ }
12
+
13
+ /** Directory resource base handed to the model with a loaded skill. */
14
+ export interface OpenGisResourceBase {
15
+ kind: "directory";
16
+ path: string;
17
+ }
18
+
19
+ /** One skill summary returned by the provider catalog. */
20
+ export interface OpenGisSkillCandidate {
21
+ name: string;
22
+ description: string;
23
+ invocation: OpenGisInvocation;
24
+ source: string;
25
+ provider: string;
26
+ rank: number;
27
+ locator: string;
28
+ path: string;
29
+ resourceBase: OpenGisResourceBase;
30
+ }
31
+
32
+ /** One loaded skill body returned by the provider. */
33
+ export interface OpenGisSkillDefinition {
34
+ name: string;
35
+ description: string;
36
+ invocation: OpenGisInvocation;
37
+ source: string;
38
+ provider: string;
39
+ resourceBase: OpenGisResourceBase;
40
+ path: string;
41
+ content: string;
42
+ }
43
+
44
+ /** The borrowed provider object the skill registry holds. */
45
+ export interface OpenGisSkillProvider {
46
+ name: string;
47
+ list(options?: { signal?: AbortSignal }): Promise<readonly OpenGisSkillCandidate[]>;
48
+ get(candidate: OpenGisSkillCandidate, options?: { signal?: AbortSignal }): Promise<OpenGisSkillDefinition>;
49
+ }
50
+
7
51
  /** Cordis context surface used by this plugin. */
8
52
  export interface OpenGisSkillHost {
9
- /** Own a registration so plugin unload removes it. */
10
- effect(callback: () => unknown, label?: string): unknown;
11
- /** The skill registry this provider registers on. */
12
53
  skills: {
13
- registerProvider(create: (control: { signal: AbortSignal; invalidate(): void }) => unknown): unknown;
54
+ registerProvider(create: (control: { signal: AbortSignal; invalidate(): void }) => OpenGisSkillProvider): unknown;
14
55
  };
15
56
  }
16
57
 
17
- /** Register the bundled OpenGIS skill provider on `ctx.skills`. */
58
+ /** Register the bundled OpenGIS skill provider on "ctx.skills". */
18
59
  export declare function apply(ctx: OpenGisSkillHost): void;
package/lib/index.js CHANGED
@@ -1,22 +1,21 @@
1
1
  /**
2
2
  * Bundled OpenGIS skill provider for DeepSeek Harness.
3
3
  *
4
- * The package ships the 32 GIS skills of the opengis-skills repository under
5
- * `assets/<skill>/SKILL.md` and registers one skill provider on `ctx.skills`.
4
+ * The package ships the "gis/" skills of the opengis-skills repository under
5
+ * "assets/<skill>/SKILL.md" and registers one skill provider on "ctx.skills".
6
6
  * Discovery reads only the frontmatter; the instruction body and the
7
- * `reference/*.md` files are read on demand, and each candidate carries the
7
+ * "reference/*.md" files are read on demand, and each candidate carries the
8
8
  * skill directory as its resource base so relative references resolve.
9
9
  *
10
10
  * @module @dsh-gis/dsh-opengis-skill
11
11
  */
12
12
 
13
- import { readFileSync, readdirSync, statSync } from "node:fs";
14
13
  import { readFile } from "node:fs/promises";
15
- import { join, sep } from "node:path";
16
14
  import { fileURLToPath } from "node:url";
15
+ import { readSkillCatalog, resourceBaseOf } from "./catalog.js";
17
16
  import { parseSkillFile } from "./skill-file.js";
18
17
 
19
- /** Provider name registered on `ctx.skills` (the runtime name "runtime" is reserved). */
18
+ /** Provider name registered on "ctx.skills" (the runtime name "runtime" is reserved). */
20
19
  const PROVIDER_NAME = "opengis";
21
20
 
22
21
  /** Source label carried by every candidate and definition. */
@@ -25,59 +24,43 @@ const SOURCE = "opengis";
25
24
  /** Standard rank for packaged skill providers and bundled roots (BUNDLED_SKILL_RANK). */
26
25
  const RANK = 600;
27
26
 
28
- /** The instruction file name inside one skill directory. */
29
- const SKILL_FILE = "SKILL.md";
30
-
31
27
  /** Absolute asset root, resolved from this module inside the installed package. */
32
28
  const ASSET_ROOT = fileURLToPath(new URL("../assets/", import.meta.url));
33
29
 
34
30
  /** Stable Loader identity. */
35
31
  const name = "opengis-skill";
36
32
 
37
- /** The skill registry must exist before `apply` runs. */
33
+ /** The skill registry must exist before "apply" runs. */
38
34
  const inject = ["skills"];
39
35
 
36
+ /** The invocation policy every bundled skill uses: model and user may load it. */
37
+ const INVOCATION = { modelInvocable: true, userInvocable: true };
38
+
40
39
  /**
41
- * Read every `assets/<name>/SKILL.md` bundle and validate it.
40
+ * Read and validate the packaged skill catalog, annotating asset failures with
41
+ * the plugin name so a broken installation is diagnosable from the host log.
42
42
  *
43
- * @param assetRoot - absolute directory holding one subdirectory per skill.
44
- * @returns the validated skills sorted by name.
45
- * @throws when two skills share a name or a directory disagrees with its frontmatter.
43
+ * @returns the validated catalog sorted by name.
44
+ * @throws when the packaged assets are missing or malformed.
46
45
  */
47
- function readAssetSkills(assetRoot) {
48
- const skills = [];
49
- const seen = new Set();
50
- for (const entry of readdirSync(assetRoot, { withFileTypes: true })) {
51
- if (!entry.isDirectory()) continue;
52
- const directory = join(assetRoot, entry.name);
53
- const path = join(directory, SKILL_FILE);
54
- if (statSync(path, { throwIfNoEntry: false })?.isFile() !== true) continue;
55
- const parsed = parseSkillFile(readFileSync(path, "utf8"), path);
56
- if (parsed.name !== entry.name) {
57
- throw new Error(`opengis-skill: ${path} declares "${parsed.name}" but its directory is "${entry.name}"`);
58
- }
59
- if (seen.has(parsed.name)) throw new Error(`opengis-skill: duplicate skill name "${parsed.name}"`);
60
- seen.add(parsed.name);
61
- skills.push({ name: parsed.name, description: parsed.description, directory, path });
46
+ function loadSkills() {
47
+ try {
48
+ return readSkillCatalog(ASSET_ROOT);
49
+ } catch (cause) {
50
+ throw new Error("opengis-skill: cannot load skill assets from " + ASSET_ROOT + ": " + cause.message, { cause });
62
51
  }
63
- return skills.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
64
- }
65
-
66
- /** Render the directory resource base with forward slashes for the model. */
67
- function resourceBaseOf(directory) {
68
- return { kind: "directory", path: directory.split(sep).join("/") };
69
52
  }
70
53
 
71
- /** The invocation policy every bundled skill uses: model and user may load it. */
72
- const INVOCATION = { modelInvocable: true, userInvocable: true };
73
-
74
54
  /**
75
- * Register the bundled OpenGIS skill provider on `ctx.skills`.
55
+ * Register the bundled OpenGIS skill provider on "ctx.skills".
56
+ *
57
+ * The returned disposer of "registerProvider" is owned by the calling fiber,
58
+ * so plugin unload removes the provider without an extra "ctx.effect" wrapper.
76
59
  *
77
60
  * @param ctx - Cordis context carrying the skill registry.
78
61
  */
79
62
  function apply(ctx) {
80
- const skills = readAssetSkills(ASSET_ROOT);
63
+ const skills = loadSkills();
81
64
  const byName = new Map(skills.map((skill) => [skill.name, skill]));
82
65
  const candidates = skills.map((skill) => ({
83
66
  name: skill.name,
@@ -95,11 +78,16 @@ function apply(ctx) {
95
78
  list: () => Promise.resolve(candidates),
96
79
  async get(candidate, options) {
97
80
  const skill = byName.get(candidate.name);
98
- if (skill === undefined) throw new Error(`opengis-skill: unknown skill "${candidate.name}"`);
81
+ if (skill === undefined) throw new Error('opengis-skill: unknown skill "' + candidate.name + '"');
99
82
  const raw = await readFile(skill.path, { encoding: "utf8", signal: options?.signal });
100
- const parsed = parseSkillFile(raw, skill.path);
83
+ let parsed;
84
+ try {
85
+ parsed = parseSkillFile(raw, skill.path);
86
+ } catch (cause) {
87
+ throw new Error("opengis-skill: " + cause.message, { cause });
88
+ }
101
89
  if (parsed.name !== candidate.name) {
102
- throw new Error(`opengis-skill: ${skill.path} now declares "${parsed.name}"`);
90
+ throw new Error("opengis-skill: " + skill.path + ' now declares "' + parsed.name + '" but was listed as "' + candidate.name + '"');
103
91
  }
104
92
  return {
105
93
  name: parsed.name,
@@ -113,7 +101,7 @@ function apply(ctx) {
113
101
  };
114
102
  }
115
103
  };
116
- ctx.effect(() => ctx.skills.registerProvider(() => provider), "opengis-skill: skill provider");
104
+ ctx.skills.registerProvider(() => provider);
117
105
  }
118
106
 
119
107
  export { apply, inject, name };
@@ -0,0 +1,11 @@
1
+ /** Extract every relative file link target from one markdown document. */
2
+ export declare function relativeMarkdownLinks(markdown: string): string[];
3
+
4
+ /** Resolve one relative markdown link against its containing file. */
5
+ export declare function resolveMarkdownLink(fromFile: string, link: string): { path: string; fragment: string };
6
+
7
+ /** Report whether one resolved path stays inside a directory. */
8
+ export declare function isInsideDirectory(root: string, candidate: string): boolean;
9
+
10
+ /** Collect every problem with the relative links in one markdown document. */
11
+ export declare function findMarkdownLinkProblems(fromFile: string, markdown: string, root: string): string[];
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Markdown link helpers shared by the validation script and the tests.
3
+ *
4
+ * Only relative file links are inspected: scheme links, fragment-only links,
5
+ * and absolute URLs are ignored. Resolution is lexical, so a link must stay
6
+ * inside the directory the caller allows.
7
+ *
8
+ * @module @dsh-gis/dsh-opengis-skill/markdown-links
9
+ */
10
+
11
+ import { existsSync } from "node:fs";
12
+ import { dirname, resolve, sep } from "node:path";
13
+
14
+ /** One inline markdown link target, as written. */
15
+ const LINK = /\]\(([^)]+)\)/gu;
16
+
17
+ /** A link with a URI scheme or a fragment-only target is not a file link. */
18
+ const NON_FILE_LINK = /^(?:[a-z][a-z0-9+.-]*:|#)/iu;
19
+
20
+ /**
21
+ * Extract every relative file link target from one markdown document.
22
+ *
23
+ * @param markdown - the complete markdown text.
24
+ * @returns the targets still carrying their optional fragment or query text.
25
+ */
26
+ export function relativeMarkdownLinks(markdown) {
27
+ const links = [];
28
+ for (const match of markdown.matchAll(LINK)) {
29
+ const target = match[1].trim();
30
+ if (target.length === 0 || NON_FILE_LINK.test(target)) continue;
31
+ links.push(target);
32
+ }
33
+ return links;
34
+ }
35
+
36
+ /**
37
+ * Resolve one relative markdown link against its containing file.
38
+ *
39
+ * @param fromFile - the markdown file that contains the link.
40
+ * @param link - a relative link target, optionally with a "#fragment".
41
+ * @returns the absolute target path and the fragment without its "#".
42
+ * @throws when the target contains invalid percent-encoding.
43
+ */
44
+ export function resolveMarkdownLink(fromFile, link) {
45
+ const hash = link.indexOf("#");
46
+ const pathPart = hash === -1 ? link : link.slice(0, hash);
47
+ const fragment = hash === -1 ? "" : link.slice(hash + 1);
48
+ if (pathPart.length === 0) return { path: fromFile, fragment };
49
+ let decoded;
50
+ try {
51
+ decoded = decodeURIComponent(pathPart);
52
+ } catch (cause) {
53
+ throw new Error('invalid URL encoding in link "' + link + '"', { cause });
54
+ }
55
+ if (decoded.length === 0) return { path: fromFile, fragment };
56
+ return { path: resolve(dirname(fromFile), decoded), fragment };
57
+ }
58
+
59
+ /**
60
+ * Report whether one resolved path stays inside a directory.
61
+ *
62
+ * @param root - the allowed directory.
63
+ * @param candidate - the resolved path to test.
64
+ * @returns whether the candidate equals or is below the root.
65
+ */
66
+ export function isInsideDirectory(root, candidate) {
67
+ const rootKey = pathKey(root);
68
+ const candidateKey = pathKey(candidate);
69
+ return candidateKey === rootKey || candidateKey.startsWith(rootKey + sep);
70
+ }
71
+
72
+ /**
73
+ * Collect every problem with the relative links in one markdown document.
74
+ *
75
+ * @param fromFile - the markdown file that contains the links; also used in messages.
76
+ * @param markdown - the complete markdown text.
77
+ * @param root - the directory every link must stay inside.
78
+ * @returns human-readable problems, empty when every link is valid.
79
+ */
80
+ export function findMarkdownLinkProblems(fromFile, markdown, root) {
81
+ const problems = [];
82
+ for (const link of relativeMarkdownLinks(markdown)) {
83
+ let resolved;
84
+ try {
85
+ resolved = resolveMarkdownLink(fromFile, link);
86
+ } catch (cause) {
87
+ problems.push(fromFile + ": " + cause.message);
88
+ continue;
89
+ }
90
+ if (!isInsideDirectory(root, resolved.path)) {
91
+ problems.push(fromFile + ": link escapes " + root + ": " + link);
92
+ continue;
93
+ }
94
+ if (!existsSync(resolved.path)) problems.push(fromFile + ": broken link " + link);
95
+ }
96
+ return problems;
97
+ }
98
+
99
+ /** Normalize one path for comparison, folding case on Windows. */
100
+ function pathKey(value) {
101
+ const normalized = resolve(value);
102
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
103
+ }