@supersuit/hyperspec 0.7.0 → 0.8.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/CHANGELOG.md +36 -0
- package/README.md +34 -8
- package/SPEC.md +1 -1
- package/WRITING.md +140 -13
- package/bin/hyperspec.mjs +5 -4
- package/examples/writing/course/claims.jsonl +0 -0
- package/examples/writing/course/goldens/lesson.md +1 -0
- package/examples/writing/course/materials/brief.md +9 -0
- package/examples/writing/course/materials/brief.md.segments.jsonl +6 -0
- package/examples/writing/course/outline.md +11 -0
- package/examples/writing/course/part-1.md +47 -0
- package/examples/writing/course/part-2.md +40 -0
- package/examples/writing/course/runs.jsonl +0 -0
- package/examples/writing/course.hyperspec.md +205 -0
- package/package.json +1 -1
- package/src/check.mjs +25 -7
- package/src/sequence-draft.mjs +75 -0
- package/src/stations/index.mjs +3 -1
- package/src/stations/links.mjs +11 -3
- package/src/stations/sequence.mjs +275 -0
- package/src/writing-fields.mjs +34 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
---
|
|
2
|
+
hyperspec: "0.1"
|
|
3
|
+
title: "Bread from zero: a four-lesson course"
|
|
4
|
+
kind: course
|
|
5
|
+
profile: writing
|
|
6
|
+
decisions:
|
|
7
|
+
- id: kind
|
|
8
|
+
state: decided
|
|
9
|
+
value: a course of four lessons in two parts, for someone who has never baked a loaf
|
|
10
|
+
source: course/materials/brief.md
|
|
11
|
+
author: example-author
|
|
12
|
+
chosen_by: human
|
|
13
|
+
- id: lesson-shape
|
|
14
|
+
state: decided
|
|
15
|
+
value: every lesson opens with what the reader can do after it and the terms it defines, and ends with one thing to do in a kitchen
|
|
16
|
+
source: course/materials/brief.md, the paragraph on ending each lesson
|
|
17
|
+
author: example-author
|
|
18
|
+
chosen_by: human
|
|
19
|
+
- id: part-files
|
|
20
|
+
state: delegated
|
|
21
|
+
rule: one file per part, named part-<n>.md, so a new part is picked up by the files pattern without editing the spec
|
|
22
|
+
source: course layout
|
|
23
|
+
author: agent:claude
|
|
24
|
+
chosen_by: agent
|
|
25
|
+
requirements:
|
|
26
|
+
- id: r1
|
|
27
|
+
text: no lesson uses a term before the lesson that defines it
|
|
28
|
+
fails_when: the sequence station reports a term used before it is defined
|
|
29
|
+
check:
|
|
30
|
+
station: sequence station, order guard
|
|
31
|
+
source: course/materials/brief.md
|
|
32
|
+
author: example-author
|
|
33
|
+
- id: r2
|
|
34
|
+
text: every term is defined in exactly one lesson
|
|
35
|
+
fails_when: the sequence station reports a term defined twice
|
|
36
|
+
check:
|
|
37
|
+
station: sequence station, defined-once guard
|
|
38
|
+
source: lesson-shape decision
|
|
39
|
+
author: example-author
|
|
40
|
+
- id: r3
|
|
41
|
+
text: every lesson carries its three sections
|
|
42
|
+
fails_when: a lesson has no "After this lesson you can", "New terms" or "Try this" section
|
|
43
|
+
check:
|
|
44
|
+
station: sequence station, sections guard
|
|
45
|
+
source: lesson-shape decision
|
|
46
|
+
author: example-author
|
|
47
|
+
- id: r4
|
|
48
|
+
text: each lesson defines the terms the outline promises for it
|
|
49
|
+
fails_when: the outline promises a term in a lesson that does not define it
|
|
50
|
+
check:
|
|
51
|
+
station: sequence station, outline guard
|
|
52
|
+
source: course/outline.md
|
|
53
|
+
author: example-author
|
|
54
|
+
- id: r5
|
|
55
|
+
text: every Try this can be done in a home kitchen in one day, apart from the starter, which takes a week
|
|
56
|
+
fails_when: a Try this needs equipment beyond a bowl, a scale, a jar, an oven and a heavy pot
|
|
57
|
+
check:
|
|
58
|
+
rubric: list what each Try this needs; fail on anything outside that list
|
|
59
|
+
source: course/materials/brief.md
|
|
60
|
+
author: example-author
|
|
61
|
+
rejects:
|
|
62
|
+
- a recipe before the reader has the words to follow it
|
|
63
|
+
- a term used before the lesson that defines it
|
|
64
|
+
examples:
|
|
65
|
+
- path: course/goldens/lesson.md
|
|
66
|
+
why: an instruction, then the reader's likely worry named plainly, then what to do next
|
|
67
|
+
resume:
|
|
68
|
+
next_action: bake the course's two loaves from Lesson 3 and photograph their crumb for Lesson 4
|
|
69
|
+
feedback:
|
|
70
|
+
issues: https://github.com/SupersuitUp/hyperspec/issues
|
|
71
|
+
fork: MIT; fork it for your own purposes
|
|
72
|
+
improvement:
|
|
73
|
+
ledger: course/runs.jsonl
|
|
74
|
+
writing:
|
|
75
|
+
materials:
|
|
76
|
+
items:
|
|
77
|
+
- id: brief
|
|
78
|
+
path: course/materials/brief.md
|
|
79
|
+
segments: course/materials/brief.md.segments.jsonl
|
|
80
|
+
produced_by: example-author
|
|
81
|
+
captured: "2026-09-20"
|
|
82
|
+
how: written after teaching the class twice
|
|
83
|
+
trust: considered
|
|
84
|
+
check:
|
|
85
|
+
station: every segment of every material carries a label from the closed set, matches its source verbatim, and the markings are current
|
|
86
|
+
source: capture step
|
|
87
|
+
author: agent:claude
|
|
88
|
+
dna:
|
|
89
|
+
writer: example-author
|
|
90
|
+
scope:
|
|
91
|
+
form: course
|
|
92
|
+
audience: first-time bakers
|
|
93
|
+
purpose: teach
|
|
94
|
+
rules: style-rules.md
|
|
95
|
+
goldens:
|
|
96
|
+
- path: course/goldens/lesson.md
|
|
97
|
+
why: an instruction, then the reader's likely worry named plainly, then what to do next
|
|
98
|
+
check:
|
|
99
|
+
rubric: blind lineup within this scope
|
|
100
|
+
source: goldens marked on the review page
|
|
101
|
+
author: example-author
|
|
102
|
+
persona:
|
|
103
|
+
identity: self
|
|
104
|
+
stance: guide
|
|
105
|
+
may_assert:
|
|
106
|
+
- what the author saw go wrong when teaching the class
|
|
107
|
+
will_not_say:
|
|
108
|
+
- a bake time or temperature for an oven the author has not used
|
|
109
|
+
facts_from: sources
|
|
110
|
+
check:
|
|
111
|
+
rubric: persona-consistency judge
|
|
112
|
+
source: persona interview
|
|
113
|
+
author: example-author
|
|
114
|
+
audience:
|
|
115
|
+
who: someone who has never baked a loaf of bread
|
|
116
|
+
funnel_now: has bought flour and has never used it for bread
|
|
117
|
+
knows:
|
|
118
|
+
- flour
|
|
119
|
+
- oven
|
|
120
|
+
believes_now: bread takes a recipe and a lot of skill
|
|
121
|
+
wants: to bake one good loaf
|
|
122
|
+
reads_on: a tablet propped up in the kitchen
|
|
123
|
+
reader: person
|
|
124
|
+
check:
|
|
125
|
+
station: the sequence station defines every term before it is used
|
|
126
|
+
rubric: simulated reader reports where it got lost
|
|
127
|
+
source: audience interview
|
|
128
|
+
author: example-author
|
|
129
|
+
goal:
|
|
130
|
+
from: has never baked bread
|
|
131
|
+
to: bakes a loaf and reads its crumb
|
|
132
|
+
next_if_worked: starts a starter the day they finish Lesson 2
|
|
133
|
+
change:
|
|
134
|
+
kind: action
|
|
135
|
+
text: the reader bakes their first loaf
|
|
136
|
+
conditions: [r1, r2, r3, r4, r5]
|
|
137
|
+
check:
|
|
138
|
+
rubric: the doctor grades the course against every condition
|
|
139
|
+
source: goal interview
|
|
140
|
+
author: example-author
|
|
141
|
+
form:
|
|
142
|
+
name: course
|
|
143
|
+
length:
|
|
144
|
+
min: 400
|
|
145
|
+
max: 1500
|
|
146
|
+
unit: words
|
|
147
|
+
required_parts:
|
|
148
|
+
- "Part 1: Dough"
|
|
149
|
+
- "Part 2: The bake"
|
|
150
|
+
stations:
|
|
151
|
+
- the sequence station
|
|
152
|
+
sequence:
|
|
153
|
+
unit: Lesson
|
|
154
|
+
files:
|
|
155
|
+
- course/part-*.md
|
|
156
|
+
sections:
|
|
157
|
+
- After this lesson you can
|
|
158
|
+
- New terms
|
|
159
|
+
- Try this
|
|
160
|
+
terms_section: New terms
|
|
161
|
+
outline: course/outline.md
|
|
162
|
+
teaser: Next,
|
|
163
|
+
check:
|
|
164
|
+
station: structure and length, then the sequence station
|
|
165
|
+
source: form decision
|
|
166
|
+
author: example-author
|
|
167
|
+
spine:
|
|
168
|
+
kind: primer
|
|
169
|
+
claims:
|
|
170
|
+
- id: c1
|
|
171
|
+
text: a first-time baker needs four words before any recipe
|
|
172
|
+
materials: [brief#s2]
|
|
173
|
+
- id: c2
|
|
174
|
+
text: the order is water and flour, then the rise, then shaping, then the oven
|
|
175
|
+
materials: [brief#s3]
|
|
176
|
+
- id: c3
|
|
177
|
+
text: each lesson ends with something to do in a real kitchen
|
|
178
|
+
materials: [brief#s4, brief#s5]
|
|
179
|
+
check:
|
|
180
|
+
rubric: each claim lands, in order
|
|
181
|
+
source: spine interview
|
|
182
|
+
author: example-author
|
|
183
|
+
sources:
|
|
184
|
+
ledger: course/claims.jsonl
|
|
185
|
+
unsourced_claim: fail
|
|
186
|
+
check:
|
|
187
|
+
station: every factual claim in the ledger points at a source span
|
|
188
|
+
source: sourcing pass
|
|
189
|
+
author: agent:claude
|
|
190
|
+
fiction: false
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
# Bread from zero
|
|
194
|
+
|
|
195
|
+
A worked example of a sequential work: a course of four lessons in two parts, one file per part.
|
|
196
|
+
The spec lists the parts as `course/part-*.md`, so `check` needs no `--draft`: the parts, joined
|
|
197
|
+
in order, are the draft.
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
npx @supersuit/hyperspec check course.hyperspec.md
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The `sequence` station holds the course to what a reader of Lesson 3 depends on: Lessons 1 and 2
|
|
204
|
+
defined every word it uses. The outline in `course/outline.md` promises the terms each lesson
|
|
205
|
+
defines, and the station checks the lessons keep that promise.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/hyperspec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "A hyperspec is a spec written for an agent: every decision accounted for, every requirement failable and checked, every field traced. The standard and its linter.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/check.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { readDraft } from "./draft.mjs";
|
|
|
21
21
|
import { openLedger, priorLines, ledgerDraftKey, ledgerVerdict } from "./ledger.mjs";
|
|
22
22
|
import { STATIONS, STATION_NAMES } from "./stations/index.mjs";
|
|
23
23
|
import { str } from "./placeholder.mjs";
|
|
24
|
+
import { readSequenceDraft, sequenceFilesDecl, sourceAt } from "./sequence-draft.mjs";
|
|
24
25
|
|
|
25
26
|
const present = (v) => typeof v === "string" && v.trim().length > 0;
|
|
26
27
|
|
|
@@ -95,17 +96,29 @@ export function lintBlock(spec, specPathArg) {
|
|
|
95
96
|
};
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// A finding from a draft assembled out of a sequence's files names the file and its own line in
|
|
100
|
+
// it, rather than a line of the joined text nobody can open.
|
|
101
|
+
function locate(finding, draft) {
|
|
102
|
+
const src = typeof finding.line === "number" ? sourceAt(draft, finding.line) : null;
|
|
103
|
+
return src ? { ...finding, file: src.file, line: finding.line - src.startLine + 1 } : finding;
|
|
104
|
+
}
|
|
105
|
+
|
|
98
106
|
// runCheck(specPathArg, draftPathArg, { only }): specPathArg and draftPathArg are exactly what
|
|
99
107
|
// the CLI (or a caller) was given, never resolved, so every path this returns or writes to the
|
|
100
108
|
// ledger is displayed and recorded the way the operator typed it, not as an absolute path on this
|
|
101
109
|
// machine. only, when given, is an array of station names to run instead of every registered one.
|
|
110
|
+
//
|
|
111
|
+
// With no draftPathArg, a spec that lists writing.form.sequence.files is checked against those
|
|
112
|
+
// files, joined in reading order (src/sequence-draft.mjs); any other spec still needs --draft.
|
|
102
113
|
export function runCheck(specPathArg, draftPathArg, { only } = {}) {
|
|
103
114
|
if (!present(specPathArg)) return { usage: true, error: "check needs a spec path" };
|
|
104
|
-
|
|
115
|
+
const needsDraft = { usage: true, error: "check needs --draft <file>" };
|
|
105
116
|
|
|
106
117
|
const loaded = loadWritingSpec(specPathArg, "check");
|
|
118
|
+
if (!present(draftPathArg) && (loaded.usage || !sequenceFilesDecl(loaded.spec).length)) return needsDraft;
|
|
107
119
|
if (loaded.usage) return loaded;
|
|
108
120
|
const { spec } = loaded;
|
|
121
|
+
const fromSequence = !present(draftPathArg);
|
|
109
122
|
|
|
110
123
|
// --only: every name must be one this build's registry knows; unknown names are a usage error
|
|
111
124
|
// (exit 2) rather than a silent no-op, and the run order always follows the registry, never the
|
|
@@ -123,12 +136,16 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
|
|
|
123
136
|
const blocked = lintBlock(spec, specPathArg);
|
|
124
137
|
if (blocked) return blocked;
|
|
125
138
|
|
|
126
|
-
// BOM stripped, CRLF-clean lines, sha256 over the raw bytes: see src/draft.mjs.
|
|
127
|
-
|
|
128
|
-
|
|
139
|
+
// BOM stripped, CRLF-clean lines, sha256 over the raw bytes: see src/draft.mjs. A sequence's
|
|
140
|
+
// draft is its files joined; its ledger key is the files entry as the spec writes it, so the
|
|
141
|
+
// history of the work stays one history as parts are added.
|
|
142
|
+
const draft = fromSequence ? readSequenceDraft(spec, specPathArg) : readDraft(draftPathArg);
|
|
143
|
+
const draftLabel = fromSequence ? sequenceFilesDecl(spec).join(", ") : draftPathArg;
|
|
144
|
+
if (!draft) return { usage: true, error: fromSequence ? `writing.form.sequence.files matches no file: ${draftLabel}` : `cannot read draft: ${draftPathArg}` };
|
|
129
145
|
|
|
130
146
|
const ctx = {};
|
|
131
|
-
const results = stationsToRun.map((s) => runStation(s, spec, draft, ctx))
|
|
147
|
+
const results = stationsToRun.map((s) => runStation(s, spec, draft, ctx))
|
|
148
|
+
.map((r) => (draft.sources ? { ...r, findings: r.findings.map((f) => locate(f, draft)) } : r));
|
|
132
149
|
const failing = results.filter((r) => r.status === "fail").map((r) => r.station);
|
|
133
150
|
const code = failing.length ? 1 : 0;
|
|
134
151
|
|
|
@@ -146,7 +163,7 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
|
|
|
146
163
|
const ledger = openLedger(spec);
|
|
147
164
|
if (ledger?.warning) ledgerWarning = ledger.warning;
|
|
148
165
|
else if (ledger) {
|
|
149
|
-
const draftKey = ledgerDraftKey(spec.dir, draftPathArg);
|
|
166
|
+
const draftKey = fromSequence ? draftLabel : ledgerDraftKey(spec.dir, draftPathArg);
|
|
150
167
|
const specSha = sha256(readFileSync(resolve(specPathArg)));
|
|
151
168
|
const statusNow = Object.fromEntries(results.map((r) => [r.station, r.status]));
|
|
152
169
|
|
|
@@ -177,7 +194,8 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
|
|
|
177
194
|
return {
|
|
178
195
|
ok: true,
|
|
179
196
|
specPath: specPathArg,
|
|
180
|
-
draftPath:
|
|
197
|
+
draftPath: draftLabel,
|
|
198
|
+
...(fromSequence ? { files: draft.sources.map((x) => x.file) } : {}),
|
|
181
199
|
draftSha256: draft.sha256,
|
|
182
200
|
stations: results,
|
|
183
201
|
failing,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// A work read in order often lives in several files (one per part, one per lesson). When a writing
|
|
2
|
+
// spec lists them as writing.form.sequence.files, `hyperspec check <spec>` needs no --draft: the
|
|
3
|
+
// draft is those files, joined in order. This module owns which files that is and how they join, so
|
|
4
|
+
// lint (every entry must match a file) and check (the draft) agree on one reading.
|
|
5
|
+
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { dirname, join, posix, resolve } from "node:path";
|
|
8
|
+
import { sha256 } from "./hash.mjs";
|
|
9
|
+
import { str } from "./placeholder.mjs";
|
|
10
|
+
import { splitLines } from "./draft.mjs";
|
|
11
|
+
|
|
12
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
13
|
+
const byNumber = (a, b) => a.localeCompare(b, "en", { numeric: true });
|
|
14
|
+
|
|
15
|
+
// The declared entries, as written: writing.form.sequence.files, strings only.
|
|
16
|
+
export function sequenceFilesDecl(spec) {
|
|
17
|
+
return list(spec?.data?.writing?.form?.sequence?.files).map(str).filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The files one entry names, relative to the spec's folder and written with "/": the entry itself
|
|
21
|
+
// when it has no "*", or every file in its folder whose name matches, sorted so part-2 comes before
|
|
22
|
+
// part-10. A "*" matches within a file name only; the folder part is taken literally. [] when
|
|
23
|
+
// nothing matches.
|
|
24
|
+
export function expandEntry(specDir, entry) {
|
|
25
|
+
const clean = entry.replace(/\\/g, "/");
|
|
26
|
+
const dir = posix.dirname(clean);
|
|
27
|
+
const base = posix.basename(clean);
|
|
28
|
+
const isFile = (rel) => { try { return statSync(resolve(specDir, rel)).isFile(); } catch { return false; } };
|
|
29
|
+
if (!base.includes("*")) return isFile(clean) ? [clean] : [];
|
|
30
|
+
const re = new RegExp(`^${base.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`);
|
|
31
|
+
let names = [];
|
|
32
|
+
try { names = readdirSync(resolve(specDir, dir)); } catch { return []; }
|
|
33
|
+
return names.filter((n) => re.test(n)).sort(byNumber).map((n) => (dir === "." ? n : `${dir}/${n}`)).filter(isFile);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Every file of the sequence in reading order, each once (at its first position).
|
|
37
|
+
export function sequenceFiles(specDir, entries) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const e of entries) for (const f of expandEntry(specDir, e)) if (!out.includes(f)) out.push(f);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The draft `check` grades when the spec lists sequence files and no --draft is given:
|
|
44
|
+
// { path, text, lines, sha256, sources }, the same shape src/draft.mjs's readDraft returns plus
|
|
45
|
+
// sources, one per file: { file, at, startLine, lineCount }. file is the path relative to the spec
|
|
46
|
+
// (what a finding names); at resolves from the working directory (what a station opens, such as
|
|
47
|
+
// links resolving a relative link beside the file that holds it). Each file's YAML frontmatter is
|
|
48
|
+
// blanked line for line, so its metadata is not prose and its line numbers stay its own. sha256
|
|
49
|
+
// covers every file's name and bytes. path is the first file's. null when no file matches.
|
|
50
|
+
export function readSequenceDraft(spec, specPathArg) {
|
|
51
|
+
const files = sequenceFiles(spec.dir, sequenceFilesDecl(spec));
|
|
52
|
+
if (!files.length) return null;
|
|
53
|
+
const parts = [];
|
|
54
|
+
const hashed = [];
|
|
55
|
+
const sources = [];
|
|
56
|
+
let startLine = 1;
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const buf = readFileSync(resolve(spec.dir, file));
|
|
59
|
+
hashed.push(Buffer.from(`${file}\n`), buf);
|
|
60
|
+
let text = buf.toString("utf8").replace(/^\uFEFF/, "");
|
|
61
|
+
text = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, (fm) => fm.replace(/[^\n]/g, ""));
|
|
62
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
63
|
+
const lineCount = text.split("\n").length - 1;
|
|
64
|
+
sources.push({ file, at: join(dirname(specPathArg), file), startLine, lineCount });
|
|
65
|
+
parts.push(text);
|
|
66
|
+
startLine += lineCount;
|
|
67
|
+
}
|
|
68
|
+
const text = parts.join("");
|
|
69
|
+
return { path: sources[0].at, text, lines: splitLines(text), sha256: sha256(Buffer.concat(hashed)), sources };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The source holding 1-based draft line `line`, or null.
|
|
73
|
+
export function sourceAt(draft, line) {
|
|
74
|
+
return list(draft?.sources).find((s) => line >= s.startLine && line < s.startLine + s.lineCount) ?? null;
|
|
75
|
+
}
|
package/src/stations/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/check.mjs calls; adding a station is adding one file plus one line here, which is the whole
|
|
4
4
|
// point of the registry existing rather than check.mjs importing each station by name itself.
|
|
5
5
|
//
|
|
6
|
-
// The order: form, terms, claims, quotes, private, dna, links. quotes and private share ctx (util.mjs's
|
|
6
|
+
// The order: form, terms, claims, quotes, private, dna, links, sequence. quotes and private share ctx (util.mjs's
|
|
7
7
|
// markedSegments caches the spec's marked materials there), so a check run reads them once.
|
|
8
8
|
|
|
9
9
|
import * as form from "./form.mjs";
|
|
@@ -13,6 +13,7 @@ import * as quotes from "./quotes.mjs";
|
|
|
13
13
|
import * as privateStation from "./private.mjs";
|
|
14
14
|
import * as dna from "./dna.mjs";
|
|
15
15
|
import * as links from "./links.mjs";
|
|
16
|
+
import * as sequence from "./sequence.mjs";
|
|
16
17
|
|
|
17
18
|
export const STATIONS = Object.freeze([
|
|
18
19
|
{ name: form.name, run: form.run },
|
|
@@ -22,6 +23,7 @@ export const STATIONS = Object.freeze([
|
|
|
22
23
|
{ name: privateStation.name, run: privateStation.run },
|
|
23
24
|
{ name: dna.name, run: dna.run },
|
|
24
25
|
{ name: links.name, run: links.run },
|
|
26
|
+
{ name: sequence.name, run: sequence.run },
|
|
25
27
|
]);
|
|
26
28
|
|
|
27
29
|
export const STATION_NAMES = Object.freeze(STATIONS.map((s) => s.name));
|
package/src/stations/links.mjs
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { existsSync } from "node:fs";
|
|
31
31
|
import { dirname, resolve } from "node:path";
|
|
32
32
|
import { lineAt, maskCode, maskRanges, truncate } from "./util.mjs";
|
|
33
|
+
import { sourceAt } from "../sequence-draft.mjs";
|
|
33
34
|
|
|
34
35
|
export const name = "links";
|
|
35
36
|
|
|
@@ -226,6 +227,12 @@ function undefinedReferenceFinding(label, line) {
|
|
|
226
227
|
|
|
227
228
|
export function run(spec, draft) {
|
|
228
229
|
const draftDirAbs = dirname(resolve(draft.path));
|
|
230
|
+
// A draft assembled from a sequence's files (src/sequence-draft.mjs) resolves each relative link
|
|
231
|
+
// beside the file that holds it.
|
|
232
|
+
const dirAt = (line) => {
|
|
233
|
+
const src = sourceAt(draft, line);
|
|
234
|
+
return src ? dirname(resolve(src.at)) : draftDirAbs;
|
|
235
|
+
};
|
|
229
236
|
|
|
230
237
|
// Masking pipeline: code first, then each link form in turn, each pass working on the text the
|
|
231
238
|
// previous pass left behind, so nothing is ever matched twice by a later, looser pattern (a
|
|
@@ -253,8 +260,9 @@ export function run(spec, draft) {
|
|
|
253
260
|
const entries = [];
|
|
254
261
|
|
|
255
262
|
for (const { start, url } of [...mdLinks, ...bare]) {
|
|
256
|
-
const
|
|
257
|
-
|
|
263
|
+
const line = lineAt(draft.text, start);
|
|
264
|
+
const reason = checkUrl(url, dirAt(line));
|
|
265
|
+
if (reason) entries.push({ start, finding: reasonFinding(reason, url, line) });
|
|
258
266
|
}
|
|
259
267
|
|
|
260
268
|
for (const { start, label } of [...fullRefs, ...shortcutRefs]) {
|
|
@@ -264,7 +272,7 @@ export function run(spec, draft) {
|
|
|
264
272
|
entries.push({ start, finding: undefinedReferenceFinding(label, line) });
|
|
265
273
|
continue;
|
|
266
274
|
}
|
|
267
|
-
const reason = checkUrl(def.url,
|
|
275
|
+
const reason = checkUrl(def.url, dirAt(line));
|
|
268
276
|
if (reason) entries.push({ start, finding: reasonFinding(reason, def.url, line) });
|
|
269
277
|
}
|
|
270
278
|
|