Compare commits

...
9 changed files with 216 additions and 27 deletions
+96
View File
@@ -0,0 +1,96 @@
# Row 231 build notes, C2-01b survey escapes, list item lines and attachment embeds (#1733)
Brief: docs/plans/2026-10-20_case2-rows.md "C2-01b survey escapes, list item lines and attachment embeds",
blob 16847e94ae17. These are Webby's row 212 notes N1 to N3 (webby-0041), split out by sage-1517.
## Base
First built as 777f7358a on 212's first round bdf18272a. 212 landed as ee7b2f137 (sage-1886), so the
commit was cherry-picked onto refactor fefd54e13 under the new branch gosalyn/c2-01b-r2 as 3647a5a93.
`git patch-id --stable` is 45911915a9ee before and after. packages/library is the same in bdf18272a
and in 212's landed round a2c72ac95, so nothing the commit touches moved under it.
## What changed
- N1, `packages/library/src/survey.mjs`: JSON strings escape C0 controls but leave DEL, C1 and bidi
controls raw. The new `quote()` escapes those too, and folder names print through it.
- N2, `packages/library/src/reader.mjs`: front matter parsing records each block list item's line, and
a link in a block list carries its item's line instead of its key's.
- N3, `links.mjs` exports `isAttachment` through `index.mjs`. A wikilink or embed whose target's last
segment ends in an extension other than `.md` is an attachment. The reader neither resolves nor
indexes it, and it raises no problem. The survey prints a count of body links to attachments.
- `packages/library/README.md` describes all three, and the links, reader and survey tests cover them.
## Outside readers
`packages/cli/tests/stack-copy.test.mjs:38` checks only that `packages/library/src/index.mjs` exists, and
`scripts/mosaic:41` execs `survey.mjs`. No test pins a hash of a library file.
## Tests
On 3647a5a93, slot grant sage-1889, 09:20:01Z to 09:20:14Z, after a busy check. One `node --test` per
file, every rc 0.
| File | pass | fail |
|---|---|---|
| packages/library/tests/links.test.mjs | 7 | 0 |
| packages/library/tests/mapping.test.mjs | 7 | 0 |
| packages/library/tests/reader.test.mjs | 13 | 0 |
| packages/library/tests/render.test.mjs | 11 | 0 |
| packages/library/tests/survey.test.mjs | 6 | 0 |
| packages/cli/tests/stack-copy.test.mjs | 16 | 0 |
Total: 60 pass, 0 fail, 0 cancelled, summed by awk.
## Round 2, after webby-0070 (CHANGES) and sage-1900
Webby's round 1 review is agents/webby/work/queue-231/REVIEW-r1.md at 387f04dfd, rev 2774.
Ruling sage-1900, recorded here as asked. B2: the brief stays as approved (sage-1517, case2-rows.md
C2-01b N3). Only a `![[x.ext]]` embed can be an attachment. Plain wikilinks and front matter values keep
resolving, and an unresolved one is R4. README's line "A link to a `.canvas` file or an attachment is
R4" is back as it was at fefd54e13. B1: resolve first. An embed is an attachment only when it resolves
to nothing and its target has an extension other than `.md`. So `![[Dr.Quinn]]` with Dr.Quinn.md
indexed resolves, `![[cover.png]]` gives no problem, `![[missing-note]]` is R4 and `![[note.md]]`
resolves. My own design ruling after r11 (28213ac27) also made plain links attachments. The brief
narrowed that, and the brief governs this row.
Changes:
- `links.mjs`: `isAttachment` is true only for a body embed, `link.embed === true && !link.field`,
with the extension test as before. Its comment says it goes by name, so the reader asks it only
after resolving.
- `reader.mjs`: every link resolves first. An unresolved one is R4 unless `isAttachment` holds.
- `survey.mjs`: `quote()` also escapes U+061C (Webby N1). The attachment count now covers body embeds
only, since it uses `isAttachment`.
- Webby N2, my choice of Sage's two options: the survey keeps a count by name and the README says so,
"the count of body embeds whose target has an extension other than `.md`", and that `![[Dr.Quinn]]`
counts there even when Dr.Quinn.md exists. Checking against the survey's path list would mean a second
resolver in the survey, for a count that only guides the mapping.
- Tests. links: embeds only, with `[[cover.png]]`, `[[Board.canvas]]`, `[[Dr.Quinn]]`, a front matter
value and a markdown link all false, and `![[Dr.Quinn]]` true by name. reader: the Shore case now
gives R4 for the front matter `"![[portrait.JPG]]"` (line 3), for `[[Board.canvas]]` (line 5) and for
`![[missing-note]]` (line 6). The new Dr.Quinn case has Dr.Quinn.md indexed and resolves `[[Dr.Quinn]]` in
a block list and in the body, plus `![[Dr.Quinn]]`, with no problem. survey: a U+061C folder name
prints escaped, and the attachment count is 1, since `[[map.PDF]]` is a plain link.
Round 2 tests, one `node --test` per file, each run after a busy check.
Run 1, slot grant sage-1903, 09:39:32Z to 09:39:46Z: 60 pass, 1 fail. The new Dr.Quinn reader test failed
on order alone. All three links resolved to Dr.Quinn.md with the right field, line and embed values, and
there was no problem. The reader lists body links before front matter links, and my expected array put
the `relationships` link first. I stopped and reported it (gosalyn-0098).
Run 2, after the test-only fix sage-1907 approved, which moves that entry to the end of the expected array,
09:43:42Z to 09:43:56Z. Every rc 0.
| File | pass | fail |
|---|---|---|
| packages/library/tests/links.test.mjs | 7 | 0 |
| packages/library/tests/mapping.test.mjs | 7 | 0 |
| packages/library/tests/reader.test.mjs | 14 | 0 |
| packages/library/tests/render.test.mjs | 11 | 0 |
| packages/library/tests/survey.test.mjs | 6 | 0 |
| packages/cli/tests/stack-copy.test.mjs | 16 | 0 |
Total: 61 pass, 0 fail, 0 cancelled, summed by awk.
+12 -2
View File
@@ -57,7 +57,8 @@ environment and ceiling directory apply. A failed git call refuses `library-git`
It returns `{ commit, files, problems }`, deep-frozen and sorted. Each file has its path, scope,
universe, book, type, numbered lines, `bodyLine`, parsed `frontmatter` or `null`, `frontmatterRaw`
and `links`. Front matter `appears_in` and `relationships` wikilinks count as links.
and `links`. Front matter `appears_in` and `relationships` wikilinks count as links. A link in a block
list carries its list item's line. One in a scalar or flow value carries its key's line.
Problems carry `{ code, path, line, message }`:
@@ -87,6 +88,11 @@ then with `.md` added:
It resolves only against indexed files. A link to a `.canvas` file or an attachment is R4. A link into
another repo resolves to nothing. `extractLinks` skips fenced and inline code.
A `![[...]]` embed resolves like any link. A body embed that resolves to nothing and whose target ends
in an extension other than `.md`, in any case, embeds an attachment, and it is never R4. An extension is
a dot and up to 8 letters and digits, at least one a letter. Any other unresolved embed is R4, and so is
an unresolved front matter value written as an embed (C2-01b N3, sage-1900).
## renderItems
`renderItems(body, { scopeOf, changed })` turns a reply body into the plain text both channels show.
@@ -110,7 +116,11 @@ mosaic library survey <repo> [--mapping <file>]
```
It reads the repo's HEAD commit and prints folder names, file counts by extension, front matter key
sets and link-form counts. It never prints file text, front matter values, file names or link targets.
sets, link-form counts and the count of body embeds whose target has an extension other than `.md`,
which still count as embeds. That count goes by the name alone, so an embed of `Dr.Quinn.md` written
`![[Dr.Quinn]]` counts there too. Folder names print as JSON strings, with DEL, C1 and bidi controls,
U+061C among them, also as `\uXXXX`. It never prints file text, front matter values, file names or
link targets.
A key that is not a short identifier prints as `(other key)`, since it could be her words. With
`--mapping` it adds scope counts, resolver step counts and problem counts by rule.
+1 -1
View File
@@ -2,7 +2,7 @@
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
export { checkMapping, TYPES } from './mapping.mjs';
export { classify } from './scope.mjs';
export { extractLinks, parseWikilink, resolver, formOf } from './links.mjs';
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
export { survey, formatSurvey, surveyMain } from './survey.mjs';
+11 -1
View File
@@ -1,6 +1,8 @@
// Wikilinks and the link resolver of design 1.3. Links are [[target]], [[target|label]] and
// [[target#heading]], with ![[...]] embeds read the same way. Links inside fenced code blocks and
// inline code spans are not links, as in Obsidian.
// inline code spans are not links, as in Obsidian. A body embed that resolves to no indexed note and whose
// target ends in an extension other than `.md`, in any case, embeds an attachment (C2-01b N3): the
// reader doesn't index it, and it raises no problem.
import { posix } from 'node:path';
const WIKILINK = /(!?)\[\[([^\[\]\n]+?)\]\]/g;
@@ -23,6 +25,14 @@ function parts(inner, embed) {
embed,
};
}
// Whether a body embed's target ends in an extension other than `.md`. An extension is a dot and up
// to 8 letters and digits, at least one a letter, at the end of the target's last segment, so
// `Chapter 1.5` and `Mr. Smith` have none and `cover.png` has one. `Dr.Quinn` has one too, so the
// reader asks this only of an embed that resolves to nothing. A plain link or a front matter value is
// never an attachment (sage-1900).
export const isAttachment = link => link.embed === true && !link.field
&& /\.(?=[a-z0-9]*[a-z])[a-z0-9]{1,8}$/i.test(link.file.slice(link.file.lastIndexOf('/') + 1))
&& !/\.md$/i.test(link.file);
// The form a link is counted under in the survey.
export const formOf = link => link.markdown ? 'markdown' : link.embed ? 'embed' : !link.file ? 'self'
: link.label !== null ? 'label' : link.heading !== null ? 'heading' : 'plain';
+14 -10
View File
@@ -5,7 +5,7 @@
// of the commit; the affected file or link is left out of the index.
import { git, gitBytes } from '../../project/src/git.mjs';
import { classify } from './scope.mjs';
import { extractLinks, parseWikilink, resolver } from './links.mjs';
import { extractLinks, isAttachment, parseWikilink, resolver } from './links.mjs';
import { LibraryError, problem, raise, sortProblems } from './problems.mjs';
export const MAX_BLOB = 1024 * 1024;
@@ -103,12 +103,13 @@ function value(raw) {
// Front matter of design 1.3 (R3): scalars, quoted strings, flow lists and block lists of scalars or
// wikilinks. Anything else, a nested map, a block scalar or an anchor for example, leaves the whole
// front matter as raw text with one R3 problem at the line where parsing stopped.
// Returns { frontmatter, raw, bodyLine, stop } with 1-based line numbers.
// Returns { frontmatter, raw, bodyLine, stop } with 1-based line numbers. Parsed front matter also
// returns `itemLines`, each block list key's item lines in order.
export function parseFrontMatter(lines) {
if (lines[0]?.replace(/^/, '').trimEnd() !== '---') return { frontmatter: null, raw: null, bodyLine: 1, stop: null };
const close = lines.findIndex((text, i) => i > 0 && /^(---|\.\.\.)\s*$/.test(text));
if (close < 0) return { frontmatter: null, raw: null, bodyLine: 1, stop: 1 };
const raw = lines.slice(1, close).join('\n'), data = {};
const raw = lines.slice(1, close).join('\n'), data = {}, itemLines = {};
let list = null;
for (let i = 1; i < close; i++) {
const text = lines[i], line = i + 1;
@@ -118,6 +119,7 @@ export function parseFrontMatter(lines) {
const parsed = scalar(item[2] ?? '');
if (!parsed.ok) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
data[list].push(parsed.value);
itemLines[list].push(line);
continue;
}
const pair = /^([^\s:#\-][^:]*?|"[^"]+"|'[^']+')\s*:(?:\s+(.*))?$/.exec(text);
@@ -126,7 +128,7 @@ export function parseFrontMatter(lines) {
if (Object.hasOwn(data, key) || key === '__proto__') return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
if (pair[2] === undefined || !pair[2].trim()) {
const next = lines[i + 1];
if (i + 1 < close && /^\s*-( |$)/.test(next)) { data[key] = []; list = key; }
if (i + 1 < close && /^\s*-( |$)/.test(next)) { data[key] = []; itemLines[key] = []; list = key; }
else { data[key] = null; list = null; }
continue;
}
@@ -135,13 +137,14 @@ export function parseFrontMatter(lines) {
if (!parsed.ok) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
data[key] = parsed.value;
}
return { frontmatter: data, raw: null, bodyLine: close + 2, stop: null };
return { frontmatter: data, raw: null, bodyLine: close + 2, stop: null, itemLines };
}
export const splitLines = text => text.replace(/^/, '').split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
// One parsed file: its lines (all of them, numbered from 1), front matter and the links it holds.
// Front matter links come from LINK_FIELDS values that are wikilinks; other values are names.
// Front matter links come from LINK_FIELDS values that are wikilinks; other values are names. A
// link in a block list carries its item's line, one in a scalar or flow value its key's line.
export function parseFile(text, { markdown = false } = {}) {
const lines = splitLines(text);
const fm = parseFrontMatter(lines);
@@ -151,10 +154,11 @@ export function parseFile(text, { markdown = false } = {}) {
for (const field of LINK_FIELDS) {
const raw = fm.frontmatter[field];
const values = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
for (const v of values) {
const items = fm.itemLines[field];
values.forEach((v, i) => {
const link = typeof v === 'string' ? parseWikilink(v.trim()) : null;
if (link) links.push({ ...link, field, line: frontMatterLine(lines, fm.bodyLine, field) });
}
if (link) links.push({ ...link, field, line: items ? items[i] : frontMatterLine(lines, fm.bodyLine, field) });
});
}
}
return { lines: numbered, bodyLine: fm.bodyLine, frontmatter: fm.frontmatter, frontmatterRaw: fm.raw, stop: fm.stop, links };
@@ -197,7 +201,7 @@ export async function readLibrary({ repo, commit, mapping }) {
for (const link of file.links) {
if (!link.file) continue;
const target = resolve(file.path, link.file);
if (!target.path) { problems.push(problem('R4', file.path, link.line)); continue; }
if (!target.path) { if (!isAttachment(link)) problems.push(problem('R4', file.path, link.line)); continue; }
const to = byPath.get(target.path);
if (file.scope === 'canon') {
if (to.scope === 'playground') problems.push(problem('R5', file.path, link.line));
+15 -10
View File
@@ -1,14 +1,15 @@
// `mosaic library survey <repo> [--mapping <file>]` (design 1.1, 10 step 7). It prints what a mapping
// needs: folder names, file counts by extension, front matter key sets and link-form counts. It
// never prints file text, front matter values, file names or link targets. With a mapping it adds
// scope counts, resolver step counts and R-problem counts by code. It reads the repo's HEAD commit
// needs: folder names, file counts by extension, front matter key sets, link-form counts and the
// count of body embeds whose target has an extension other than `.md`. It never prints file text,
// front matter values, file names or link targets. With a mapping it adds scope counts, resolver
// step counts and R-problem counts by code. It reads the repo's HEAD commit
// through the commit reader, never the working tree.
import { readFileSync, realpathSync, statSync } from 'node:fs';
import { resolve as resolvePath } from 'node:path';
import { pathToFileURL } from 'node:url';
import { checkMapping } from './mapping.mjs';
import { classify } from './scope.mjs';
import { formOf } from './links.mjs';
import { formOf, isAttachment } from './links.mjs';
import { headCommit, listTree, parseFile, readable, readLibrary, readTexts } from './reader.mjs';
import { LibraryError, RULES } from './problems.mjs';
@@ -37,7 +38,7 @@ export async function survey({ repo, commit, mapping }) {
const texts = await readTexts(repo, markdown.filter(readable));
const keySets = new Map(), forms = Object.fromEntries(FORMS.map(f => [f, 0]));
const fields = { appears_in: { wikilink: 0, name: 0 }, relationships: { wikilink: 0, name: 0 } };
let noFrontMatter = 0, unparsed = 0;
let noFrontMatter = 0, unparsed = 0, attachments = 0;
for (const text of texts.values()) {
const file = parseFile(text, { markdown: true });
if (file.stop !== null) unparsed++;
@@ -51,14 +52,14 @@ export async function survey({ repo, commit, mapping }) {
fields[field][typeof v === 'string' && /^\s*!?\[\[[^\]]+\]\]\s*$/.test(v) ? 'wikilink' : 'name']++;
}
}
for (const link of file.links) if (!link.field) forms[formOf(link)]++;
for (const link of file.links) if (!link.field) { forms[formOf(link)]++; if (isAttachment(link)) attachments++; }
}
const result = {
commit: head, entries: entries.length,
folders: [...folders].sort(([a], [b]) => a < b ? -1 : 1).map(([name, counts]) => ({ name, ...counts })),
markdown: { files: markdown.length, read: texts.size, noFrontMatter, unparsed },
keySets: [...keySets].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : 1)).map(([keys, count]) => ({ keys, count })),
forms, fields,
forms, attachments, fields,
};
if (mapping) {
const scopes = { canon: 0, playground: 0, unmapped: 0 };
@@ -74,14 +75,18 @@ export async function survey({ repo, commit, mapping }) {
return result;
}
// Folder names are quoted as JSON strings, so no name can carry a control sequence to the terminal.
// Folder names are quoted as JSON strings, which escape C0 controls but leave DEL, C1 and bidi
// controls raw. quote() escapes those too, so no name can carry a control sequence to the terminal
// or reorder the line around it.
const RAW_CONTROLS = /[\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
export const quote = name => JSON.stringify(name).replace(RAW_CONTROLS, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
export function formatSurvey(s) {
const out = [`commit ${s.commit}`, `entries ${s.entries}`, 'folders (direct children: md canvas base other)'];
for (const f of s.folders) out.push(` ${f.name === '' ? '(root)' : JSON.stringify(f.name)} ${f.md} ${f.canvas} ${f.base} ${f.other}`);
for (const f of s.folders) out.push(` ${f.name === '' ? '(root)' : quote(f.name)} ${f.md} ${f.canvas} ${f.base} ${f.other}`);
out.push(`markdown ${s.markdown.files} files, ${s.markdown.read} read, ${s.markdown.noFrontMatter} without front matter, ${s.markdown.unparsed} with front matter not fully parsed`);
out.push('front matter key sets');
for (const k of s.keySets) out.push(` ${k.count} [${k.keys}]`);
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}`);
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}; attachment ${s.attachments}`);
for (const [field, c] of Object.entries(s.fields)) out.push(`field ${field} wikilink ${c.wikilink}, name ${c.name}`);
if (s.scopes) {
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
+13 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { extractLinks, parseWikilink, resolver } from '../src/index.mjs';
import { extractLinks, isAttachment, parseWikilink, resolver } from '../src/index.mjs';
const PATHS = ['Book One/Characters/Mara Vell.md', 'Book One/Characters/Tomas.md', 'Book One/Places/Harbor Town.md',
'Book One/Chapters/Chapter 01.md', 'Book One/Places/Notes.md', 'Book One/Characters/Notes.md', 'Mara Vell.md'];
@@ -48,3 +48,15 @@ test('wikilink forms, embeds, and links skipped inside code', () => {
assert.deepEqual(extractLinks(lines).map(l => [l.file, l.line]), [['One', 1], ['Two', 1], ['Three', 1], ['Four', 1], ['', 1]]);
assert.deepEqual(extractLinks(lines, { markdown: true }).filter(l => l.markdown).map(l => l.file), ['Five.md']);
});
test('only a body embed whose target has an extension other than .md, in any case, is an attachment', () => {
const att = text => isAttachment(parseWikilink(text));
// By name alone: the reader asks only of an embed that resolved to nothing (sage-1900).
for (const s of ['![[cover.png]]', '![[cover.PNG]]', '![[Art/map.pdf|Map]]', '![[Board.canvas]]', '![[clip.mp4#t=5]]', '![[Dr.Quinn]]'])
assert.equal(att(s), true, s);
for (const s of ['![[missing-note]]', '![[note.md]]', '![[Note.MD]]', '![[Chapter 1.5]]', '![[Mr. Smith]]', '![[A.b/Note]]',
'[[cover.png]]', '[[Board.canvas]]', '[[Dr.Quinn]]', '[[#Self]]'])
assert.equal(att(s), false, s);
assert.equal(isAttachment({ ...parseWikilink('![[portrait.jpg]]'), field: 'relationships' }), false, 'a front matter value');
assert.equal(isAttachment({ file: 'pic.png', markdown: true, embed: false }), false, 'a markdown link');
});
+35 -1
View File
@@ -24,7 +24,7 @@ test('the fixture reads clean: every resolver step, appears_in and relationships
assert.deepEqual(mara.frontmatter, { type: 'character', name: 'Mara Vell', appears_in: '[[Book One/Chapters/Chapter 01]]',
relationships: ['[[Tomas]]', 'The Ferryman'] });
assert.deepEqual(mara.links.map(l => [l.field, l.path, l.line]), [
['appears_in', 'Book One/Chapters/Chapter 01.md', 4], ['relationships', 'Book One/Characters/Tomas.md', 5]]);
['appears_in', 'Book One/Chapters/Chapter 01.md', 4], ['relationships', 'Book One/Characters/Tomas.md', 6]]);
assert.deepEqual(file(result, 'Book One/Characters/Tomas.md').links.map(l => [l.field, l.path]),
[['appears_in', 'Book One/Chapters/Chapter 01.md']]);
assert.equal(mara.bodyLine, 9);
@@ -97,6 +97,40 @@ test('R4: a link to no file, to more than one, or into another repo is reported
assert.deepEqual(file(result, 'Book One/Chapters/Links.md').links.map(l => l.path), ['Book One/Characters/Tomas.md']);
});
test('a block list link carries its item line, a scalar or flow link its key line, and problems follow', async t => {
const r = repo(t, { ...BASE, 'Book One/Characters/Ana.md': ['---', 'appears_in: ["[[Nobody]]"]', 'relationships:', ' - "[[Tomas]]"',
' - Plain Name', ' - "[[Ghost]]"', ' - "[[What If]]"', '---', ''].join('\n') });
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
assert.deepEqual(file(result, 'Book One/Characters/Ana.md').links.map(l => [l.field, l.path, l.line]), [
['relationships', 'Book One/Characters/Tomas.md', 4], ['relationships', 'Book One/Playground/What If.md', 7]]);
assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Characters/Ana.md').map(p => [p.code, p.line]),
[['R4', 2], ['R4', 6], ['R5', 7]]);
});
// sage-1900: only an unresolved body embed with an extension other than .md is an attachment. A plain
// link to one and a front matter value stay R4 when they resolve to nothing.
test('an unresolved attachment embed gives no problem; a link or front matter value to one is R4; a note embed resolves', async t => {
const r = repo(t, { ...BASE, 'Book One/Places/note.md': 'n\n', 'Book One/Places/Shore.md': ['---',
'relationships:', ' - "![[portrait.JPG]]"', '---', '![[cover.png]] [[Board.canvas]] ![[map.pdf|Map]]',
'![[missing-note]] ![[note.md]] ![[Harbor Town]]', ''].join('\n') });
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Places/Shore.md').map(p => [p.code, p.line]),
[['R4', 3], ['R4', 5], ['R4', 6]]);
assert.deepEqual(file(result, 'Book One/Places/Shore.md').links.map(l => [l.path, l.embed]),
[['Book One/Places/note.md', true], ['Book One/Places/Harbor Town.md', true]]);
});
// webby-0070 B1: a note whose name looks like it has an extension resolves before the attachment test.
test('an indexed Dr.Quinn.md resolves as a body link, a block list link and an embed', async t => {
const r = repo(t, { ...BASE, 'Book One/Characters/Dr.Quinn.md': 'q\n', 'Book One/Characters/Ana.md': ['---',
'relationships:', ' - "[[Dr.Quinn]]"', '---', '[[Dr.Quinn]] ![[Dr.Quinn]]', ''].join('\n') });
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
assert.deepEqual(file(result, 'Book One/Characters/Ana.md').links.map(l => [l.field ?? null, l.path, l.line, l.embed]), [
[null, 'Book One/Characters/Dr.Quinn.md', 5, false], [null, 'Book One/Characters/Dr.Quinn.md', 5, true],
['relationships', 'Book One/Characters/Dr.Quinn.md', 3, false]]);
assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Characters/Ana.md'), []);
});
test('R5: a canon file that refers into the playground is reported, and the link still goes by path', async t => {
const r = repo(t, { ...BASE, 'Book One/Places/Dock.md': 'See [[What If]].\n' });
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
+19 -1
View File
@@ -39,13 +39,31 @@ test('the survey prints folder names, counts, key sets and link-form counts, and
' 1 [appears_in, name, type]',
' 1 [name]',
' 1 [type]',
'body links plain 3, label 1, heading 1, embed 0, self 0, markdown 1',
'body links plain 3, label 1, heading 1, embed 0, self 0, markdown 1; attachment 0',
'field appears_in wikilink 2, name 0',
'field relationships wikilink 1, name 1',
''].join('\n'));
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
});
test('folder names print DEL, C1 and bidi controls, U+061C among them, as escapes', async t => {
// One name per escaped range, with each range's first and last code point.
const names = ['A\u007fB', 'C\u0080D\u009fE', 'F\u200eG\u200fH', 'I\u202aJ\u202eK', 'L\u2066M\u2069N', 'O\u061cP'];
const r = repo(t, Object.fromEntries(names.map(n => [`${n}/a.md`, `${MARKER}\n`])));
const out = await run(['survey', r.repo]);
assert.equal(out.code, 0, out.stderr);
assert.deepEqual(out.stdout.split('\n').slice(3, 9), [
' "A\\u007fB" 1 0 0 0', ' "C\\u0080D\\u009fE" 1 0 0 0', ' "F\\u200eG\\u200fH" 1 0 0 0',
' "I\\u202aJ\\u202eK" 1 0 0 0', ' "L\\u2066M\\u2069N" 1 0 0 0', ' "O\\u061cP" 1 0 0 0']);
assert.doesNotMatch(out.stdout, /[\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/);
});
test('an embed of an attachment counts as an embed and as an attachment; a plain link to one does not', async t => {
const r = repo(t, { 'Book One/Chapters/A.md': ['![[cover.png]] ![[missing-note]] [[map.PDF]] [[Chapter 1.5]]', ''].join('\n') });
const out = await run(['survey', r.repo]);
assert.match(out.stdout, /^body links plain 2, label 0, heading 0, embed 2, self 0, markdown 0; attachment 1$/m);
});
test('with --mapping the survey adds scope, resolver step and problem counts', async t => {
const r = repo(t);
const out = await run(['survey', r.repo, '--mapping', join(FIXTURES, 'mapping.json')]);