@opum-ai/lore 0.1.0 → 0.1.1
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 +33 -22
- package/bin/lore.cjs +35 -7
- package/package.json +17 -17
- package/src/adapters/backlog.ts +0 -1084
- package/src/adapters/git.ts +0 -221
- package/src/cli.ts +0 -667
- package/src/commands/agent.ts +0 -301
- package/src/commands/agents.ts +0 -302
- package/src/commands/args.ts +0 -209
- package/src/commands/changed.ts +0 -70
- package/src/commands/check.ts +0 -1031
- package/src/commands/codex-bridge.ts +0 -49
- package/src/commands/concurrency.ts +0 -48
- package/src/commands/context.ts +0 -292
- package/src/commands/discover.ts +0 -89
- package/src/commands/explorer.ts +0 -253
- package/src/commands/export.ts +0 -93
- package/src/commands/fswrite.ts +0 -928
- package/src/commands/graph.ts +0 -291
- package/src/commands/help.ts +0 -151
- package/src/commands/impact.ts +0 -59
- package/src/commands/init.ts +0 -583
- package/src/commands/instructions.ts +0 -91
- package/src/commands/link.ts +0 -929
- package/src/commands/new.ts +0 -476
- package/src/commands/orphans.ts +0 -457
- package/src/commands/path.ts +0 -67
- package/src/commands/provenance.ts +0 -68
- package/src/commands/query.ts +0 -312
- package/src/commands/reconcile-shared.ts +0 -280
- package/src/commands/rename.ts +0 -585
- package/src/commands/replace.ts +0 -320
- package/src/commands/scaffold.ts +0 -346
- package/src/commands/schema.ts +0 -293
- package/src/commands/snapshot.ts +0 -130
- package/src/commands/supersede.ts +0 -400
- package/src/commands/sync.ts +0 -371
- package/src/commands/tasks.ts +0 -271
- package/src/commands/traversal.ts +0 -151
- package/src/commands/validate.ts +0 -226
- package/src/config.ts +0 -598
- package/src/core/agent-bridge.ts +0 -287
- package/src/core/agent-context.ts +0 -498
- package/src/core/agent-profile.ts +0 -447
- package/src/core/bundle.ts +0 -893
- package/src/core/check.ts +0 -853
- package/src/core/codex-bridge.ts +0 -100
- package/src/core/concept.ts +0 -597
- package/src/core/consumer-scaffold.ts +0 -433
- package/src/core/context.ts +0 -271
- package/src/core/explorer-contract.ts +0 -441
- package/src/core/explorer-qualification.ts +0 -58
- package/src/core/explorer.ts +0 -518
- package/src/core/finding.ts +0 -31
- package/src/core/graph.ts +0 -201
- package/src/core/indexes.ts +0 -436
- package/src/core/instructions.ts +0 -209
- package/src/core/ladybug-driver.ts +0 -1795
- package/src/core/ladybug-lifecycle.ts +0 -1178
- package/src/core/ladybug-native.ts +0 -95
- package/src/core/ladybug-source.ts +0 -667
- package/src/core/links.ts +0 -681
- package/src/core/log.ts +0 -253
- package/src/core/managed-block.ts +0 -540
- package/src/core/manifest.ts +0 -718
- package/src/core/order.ts +0 -13
- package/src/core/profile.ts +0 -1007
- package/src/core/projection.ts +0 -195
- package/src/core/query.ts +0 -542
- package/src/core/reconcile.ts +0 -236
- package/src/core/replace.ts +0 -419
- package/src/core/retrieval.ts +0 -213
- package/src/core/rewrite.ts +0 -940
- package/src/core/scaffold.ts +0 -255
- package/src/core/schema.ts +0 -366
- package/src/core/snapshot-runtime.ts +0 -52
- package/src/core/snapshot-store.ts +0 -287
- package/src/core/snapshot.ts +0 -711
- package/src/core/template.ts +0 -429
- package/src/core/traversal.ts +0 -487
- package/src/core/validate.ts +0 -517
- package/src/core/workspace-contract.ts +0 -473
- package/src/core/workspace-projection.ts +0 -365
- package/src/core/workspace-retrieval.ts +0 -196
- package/src/core/workspace-source.ts +0 -174
- package/src/errors.ts +0 -697
- package/src/meta.ts +0 -7
- package/src/output.ts +0 -589
- package/src/scripts/upstream-backlog-watch.ts +0 -288
- package/src/state.ts +0 -390
package/src/core/links.ts
DELETED
|
@@ -1,681 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* links.ts — the canonical lore cross-link form, in one place.
|
|
3
|
-
*
|
|
4
|
-
* Every internal cross-link lore writes — in scaffolded concepts, regenerated
|
|
5
|
-
* index/log files, managed `lore:tasks` blocks, and graph-aware rewrites — is the
|
|
6
|
-
* single form ADR-0010 pins as the only one that resolves across GitHub, Obsidian
|
|
7
|
-
* (and feeds its graph/backlinks), MkDocs/Material, and Docusaurus simultaneously:
|
|
8
|
-
*
|
|
9
|
-
* - **relative** — computed from the *linking file's directory*
|
|
10
|
-
* (`../reference/orders.md`), because the bundle has no single deploy root
|
|
11
|
-
* (GitHub serves the repo path, MkDocs/Docusaurus a site subpath, Obsidian the
|
|
12
|
-
* vault root);
|
|
13
|
-
* - **URL-encoded** — spaces and other reserved characters percent-encoded
|
|
14
|
-
* per path segment (`order%20schema.md`), or GitHub and several parsers fail to
|
|
15
|
-
* resolve the target;
|
|
16
|
-
* - **`.md`-suffixed** — the link points at the *file*, not an extensionless route
|
|
17
|
-
* (extensionless breaks GitHub and Obsidian);
|
|
18
|
-
* - **no leading slash** — never `/reference/orders.md`, which points at each
|
|
19
|
-
* consumer's *server root* and so breaks somewhere every time;
|
|
20
|
-
* - **no wikilinks** — never `[[orders]]`, which renders as literal text off
|
|
21
|
-
* Obsidian.
|
|
22
|
-
*
|
|
23
|
-
* This module is the shared home for that form so it can never be spelled two ways:
|
|
24
|
-
* {@link normalizeLink} *writes* it ({@link `lore new`}, sync/index generation,
|
|
25
|
-
* `lore link`, managed blocks, and the rewrite half of `lore rename`/`supersede`),
|
|
26
|
-
* and {@link validateLink} *detects* deviations from it (the per-link half of `lore
|
|
27
|
-
* check`'s portability lint). The segment encoder ({@link encodePathSegments}) is
|
|
28
|
-
* shared with the `resource:` URL stamper (template.ts), and the low-level
|
|
29
|
-
* destination classifiers ({@link isExternalTarget}, {@link stripFragment},
|
|
30
|
-
* {@link stripQuery}, {@link decodeTarget}) are reused by the bundle graph's
|
|
31
|
-
* cross-link resolution — so linting and resolving agree on what a link destination
|
|
32
|
-
* *is*, and every place that encodes a path encodes it the same way.
|
|
33
|
-
*
|
|
34
|
-
* Per the core contract (design §2.1) everything here is pure: string in, string
|
|
35
|
-
* or typed finding out — no filesystem, no printing, no flags, no `process.exit`.
|
|
36
|
-
* A non-portable input is reported as a {@link LinkFinding}, never thrown; these
|
|
37
|
-
* functions do not fail.
|
|
38
|
-
*
|
|
39
|
-
* Scope (the rest of `links.ts` in design §2.1 lands with its consumers, not here):
|
|
40
|
-
* whole-graph link/anchor resolution (`validateLinks(graph)` via
|
|
41
|
-
* remark-validate-links) pairs with `lore check` (LORE-30); inbound-rewrite
|
|
42
|
-
* orchestration (`rewriteInbound(graph, from, to)`, which composes
|
|
43
|
-
* {@link normalizeLink}) pairs with `lore rename`/`supersede` (LORE-35); and the
|
|
44
|
-
* body-text portability scan (wikilinks, embeds, Obsidian-isms, MDX `<`/`{`) is
|
|
45
|
-
* part of `lore check`'s lint (LORE-30). This module ships the link-form
|
|
46
|
-
* primitives those compose.
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
import { posix } from "node:path";
|
|
50
|
-
import { singleLine, stripAnsiAndControls } from "../errors";
|
|
51
|
-
import { idFromPath } from "./concept";
|
|
52
|
-
|
|
53
|
-
// ── Shared patterns ───────────────────────────────────────────────────────────
|
|
54
|
-
// Hoisted to module scope so each is compiled once rather than re-built per call.
|
|
55
|
-
|
|
56
|
-
/** A trailing `.md` in **canonical lowercase** only. */
|
|
57
|
-
const MD_SUFFIX_CANONICAL = /\.md$/;
|
|
58
|
-
/** A trailing `.md` in any case (`.md`/`.MD`/`.Md`) — the writer's id-stripping rule. */
|
|
59
|
-
const MD_SUFFIX_ANY_CASE = /\.md$/i;
|
|
60
|
-
/** The markdown-significant characters {@link encodeURIComponent} leaves raw (`! ' ( ) *`). */
|
|
61
|
-
const MARKDOWN_SIGNIFICANT = /[!'()*]/g;
|
|
62
|
-
/** A `%` that is **not** the start of a valid two-hex-digit escape. */
|
|
63
|
-
const MALFORMED_PERCENT = /%(?![0-9A-Fa-f]{2})/;
|
|
64
|
-
/** A path segment in canonical form: only RFC-3986 unreserved chars or valid `%XX` escapes. */
|
|
65
|
-
const CANONICAL_SEGMENT = /^(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})*$/;
|
|
66
|
-
/** A raw character that breaks a markdown link destination (whitespace or a paren). */
|
|
67
|
-
const RAW_BREAKER = /[\s()]/;
|
|
68
|
-
/** An RFC-3986 `scheme:` prefix — what makes a destination look like an absolute URL. */
|
|
69
|
-
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
70
|
-
|
|
71
|
-
// ── Writing the canonical form ──────────────────────────────────────────────────
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Compute the canonical lore cross-link **destination** from one file to another:
|
|
75
|
-
* the value that goes inside `[text](…)`, in the relative · URL-encoded ·
|
|
76
|
-
* `.md`-suffixed · no-leading-slash form (ADR-0010).
|
|
77
|
-
*
|
|
78
|
-
* **Inputs are file *paths*, not link destinations.** `fromPath` and `toPath` must
|
|
79
|
-
* be expressed in the **same coordinate space** — both bundle-root-relative
|
|
80
|
-
* (`stories/x.md` → `reference/orders.md`) or both repo-relative
|
|
81
|
-
* (`docs/stories/x.md` → `docs/reference/orders.md`) — because the result is
|
|
82
|
-
* `posix.relative(dirname(fromPath), toPath)`: pure path arithmetic that happily
|
|
83
|
-
* crosses subtrees (`../../backlog/tasks/…`) when the two live apart, which is
|
|
84
|
-
* exactly what a managed `lore:tasks` block linking a `docs/` story to a Backlog
|
|
85
|
-
* task file needs. The coordinate space is a **caller precondition** the two-string
|
|
86
|
-
* arithmetic cannot itself verify; a mismatched pair yields a wrong relative link,
|
|
87
|
-
* not an error. `toPath` is a path, so it must **not** carry a `#fragment` or
|
|
88
|
-
* `?query` — pass a heading via `anchor` (a caller rewriting an existing link, e.g.
|
|
89
|
-
* `lore rename`, splits the fragment off first and feeds it here).
|
|
90
|
-
*
|
|
91
|
-
* Mechanics, each pinned to one property of the form:
|
|
92
|
-
*
|
|
93
|
-
* - **`.md`-suffixed** — `toPath` is normalized and coerced to a single canonical
|
|
94
|
-
* lowercase `.md` ({@link ensureMarkdownSuffix}), so a caller may pass a path
|
|
95
|
-
* (`reference/orders.md`), a bare concept id (`reference/orders`), or even a
|
|
96
|
-
* wrong-case `reference/orders.MD` and get the one canonical file link.
|
|
97
|
-
* - **relative, no leading slash** — `posix.relative` is computed from the linking
|
|
98
|
-
* file's *directory* and never returns a leading slash, so both properties hold
|
|
99
|
-
* by construction (a sibling becomes `orders.md`, a parent-dir target
|
|
100
|
-
* `../reference/orders.md`).
|
|
101
|
-
* - **URL-encoded** — every path segment is percent-encoded by
|
|
102
|
-
* {@link encodePathSegments}, which escapes a space to `%20` *and* the markdown-
|
|
103
|
-
* significant `! ' ( ) *` that bare `encodeURIComponent` leaves raw (an
|
|
104
|
-
* unbalanced `)` would otherwise truncate the link destination on
|
|
105
|
-
* CommonMark/Python-Markdown), while leaving the `/` separators and `.`/`..`
|
|
106
|
-
* steps literal.
|
|
107
|
-
*
|
|
108
|
-
* `anchor`, when given, is appended as `#<anchor>` **verbatim** — the caller passes
|
|
109
|
-
* an already-resolved GitHub heading slug (lowercased, spaces → `-`, punctuation
|
|
110
|
-
* stripped), which by construction needs no encoding; re-encoding it would mangle a
|
|
111
|
-
* correct slug, since GitHub anchors are not percent-encoded.
|
|
112
|
-
*
|
|
113
|
-
* **Deterministic, not idempotent on its own output.** The same `(fromPath, toPath,
|
|
114
|
-
* anchor)` always yields the same destination, so a regenerate-and-compare pass
|
|
115
|
-
* over canonical links is a no-op — but note the inputs are *paths*: feeding this
|
|
116
|
-
* function's *output* (an already-relative, already-encoded destination) back in as
|
|
117
|
-
* `toPath` is misuse and would double-encode and re-base it.
|
|
118
|
-
*
|
|
119
|
-
* @param fromPath the linking file (its directory anchors the relative path).
|
|
120
|
-
* @param toPath the target file or bare concept id (a missing `.md` is added; no `#`/`?`).
|
|
121
|
-
* @param anchor optional pre-slugified heading anchor, appended as `#anchor`.
|
|
122
|
-
* @returns the canonical relative destination string for `[text](…)`.
|
|
123
|
-
*/
|
|
124
|
-
export function normalizeLink(fromPath: string, toPath: string, anchor?: string): string {
|
|
125
|
-
// Precondition guard (the docstring's coordinate-space rule the arithmetic can't verify):
|
|
126
|
-
// an **absolute** operand has no place in lore's relative coordinate space and would make the
|
|
127
|
-
// result depend on process.cwd() — reject it loudly as the caller bug it is rather than emit a
|
|
128
|
-
// silently-wrong link.
|
|
129
|
-
if (posix.isAbsolute(fromPath) || posix.isAbsolute(toPath)) {
|
|
130
|
-
throw new Error(`normalizeLink expects relative paths (got from="${fromPath}", to="${toPath}")`);
|
|
131
|
-
}
|
|
132
|
-
// Root both operands at a fixed virtual "/" before the relative computation so the output is
|
|
133
|
-
// cwd-independent even when a path carries `..` segments — `posix.relative` otherwise resolves
|
|
134
|
-
// a relative operand against process.cwd(), which cancels only for two in-tree paths. `posix.join`
|
|
135
|
-
// also collapses `.`/`..`, so no separate normalize is needed.
|
|
136
|
-
const fromDir = posix.join("/", posix.dirname(fromPath));
|
|
137
|
-
const toFile = posix.join("/", ensureMarkdownSuffix(toPath));
|
|
138
|
-
const relative = posix.relative(fromDir, toFile);
|
|
139
|
-
const encoded = encodePathSegments(relative);
|
|
140
|
-
return anchor ? `${encoded}#${anchor}` : encoded;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* POSIX-normalize a target and coerce it to a single **canonical lowercase `.md`**
|
|
145
|
-
* suffix. A bundle never holds a `.MD`/`.Md` file (the walk matches lowercase `.md`
|
|
146
|
-
* only) and {@link idFromPath} strips `.md` case-insensitively, so the writer must
|
|
147
|
-
* re-add it in canonical case: a wrong-case `orders.MD` becomes `orders.md`, a
|
|
148
|
-
* bare id `orders` becomes `orders.md`, and an already-correct `orders.md` is
|
|
149
|
-
* unchanged.
|
|
150
|
-
*/
|
|
151
|
-
function ensureMarkdownSuffix(path: string): string {
|
|
152
|
-
// idFromPath POSIX-normalizes and strips a case-insensitive `.md`; re-adding the canonical
|
|
153
|
-
// lowercase suffix yields the one canonical file link and reuses the single path→id rule (so a
|
|
154
|
-
// bare id, a correct `.md`, and a wrong-case `.MD` all converge) instead of re-rolling it here.
|
|
155
|
-
return `${idFromPath(path)}.md`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Percent-encode each `/`-separated segment of a relative path for use as a
|
|
160
|
-
* markdown link destination, leaving the separators and `.`/`..` steps literal.
|
|
161
|
-
*
|
|
162
|
-
* Exported as the **single** path-segment encoder so body cross-links
|
|
163
|
-
* ({@link normalizeLink}) and the `resource:` frontmatter URL (template.ts
|
|
164
|
-
* `resourceFor`) encode a path the exact same way and can never drift — the whole
|
|
165
|
-
* reason this module exists. Each segment goes through {@link encodePathSegment},
|
|
166
|
-
* which extends {@link encodeURIComponent} to also escape the markdown-significant
|
|
167
|
-
* `! ' ( ) *` it leaves raw.
|
|
168
|
-
*/
|
|
169
|
-
export function encodePathSegments(path: string): string {
|
|
170
|
-
return path.split("/").map(encodePathSegment).join("/");
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Percent-encode one path segment to the canonical form: {@link encodeURIComponent}
|
|
175
|
-
* plus the five characters it leaves raw (`! ' ( ) *`), which are unreserved in a
|
|
176
|
-
* URI but significant in a markdown link destination — an unbalanced `)` truncates
|
|
177
|
-
* the destination on CommonMark/Python-Markdown. Hex is uppercase, matching
|
|
178
|
-
* `encodeURIComponent`, so {@link normalizeLink}'s output and the
|
|
179
|
-
* {@link validateLink} round-trip agree on the canonical bytes.
|
|
180
|
-
*/
|
|
181
|
-
function encodePathSegment(segment: string): string {
|
|
182
|
-
return encodeURIComponent(segment).replace(
|
|
183
|
-
MARKDOWN_SIGNIFICANT,
|
|
184
|
-
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ── Linting the canonical form ──────────────────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* The portable-markdown path **part** of a destination — everything before a
|
|
192
|
-
* `#fragment` or `?query`. The single composition of {@link stripFragment} and
|
|
193
|
-
* {@link stripQuery} (fragment first, so a `?` that lives *inside* a fragment is
|
|
194
|
-
* not mistaken for a query), shared by the linter ({@link validateLink}) and the
|
|
195
|
-
* bundle resolver so both split a destination into path / suffix the same way.
|
|
196
|
-
*/
|
|
197
|
-
export function pathPart(target: string): string {
|
|
198
|
-
return stripQuery(stripFragment(target));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* The way a link destination departs from the canonical form. Each value is one
|
|
203
|
-
* property of the ADR-0010 link rule, so a caller can branch on the *kind* of
|
|
204
|
-
* portability problem rather than parse the message.
|
|
205
|
-
*/
|
|
206
|
-
export type LinkIssue = "leading-slash" | "missing-extension" | "unencoded" | "accidental-colon" | "directory-link";
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* One portability problem with a single link destination. `target` is the
|
|
210
|
-
* offending destination as authored, `issue` the machine-branchable kind, and
|
|
211
|
-
* `message` a human-readable one-liner for the warning stream. A destination can
|
|
212
|
-
* produce more than one finding (a `/reference/orders` link is both leading-slash
|
|
213
|
-
* *and* missing-extension).
|
|
214
|
-
*/
|
|
215
|
-
export interface LinkFinding {
|
|
216
|
-
/** The link destination as authored (the value inside `[text](…)`). */
|
|
217
|
-
readonly target: string;
|
|
218
|
-
/** Which property of the canonical form the destination violates. */
|
|
219
|
-
readonly issue: LinkIssue;
|
|
220
|
-
/** A human-readable description of the violation, for the warning stream. */
|
|
221
|
-
readonly message: string;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Classify a single markdown link **destination** against the canonical internal
|
|
226
|
-
* cross-link form, returning a finding per violation (an empty array means the
|
|
227
|
-
* destination is already portable). This is the per-link primitive `lore check`'s
|
|
228
|
-
* portability lint composes over a bundle's body links (LORE-30); the broader
|
|
229
|
-
* body-text scan for wikilinks, embeds, Obsidian-isms, and MDX hazards is the
|
|
230
|
-
* lint's own concern, not this function's — a wikilink never parses to a link
|
|
231
|
-
* destination, so it cannot reach here.
|
|
232
|
-
*
|
|
233
|
-
* **External destinations are clean here.** An empty target, a bare `#anchor`, a
|
|
234
|
-
* protocol-relative `//host/…`, or a `scheme:`-qualified URL ({@link isExternalTarget})
|
|
235
|
-
* is not an internal cross-link, so it yields no findings — linting an external URL
|
|
236
|
-
* for a `.md` suffix would be nonsense. This uses the **same** classifier the bundle
|
|
237
|
-
* graph's resolver applies, so writer, resolver, and linter agree on what counts as
|
|
238
|
-
* external. The **one** exception (LORE-48) is the pathological destination whose first
|
|
239
|
-
* segment carries a colon (`notes:2026.md`): the shared classifier reads it as a
|
|
240
|
-
* `scheme:` URL (ADR-0010) — a form lore never writes — so it is flagged here as an
|
|
241
|
-
* `accidental-colon` filename rather than skipped in silence by both linter and resolver.
|
|
242
|
-
*
|
|
243
|
-
* The checks mirror the machine-checkable properties of the form (the "relative"
|
|
244
|
-
* property is implied by "no leading slash"; "no wikilinks" is a body scan):
|
|
245
|
-
*
|
|
246
|
-
* - **`leading-slash`** — a `/`-absolute destination, which resolves against each
|
|
247
|
-
* consumer's differing server root.
|
|
248
|
-
* - **`accidental-colon`** — a relative file whose first segment carries a colon
|
|
249
|
-
* ({@link accidentalColonFile}), read as a `scheme:` URL and otherwise unlinted.
|
|
250
|
-
* - **`directory-link`** — a non-external destination ending in `/` (`../reference/`), or
|
|
251
|
-
* whose final path segment is bare `.`/`..` navigation (`.`, `..`, `../..`, `foo/..`):
|
|
252
|
-
* it resolves to a file on no renderer and is almost always a dropped filename.
|
|
253
|
-
* - **`missing-extension`** — an internal target missing the canonical **lowercase**
|
|
254
|
-
* `.md` suffix ({@link lacksMarkdownSuffix}): no extension at all
|
|
255
|
-
* (`../reference/orders`), a wrong-case `.md` (`orders.MD`), or a dotted last
|
|
256
|
-
* segment whose "extension" is not on the closed {@link KNOWN_ASSET_EXTENSIONS}
|
|
257
|
-
* list (`orders.v2`, presumed a dropped `.md` suffix rather than an opaque asset —
|
|
258
|
-
* LORE-152). A directory link is its own `directory-link` finding (above); a
|
|
259
|
-
* dotfile (`../config/.gitignore`) and a recognized asset extension
|
|
260
|
-
* (`../img/x.png`) are left alone. The third case is the one place this lint and
|
|
261
|
-
* the bundle resolver deliberately **disagree**: the resolver still requires a
|
|
262
|
-
* literal `.md` suffix to form an edge at all, so it never flags `orders.v2` as
|
|
263
|
-
* broken even when no `orders.v2.md` file exists — this finding is the only
|
|
264
|
-
* mechanism left to catch that shape. The extension is judged on the *decoded*
|
|
265
|
-
* path, so the linter and the resolver (which decodes first) at least agree on
|
|
266
|
-
* what the path *is*.
|
|
267
|
-
* - **`unencoded`** — a destination that will not survive a markdown parser
|
|
268
|
-
* ({@link encodingProblem}): a raw space or paren in the path *or* in the
|
|
269
|
-
* `#fragment`/`?query`, a malformed `%`-escape, or an interior `//`. A valid but
|
|
270
|
-
* non-canonical encoding (`a%41b.md`, lowercase `%c3`) is **clean** — over-encoding
|
|
271
|
-
* renders everywhere, so the lint flags what *breaks*, not every deviation from the
|
|
272
|
-
* writer's exact bytes.
|
|
273
|
-
*
|
|
274
|
-
* @param target the link destination to classify (the value inside `[text](…)`).
|
|
275
|
-
* @returns a {@link LinkFinding} per violation; empty when portable or external.
|
|
276
|
-
*/
|
|
277
|
-
export function validateLink(target: string): LinkFinding[] {
|
|
278
|
-
const findings: LinkFinding[] = [];
|
|
279
|
-
const trimmed = target.trim();
|
|
280
|
-
if (isExternalTarget(trimmed)) {
|
|
281
|
-
// A scheme:-qualified destination is external and not linted — with one exception. lore never
|
|
282
|
-
// writes a colon inside a path segment, so a `scheme:` whose tail is a relative `.md` file
|
|
283
|
-
// (`notes:2026.md`) is almost certainly a filename with an accidental colon, which both this
|
|
284
|
-
// linter and the bundle resolver would otherwise skip in silence. Flag that one case;
|
|
285
|
-
// everything else (`http:`, `mailto:`, a bare `#anchor`, a protocol-relative `//host`) is
|
|
286
|
-
// genuinely external.
|
|
287
|
-
if (accidentalColonFile(trimmed)) {
|
|
288
|
-
findings.push({
|
|
289
|
-
target,
|
|
290
|
-
issue: "accidental-colon",
|
|
291
|
-
message: `link "${sanitizeForMessage(target)}" looks like a relative file whose name contains a ":"; it is read as a "scheme:" URL and skipped by link resolution — remove the colon (lore filenames never contain one)`,
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
return findings;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
if (trimmed.startsWith("/")) {
|
|
298
|
-
findings.push({
|
|
299
|
-
target,
|
|
300
|
-
issue: "leading-slash",
|
|
301
|
-
message: `link "${sanitizeForMessage(target)}" is /-absolute; use a relative path (it resolves against each consumer's differing root)`,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Split into the path part and the raw `#fragment`/`?query` remainder, the same
|
|
306
|
-
// way the bundle resolver does (shared {@link pathPart}). Both halves are scanned:
|
|
307
|
-
// a destination-breaking char in a fragment (`orders.md#Archival Policy`) truncates
|
|
308
|
-
// the link just as surely as one in the path.
|
|
309
|
-
const path = pathPart(trimmed);
|
|
310
|
-
const suffix = trimmed.slice(path.length);
|
|
311
|
-
|
|
312
|
-
if (isDirectoryLink(path)) {
|
|
313
|
-
findings.push({
|
|
314
|
-
target,
|
|
315
|
-
issue: "directory-link",
|
|
316
|
-
message: `link "${sanitizeForMessage(target)}" points at a directory (a trailing "/", or a bare "."/".." navigation segment); name the .md file instead (a directory link resolves on no renderer — likely a dropped filename)`,
|
|
317
|
-
});
|
|
318
|
-
} else if (lacksMarkdownSuffix(path)) {
|
|
319
|
-
findings.push({
|
|
320
|
-
target,
|
|
321
|
-
issue: "missing-extension",
|
|
322
|
-
message: `link "${sanitizeForMessage(target)}" is missing the .md suffix; GitHub and Obsidian link to the file and need the lowercase .md extension`,
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const problem = encodingProblem(path, suffix);
|
|
327
|
-
if (problem !== null) {
|
|
328
|
-
findings.push({ target, issue: "unencoded", message: encodingMessage(target, problem) });
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
return findings;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* The extensions of non-`.md` files a docs bundle legitimately links to directly:
|
|
336
|
-
* images, common document/archive/media formats, and the handful of source-file
|
|
337
|
-
* extensions a doc might reference (this repo's own `docs/adr/0006` links straight at
|
|
338
|
-
* `../../src/core/profile.ts`). Judged as a **closed, known list** rather than "the
|
|
339
|
-
* last segment has a dot at all" — the rule {@link lacksMarkdownSuffix} used to apply,
|
|
340
|
-
* which made a dotted-but-extensionless concept link (`orders.v2`, meant for
|
|
341
|
-
* `orders.v2.md`) indistinguishable from a real asset link and so let it through in
|
|
342
|
-
* silence (LORE-152). The trade-off this closed list accepts: a real asset whose
|
|
343
|
-
* extension is not on it is a false positive {@link lacksMarkdownSuffix} cannot avoid
|
|
344
|
-
* without a filesystem probe, which {@link validateLink} — pure by contract — does
|
|
345
|
-
* not have.
|
|
346
|
-
*/
|
|
347
|
-
const KNOWN_ASSET_EXTENSIONS = new Set([
|
|
348
|
-
// images
|
|
349
|
-
"png",
|
|
350
|
-
"jpg",
|
|
351
|
-
"jpeg",
|
|
352
|
-
"gif",
|
|
353
|
-
"svg",
|
|
354
|
-
"webp",
|
|
355
|
-
"bmp",
|
|
356
|
-
"ico",
|
|
357
|
-
"avif",
|
|
358
|
-
"tif",
|
|
359
|
-
"tiff",
|
|
360
|
-
// documents
|
|
361
|
-
"pdf",
|
|
362
|
-
"csv",
|
|
363
|
-
"txt",
|
|
364
|
-
"rtf",
|
|
365
|
-
"doc",
|
|
366
|
-
"docx",
|
|
367
|
-
"xls",
|
|
368
|
-
"xlsx",
|
|
369
|
-
"ppt",
|
|
370
|
-
"pptx",
|
|
371
|
-
// data/config
|
|
372
|
-
"json",
|
|
373
|
-
"yaml",
|
|
374
|
-
"yml",
|
|
375
|
-
"toml",
|
|
376
|
-
"xml",
|
|
377
|
-
// archives
|
|
378
|
-
"zip",
|
|
379
|
-
"tar",
|
|
380
|
-
"gz",
|
|
381
|
-
"tgz",
|
|
382
|
-
"rar",
|
|
383
|
-
"7z",
|
|
384
|
-
// media
|
|
385
|
-
"mp4",
|
|
386
|
-
"mp3",
|
|
387
|
-
"wav",
|
|
388
|
-
"mov",
|
|
389
|
-
"webm",
|
|
390
|
-
// source (a doc legitimately links straight at the code it describes)
|
|
391
|
-
"ts",
|
|
392
|
-
"tsx",
|
|
393
|
-
"js",
|
|
394
|
-
"jsx",
|
|
395
|
-
"sh",
|
|
396
|
-
"py",
|
|
397
|
-
"go",
|
|
398
|
-
"rs",
|
|
399
|
-
]);
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Whether a destination's path part lacks the canonical lowercase `.md` suffix and
|
|
403
|
-
* so looks like a dropped-extension concept link — judged on the **decoded** path
|
|
404
|
-
* (`orders%2Emd` decodes to `orders.md`), so the linter and the bundle resolver,
|
|
405
|
-
* which {@link decodeTarget}s before resolving, agree on what the extension *is*.
|
|
406
|
-
*
|
|
407
|
-
* The canonical form is **lowercase** `.md` only; three shapes are flagged:
|
|
408
|
-
*
|
|
409
|
-
* - **no extension at all** (`../reference/orders`) — a dropped-suffix concept link;
|
|
410
|
-
* - **a wrong-case `.md`** (`orders.MD`/`orders.Md`) — meant to be the portable
|
|
411
|
-
* suffix but case-broken on a case-sensitive host;
|
|
412
|
-
* - **a dotted last segment whose "extension" is not on the closed
|
|
413
|
-
* {@link KNOWN_ASSET_EXTENSIONS} list** (`orders.v2`) — presumed a dropped `.md`
|
|
414
|
-
* suffix rather than an opaque asset (LORE-152).
|
|
415
|
-
*
|
|
416
|
-
* Two shapes are deliberately **left alone**, so the lint does not cry wolf on a real,
|
|
417
|
-
* non-concept link:
|
|
418
|
-
*
|
|
419
|
-
* - an empty path (a pure `#fragment`/`?query` destination), or a **directory** link
|
|
420
|
-
* (a trailing `/`, e.g. `../reference/`, or a bare `.`/`..` navigation segment —
|
|
421
|
-
* {@link isDirectoryLink} — which is why this function's own dotfile check below
|
|
422
|
-
* never has to tell `.`/`..` apart from a real dotfile: it never sees them);
|
|
423
|
-
* - a **dotfile** (`../config/.gitignore`) or a recognized **asset** extension
|
|
424
|
-
* (`../img/x.png`, {@link KNOWN_ASSET_EXTENSIONS}) — a real non-concept link, not a
|
|
425
|
-
* dropped suffix.
|
|
426
|
-
*
|
|
427
|
-
* The third flagged shape is where this function and the bundle resolver part ways:
|
|
428
|
-
* the resolver still requires a literal `.md` suffix to form an edge at all, so a
|
|
429
|
-
* genuinely broken `orders.v2` link (no matching `orders.v2.md`) never surfaces as a
|
|
430
|
-
* dangling edge in `lore check`'s link-existence pass — this finding is the only
|
|
431
|
-
* mechanism left to catch it.
|
|
432
|
-
*/
|
|
433
|
-
function lacksMarkdownSuffix(rawPath: string): boolean {
|
|
434
|
-
const path = decodeTarget(rawPath);
|
|
435
|
-
if (path === "" || path.endsWith("/")) {
|
|
436
|
-
return false; // pure fragment/query, or a directory link — no file suffix to demand
|
|
437
|
-
}
|
|
438
|
-
const last = path.slice(path.lastIndexOf("/") + 1);
|
|
439
|
-
if (last === "" || last.startsWith(".")) {
|
|
440
|
-
return false; // a dotfile (.gitignore) is an asset, not a dropped suffix
|
|
441
|
-
}
|
|
442
|
-
if (MD_SUFFIX_CANONICAL.test(last)) {
|
|
443
|
-
return false; // canonical lowercase .md
|
|
444
|
-
}
|
|
445
|
-
if (MD_SUFFIX_ANY_CASE.test(last)) {
|
|
446
|
-
return true; // wrong-case .MD/.Md — meant to be .md, 404s on a case-sensitive host
|
|
447
|
-
}
|
|
448
|
-
const dot = last.lastIndexOf(".");
|
|
449
|
-
if (dot === -1) {
|
|
450
|
-
return true; // no extension at all → dropped suffix
|
|
451
|
-
}
|
|
452
|
-
// A recognized asset extension is a real non-.md link, left alone; any other dotted
|
|
453
|
-
// segment (`orders.v2`) is presumed a dropped .md suffix, not an opaque asset (LORE-152).
|
|
454
|
-
return !KNOWN_ASSET_EXTENSIONS.has(last.slice(dot + 1).toLowerCase());
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** The URL schemes a docs bundle legitimately links with — never an accidental-colon filename. */
|
|
458
|
-
const KNOWN_URL_SCHEMES = new Set([
|
|
459
|
-
"http",
|
|
460
|
-
"https",
|
|
461
|
-
"ftp",
|
|
462
|
-
"ftps",
|
|
463
|
-
"file",
|
|
464
|
-
"mailto",
|
|
465
|
-
"tel",
|
|
466
|
-
"sms",
|
|
467
|
-
"data",
|
|
468
|
-
"ws",
|
|
469
|
-
"wss",
|
|
470
|
-
"git",
|
|
471
|
-
"ssh",
|
|
472
|
-
"irc",
|
|
473
|
-
"urn",
|
|
474
|
-
]);
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Whether a destination is a relative file whose **first path segment carries a colon**
|
|
478
|
-
* (`notes:2026.md`), which {@link isExternalTarget} reads as a `scheme:` URL and so leaves
|
|
479
|
-
* unlinted. Real URLs are excluded three ways, because `.md` is a live TLD (Moldova) so "ends in
|
|
480
|
-
* `.md`" alone is not enough:
|
|
481
|
-
*
|
|
482
|
-
* - a **known** URL scheme (`http`, `mailto`, `file`, …) is a real link, never a filename;
|
|
483
|
-
* - a hierarchical tail beginning with `/` (`http://…`, `file:///…`) is a real URL;
|
|
484
|
-
* - a tail containing `@` (`mailto:user@example.md`) is a mailbox, not a path.
|
|
485
|
-
*
|
|
486
|
-
* Only an *unknown* scheme whose tail is a bare relative-looking `.md` path is taken to be a
|
|
487
|
-
* filename with an accidental colon — the one case lore never writes and the resolver silently
|
|
488
|
-
* skips. (A colon in a *later* segment, `dir/notes:2026.md`, is not a scheme and is already
|
|
489
|
-
* flagged `unencoded`.)
|
|
490
|
-
*/
|
|
491
|
-
function accidentalColonFile(target: string): boolean {
|
|
492
|
-
const path = pathPart(target);
|
|
493
|
-
const scheme = URL_SCHEME.exec(path);
|
|
494
|
-
if (scheme === null) {
|
|
495
|
-
return false; // a bare #anchor or `//host` reaches here with no scheme to mistake for a colon
|
|
496
|
-
}
|
|
497
|
-
const name = scheme[0].slice(0, -1).toLowerCase(); // the scheme word, without its trailing ":"
|
|
498
|
-
if (KNOWN_URL_SCHEMES.has(name)) {
|
|
499
|
-
return false; // a genuine URL scheme, not a mistyped filename
|
|
500
|
-
}
|
|
501
|
-
const tail = path.slice(scheme[0].length);
|
|
502
|
-
return !tail.startsWith("/") && !tail.includes("@") && MD_SUFFIX_ANY_CASE.test(tail);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Whether a (non-external) destination's path part names a **directory** — a trailing `/` after
|
|
507
|
-
* decoding (`../reference/`), *or* a final path segment that is bare navigation, exactly `.` or
|
|
508
|
-
* `..` (`.`, `..`, `../..`, `foo/..`, `foo/.`). Both shapes name a directory, not the canonical
|
|
509
|
-
* `.md` file the link form requires, and resolve inconsistently across consumers (GitHub browses
|
|
510
|
-
* to the directory; Obsidian will not resolve a `..` to a note) — so both warn. The pure
|
|
511
|
-
* leading-slash `/` (already flagged `leading-slash`) and an empty path (a bare fragment) are
|
|
512
|
-
* excluded so neither double-reports.
|
|
513
|
-
*
|
|
514
|
-
* A bare/trailing `.`/`..` segment is deliberately checked **here**, not left for
|
|
515
|
-
* {@link lacksMarkdownSuffix}'s dotfile exemption to (mis)judge: that exemption tests
|
|
516
|
-
* `startsWith(".")`, which would otherwise read a final segment of exactly `.`/`..` as a genuine
|
|
517
|
-
* dotfile like `.gitignore` and wave it through. Catching it in this earlier check keeps the
|
|
518
|
-
* dotfile exemption itself unchanged and correct for real dotfiles.
|
|
519
|
-
*/
|
|
520
|
-
function isDirectoryLink(rawPath: string): boolean {
|
|
521
|
-
const path = decodeTarget(rawPath);
|
|
522
|
-
if (path === "" || path === "/") {
|
|
523
|
-
return false;
|
|
524
|
-
}
|
|
525
|
-
if (path.endsWith("/")) {
|
|
526
|
-
return true;
|
|
527
|
-
}
|
|
528
|
-
const last = path.slice(path.lastIndexOf("/") + 1);
|
|
529
|
-
return last === "." || last === "..";
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* The kind of encoding problem a destination carries, or `null` when its path and
|
|
534
|
-
* suffix are both portable. One finding per destination is enough to flag the form,
|
|
535
|
-
* so the first problem found (in this fixed precedence) wins:
|
|
536
|
-
*
|
|
537
|
-
* - **`empty-segment`** — an *interior* `//` (`a//b.md`): an empty path step that is
|
|
538
|
-
* non-canonical (a leading `//` is protocol-relative and already external; a
|
|
539
|
-
* trailing `/` is a directory link, not an empty segment).
|
|
540
|
-
* - **`malformed`** — a `%` not followed by two hex digits (`order%2.md`): broken
|
|
541
|
-
* percent-escaping, where the generic "encode a space" advice would be wrong.
|
|
542
|
-
* - **`raw`** — a **path segment** that is not in the canonical form
|
|
543
|
-
* ({@link isCanonicalSegment}: only RFC-3986 unreserved characters and valid
|
|
544
|
-
* `%`-escapes), or a destination-breaking character (ASCII whitespace, `(`/`)`) in
|
|
545
|
-
* the `#fragment`/`?query`. The path check is what keeps the linter and
|
|
546
|
-
* {@link encodePathSegment writer} in agreement: a raw space, paren, or any of the
|
|
547
|
-
* markdown-significant `! ' *` the writer percent-encodes is flagged, so a bundle the
|
|
548
|
-
* lint calls clean is one the writer would re-emit byte-for-byte.
|
|
549
|
-
*
|
|
550
|
-
* The scan still accepts a **valid but non-canonical** encoding (`a%41b.md` = `aAb.md`,
|
|
551
|
-
* a lowercase `%c3`): over-encoding survives every renderer and round-trips, so it is
|
|
552
|
-
* portable even though {@link normalizeLink} would not emit it. The `#fragment` is held
|
|
553
|
-
* to the looser "no destination-breaker" rule, not full canonical encoding — a GitHub
|
|
554
|
-
* anchor slug (`#café`) is not percent-encoded and must not be flagged.
|
|
555
|
-
*/
|
|
556
|
-
function encodingProblem(rawPath: string, suffix: string): "empty-segment" | "malformed" | "raw" | null {
|
|
557
|
-
const segments = rawPath.split("/");
|
|
558
|
-
for (let i = 1; i < segments.length - 1; i++) {
|
|
559
|
-
if (segments[i] === "") {
|
|
560
|
-
return "empty-segment"; // interior // — endpoints are a leading slash / trailing dir slash
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
if (hasMalformedPercent(rawPath) || hasMalformedPercent(suffix)) {
|
|
564
|
-
return "malformed";
|
|
565
|
-
}
|
|
566
|
-
if (segments.some((segment) => segment !== "" && !isCanonicalSegment(segment)) || hasRawBreaker(suffix)) {
|
|
567
|
-
return "raw";
|
|
568
|
-
}
|
|
569
|
-
return null;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/** The human-readable message for an {@link encodingProblem} kind, naming the offending `target`. */
|
|
573
|
-
function encodingMessage(target: string, problem: "empty-segment" | "malformed" | "raw"): string {
|
|
574
|
-
const clean = sanitizeForMessage(target);
|
|
575
|
-
switch (problem) {
|
|
576
|
-
case "empty-segment":
|
|
577
|
-
return `link "${clean}" has an empty path segment ("//"); use a single "/" separator`;
|
|
578
|
-
case "malformed":
|
|
579
|
-
return `link "${clean}" has a malformed percent-escape (a "%" not followed by two hex digits); fix or remove it`;
|
|
580
|
-
case "raw":
|
|
581
|
-
return `link "${clean}" has an unencoded character; percent-encode reserved characters (e.g. space -> %20)`;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/**
|
|
586
|
-
* Collapse a bundle-authored link `target` to a single line with no ANSI escape sequences or
|
|
587
|
-
* other control bytes, for safe embedding into a {@link LinkFinding} `message`. `target` is a
|
|
588
|
-
* raw destination string lifted verbatim from a markdown link — `[text](target)` — so nothing
|
|
589
|
-
* about the bundle guarantees it is display-safe: a crafted or corrupted file can carry an
|
|
590
|
-
* ESC-led ANSI/OSC sequence or a stray C0/C1/DEL control byte in the destination text, and
|
|
591
|
-
* `message` is documented as a plain human-readable one-liner for the warning stream, not a
|
|
592
|
-
* raw byte passthrough. {@link LinkFinding.target} itself is left untouched — it is the
|
|
593
|
-
* destination *as authored*, for a caller that wants to act on the exact bytes — only the
|
|
594
|
-
* printable `message` is sanitized (LORE-153).
|
|
595
|
-
*
|
|
596
|
-
* Runs {@link singleLine} first (folds line terminators), then strips what it leaves via the
|
|
597
|
-
* shared {@link stripAnsiAndControls} (LORE-181) — the single home for that two-pass strip,
|
|
598
|
-
* also used by `output.ts`, `commands/query.ts`, and `core/validate.ts` — imported from
|
|
599
|
-
* `errors.ts` rather than `output.ts`: this module stays filesystem/output-layer-free by design
|
|
600
|
-
* (module doc above — pure, no printing), and `errors.ts` is layer-neutral.
|
|
601
|
-
*/
|
|
602
|
-
function sanitizeForMessage(text: string): string {
|
|
603
|
-
return stripAnsiAndControls(singleLine(text));
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/** Whether a string carries a `%` that is not the start of a valid two-hex-digit escape. */
|
|
607
|
-
function hasMalformedPercent(s: string): boolean {
|
|
608
|
-
return MALFORMED_PERCENT.test(s);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* Whether a path segment is in the canonical percent-encoded form: every character is
|
|
613
|
-
* either an RFC-3986 **unreserved** char (`A-Za-z0-9-._~`, which includes the `.`/`..`
|
|
614
|
-
* relative steps) or a valid `%XX` escape. This is exactly the alphabet
|
|
615
|
-
* {@link encodePathSegment} can emit, so a raw space, a `(`/`)`, or any of the
|
|
616
|
-
* markdown-significant `! ' *` the writer escapes makes a segment non-canonical — while an
|
|
617
|
-
* over-encoded `%41` or a lowercase `%c3` still passes (both are valid `%`-escapes).
|
|
618
|
-
*/
|
|
619
|
-
function isCanonicalSegment(segment: string): boolean {
|
|
620
|
-
return CANONICAL_SEGMENT.test(segment);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/** Whether a string carries a raw character that breaks a markdown link destination (whitespace or a paren). */
|
|
624
|
-
function hasRawBreaker(s: string): boolean {
|
|
625
|
-
return RAW_BREAKER.test(s);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// ── Destination classification (shared with the bundle graph) ────────────────────
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Whether a destination is **not** an internal concept reference: empty, a bare
|
|
632
|
-
* `#anchor`, a protocol-relative URL (`//host/…`), or a `scheme:`-qualified URL
|
|
633
|
-
* (`http:`, `mailto:`, …, per RFC-3986 — including the pathological relative file
|
|
634
|
-
* whose first segment carries a colon, which lore's canonical relative `.md` links
|
|
635
|
-
* never do, ADR-0010). The single classifier shared by the portability linter
|
|
636
|
-
* ({@link validateLink}) and link resolution (the bundle graph's body-link and
|
|
637
|
-
* frontmatter-ref handling), so the linter and resolver agree on what counts as
|
|
638
|
-
* external. ({@link normalizeLink}, the writer, takes a known concept *path* and so
|
|
639
|
-
* needs no classification — it never sees an arbitrary destination.)
|
|
640
|
-
*/
|
|
641
|
-
export function isExternalTarget(s: string): boolean {
|
|
642
|
-
return s === "" || s.startsWith("#") || s.startsWith("//") || URL_SCHEME.test(s);
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/** Drop a `#fragment` (and anything after it) from a destination. */
|
|
646
|
-
export function stripFragment(target: string): string {
|
|
647
|
-
const hash = target.indexOf("#");
|
|
648
|
-
return hash === -1 ? target : target.slice(0, hash);
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
/** Drop a `?query` (and anything after it) from a destination. */
|
|
652
|
-
export function stripQuery(target: string): string {
|
|
653
|
-
const question = target.indexOf("?");
|
|
654
|
-
return question === -1 ? target : target.slice(0, question);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
/**
|
|
658
|
-
* URL-decode a destination **per path segment**, degrading a segment to its raw text
|
|
659
|
-
* if it is not valid percent-encoding. Splitting on the *literal* `/` first (the only
|
|
660
|
-
* structural separators the raw target actually has) and decoding each piece in
|
|
661
|
-
* isolation stops an *encoded* separator — `%2F`/`%2f` — from ever surfacing as a bare
|
|
662
|
-
* `/` in the result: {@link decodeURIComponent} would otherwise turn a single-segment
|
|
663
|
-
* target like `orders%2Fv2.md` into `orders/v2.md`, forging a second path segment the
|
|
664
|
-
* link's literal text never named and letting it resolve to an unrelated concept
|
|
665
|
-
* (`orders/v2`) the author never linked to. A `/` a segment's decode *does* produce is
|
|
666
|
-
* re-escaped back to `%2F` before rejoining, so the output always has exactly as many
|
|
667
|
-
* structural `/` boundaries as the input — legitimately-encoded characters within a
|
|
668
|
-
* segment (a space, `%23`) still decode normally either way.
|
|
669
|
-
*/
|
|
670
|
-
export function decodeTarget(target: string): string {
|
|
671
|
-
return target.split("/").map(decodeSegmentKeepingSlash).join("/");
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/** Decode one `/`-free destination segment, folding any decoded `/` back to `%2F` so it cannot pass as a structural separator; degrades to the raw segment on malformed percent-encoding. */
|
|
675
|
-
function decodeSegmentKeepingSlash(segment: string): string {
|
|
676
|
-
try {
|
|
677
|
-
return decodeURIComponent(segment).replace(/\//g, "%2F");
|
|
678
|
-
} catch {
|
|
679
|
-
return segment;
|
|
680
|
-
}
|
|
681
|
-
}
|