feat: add persistent book navigation and public-only pages

This commit is contained in:
2026-10-15 17:53:48 +09:00
parent f6905074b1
commit 839eb2a6a4
22 changed files with 1191 additions and 269 deletions
+69 -7
View File
@@ -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);