@fro.bot/wiki-write-core 0.1.3 → 0.2.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 +35 -4
- package/dist/code-regions.js +34 -15
- package/dist/corrections-survival.d.ts +7 -1
- package/dist/corrections-survival.js +48 -41
- package/dist/frontmatter-block.d.ts +49 -0
- package/dist/frontmatter-block.js +62 -0
- package/dist/frontmatter.d.ts +5 -1
- package/dist/frontmatter.js +11 -5
- package/dist/gate-contract.js +1 -1
- package/dist/gate-contract.json +1 -1
- package/dist/inline-links.d.ts +35 -0
- package/dist/inline-links.js +120 -0
- package/dist/line-scanners.d.ts +31 -0
- package/dist/line-scanners.js +111 -0
- package/dist/link-labels.d.ts +22 -0
- package/dist/link-labels.js +71 -0
- package/dist/markdown-links.js +11 -9
- package/dist/rendering-policy.js +18 -4
- package/dist/repo-page-move.js +21 -13
- package/dist/wiki-ingest.js +94 -29
- package/dist/wiki-utils.d.ts +2 -2
- package/dist/wiki-utils.js +17 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,13 @@ pnpm add @fro.bot/wiki-write-core
|
|
|
15
15
|
- Node `>=24`.
|
|
16
16
|
- **ESM-only.** The exports declare `import` and `types` conditions, with no `require` condition and no CommonJS build. CommonJS consumers fail at package resolution by design.
|
|
17
17
|
|
|
18
|
-
The package ships unbundled ESM JavaScript with declaration files
|
|
18
|
+
The published package ships unbundled ESM JavaScript with declaration files in `dist/`. No install-time build step is required.
|
|
19
|
+
|
|
20
|
+
In the repository the package resolves to its TypeScript source: `exports` maps every subpath to `./src/<name>.ts`, and the only file committed under `dist/` is `gate-contract.json`. The release build compiles `dist/` and stages a manifest that differs from the committed one in `exports` alone, pointing each subpath at `./dist/<name>.js` with `./dist/<name>.d.ts` as its types. `npm pack` and `npm publish` run from that staged directory, so the committed manifest is never rewritten.
|
|
21
|
+
|
|
22
|
+
`bun run check:wiki-write-core-package` is the local and CI check: it builds the package, fails when the committed `dist/gate-contract.json` differs from the fresh build, packs the staged package, unpacks the tarball into a scratch `node_modules`, then resolves and imports every `exports` subpath under Node and type-checks a consumer module that imports them all. `bun run build` rewrites the committed marker after a change under `src/`, `tsconfig.build.json` or the manifest's `exports` or `files`.
|
|
23
|
+
|
|
24
|
+
`--stage <dir>` replaces whatever is at `<dir>`, so it refuses anything but a target that resolves (through symlinks) to somewhere strictly under the OS temp directory or `RUNNER_TEMP`, and it replaces an existing directory only when a previous stage left its `wiki-write-core-stage.marker` there. A root must be absolute and must not resolve to a filesystem root (so `RUNNER_TEMP=/` is refused), and a stage that contains a hidden file or directory is refused, because `upload-artifact` drops them and the publish job's digest would no longer match. The target is judged before anything is built or written, so a refused run leaves the committed `dist/gate-contract.json` alone, and again just before the replacement. Refusals happen before anything is created, moved or removed. If a replacement fails and the original cannot be restored either, the original is kept in a `.wiki-write-core-dist-backup-*` sibling and the error names it.
|
|
19
25
|
|
|
20
26
|
## Fro Bot defaults
|
|
21
27
|
|
|
@@ -27,9 +33,9 @@ The package targets the Fro Bot control plane and carries its coordinates as def
|
|
|
27
33
|
|
|
28
34
|
When a call receives no Octokit client (`params.octokit`), it reads `GITHUB_TOKEN` from the environment and fails with a remediation message if the variable is unset.
|
|
29
35
|
|
|
30
|
-
The build also emits `dist/gate-contract.json` with `version` from `GATE_CONTRACT_VERSION` and `sourceTreeHash` from the same computed source hash embedded in `gate-contract.js`.
|
|
36
|
+
The build also emits `dist/gate-contract.json` with `version` from `GATE_CONTRACT_VERSION` and `sourceTreeHash` from the same computed source hash embedded in `gate-contract.js`. This is the one file committed under `dist/`: downstream writers fetch it from `main`, so it stays at that path. `bun run check:wiki-write-core-package` verifies the committed marker against a fresh build, and the same marker ships in the tarball. `version` is the gate criterion: a mismatch means a deliberate contract break and refuses writes. `sourceTreeHash` is diagnostic only: it moves with ordinary source changes and reports drift in the pinned copy, but must never block writes.
|
|
31
37
|
|
|
32
|
-
The package exports `GATE_CONTRACT_VERSION` and `GATE_SOURCE_TREE_HASH` from its root entrypoint. The build embeds a deterministic hash covering every non-test TypeScript file under `src/`, the resolved `tsconfig.build.json` configuration including inherited settings, and canonicalized `exports` and `files` values
|
|
38
|
+
The package exports `GATE_CONTRACT_VERSION` and `GATE_SOURCE_TREE_HASH` from its root entrypoint. In the repository that entrypoint is source, so `GATE_SOURCE_TREE_HASH` is the placeholder `__SOURCE_TREE_HASH__`; only the built `dist/gate-contract.js` in the tarball carries the hash. The build embeds a deterministic hash covering every non-test TypeScript file under `src/`, the resolved `tsconfig.build.json` configuration including inherited settings, and canonicalized `exports` and `files` values of the published manifest (`exports` in its `dist` form). Object-key order and `files` array order do not affect the hash. The hash does not cover tests, this README, other manifest fields such as dependencies or version, or runtime dependencies. Because it hashes the _resolved_ compiler configuration, a TypeScript upgrade that changes an inherited default or the `--showConfig` serialization moves the hash with no semantic change to the shipped code; a hash that shifts across a compiler bump is expected and does not by itself indicate gate drift.
|
|
33
39
|
|
|
34
40
|
`GATE_SOURCE_TREE_HASH` is the automatic drift detector: compare it when you need to know whether the shipped gate inputs changed. `GATE_CONTRACT_VERSION` is a deliberate, human-set marker for an intentional consumer-visible contract break; it is not expected to change for every source edit. Neither value proves that dependencies or the runtime behave identically. Consumers should compare the source hash for ordinary gate-input drift and the contract version when deciding whether an intentional contract migration is required.
|
|
35
41
|
|
|
@@ -39,6 +45,28 @@ The package exports `GATE_CONTRACT_VERSION` and `GATE_SOURCE_TREE_HASH` from its
|
|
|
39
45
|
|
|
40
46
|
`WikiLintFindingKind` includes `correction-eroded` for a blocking active-correction failure and `correction-needs-reconfirmation` for an advisory lifecycle or formatting-only change. Both retain the existing `{kind, path, message, target?}` runtime shape and fingerprint derivation. Consumers with exhaustive matches must handle both kinds.
|
|
41
47
|
|
|
48
|
+
A page that opens a frontmatter block and never closes it is reported as `invalid-frontmatter`; it was reported as `missing-frontmatter` while the block was read as no block. Wiki repair counts such a page as out of scope, as it does for any `invalid-frontmatter` finding.
|
|
49
|
+
|
|
50
|
+
## Frontmatter block
|
|
51
|
+
|
|
52
|
+
`splitFrontmatterBlock(content)` (exported from the root entry and from `@fro.bot/wiki-write-core/frontmatter`) finds the YAML frontmatter block at the start of a document. It does not parse YAML. The root entry already exports the wiki page reader as `splitFrontmatter`, so the splitter has another name.
|
|
53
|
+
|
|
54
|
+
- The block opens only when the content starts with a line that is exactly `---`. A trailing space, a byte-order mark or a blank line before it means there is no block.
|
|
55
|
+
- A line ending is LF or CRLF; a lone CR is part of its line.
|
|
56
|
+
- The block closes at the first later line that is `---` followed only by spaces or tabs. `---x`, `----` and `--- text` are content of the block.
|
|
57
|
+
- The result is `{kind: 'none'}`, `{kind: 'malformed'}` (opened and never closed, which includes a file that is only `---`) or `{kind: 'block', opening, text, textEnding, closing, body}`. A block carries the pieces that put the document back: `opening + text + textEnding + closing + body` is the original content. `text` is what a YAML parser reads (it is `''` for an empty block); `textEnding` is the line ending of the last line of `text`, or `''` for a block with no lines; `closing` is the closing line with its spaces, tabs and line ending. A caller that rewrites a line of `text` rebuilds the file from the result alone and keeps every other byte.
|
|
58
|
+
- The scan visits each line once, so its running time grows linearly with the content, an unclosed block included.
|
|
59
|
+
|
|
60
|
+
The package's own readers (`parseFrontmatterDocument`, `splitFrontmatter`, the ingest's page parser and the repo-page move) all use it. Each keeps its error texts and its body handling. An empty block is an empty mapping for the document reader and the ingest parser; a block that parses to `null` (whitespace, comments, `null`, `...`) is not a mapping and gets each reader's rule for a non-mapping. The wiki page reader reports an unclosed block as `Invalid frontmatter: missing closing --- delimiter`.
|
|
61
|
+
|
|
62
|
+
## Target page sources
|
|
63
|
+
|
|
64
|
+
When the ingest writes the page of the `repo:` target and that page lists no sources (no `sources` key, or an empty list), the page lists `{url, accessed}` with the target's `https://github.com/<owner>/<name>` URL and the date of the ingest's timestamp. When the page on the branch already has an entry for the target, its whole `sources` list is carried over as it is, so a repeat write changes nothing, a repeat after a migration included. The stamp applies only when the owner and name are plain GitHub names (ASCII letters, digits, `.`, `_` and `-`, not `.` or `..`); for any other target the page is written without one. A page that lists any source is never changed, and neither is a `sources` value that is not a list. When the ingest migrates a page to the target's slug by a trusted node ID, the merged page also gains the target's URL if no entry matches it, and keeps its other entries. No other repo page in the write is stamped, and a write whose target is a page path (the dashboard's case) is never stamped. Whether an entry matches the target uses the privacy gate's URL rule: `https`, host `github.com`, no port, owner and name equal without case, any path after them.
|
|
65
|
+
|
|
66
|
+
## Gate contract version
|
|
67
|
+
|
|
68
|
+
`GATE_CONTRACT_VERSION` stays at 1 for the splitter and the reader changes. A bump makes every dashboard save fail until the dashboard ships a release that carries the same version, and the changes here do not need that: no page on `data` changes meaning, the dashboard already has a message for both lint finding kinds, and the dashboard writes with its own installed copy of this package. `dist/gate-contract.json` still changes, because its `sourceTreeHash` follows the source.
|
|
69
|
+
|
|
42
70
|
## Releasing
|
|
43
71
|
|
|
44
72
|
A merge to `main` that changes `packages/wiki-write-core/` releases the package through `.github/workflows/release-wiki-write-core.yaml`. Versions are the `wiki-write-core-v<version>` tags (for example `wiki-write-core-v0.1.1`), and each tag has a GitHub release.
|
|
@@ -55,10 +83,13 @@ Only commits that touch `packages/wiki-write-core/` are analyzed. The commit typ
|
|
|
55
83
|
- A scoped suppression wins over a breaking marker: `build(dev)!:` releases nothing.
|
|
56
84
|
- While the package is 0.x it never releases a major version; a breaking change is a minor bump.
|
|
57
85
|
- A commit containing `[skip release]` (or `[release skip]`) is ignored by the release analysis.
|
|
86
|
+
- Pull requests are squash-merged with `squash_merge_commit_title: PR_TITLE` (`common-settings.yaml`), so the PR title is the commit header the analysis reads. Pick the title type for the release you want.
|
|
87
|
+
- A PR that changes only tests, docs other than the README, or other files under `packages/wiki-write-core/` that should not ship needs a non-releasing title (`test:`, `docs:`, `chore:`, `refactor:` or `build(dev):`). A `fix:` or `build:` title there publishes a patch.
|
|
88
|
+
- A PR that changes nothing under `packages/wiki-write-core/` does not count toward the release type, whatever its own type. The release runs on every push to `main` and reads every package commit since the last release tag, so such a merge can still publish an earlier package change that has not been released yet.
|
|
58
89
|
|
|
59
90
|
### Publishing
|
|
60
91
|
|
|
61
|
-
The `publish` job uses npm trusted publishing (OIDC) from `release-wiki-write-core.yaml` in the `release` environment, and publishes with provenance. There is no npm token. It publishes the
|
|
92
|
+
The `publish` job uses npm trusted publishing (OIDC) from `release-wiki-write-core.yaml` in the `release` environment, and publishes with provenance. There is no npm token. It publishes the package the `build` job staged: the compiled `dist/` and a manifest whose `exports` point at it. The `build` job proves the packed tarball resolves, imports and type-checks every export. The `publish` job runs no repository script. The `build` job exposes a digest of the staged tree (sha256 over the `<sha256> <path>` listing of every file, in byte order of the path) as a job output, which comes from the runner that built the package and not from the artifact store. The `publish` job recomputes it over what it downloaded and refuses unless the two are equal, before it reads or installs anything. It then checks that the staged manifest is the committed one with `exports` rewritten (each `./src/<name>.ts` becomes `./dist/<name>.js` with `./dist/<name>.d.ts` as its types, and no target has a `.`, `..` or empty segment) and replaces the checkout's `package.json` and `dist/` with the staged files. semantic-release then changes only the manifest's `version` (`npm version`, which the digest does not cover because it is checked first), and `npm publish` packs the same files the `build` job's tarball check proved.
|
|
62
93
|
|
|
63
94
|
Verify a release:
|
|
64
95
|
|
package/dist/code-regions.js
CHANGED
|
@@ -88,6 +88,8 @@ export function findRegionAt(regions, offset) {
|
|
|
88
88
|
}
|
|
89
89
|
function findInlineSpans(line) {
|
|
90
90
|
const spans = [];
|
|
91
|
+
// Where each length of backtick run starts on this line, built when the first opener needs it.
|
|
92
|
+
let runStartsByLength;
|
|
91
93
|
let index = 0;
|
|
92
94
|
while (index < line.length) {
|
|
93
95
|
const character = line[index];
|
|
@@ -101,30 +103,47 @@ function findInlineSpans(line) {
|
|
|
101
103
|
}
|
|
102
104
|
const openEnd = skipBackticks(line, index);
|
|
103
105
|
const runLength = openEnd - index;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
break;
|
|
110
|
-
}
|
|
111
|
-
const candidateEnd = skipBackticks(line, closeStart);
|
|
112
|
-
if (candidateEnd - closeStart === runLength) {
|
|
113
|
-
closeEnd = candidateEnd;
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
searchFrom = candidateEnd;
|
|
117
|
-
}
|
|
118
|
-
if (closeEnd === -1) {
|
|
106
|
+
runStartsByLength ??= indexBacktickRuns(line);
|
|
107
|
+
// The closer is the first later run of exactly the opener's length. Openers only move forward, so each
|
|
108
|
+
// length's cursor does too, and every run is passed over at most once.
|
|
109
|
+
const closeStart = nextRunAtOrAfter(runStartsByLength.get(runLength), openEnd);
|
|
110
|
+
if (closeStart === undefined) {
|
|
119
111
|
index = openEnd;
|
|
120
112
|
}
|
|
121
113
|
else {
|
|
114
|
+
const closeEnd = closeStart + runLength;
|
|
122
115
|
spans.push({ start: index, end: closeEnd });
|
|
123
116
|
index = closeEnd;
|
|
124
117
|
}
|
|
125
118
|
}
|
|
126
119
|
return spans;
|
|
127
120
|
}
|
|
121
|
+
/** The start offsets of the maximal backtick runs of `line`, grouped by the length of the run. */
|
|
122
|
+
function indexBacktickRuns(line) {
|
|
123
|
+
const runStartsByLength = new Map();
|
|
124
|
+
for (let start = line.indexOf('`'); start !== -1;) {
|
|
125
|
+
const end = skipBackticks(line, start);
|
|
126
|
+
const runs = runStartsByLength.get(end - start);
|
|
127
|
+
if (runs === undefined) {
|
|
128
|
+
runStartsByLength.set(end - start, { starts: [start], next: 0 });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
runs.starts.push(start);
|
|
132
|
+
}
|
|
133
|
+
start = line.indexOf('`', end);
|
|
134
|
+
}
|
|
135
|
+
return runStartsByLength;
|
|
136
|
+
}
|
|
137
|
+
/** The first run start at least `minimum`. Callers ask with increasing `minimum`, so the cursor only advances. */
|
|
138
|
+
function nextRunAtOrAfter(runs, minimum) {
|
|
139
|
+
if (runs === undefined) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
while (runs.next < runs.starts.length && (runs.starts[runs.next] ?? minimum) < minimum) {
|
|
143
|
+
runs.next += 1;
|
|
144
|
+
}
|
|
145
|
+
return runs.starts[runs.next];
|
|
146
|
+
}
|
|
128
147
|
function skipBackticks(line, from) {
|
|
129
148
|
let index = from;
|
|
130
149
|
while (line[index] === '`') {
|
|
@@ -30,6 +30,12 @@ export declare function normalizeFormattingText(value: string): string;
|
|
|
30
30
|
* Masks markdown inline links `[label](url)` to spaces so exact prose matching ignores link
|
|
31
31
|
* targets; wiki links `[[...]]` are left untouched (module docstring). A regex port was proven
|
|
32
32
|
* non-equivalent by exhaustive differential testing (corrections-survival.test.ts), so this
|
|
33
|
-
*
|
|
33
|
+
* stays a scanner.
|
|
34
|
+
*
|
|
35
|
+
* At each `]` that is followed by `(` and preceded by a `[`, the link runs from the last `[` to the
|
|
36
|
+
* `)` that closes that `(`, counting nested parentheses. A `(` with no closing `)` is not a link,
|
|
37
|
+
* and the scan goes on from the next character. After a link, the scan resumes past its `)` with no
|
|
38
|
+
* `[` open. The closing `)` of every `(` is found once, up front, so the scan is linear in the
|
|
39
|
+
* length of the content.
|
|
34
40
|
*/
|
|
35
41
|
export declare function maskMarkdownLinks(content: string): string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeCorrectionText } from "./correction-text.js";
|
|
2
2
|
import { assertCorrectionsFile, getCorrectionLifecycle, } from "./corrections.js";
|
|
3
|
+
import { replaceLinkLabels } from "./link-labels.js";
|
|
3
4
|
import { maskNonProseContent } from "./rendering-policy.js";
|
|
4
5
|
import { collectWikiPages } from "./wiki-utils.js";
|
|
5
6
|
/**
|
|
@@ -94,21 +95,18 @@ export function verifyCorrectionSurvival(files, corrections, fallbackFiles = {})
|
|
|
94
95
|
* the pre-refactor reference implementation.
|
|
95
96
|
*/
|
|
96
97
|
export function normalizeFormattingText(value) {
|
|
97
|
-
|
|
98
|
-
return (value
|
|
99
|
-
.normalize('NFKC')
|
|
100
|
-
.replaceAll(markdownLinkPattern, renderVisibleLinkText)
|
|
98
|
+
return (replaceLinkLabels(value.normalize('NFKC'), renderVisibleLinkText)
|
|
101
99
|
// `\s+` below absorbs any run this leaves; a `+` here would be unobservable.
|
|
102
100
|
.replaceAll(/[^\p{L}\p{N}]/gu, ' ')
|
|
103
101
|
.trim()
|
|
104
102
|
.replaceAll(/\s+/gu, ' ')
|
|
105
103
|
.toLowerCase());
|
|
106
104
|
}
|
|
107
|
-
function renderVisibleLinkText(
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
105
|
+
function renderVisibleLinkText({ markdownText, wikiTarget, wikiLabel }) {
|
|
106
|
+
// replaceLinkLabels calls this with exactly one branch matched: the markdown branch always has
|
|
107
|
+
// markdownText (possibly '', never undefined), and the wiki branch always has wikiTarget
|
|
108
|
+
// (required, 1+ chars). A final '' fallback covering "neither captured" is unreachable --
|
|
109
|
+
// asserted, not defaulted.
|
|
112
110
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see comment above
|
|
113
111
|
return (markdownText ?? wikiLabel ?? wikiTarget);
|
|
114
112
|
}
|
|
@@ -116,45 +114,54 @@ function renderVisibleLinkText(_match, markdownText, wikiTarget, wikiLabel) {
|
|
|
116
114
|
* Masks markdown inline links `[label](url)` to spaces so exact prose matching ignores link
|
|
117
115
|
* targets; wiki links `[[...]]` are left untouched (module docstring). A regex port was proven
|
|
118
116
|
* non-equivalent by exhaustive differential testing (corrections-survival.test.ts), so this
|
|
119
|
-
*
|
|
117
|
+
* stays a scanner.
|
|
118
|
+
*
|
|
119
|
+
* At each `]` that is followed by `(` and preceded by a `[`, the link runs from the last `[` to the
|
|
120
|
+
* `)` that closes that `(`, counting nested parentheses. A `(` with no closing `)` is not a link,
|
|
121
|
+
* and the scan goes on from the next character. After a link, the scan resumes past its `)` with no
|
|
122
|
+
* `[` open. The closing `)` of every `(` is found once, up front, so the scan is linear in the
|
|
123
|
+
* length of the content.
|
|
120
124
|
*/
|
|
121
125
|
export function maskMarkdownLinks(content) {
|
|
122
|
-
const
|
|
126
|
+
const characters = content.split('');
|
|
127
|
+
const closers = findClosingParentheses(characters);
|
|
128
|
+
const masked = [...characters];
|
|
123
129
|
let open = -1;
|
|
124
|
-
let
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
let resumeAt = 0;
|
|
131
|
+
for (const [index, character] of characters.entries()) {
|
|
132
|
+
if (index < resumeAt)
|
|
133
|
+
continue;
|
|
134
|
+
if (character === '[')
|
|
128
135
|
open = index;
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// output, and are killed by the differential test like any other behavior change.
|
|
136
|
-
// Stryker disable next-line BlockStatement,EqualityOperator: BlockStatement mutation destroys mandatory loop progress (deterministic hang, not a timing artifact); EqualityOperator's >= variant hangs when close starts at or past content.length (a link's `](` at the very end of the string) for the same reason as the outer loop above, and its <= variant only extends the loop to content[content.length], which is always undefined and has no side effect on open/masked/depth
|
|
137
|
-
while (close < content.length) {
|
|
138
|
-
if (depth <= 0)
|
|
139
|
-
break;
|
|
140
|
-
if (content[close] === '(')
|
|
141
|
-
depth += 1;
|
|
142
|
-
else if (content[close] === ')')
|
|
143
|
-
depth -= 1;
|
|
144
|
-
// Stryker disable next-line AssignmentOperator: mutation destroys mandatory loop progress; deterministic hang, not a timing artifact
|
|
145
|
-
close += 1;
|
|
146
|
-
}
|
|
147
|
-
if (depth === 0) {
|
|
148
|
-
// Stryker disable next-line AssignmentOperator: mutation destroys mandatory loop progress; deterministic hang, not a timing artifact
|
|
149
|
-
for (let maskIndex = open; maskIndex < close; maskIndex += 1)
|
|
150
|
-
masked[maskIndex] = ' ';
|
|
151
|
-
index = close;
|
|
136
|
+
if (character === ']' && open !== -1) {
|
|
137
|
+
// `closers` has an entry only for a `(`, so the next character is a `(` with a closer or there is no link.
|
|
138
|
+
const close = closers.get(index + 1);
|
|
139
|
+
if (close !== undefined) {
|
|
140
|
+
masked.fill(' ', open, close + 1);
|
|
141
|
+
resumeAt = close + 1;
|
|
152
142
|
open = -1;
|
|
153
|
-
continue;
|
|
154
143
|
}
|
|
155
144
|
}
|
|
156
|
-
// Stryker disable next-line AssignmentOperator: mutation destroys mandatory loop progress; deterministic hang, not a timing artifact
|
|
157
|
-
index += 1;
|
|
158
145
|
}
|
|
159
146
|
return masked.join('');
|
|
160
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Maps the index of each `(` to the index of the `)` that closes it, counting nested parentheses,
|
|
150
|
+
* or to `undefined` when nothing closes it. Read from the right, each `(` takes the nearest `)`
|
|
151
|
+
* that no `(` between them has taken, which pairs the same parentheses as counting depth to the
|
|
152
|
+
* right of each `(`. A `)` with no `(` before it is left unpaired and never looked up.
|
|
153
|
+
*/
|
|
154
|
+
function findClosingParentheses(characters) {
|
|
155
|
+
const closers = new Map();
|
|
156
|
+
const unpaired = [];
|
|
157
|
+
for (const [reversedIndex, character] of characters.toReversed().entries()) {
|
|
158
|
+
const index = characters.length - 1 - reversedIndex;
|
|
159
|
+
if (character === ')') {
|
|
160
|
+
unpaired.push(index);
|
|
161
|
+
}
|
|
162
|
+
else if (character === '(') {
|
|
163
|
+
closers.set(index, unpaired.pop());
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return closers;
|
|
167
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `splitFrontmatterBlock` found at the start of a document.
|
|
3
|
+
*
|
|
4
|
+
* - `none`: the content does not open a block.
|
|
5
|
+
* - `malformed`: the content opens a block and never closes it. It carries no content, so a caller cannot read
|
|
6
|
+
* the pieces of a block that was not closed.
|
|
7
|
+
* - `block`: the content opens and closes a block. The five strings put the content back byte for byte:
|
|
8
|
+
* `opening + text + textEnding + closing + body` is the original content.
|
|
9
|
+
*
|
|
10
|
+
* The pieces are strings, not offsets, so an editor that rewrites a line of the block rebuilds the file from the
|
|
11
|
+
* result alone, with the line endings, trailing spaces and body it found.
|
|
12
|
+
*/
|
|
13
|
+
export type FrontmatterBlockSplit = {
|
|
14
|
+
readonly kind: 'none';
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: 'malformed';
|
|
17
|
+
} | {
|
|
18
|
+
readonly kind: 'block';
|
|
19
|
+
/** The opening line with its line ending: `---` and LF or CRLF. */
|
|
20
|
+
readonly opening: string;
|
|
21
|
+
/**
|
|
22
|
+
* The lines between the opening and the closing line, each with the line ending it has, except the last line,
|
|
23
|
+
* whose ending is `textEnding`. It is what a YAML parser reads. It is `''` for a block with no lines and for
|
|
24
|
+
* a block of one blank line, which `textEnding` tells apart.
|
|
25
|
+
*/
|
|
26
|
+
readonly text: string;
|
|
27
|
+
/** The line ending of the last line of `text`: `\n`, `\r\n`, or `''` when the block has no lines. */
|
|
28
|
+
readonly textEnding: string;
|
|
29
|
+
/** The closing line: `---`, any spaces or tabs after it, and its line ending. It has none at the end of the content. */
|
|
30
|
+
readonly closing: string;
|
|
31
|
+
/** Everything after the closing line, untouched. */
|
|
32
|
+
readonly body: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Finds the YAML frontmatter block at the start of `content`. It does not parse the YAML.
|
|
36
|
+
*
|
|
37
|
+
* The block rule:
|
|
38
|
+
* - The content starts with a line that is exactly `---`. A space, a byte-order mark, a blank line or any other
|
|
39
|
+
* character before it or after it, on that line, means there is no block.
|
|
40
|
+
* - A line ending is LF or CRLF. A lone CR is part of its line.
|
|
41
|
+
* - The block ends at the first later line that is `---` followed only by spaces or tabs. A line such as `---x`,
|
|
42
|
+
* `----` or `--- text` is part of the block.
|
|
43
|
+
* - A block can be empty: `---` directly followed by `---` gives empty text.
|
|
44
|
+
* - A block that is opened and never closed is malformed, and so is a file that is only `---`.
|
|
45
|
+
*
|
|
46
|
+
* The content is cut at its line feeds once and each piece is visited once, so the running time is linear in the
|
|
47
|
+
* length of the content, and the loop is bounded by the number of pieces.
|
|
48
|
+
*/
|
|
49
|
+
export declare function splitFrontmatterBlock(content: string): FrontmatterBlockSplit;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the YAML frontmatter block at the start of `content`. It does not parse the YAML.
|
|
3
|
+
*
|
|
4
|
+
* The block rule:
|
|
5
|
+
* - The content starts with a line that is exactly `---`. A space, a byte-order mark, a blank line or any other
|
|
6
|
+
* character before it or after it, on that line, means there is no block.
|
|
7
|
+
* - A line ending is LF or CRLF. A lone CR is part of its line.
|
|
8
|
+
* - The block ends at the first later line that is `---` followed only by spaces or tabs. A line such as `---x`,
|
|
9
|
+
* `----` or `--- text` is part of the block.
|
|
10
|
+
* - A block can be empty: `---` directly followed by `---` gives empty text.
|
|
11
|
+
* - A block that is opened and never closed is malformed, and so is a file that is only `---`.
|
|
12
|
+
*
|
|
13
|
+
* The content is cut at its line feeds once and each piece is visited once, so the running time is linear in the
|
|
14
|
+
* length of the content, and the loop is bounded by the number of pieces.
|
|
15
|
+
*/
|
|
16
|
+
export function splitFrontmatterBlock(content) {
|
|
17
|
+
const pieces = content.split('\n');
|
|
18
|
+
const lastIndex = pieces.length - 1;
|
|
19
|
+
// `offset` is where the piece being read starts. `openingLength` is the length of the opening line with its line
|
|
20
|
+
// feed, and `textEnd` is where the lines before the piece end, before their line ending.
|
|
21
|
+
let offset = 0;
|
|
22
|
+
let openingLength = 0;
|
|
23
|
+
let textEnd = 0;
|
|
24
|
+
for (const [index, piece] of pieces.entries()) {
|
|
25
|
+
// A piece that ends with a carriage return has a CRLF ending when a line feed followed it, that is, unless it is
|
|
26
|
+
// the last piece.
|
|
27
|
+
const line = index < lastIndex && piece.endsWith('\r') ? piece.slice(0, -1) : piece;
|
|
28
|
+
if (index === 0) {
|
|
29
|
+
if (line !== '---')
|
|
30
|
+
return { kind: 'none' };
|
|
31
|
+
openingLength = piece.length + 1;
|
|
32
|
+
textEnd = openingLength;
|
|
33
|
+
}
|
|
34
|
+
else if (isCloseLine(line)) {
|
|
35
|
+
const lineEnd = offset + piece.length + 1;
|
|
36
|
+
return {
|
|
37
|
+
kind: 'block',
|
|
38
|
+
opening: content.slice(0, openingLength),
|
|
39
|
+
text: content.slice(openingLength, textEnd),
|
|
40
|
+
textEnding: content.slice(textEnd, offset),
|
|
41
|
+
closing: content.slice(offset, lineEnd),
|
|
42
|
+
body: content.slice(lineEnd),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
textEnd = offset + line.length;
|
|
47
|
+
}
|
|
48
|
+
offset += piece.length + 1;
|
|
49
|
+
}
|
|
50
|
+
return { kind: 'malformed' };
|
|
51
|
+
}
|
|
52
|
+
/** Whether `line` is `---` followed only by spaces and tabs. */
|
|
53
|
+
function isCloseLine(line) {
|
|
54
|
+
if (!line.startsWith('---'))
|
|
55
|
+
return false;
|
|
56
|
+
for (let at = 3; at < line.length; at += 1) {
|
|
57
|
+
const character = line.charAt(at);
|
|
58
|
+
if (character !== ' ' && character !== '\t')
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
package/dist/frontmatter.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
export { splitFrontmatterBlock, type FrontmatterBlockSplit } from './frontmatter-block.js';
|
|
1
2
|
export interface FrontmatterDocument {
|
|
2
3
|
readonly values: Record<string, unknown>;
|
|
3
4
|
readonly body: string;
|
|
4
5
|
}
|
|
5
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Parse a validated wiki document while retaining its raw body. A document with no block, or with a block that is
|
|
8
|
+
* never closed, is missing its frontmatter. A block with no lines is an empty mapping; any other block must parse to one.
|
|
9
|
+
*/
|
|
6
10
|
export declare function parseFrontmatterDocument(content: string): FrontmatterDocument;
|
|
7
11
|
/** Reconstruct a wiki document with system-owned frontmatter and normalized body. */
|
|
8
12
|
export declare function renderFrontmatterDocument(values: Record<string, unknown>, body: string): string;
|
package/dist/frontmatter.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { parse, stringify } from 'yaml';
|
|
2
|
-
|
|
2
|
+
import { splitFrontmatterBlock } from "./frontmatter-block.js";
|
|
3
|
+
export { splitFrontmatterBlock } from "./frontmatter-block.js";
|
|
4
|
+
/**
|
|
5
|
+
* Parse a validated wiki document while retaining its raw body. A document with no block, or with a block that is
|
|
6
|
+
* never closed, is missing its frontmatter. A block with no lines is an empty mapping; any other block must parse to one.
|
|
7
|
+
*/
|
|
3
8
|
export function parseFrontmatterDocument(content) {
|
|
4
|
-
const
|
|
5
|
-
if (
|
|
9
|
+
const split = splitFrontmatterBlock(content);
|
|
10
|
+
if (split.kind !== 'block') {
|
|
6
11
|
throw new Error('validated wiki page is missing frontmatter');
|
|
7
12
|
}
|
|
8
|
-
|
|
13
|
+
// Only a block with no lines is an empty mapping. A block of one blank line parses to null, like any blank text.
|
|
14
|
+
const parsed = split.text === '' && split.textEnding === '' ? {} : parse(split.text);
|
|
9
15
|
if (!isRecord(parsed))
|
|
10
16
|
throw new Error('validated wiki page frontmatter is not an object');
|
|
11
|
-
return { values: parsed, body:
|
|
17
|
+
return { values: parsed, body: split.body };
|
|
12
18
|
}
|
|
13
19
|
/** Reconstruct a wiki document with system-owned frontmatter and normalized body. */
|
|
14
20
|
export function renderFrontmatterDocument(values, body) {
|
package/dist/gate-contract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Bump deliberately when an intentional consumer-visible gate contract break ships.
|
|
2
2
|
export const GATE_CONTRACT_VERSION = 1;
|
|
3
3
|
// Replaced with the deterministic source-tree hash by the package build.
|
|
4
|
-
export const GATE_SOURCE_TREE_HASH = '
|
|
4
|
+
export const GATE_SOURCE_TREE_HASH = '637b237dc25880625d73dde771514ff4ff64fd845cb6dd1a07c61184570de419';
|
package/dist/gate-contract.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":1,"sourceTreeHash":"
|
|
1
|
+
{"version":1,"sourceTreeHash":"637b237dc25880625d73dde771514ff4ff64fd845cb6dd1a07c61184570de419"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline-link scanner behind `resolveMarkdownLinks`. It is an internal module: the package does not
|
|
3
|
+
* export it, so the only way to reach it is through `markdown-links`.
|
|
4
|
+
*/
|
|
5
|
+
export interface InlineLink {
|
|
6
|
+
/** Offset of the opening `[` in the line. */
|
|
7
|
+
readonly index: number;
|
|
8
|
+
/** Length of the whole `[text](destination "title")` span. */
|
|
9
|
+
readonly length: number;
|
|
10
|
+
/** The destination as written: `<angle>` with its brackets, or the bare run. */
|
|
11
|
+
readonly target: string;
|
|
12
|
+
}
|
|
13
|
+
/** The characters `\s` matches: ASCII spaces and controls, then the Unicode space separators. */
|
|
14
|
+
export declare function isWhitespaceCode(code: number): boolean;
|
|
15
|
+
/** For every offset `i` up to the line length plus one: the first offset at or after `i` whose character stops a run, or the line length. */
|
|
16
|
+
export declare function buildStopTable(line: string, stops: (code: number) => boolean): Int32Array;
|
|
17
|
+
/**
|
|
18
|
+
* Builds a function that finds the inline links of one line, `[text](destination "title")`, left to right.
|
|
19
|
+
* `find(from)` returns the leftmost link that starts at or after `from`, or `undefined`.
|
|
20
|
+
*
|
|
21
|
+
* The grammar, over the characters of the line:
|
|
22
|
+
* - `[`, a non-empty text with no `]` or newline, then `](`;
|
|
23
|
+
* - optional whitespace, then the destination: `<` with a non-empty run of no `>` or newline and `>`, or else
|
|
24
|
+
* a non-empty run of no `)` or whitespace; the angle form is tried first and the bare run is the fallback
|
|
25
|
+
* when the angle form is not followed by a valid ending;
|
|
26
|
+
* - an optional title, set off by whitespace, in double quotes, in single quotes or in parentheses, each with
|
|
27
|
+
* no newline inside and no closing delimiter before its end;
|
|
28
|
+
* - optional whitespace, then `)`. A title that is present but does not lead to `)` is dropped, and the
|
|
29
|
+
* destination alone must then be followed by whitespace and `)`.
|
|
30
|
+
*
|
|
31
|
+
* Every run in the grammar ends at the first character that cannot continue it, so a run is read once from
|
|
32
|
+
* a table of stop offsets built right to left, and every start offset is decided in constant time. The
|
|
33
|
+
* tables are built on first use and only for a line that holds `](`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createInlineLinkFinder(line: string): (from: number) => InlineLink | undefined;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline-link scanner behind `resolveMarkdownLinks`. It is an internal module: the package does not
|
|
3
|
+
* export it, so the only way to reach it is through `markdown-links`.
|
|
4
|
+
*/
|
|
5
|
+
/** The character codes the grammar tests for. */
|
|
6
|
+
const CLOSE_BRACKET = 0x5d;
|
|
7
|
+
const OPEN_PAREN = 0x28;
|
|
8
|
+
const CLOSE_PAREN = 0x29;
|
|
9
|
+
const LESS_THAN = 0x3c;
|
|
10
|
+
const GREATER_THAN = 0x3e;
|
|
11
|
+
const DOUBLE_QUOTE = 0x22;
|
|
12
|
+
const SINGLE_QUOTE = 0x27;
|
|
13
|
+
const NEWLINE = 0x0a;
|
|
14
|
+
/** The characters `\s` matches: ASCII spaces and controls, then the Unicode space separators. */
|
|
15
|
+
export function isWhitespaceCode(code) {
|
|
16
|
+
if (code <= 0x20)
|
|
17
|
+
return code === 0x20 || (code >= 0x09 && code <= 0x0d);
|
|
18
|
+
if (code < 0xa0)
|
|
19
|
+
return false;
|
|
20
|
+
return (code === 0xa0 ||
|
|
21
|
+
code === 0x1680 ||
|
|
22
|
+
(code >= 0x2000 && code <= 0x200a) ||
|
|
23
|
+
code === 0x2028 ||
|
|
24
|
+
code === 0x2029 ||
|
|
25
|
+
code === 0x202f ||
|
|
26
|
+
code === 0x205f ||
|
|
27
|
+
code === 0x3000 ||
|
|
28
|
+
code === 0xfeff);
|
|
29
|
+
}
|
|
30
|
+
/** For every offset `i` up to the line length plus one: the first offset at or after `i` whose character stops a run, or the line length. */
|
|
31
|
+
export function buildStopTable(line, stops) {
|
|
32
|
+
const table = new Int32Array(line.length + 2).fill(line.length);
|
|
33
|
+
for (let index = line.length - 1; index >= 0; index -= 1) {
|
|
34
|
+
table[index] = stops(line.charCodeAt(index)) ? index : (table[index + 1] ?? line.length);
|
|
35
|
+
}
|
|
36
|
+
return table;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Builds a function that finds the inline links of one line, `[text](destination "title")`, left to right.
|
|
40
|
+
* `find(from)` returns the leftmost link that starts at or after `from`, or `undefined`.
|
|
41
|
+
*
|
|
42
|
+
* The grammar, over the characters of the line:
|
|
43
|
+
* - `[`, a non-empty text with no `]` or newline, then `](`;
|
|
44
|
+
* - optional whitespace, then the destination: `<` with a non-empty run of no `>` or newline and `>`, or else
|
|
45
|
+
* a non-empty run of no `)` or whitespace; the angle form is tried first and the bare run is the fallback
|
|
46
|
+
* when the angle form is not followed by a valid ending;
|
|
47
|
+
* - an optional title, set off by whitespace, in double quotes, in single quotes or in parentheses, each with
|
|
48
|
+
* no newline inside and no closing delimiter before its end;
|
|
49
|
+
* - optional whitespace, then `)`. A title that is present but does not lead to `)` is dropped, and the
|
|
50
|
+
* destination alone must then be followed by whitespace and `)`.
|
|
51
|
+
*
|
|
52
|
+
* Every run in the grammar ends at the first character that cannot continue it, so a run is read once from
|
|
53
|
+
* a table of stop offsets built right to left, and every start offset is decided in constant time. The
|
|
54
|
+
* tables are built on first use and only for a line that holds `](`.
|
|
55
|
+
*/
|
|
56
|
+
export function createInlineLinkFinder(line) {
|
|
57
|
+
if (!line.includes(']('))
|
|
58
|
+
return () => undefined;
|
|
59
|
+
const tables = new Map();
|
|
60
|
+
const stopTable = (name, stops) => {
|
|
61
|
+
let table = tables.get(name);
|
|
62
|
+
if (table === undefined) {
|
|
63
|
+
table = buildStopTable(line, stops);
|
|
64
|
+
tables.set(name, table);
|
|
65
|
+
}
|
|
66
|
+
return table;
|
|
67
|
+
};
|
|
68
|
+
const stopAt = (name, from, stops) => stopTable(name, stops)[from] ?? line.length;
|
|
69
|
+
/** Offset of the `)` that ends a link whose destination ends at `destinationEnd`, plus one; -1 when there is none. */
|
|
70
|
+
const endOfLink = (destinationEnd) => {
|
|
71
|
+
const afterSpace = stopAt('nonSpace', destinationEnd, code => !isWhitespaceCode(code));
|
|
72
|
+
if (afterSpace > destinationEnd) {
|
|
73
|
+
const opener = line.charCodeAt(afterSpace);
|
|
74
|
+
const closer = opener === OPEN_PAREN ? CLOSE_PAREN : opener;
|
|
75
|
+
if (opener === DOUBLE_QUOTE || opener === SINGLE_QUOTE || opener === OPEN_PAREN) {
|
|
76
|
+
const stop = stopAt(`title${closer}`, afterSpace + 1, code => code === closer || code === NEWLINE);
|
|
77
|
+
if (line.charCodeAt(stop) === closer) {
|
|
78
|
+
const closeAt = stopAt('nonSpace', stop + 1, code => !isWhitespaceCode(code));
|
|
79
|
+
if (line.charCodeAt(closeAt) === CLOSE_PAREN)
|
|
80
|
+
return closeAt + 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return line.charCodeAt(afterSpace) === CLOSE_PAREN ? afterSpace + 1 : -1;
|
|
85
|
+
};
|
|
86
|
+
const linkAt = (start) => {
|
|
87
|
+
const textEnd = stopAt('text', start + 1, code => code === CLOSE_BRACKET || code === NEWLINE);
|
|
88
|
+
if (textEnd === start + 1 ||
|
|
89
|
+
line.charCodeAt(textEnd) !== CLOSE_BRACKET ||
|
|
90
|
+
line.charCodeAt(textEnd + 1) !== OPEN_PAREN) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const destinationStart = stopAt('nonSpace', textEnd + 2, code => !isWhitespaceCode(code));
|
|
94
|
+
if (line.charCodeAt(destinationStart) === LESS_THAN) {
|
|
95
|
+
const angleClose = stopAt('angle', destinationStart + 1, code => code === GREATER_THAN || code === NEWLINE);
|
|
96
|
+
if (angleClose > destinationStart + 1 && line.charCodeAt(angleClose) === GREATER_THAN) {
|
|
97
|
+
const end = endOfLink(angleClose + 1);
|
|
98
|
+
if (end !== -1) {
|
|
99
|
+
return { index: start, length: end - start, target: line.slice(destinationStart, angleClose + 1) };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const bareEnd = stopAt('bare', destinationStart, code => code === CLOSE_PAREN || isWhitespaceCode(code));
|
|
104
|
+
if (bareEnd > destinationStart) {
|
|
105
|
+
const end = endOfLink(bareEnd);
|
|
106
|
+
if (end !== -1) {
|
|
107
|
+
return { index: start, length: end - start, target: line.slice(destinationStart, bareEnd) };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
};
|
|
112
|
+
return from => {
|
|
113
|
+
for (let start = line.indexOf('[', from); start !== -1; start = line.indexOf('[', start + 1)) {
|
|
114
|
+
const link = linkAt(start);
|
|
115
|
+
if (link !== undefined)
|
|
116
|
+
return link;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
};
|
|
120
|
+
}
|