feat: add persistent book navigation and public-only pages
This commit is contained in:
@@ -3,7 +3,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { contentsRoot } from '../plugins/document-links.mjs';
|
||||
import { documentTree, childrenOf, visibleNodes, treePrefix, serializeTree } from '../src/lib/landing-tree.ts';
|
||||
import { documentTree, chapterTree, bookTree, childrenOf, visibleNodes, treePrefix, serializeTree } from '../src/lib/landing-tree.ts';
|
||||
|
||||
async function sourceFiles(directory, prefix = '') {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
@@ -37,10 +37,72 @@ test('tree mirrors only real content files and their directory parents', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('existing titles, hrefs and content pass through without added descriptions or operations', () => {
|
||||
test('chapter display nests existing numbered sections while preserving paths and URLs', () => {
|
||||
const display = chapterTree(documents);
|
||||
for (const [chapter, sections] of [
|
||||
['/1-concept.md', ['/1.1-interaction-abstraction.md', '/1.2-existing-abstractions.md', '/1.3-design-principles.md', '/1.4-concepts-and-scope.md']],
|
||||
['/2-core-model.md', ['/2.1-object-tree-schema.md', '/2.2-operation-and-interface.md']],
|
||||
['/4-client.md', ['/4.1-ai-tools.md', '/4.2-text-view.md']],
|
||||
['/5-transports.md', ['/5.1-wip-over-http.md']],
|
||||
]) {
|
||||
assert.deepEqual(childrenOf(display, chapter).map((node) => node.path), sections);
|
||||
for (const section of sections) {
|
||||
const node = display.find((node) => node.path === section);
|
||||
assert.equal(node.depth, 3);
|
||||
assert.equal(treePrefix(node, display).length, 6);
|
||||
assert.ok(!visibleNodes(display, new Set([chapter])).includes(node));
|
||||
}
|
||||
}
|
||||
for (const node of display) {
|
||||
const original = nodes.find((candidate) => candidate.path === node.path);
|
||||
assert.equal(node.href, original.href);
|
||||
assert.equal(node.name, original.name);
|
||||
assert.equal(node.title, original.title);
|
||||
}
|
||||
assert.deepEqual(childrenOf(display, '/draft'), childrenOf(nodes, '/draft'));
|
||||
// The directory index still lists the physical files, not the display grouping.
|
||||
assert.equal(nodes.find((node) => node.path === '/1.1-interaction-abstraction.md').parent, '/');
|
||||
});
|
||||
|
||||
test('chapter grouping supports deeper numbering and never invents or crosses directory parents', () => {
|
||||
const source = ['1.2.3-leaf', '1.2-section', '1-chapter', '10-other', '10.1-section', '3.1-orphan', 'draft/1.1-independent']
|
||||
.map((id) => ({ id, title: id, href: `/book/${id}/` }));
|
||||
const display = chapterTree(source);
|
||||
const at = (id) => display.find((node) => node.path === `/${id}.md`);
|
||||
assert.equal(at('1.2-section').parent, '/1-chapter.md');
|
||||
assert.equal(at('1.2.3-leaf').parent, '/1.2-section.md');
|
||||
assert.equal(at('1.2.3-leaf').depth, 4);
|
||||
assert.equal(at('10.1-section').parent, '/10-other.md');
|
||||
assert.equal(at('3.1-orphan').parent, '/');
|
||||
assert.equal(at('draft/1.1-independent').parent, '/draft');
|
||||
assert.ok(display.indexOf(at('1-chapter')) < display.indexOf(at('1.2-section')));
|
||||
assert.ok(display.indexOf(at('1.2-section')) < display.indexOf(at('1.2.3-leaf')));
|
||||
});
|
||||
|
||||
test('public book container wraps the numbered chapters without changing document identity', () => {
|
||||
const published = documents.filter((document) => !document.id.startsWith('draft/'));
|
||||
const display = bookTree(published);
|
||||
assert.deepEqual(childrenOf(display, '/').map((node) => node.path), ['/book']);
|
||||
assert.equal(display[1].name, 'book/');
|
||||
assert.equal(display[1].depth, 2);
|
||||
assert.equal(display.length, published.length + 2);
|
||||
assert.ok(!display.some((node) => node.path.startsWith('/draft')));
|
||||
assert.deepEqual(childrenOf(display, '/book').map((node) => node.path), ['/1-concept.md', '/2-core-model.md', '/3-protocol.md', '/4-client.md', '/5-transports.md']);
|
||||
const section = display.find((node) => node.path === '/1.1-interaction-abstraction.md');
|
||||
assert.equal(section.parent, '/1-concept.md');
|
||||
assert.equal(section.depth, 4);
|
||||
assert.equal(treePrefix(section, display), ' │ ├─ ');
|
||||
assert.deepEqual(visibleNodes(display, new Set(['/book'])), display.slice(0, 2));
|
||||
for (const node of display.filter((node) => node.href)) {
|
||||
assert.equal(node.href, nodes.find((original) => original.path === node.path).href);
|
||||
}
|
||||
});
|
||||
|
||||
test('existing titles and hrefs pass through without embedding document bodies', () => {
|
||||
for (const document of documents) {
|
||||
const node = nodes.find((node) => node.path === '/' + document.id + '.md');
|
||||
assert.equal(node.html, document.html);
|
||||
assert.equal('html' in node, false);
|
||||
assert.equal('body' in node, false);
|
||||
assert.equal(node.title, document.title);
|
||||
assert.equal(node.href, document.href);
|
||||
assert.equal('description' in node, false);
|
||||
@@ -61,14 +123,14 @@ test('collapse hides descendants only and joins follow the visible tree', () =>
|
||||
for (const shown of [nodes, collapsed]) {
|
||||
for (const node of shown) {
|
||||
const prefix = treePrefix(node, nodes, shown);
|
||||
assert.equal(prefix.length, (node.depth - 1) * 4);
|
||||
if (node.parent) assert.ok(prefix.endsWith(childrenOf(shown, node.parent).at(-1) === node ? '└── ' : '├── '));
|
||||
assert.equal(prefix.length, (node.depth - 1) * 3);
|
||||
if (node.parent) assert.ok(prefix.endsWith(childrenOf(shown, node.parent).at(-1) === node ? '└─ ' : '├─ '));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('embedded data cannot close its JSON script and roundtrips exact content', () => {
|
||||
const input = documentTree([{ id: 'source', title: '<title>', href: '/book/source/', html: '</script><script>alert(1)</script>\u2028&' }]);
|
||||
test('embedded metadata cannot close its JSON script and roundtrips exactly', () => {
|
||||
const input = documentTree([{ id: 'source', title: '</script><script>alert(1)</script>\u2028&', href: '/book/source/' }]);
|
||||
const serialized = serializeTree(input);
|
||||
assert.equal(serialized.includes('<'), false);
|
||||
assert.deepEqual(JSON.parse(serialized), input);
|
||||
|
||||
Reference in New Issue
Block a user