diff --git a/site/plugins/document-links.mjs b/site/plugins/document-links.mjs index 7f70044..f39fd04 100644 --- a/site/plugins/document-links.mjs +++ b/site/plugins/document-links.mjs @@ -10,7 +10,7 @@ export function normalizeBase(value = '/') { return value === '/' ? '/' : `/${value.split('/').filter(Boolean).join('/')}/`; } -/** Rewrite Markdown document links, not code, fragments, or external URLs. */ +/** Rewrite published Markdown links; null means a source-only draft target. */ export function documentHref(href, sourceFile, base = '/') { if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(href)) return href; const match = href.match(/^([^?#]+\.md)([?#].*)?$/i); @@ -20,18 +20,30 @@ export function documentHref(href, sourceFile, base = '/') { if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error(`Markdown link outside contents/: ${href} in ${sourceFile}`); } + if (relative.split(path.sep)[0] === 'draft') return null; const slug = relative.replace(/\.md$/i, '').split(path.sep).map(encodeURIComponent).join('/'); return `${normalizeBase(base)}book/${slug}/${match[2] ?? ''}`; } export default function documentLinks({ base = '/' } = {}) { return (tree, file) => { - function walk(node) { - if ((node.type === 'link' || node.type === 'definition') && typeof node.url === 'string') { - node.url = documentHref(node.url, file.path, base); - } - for (const child of node.children ?? []) walk(child); + const draftReferences = new Set(); + function collect(node) { + if (node.type === 'definition' && documentHref(node.url, file.path, base) === null) draftReferences.add(node.identifier); + for (const child of node.children ?? []) collect(child); } - walk(tree); + collect(tree); + function rewrite(node) { + if (node.children) node.children = node.children.flatMap(rewrite); + if (node.type === 'linkReference' && draftReferences.has(node.identifier)) return node.children; + if ((node.type === 'link' || node.type === 'definition') && typeof node.url === 'string') { + const url = documentHref(node.url, file.path, base); + // Retain authored link text/formatting, but expose no unpublished URL. + if (url === null) return node.type === 'link' ? node.children : []; + node.url = url; + } + return [node]; + } + rewrite(tree); }; } diff --git a/site/scripts/check-links.mjs b/site/scripts/check-links.mjs index 2938e32..7da8115 100644 --- a/site/scripts/check-links.mjs +++ b/site/scripts/check-links.mjs @@ -18,7 +18,12 @@ async function document(filename) { if (!cache.has(filename)) cache.set(filename, load(await readFile(filename, 'utf8'))); return cache.get(filename); } -const htmlFiles = (await files(root)).filter((file) => file.endsWith('.html')); +const outputFiles = await files(root); +for (const filename of outputFiles) { + const relative = path.relative(root, filename).split(path.sep).join('/'); + if (/^(?:book\/)?draft(?:\/|$)/.test(relative)) failures.push(`Unpublished draft in output: ${relative}`); +} +const htmlFiles = outputFiles.filter((file) => file.endsWith('.html')); for (const filename of htmlFiles) { const relative = path.relative(root, filename).split(path.sep).join('/'); const pagePath = base + relative.replace(/index\.html$/, ''); @@ -57,9 +62,9 @@ for (const filename of htmlFiles) { } } } -// Every source document must have its own generated page (including drafts). +// Every published source has a page; draft sources must never be emitted. const contents = fileURLToPath(new URL('../../contents/', import.meta.url)); -const markdown = (await files(contents)).filter((file) => file.endsWith('.md')); +const markdown = (await files(contents)).filter((file) => file.endsWith('.md') && path.relative(contents, file).split(path.sep)[0] !== 'draft'); for (const file of markdown) { const slug = path.relative(contents, file).replace(/\.md$/, ''); const output = path.join(root, 'book', slug, 'index.html'); diff --git a/site/src/components/DirectoryIndex.astro b/site/src/components/DirectoryIndex.astro new file mode 100644 index 0000000..a2acd74 --- /dev/null +++ b/site/src/components/DirectoryIndex.astro @@ -0,0 +1,12 @@ +--- +import { documents, bookHref, title, href } from '../lib/book'; +import { documentTree, childrenOf } from '../lib/landing-tree'; +interface Props { path?: string; } +const { path = '/' } = Astro.props; +const nodes = documentTree((await documents()).map((entry) => ({ id: entry.id, title: title(entry), href: bookHref(entry.id) }))); +const children = childrenOf(nodes, path); +--- +

{path === '/' ? 'Book' : `${path.slice(1)}/`}

+ diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index 01656fe..9671534 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -1,19 +1,21 @@ --- -import { documents, bookHref, title } from '../lib/book'; -import { documentTree, childrenOf, treePrefix, serializeTree } from '../lib/landing-tree'; +import { documents, bookHref, title, href } from '../lib/book'; +import { bookTree, childrenOf, treePrefix, serializeTree } from '../lib/landing-tree'; import '../styles/landing.css'; import TreePrefix from './TreePrefix.astro'; import TreeGlyphs from './TreeGlyphs.astro'; import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs'; -const objects = documentTree((await documents()).map((entry) => { - if (!entry.rendered) throw new Error(`Missing rendered content: ${entry.id}`); - return { id: entry.id, title: title(entry), href: bookHref(entry.id), html: entry.rendered.html }; -})); -const initialObject = objects.find((object) => object.href)!; +interface Props { currentPath?: string; label?: string; draft?: boolean; } +const entries = await documents(); +const { currentPath = `/${entries[0].id}.md`, label, draft = false } = Astro.props; +const folderHref = (path: string) => path === '/' ? href() : path === '/book' ? href('book/') : href(`book${path}/`); +const objects = bookTree(entries.map((entry) => ({ id: entry.id, title: title(entry), href: bookHref(entry.id) }))) + .map((object) => ({ ...object, href: object.href ?? folderHref(object.path) })); +const current = objects.find((object) => object.path === currentPath); +const initialContent = entries.find((entry) => `/${entry.id}.md` === currentPath)?.rendered?.html; const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1) * 24 + branchY + branchThickness / 2; --- -
- +
-
+
+
{objects.map((object, index) => { const root = object.path === '/'; const children = childrenOf(objects, object.path); - const Tag = object.href ? 'a' : 'button'; return
{!root && } - item.path === object.path) + 1} - aria-selected={object.path === initialObject.path} - tabindex={object.href ? 0 : -1} + aria-selected={object.path === currentPath} + aria-current={object.path === currentPath ? 'page' : undefined} + tabindex="0" > {root ? : <>{object.name}{children.length > 0 && }} - + : {object.name}} + + {!root && children.length > 0 && }
; })}
-
+
+ {draft && } +
+ +
+
diff --git a/site/src/content.config.ts b/site/src/content.config.ts index cd94c01..58fdaef 100644 --- a/site/src/content.config.ts +++ b/site/src/content.config.ts @@ -4,8 +4,8 @@ import { glob } from 'astro/loaders'; const book = defineCollection({ loader: glob({ base: '../contents', - pattern: '**/*.md', - // Keep source filenames stable, including chapter numbers and draft paths. + pattern: ['**/*.md', '!draft/**'], + // Keep published source filenames stable; drafts remain source-only. generateId: ({ entry }) => entry.replace(/\.md$/, ''), }), }); diff --git a/site/src/layouts/Site.astro b/site/src/layouts/Site.astro index 559aebf..d39d155 100644 --- a/site/src/layouts/Site.astro +++ b/site/src/layouts/Site.astro @@ -1,23 +1,27 @@ --- +import { ClientRouter } from 'astro:transitions'; import '../styles/global.css'; import { href, repository } from '../lib/book'; -interface Props { title?: string; description?: string; book?: boolean; landing?: boolean; } -const { title = 'Web Interface Protocol', description = 'デジタル環境を、発見・観測・操作できるWorldspaceへ。WIPのコンセプトと仕様を読む。', book = false, landing = false } = Astro.props; +interface Props { title?: string; description?: string; book?: boolean; landing?: boolean; intro?: boolean; } +const { title = 'Worldspace Interface Protocol', description = 'デジタル環境を、発見・観測・操作できるWorldspaceへ。WIPのコンセプトと仕様を読む。', book = false, landing = true, intro = false } = Astro.props; const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined; --- - + - {landing && } + window.__wipVisited = true; + {title} · WIP @@ -32,7 +36,7 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin - {!landing && } + {!landing && } diff --git a/site/src/lib/book.ts b/site/src/lib/book.ts index 8c77806..6fa9d30 100644 --- a/site/src/lib/book.ts +++ b/site/src/lib/book.ts @@ -19,5 +19,5 @@ export function sourceHref(document: Document): string { return `${repository}/src/branch/main/contents/${document.id.split('/').map(encodeURIComponent).join('/')}.md`; } export async function documents(): Promise { - return (await getCollection('book')).sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true })); + return (await getCollection('book', (entry) => !isDraft(entry))).sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true })); } diff --git a/site/src/lib/landing-tree.ts b/site/src/lib/landing-tree.ts index 6f01b85..e53e280 100644 --- a/site/src/lib/landing-tree.ts +++ b/site/src/lib/landing-tree.ts @@ -2,7 +2,6 @@ export interface DocumentSource { id: string; title: string; href: string; - html: string; } export interface TreeNode { @@ -12,7 +11,6 @@ export interface TreeNode { depth: number; title?: string; href?: string; - html?: string; } // Mirror the content directory. No authored taxonomy, summaries or demo data. @@ -25,7 +23,7 @@ export function documentTree(documents: readonly DocumentSource[]): TreeNode[] { const path = `${parent === '/' ? '' : parent}/${name}`; const file = index === segments.length - 1; if (!nodes.some((node) => node.path === path)) { - nodes.push({ name, path, parent, depth: index + 2, ...(file ? { title: document.title, href: document.href, html: document.html } : {}) }); + nodes.push({ name, path, parent, depth: index + 2, ...(file ? { title: document.title, href: document.href } : {}) }); } parent = path; }); @@ -34,6 +32,35 @@ export function documentTree(documents: readonly DocumentSource[]): TreeNode[] { return [nodes[0], ...visit('/')]; } +// Display numbered sections beneath their chapter without changing file paths, +// links, or the physical directory tree used by directory indexes and routes. +export function chapterTree(documents: readonly DocumentSource[]): TreeNode[] { + const nodes = documentTree(documents); + const numbered = nodes.filter((node) => node.href).flatMap((node) => { + const number = node.name.match(/^(\d+(?:\.\d+)*)-/)?.[1]; + return number ? [{ node, number, directory: node.parent! }] : []; + }); + for (const { node, number, directory } of numbered) { + const separator = number.lastIndexOf('.'); + if (separator < 0) continue; + const parent = numbered.find((candidate) => candidate.directory === directory && candidate.number === number.slice(0, separator)); + if (parent) node.parent = parent.node.path; + } + const visit = (parent: TreeNode): TreeNode[] => childrenOf(nodes, parent.path).flatMap((node) => { + node.depth = parent.depth + 1; + return [node, ...visit(node)]; + }); + return [nodes[0], ...visit(nodes[0])]; +} + +// The public explorer has a single book/ container below the site root. +export function bookTree(documents: readonly DocumentSource[]): TreeNode[] { + const [root, ...chapters] = chapterTree(documents); + return [root, { name: 'book/', path: '/book', parent: '/', depth: 2 }, ...chapters.map((node) => ({ + ...node, parent: node.parent === '/' ? '/book' : node.parent, depth: node.depth + 1, + }))]; +} + export const childrenOf = (nodes: readonly TreeNode[], path: string) => nodes.filter((node) => node.parent === path); export function visibleNodes(nodes: readonly TreeNode[], collapsed: ReadonlySet): TreeNode[] { @@ -50,10 +77,10 @@ export function visibleNodes(nodes: readonly TreeNode[], collapsed: ReadonlySet< export function treePrefix(node: TreeNode, nodes: readonly TreeNode[], visible: readonly TreeNode[] = nodes): string { if (!node.parent) return ''; const isLast = (item: TreeNode) => childrenOf(visible, item.parent!).at(-1)?.path === item.path; - let prefix = isLast(node) ? '└── ' : '├── '; + let prefix = isLast(node) ? '└─ ' : '├─ '; let parent = nodes.find((item) => item.path === node.parent); while (parent?.parent) { - prefix = (isLast(parent) ? ' ' : '│ ') + prefix; + prefix = (isLast(parent) ? ' ' : '│ ') + prefix; parent = nodes.find((item) => item.path === parent!.parent); } return prefix; diff --git a/site/src/pages/404.astro b/site/src/pages/404.astro index b1d9dcf..7cb48ed 100644 --- a/site/src/pages/404.astro +++ b/site/src/pages/404.astro @@ -1,7 +1,10 @@ --- import Site from '../layouts/Site.astro'; +import Landing from '../components/Landing.astro'; import { href } from '../lib/book'; --- -

404 / NOT FOUND

このページは見つかりません。

Bookの目次から、お探しの章へ移動できます。

Bookの目次へ →
+
+

404 / NOT FOUND

このページは見つかりません。

Bookの目次から、お探しの章へ移動できます。

Bookの目次へ → +
diff --git a/site/src/pages/book/[...slug].astro b/site/src/pages/book/[...slug].astro index 9f0e05f..d9647af 100644 --- a/site/src/pages/book/[...slug].astro +++ b/site/src/pages/book/[...slug].astro @@ -1,36 +1,27 @@ --- import { render } from 'astro:content'; import Site from '../../layouts/Site.astro'; -import ChapterNav from '../../components/ChapterNav.astro'; -import { documents, bookHref, title, isDraft, sourceHref, href, type Document } from '../../lib/book'; +import Landing from '../../components/Landing.astro'; +import DirectoryIndex from '../../components/DirectoryIndex.astro'; +import { documents, bookHref, title, isDraft, type Document } from '../../lib/book'; +import { documentTree } from '../../lib/landing-tree'; export async function getStaticPaths() { - return (await documents()).map((entry) => ({ params: { slug: entry.id }, props: { entry } })); + const entries = await documents(); + const folders = documentTree(entries.map((entry) => ({ id: entry.id, title: title(entry), href: bookHref(entry.id) }))) + .filter((node) => !node.href && node.path !== '/'); + return [ + ...entries.map((entry) => ({ params: { slug: entry.id }, props: { entry, path: `/${entry.id}.md` } })), + ...folders.map((node) => ({ params: { slug: node.path.slice(1) }, props: { path: node.path } })), + ]; } -interface Props { entry: Document; } -const { entry } = Astro.props; -const entries = await documents(); -const { Content, headings } = await render(entry); -const draft = isDraft(entry); -const sequence = entries.filter((item) => isDraft(item) === draft); -const position = sequence.findIndex((item) => item.id === entry.id); -const previous = sequence[position - 1]; -const next = sequence[position + 1]; -const toc = headings.filter((heading) => heading.depth === 2 || heading.depth === 3); +interface Props { entry?: Document; path: string; } +const { entry, path } = Astro.props; +const rendered = entry ? await render(entry) : undefined; +const Content = rendered?.Content; +const label = entry ? title(entry) : `${path.slice(1)}/`; --- - -
- -
-
Book/ {draft ? 'Drafts' : 'Reference'}Markdown ↗
-
章を選ぶ
- {draft && } - {toc.length > 0 &&
このページの目次
} -
- -
- -
+ +
+ {Content ? : } +
diff --git a/site/src/pages/book/index.astro b/site/src/pages/book/index.astro index fe001c0..89f76dc 100644 --- a/site/src/pages/book/index.astro +++ b/site/src/pages/book/index.astro @@ -1,30 +1,8 @@ --- import Site from '../../layouts/Site.astro'; -import ChapterNav from '../../components/ChapterNav.astro'; -import { documents, bookHref, title, isDraft, href } from '../../lib/book'; -const entries = await documents(); -const chapters = entries.filter((entry) => !isDraft(entry)); -const drafts = entries.filter(isDraft); +import Landing from '../../components/Landing.astro'; +import DirectoryIndex from '../../components/DirectoryIndex.astro'; --- -
- -
-

THE WIP BOOK

-

世界の見方から、
通信の約束まで。

-

WIPのコンセプト、共通モデル、ClientとHostの契約を、ひとつの流れで読むためのリファレンスです。

- 第1章から読む -
-

仕様を読む {chapters.length} chapters

-
    {chapters.map((entry) =>
  1. {title(entry)}
  2. )}
-
-
-

WORK IN PROGRESS

-

草案と未解決事項

-

API設計や投影方針を検討する文書です。正本とは分けて掲載し、確定したProtocolの契約と混同しません。

- -
-

← WIPについて

-
-
+
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 1ce54d3..ea62cbe 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -2,7 +2,7 @@ import Site from '../layouts/Site.astro'; import Landing from '../components/Landing.astro'; --- - +
diff --git a/site/src/scripts/landing.ts b/site/src/scripts/landing.ts index 9c87582..11d762a 100644 --- a/site/src/scripts/landing.ts +++ b/site/src/scripts/landing.ts @@ -24,22 +24,48 @@ const circumference = 2 * Math.PI * orbitRadius; // Scene 4 feeds the text down this rail; the circle's geometry never changes. const circularTrack = 'M0 -180 A180 180 0 1 1 0 180 A180 180 0 1 1 0 -180'; -export async function initLanding() { +export async function initLanding(lifetime: AbortSignal) { + const element = document.querySelector('[data-landing-stage]'); + if (!element) return; + // Keep the visible boot controls usable while font metrics are loading. + const boot = new AbortController(); + let bootStart = false; + let bootKeyboard = false; + const skipBoot = () => { delete document.documentElement.dataset.landing; }; + const onBootClick = (event: MouseEvent) => { + if (document.documentElement.dataset.landing !== 'boot') return; + if ((event.target as Element).closest('.intro-skip, .skip-link')) { skipBoot(); return; } + if ((event.target as Element).closest('.root-mark')) event.preventDefault(); + bootStart = true; + }; + document.addEventListener('click', onBootClick, { signal: boot.signal, capture: true }); + document.addEventListener('keydown', (event) => { + if (document.documentElement.dataset.landing !== 'boot' || event.ctrlKey || event.metaKey || event.altKey) return; + if (['Meta', 'Control', 'Alt', 'Shift'].includes(event.key)) return; + if (event.key === 'Escape') { skipBoot(); return; } + if ((event.target as Element).closest('.intro-skip, .skip-link') && event.key === 'Enter') return; + if (event.key !== 'Tab') event.preventDefault(); + bootStart = true; + bootKeyboard = true; + }, { signal: boot.signal }); + const abortBoot = () => boot.abort(); + lifetime.addEventListener('abort', abortBoot, { once: true }); // Wait for fixed mono metrics before measuring the landing trajectory. // A rejected font fetch still leaves usable fallback text and SVG outlines. await Promise.race([ document.fonts.load('14px "Lyth Mono"').catch(() => []), new Promise((resolve) => setTimeout(resolve, 2500)), ]); - const element = document.querySelector('[data-landing-stage]'); - if (!element) return; + boot.abort(); + lifetime.removeEventListener('abort', abortBoot); + if (lifetime.aborted || !element.isConnected) return; const stage: HTMLElement = element; const objects: TreeNode[] = JSON.parse(stage.querySelector('#landing-documents')!.textContent!); - const initialObject = objects.find((object) => object.href)!; + const currentPath = stage.dataset.currentPath; const html = document.documentElement; const motion = matchMedia('(prefers-reduced-motion: reduce)'); const get = (selector: string) => stage.querySelector(selector)!; - const root = get('.root-mark'); + const root = get('.root-mark'); const slashArt = get('.slash-art'); const restingSlashScale = Number(getComputedStyle(slashArt).getPropertyValue('--slash-rest-scale')); const trunk = get('.trunk-line'); @@ -63,13 +89,25 @@ export async function initLanding() { const orbitPath = get('#welcome-orbit'); const textPath = get('.orbit textPath'); const skip = get('.intro-skip'); - const result = get('[data-selected-content]'); const blocked = [...document.querySelectorAll('.site-header, .site-footer, main > :not(.landing)')]; const abort = new AbortController(); let state: 'armed' | 'playing' | 'complete' = 'complete'; - let selected = initialObject; + let focused = Math.max(0, objects.findIndex((object) => object.path === currentPath)); let keyboardStarted = false; - const collapsed = new Set(); + // The persisted tree is the source of its expansion state across URL changes. + const collapsed = new Set(nodes.filter((node) => node.getAttribute('aria-expanded') === 'false').map((node) => node.dataset.path!)); + let ancestor = objects.find((object) => object.path === currentPath)?.parent; + while (ancestor) { + collapsed.delete(ancestor); + ancestor = objects.find((object) => object.path === ancestor)?.parent; + } + nodes.forEach((node) => { + const current = node.dataset.path === currentPath; + node.setAttribute('aria-selected', String(current)); + if (current) node.setAttribute('aria-current', 'page'); + else node.removeAttribute('aria-current'); + }); + tree.dataset.enhanced = 'true'; const glitchTargets = [...stage.querySelectorAll('.node-label')].map((element, seed) => ({ element, text: element.textContent ?? '', start: rowStart(element.closest('.node-row') as HTMLElement), duration: 580, seed, })); @@ -148,17 +186,9 @@ export async function initLanding() { trunk.dataset.length = String(Math.max(0, length)); } - function select(index: number, focus = false) { - selected = objects[index]; - nodes.forEach((node, i) => { - node.setAttribute('aria-selected', String(i === index)); - node.tabIndex = i === index ? 0 : -1; - }); - // HTML is the site's existing, build-rendered Markdown, not generated copy. - result.innerHTML = selected.html ?? ''; - result.hidden = selected.html === undefined; - result.setAttribute('aria-label', selected.title ?? selected.path); - result.scrollTop = 0; + function focusNode(index: number, focus = false) { + focused = index; + nodes.forEach((node, i) => { node.tabIndex = i === index ? 0 : -1; }); if (focus) { nodes[index].focus({ preventScroll: true }); const box = nodes[index].getBoundingClientRect(), bounds = viewport.getBoundingClientRect(); @@ -177,8 +207,11 @@ export async function initLanding() { if (childrenOf(objects, object.path).length) { const expanded = !collapsed.has(object.path); nodes[index].setAttribute('aria-expanded', String(expanded)); - const indicator = nodes[index].querySelector('.node-indicator'); - if (indicator) indicator.textContent = expanded ? '−' : '+'; + const indicator = rows[index].querySelector('.node-indicator'); + if (indicator) { + indicator.textContent = expanded ? '−' : '+'; + indicator.setAttribute('aria-expanded', String(expanded)); + } } const prefixElement = rows[index].querySelector('.branch-prefix'); if (prefixElement && paths.has(object.path)) { @@ -192,26 +225,30 @@ export async function initLanding() { } }); drawTrunk(restingLength()); - if (!paths.has(selected.path)) { - const next = shown.find((object) => object.href) ?? shown[0]; - select(objects.indexOf(next)); + if (!paths.has(objects[focused].path)) { + let parent = objects[focused].parent; + while (parent && !paths.has(parent)) parent = objects.find((object) => object.path === parent)?.parent; + focusNode(Math.max(0, objects.findIndex((object) => object.path === parent))); } } + // Ordinary anchors own navigation (including modifier clicks and no-JS). nodes.forEach((node, index) => node.addEventListener('click', (event) => { - if (state !== 'complete') return; - if (node instanceof HTMLAnchorElement && (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)) return; - event.preventDefault(); - select(index); - if (childrenOf(objects, objects[index].path).length) { - const path = objects[index].path; + if (state !== 'complete') { event.preventDefault(); return; } + focusNode(index); + }, { signal: lifetime })); + stage.querySelectorAll('[data-toggle-path]').forEach((toggle) => { + toggle.addEventListener('click', () => { + if (state !== 'complete') return; + const path = toggle.dataset.togglePath!; collapsed.has(path) ? collapsed.delete(path) : collapsed.add(path); updateExpanded(); - } - })); + focusNode(objects.findIndex((object) => object.path === path), true); + }, { signal: lifetime }); + }); tree.addEventListener('keydown', (event) => { - if (state !== 'complete') return; + if (state !== 'complete' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; const index = nodes.indexOf(document.activeElement as HTMLElement); if (index < 0) return; const visible = nodes.map((_, i) => i).filter((i) => !rows[i].hidden); @@ -238,8 +275,8 @@ export async function initLanding() { default: return; } event.preventDefault(); - select(target, true); - }); + focusNode(target, true); + }, { signal: lifetime }); function finish() { timeline.pause(); @@ -277,7 +314,7 @@ export async function initLanding() { wire.style.removeProperty('clip-path'); }); updateExpanded(); - select(objects.indexOf(selected), keyboardStarted || document.activeElement === skip || document.activeElement === root); + focusNode(focused, !lifetime.aborted && (keyboardStarted || document.activeElement === skip)); } // Anime.js owns the clock and easing; this renderer resolves only the @@ -378,6 +415,7 @@ export async function initLanding() { document.addEventListener('wheel', scrollTrigger, { signal, passive: false }); document.addEventListener('touchmove', scrollTrigger, { signal, passive: false }); document.addEventListener('scroll', () => start(), { signal, passive: true }); + if (bootStart) start(bootKeyboard); } else { finish(); } @@ -391,17 +429,61 @@ export async function initLanding() { const onVisibility = () => { if (document.hidden && state === 'playing') finish(); }; const onMotion = () => { if (motion.matches && state !== 'complete') finish(); }; const onPageShow = (event: PageTransitionEvent) => { if (event.persisted && state !== 'complete') finish(); }; - document.addEventListener('visibilitychange', onVisibility); - motion.addEventListener('change', onMotion); - window.addEventListener('pageshow', onPageShow); - window.addEventListener('pagehide', (event) => { + document.addEventListener('visibilitychange', onVisibility, { signal: lifetime }); + motion.addEventListener('change', onMotion, { signal: lifetime }); + window.addEventListener('pageshow', onPageShow, { signal: lifetime }); + const dispose = () => { if (state !== 'complete') finish(); - if (event.persisted) return; timeline.cancel(); tapLoops.forEach((loop) => loop.cancel()); resize.disconnect(); - document.removeEventListener('visibilitychange', onVisibility); - motion.removeEventListener('change', onMotion); - window.removeEventListener('pageshow', onPageShow); + }; + lifetime.addEventListener('abort', dispose, { once: true }); + window.addEventListener('pagehide', (event) => { + if (state !== 'complete') finish(); + if (!event.persisted) dispose(); + }, { signal: lifetime }); +} + +export function setupExplorerNavigation() { + let controller = new AbortController(); + let activeStage = document.querySelector('[data-landing-stage]'); + // Initial page-load waits for font downloads. Install boot controls at module + // execution instead; later ClientRouter loads still initialize after each swap. + void initLanding(controller.signal); + let position = { top: 0, left: 0 }; + // Each history entry owns its reading position, even when a URL is revisited. + // Keep this separate from Astro's window scroll and the persistent tree scroll. + const readingPositions = new Map(); + let readingEntry: string | undefined; + let entrySequence = 0; + document.addEventListener('astro:before-swap', () => { + const article = document.querySelector('[data-selected-content]'); + if (article && readingEntry) readingPositions.set(readingEntry, { top: article.scrollTop, left: article.scrollLeft }); + const viewport = document.querySelector('.tree-viewport'); + if (viewport) position = { top: viewport.scrollTop, left: viewport.scrollLeft }; + controller.abort(); + }); + document.addEventListener('astro:page-load', () => { + const stage = document.querySelector('[data-landing-stage]'); + const initialize = stage !== activeStage || controller.signal.aborted; + if (initialize) { + controller.abort(); + controller = new AbortController(); + activeStage = stage; + } + readingEntry = history.state?.wipReadingEntry ?? `${Date.now()}-${entrySequence++}`; + history.replaceState({ ...history.state, wipReadingEntry: readingEntry }, ''); + const article = document.querySelector('[data-selected-content]'); + const readingPosition = readingPositions.get(readingEntry!); + if (article && readingPosition) { + article.scrollTop = readingPosition.top; + article.scrollLeft = readingPosition.left; + } + if (initialize) { + const viewport = document.querySelector('.tree-viewport'); + if (viewport) { viewport.scrollTop = position.top; viewport.scrollLeft = position.left; } + void initLanding(controller.signal); + } }); } diff --git a/site/src/styles/landing.css b/site/src/styles/landing.css index 7a8ee3f..86098f5 100644 --- a/site/src/styles/landing.css +++ b/site/src/styles/landing.css @@ -35,7 +35,12 @@ html[data-landing] .tree-viewport { overflow: visible; } .demo-node[aria-selected="true"] { background: var(--accent); color: #273621; } .demo-node:hover { background: #eaf0e2; } .demo-node[aria-selected="true"]:hover { background: #d1f39b; } -.node-indicator { width: 1ch; color: var(--muted); } +.node-indicator { color: var(--muted); } +.node-toggle { display: none; border: 0; padding: 0 1ch; height: var(--row); background: transparent; cursor: pointer; } +.demo-tree[data-enhanced] .node-toggle { display: block; } +.node-toggle:hover { background: #eaf0e2; } +html[data-landing] .node-toggle { visibility: hidden; } +.document-pane { min-width: 0; } .root-mark { width: 3ch; height: var(--row); display: block; padding: 0; background: transparent !important; transform-origin: center; z-index: 25; transition: none; } .root-vector { display: block; width: 100%; height: 100%; overflow: visible; } .slash-art { --slash-rest-scale: 0.5833333333333333; transform-origin: 12px 64px; transform: scale(var(--slash-scale, var(--slash-rest-scale))); } diff --git a/site/tests/browser/documents.ts b/site/tests/browser/documents.ts index 91caf74..a4f71e1 100644 --- a/site/tests/browser/documents.ts +++ b/site/tests/browser/documents.ts @@ -1,6 +1,10 @@ // Existing Astro book collection filenames, not a second authored guide dataset. export const documentPaths = [ '/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', @@ -10,13 +14,26 @@ export const documentPaths = [ '/4.2-text-view.md', '/5-transports.md', '/5.1-wip-over-http.md', +]; +// Formerly published draft URLs remain regression inputs, never public documents. +export const draftPaths = [ '/draft/dynamic-worldspace-projection.md', '/draft/filesystem-discovery.md', '/draft/rust-api-model.md', '/draft/typescript-api-model.md', '/draft/unresolved-issues.md', ]; -export const treePaths = ['/', ...documentPaths.filter((path) => !path.startsWith('/draft/')), '/draft', ...documentPaths.filter((path) => path.startsWith('/draft/'))]; +export const treePaths = ['/', '/book', ...documentPaths]; export const treeNodeCount = treePaths.length; -export const rootChildCount = treePaths.filter((path) => path !== '/' && path.lastIndexOf('/') === 0).length; +export const chapterSections: Record = { + '/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'], +}; +export const treeParents: Record = Object.fromEntries(treePaths.filter((path) => path !== '/').map((path) => [path, path === '/book' ? '/' : '/book'])); +for (const [chapter, sections] of Object.entries(chapterSections)) { + for (const section of sections) treeParents[section] = chapter; +} +export const rootChildCount = treePaths.filter((path) => treeParents[path] === '/').length; export const bookURL = (path: string, base: string) => new URL(`book/${path.slice(1, -3).split('/').map(encodeURIComponent).join('/')}/`, base).href; diff --git a/site/tests/browser/fonts.spec.ts b/site/tests/browser/fonts.spec.ts index 0e5a670..41a44eb 100644 --- a/site/tests/browser/fonts.spec.ts +++ b/site/tests/browser/fonts.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { rootChildCount } from './documents'; +import { rootChildCount, treeParents } from './documents'; test.use({ javaScriptEnabled: true }); @@ -34,27 +34,40 @@ test('self-hosted Lyth Mono and EB Garamond load with the configured base', asyn await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト'); expect(await page.locator('[data-selected-content]').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond'); await page.locator('[data-selected-content]').screenshot({ path: testInfo.outputPath('eb-garamond-selected-content.png') }); - await page.goto('./book/1-concept/'); - expect(await page.locator('.prose').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond'); + const tree = await page.locator('.tree-pane').elementHandle(); + await page.locator('.demo-node[data-path="/1-concept.md"]').click(); + await expect(page).toHaveURL(/\/book\/1-concept\/$/); + await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete'); + expect(await tree!.evaluate((element) => element === document.querySelector('.tree-pane'))).toBe(true); + await expect(page.locator('.tree-pane .root-vector .trunk-line')).toHaveCount(1); + await expect(page.locator('.tree-pane .trunk-reveal')).toHaveCount(1); + // Measure the actual root trunk, not branch-spine glyphs (nested prefixes have their own spines). + expect(await page.locator('.tree-pane .root-vector .trunk-reveal').evaluate((rect) => (rect as SVGRectElement).height.baseVal.value)).toBeGreaterThan(0); + expect(await page.locator('article.prose').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond'); await page.screenshot({ path: testInfo.outputPath('eb-garamond-book.png') }); }); test.describe('static font and tree rendering', () => { test.use({ javaScriptEnabled: false, deviceScaleFactor: 4 }); - test('glyph joins paint continuously across SVG cell boundaries', async ({ page }, testInfo) => { + test('single horizontal branches paint continuously from their stems', async ({ page }, testInfo) => { await page.goto('./'); await page.evaluate(() => document.fonts.ready); - // Sample every root-child join without clipping the final branch in the scrollable tree. + // Sample root and book-child joins without clipping the final branch in the scrollable tree. await page.setViewportSize({ width: page.viewportSize()!.width, height: 2000 }); await page.locator('.tree-viewport').evaluate((el) => { (el as HTMLElement).style.height = 'auto'; }); - const joins = await page.locator('.demo-node[aria-level="2"]').evaluateAll((nodes) => nodes.map((node) => { + const joins = await page.locator('.demo-node[aria-level="2"], .demo-node[aria-level="3"]').evaluateAll((nodes) => nodes.map((node) => { const svg = node.parentElement!.querySelector('.branch-vector')!; - const p = new DOMPoint(Number(svg.dataset.cellWidth) / 2, 12).matrixTransform(svg.getScreenCTM()!); - return { x: p.x, y: p.y }; + const level = Number(node.getAttribute('aria-level')); + const p = new DOMPoint((3 * (level - 2) + .5) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!); + const end = new DOMPoint((3 * (level - 2) + 2) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!); + const gapEnd = new DOMPoint((3 * (level - 2) + 3) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!); + return { x: p.x, y: p.y, end: end.x, gapEnd: gapEnd.x, level }; })); - expect(joins).toHaveLength(rootChildCount); + expect(rootChildCount).toBe(1); + expect(joins.filter(({ level }) => level === 2)).toHaveLength(rootChildCount); + expect(joins.filter(({ level }) => level === 3)).toHaveLength(Object.values(treeParents).filter((parent) => parent === '/book').length); for (const [index, join] of joins.entries()) { - const clip = { x: Math.floor(join.x), y: Math.floor(join.y) - 1, width: 14, height: 3 }; + const clip = { x: Math.floor(join.x), y: Math.floor(join.y) - 1, width: Math.ceil(join.gapEnd) - Math.floor(join.x), height: 3 }; const png = (await page.screenshot({ clip, path: testInfo.outputPath(`glyph-join-${index}.png`) })).toString('base64'); const columns = await page.evaluate(async (png) => { const image = new Image(); image.src = 'data:image/png;base64,' + png; @@ -72,9 +85,15 @@ test.describe('static font and tree rendering', () => { return darkest; }); }, png); - // Ignore only the outer antialiasing fringe at the trunk's left edge. + // The hook and one horizontal glyph are followed by a blank cell. const start = Math.ceil((join.x - clip.x + .25) * 4); - expect(columns.slice(start).every((ink) => ink < 710), JSON.stringify(columns)).toBe(true); + const end = Math.floor((join.end - clip.x - .25) * 4); + expect(end).toBeGreaterThan(start); + expect(columns.slice(start, end).every((ink) => ink < 710), JSON.stringify(columns)).toBe(true); + const gapStart = Math.ceil((join.end - clip.x + 1) * 4); + const gapEnd = Math.floor((join.gapEnd - clip.x - .25) * 4); + expect(gapEnd).toBeGreaterThan(gapStart); + expect(columns.slice(gapStart, gapEnd).every((ink) => ink > 710), JSON.stringify(columns)).toBe(true); } }); test('the same SVG trunk and Lyth Mono joins are visible without JavaScript', async ({ page }, testInfo) => { @@ -82,10 +101,10 @@ test.describe('static font and tree rendering', () => { await expect(page.locator('html')).not.toHaveAttribute('data-landing'); await expect(page.locator('.trunk-line')).toBeVisible(); await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none'); - await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── '); + await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(0); - await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(4); - await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9500'); + await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(3); + await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9492'); await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5); await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); diff --git a/site/tests/browser/landing.spec.ts b/site/tests/browser/landing.spec.ts index b93fcc5..f161a20 100644 --- a/site/tests/browser/landing.spec.ts +++ b/site/tests/browser/landing.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { documentPaths, treePaths, treeNodeCount, bookURL } from './documents'; +import { documentPaths, treePaths, treeNodeCount, bookURL, chapterSections, treeParents } from './documents'; test.use({ javaScriptEnabled: true }); @@ -11,7 +11,7 @@ const visiblePrefixes = '.node-row:not([hidden]) .branch-prefix'; async function expectNodeGlitch(page: import('@playwright/test').Page) { expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.some((node) => - node.querySelector('.node-label')!.textContent !== (node as HTMLElement).dataset.path!.split('/').at(-1) + node.querySelector('.node-label')!.textContent !== ((node as HTMLElement).dataset.path === '/book' ? 'book/' : (node as HTMLElement).dataset.path!.split('/').at(-1)) ))).toBe(true); } @@ -27,16 +27,16 @@ async function exposeTree(page: import('@playwright/test').Page) { } async function checkGlyphRefs(page: import('@playwright/test').Page) { - const rendered = await page.locator(visiblePrefixes).evaluateAll((prefixes) => { + const rendered = await page.locator(visiblePrefixes).evaluateAll((prefixes, parents) => { const paths = [...document.querySelectorAll('.node-row:not([hidden]) .demo-node')].map((node) => node.dataset.path!); - const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/'; + const parent = (path: string) => parents[path]; const last = (path: string) => paths.filter((other) => other !== '/' && parent(other) === parent(path)).at(-1) === path; return prefixes.map((prefix) => { const path = prefix.parentElement!.querySelector('.demo-node')!.dataset.path!; - let expected = last(path) ? '└── ' : '├── '; + let expected = last(path) ? '└─ ' : '├─ '; let ancestor = parent(path); while (ancestor !== '/') { - expected = (last(ancestor) ? ' ' : '│ ') + expected; + expected = (last(ancestor) ? ' ' : '│ ') + expected; ancestor = parent(ancestor); } const refs = [...prefix.querySelectorAll('use[data-cell]')].map((use) => ({ @@ -45,7 +45,7 @@ async function checkGlyphRefs(page: import('@playwright/test').Page) { })); return { actual: prefix.getAttribute('data-prefix'), expected, refs }; }); - }); + }, treeParents); expect(rendered.length).toBeGreaterThan(0); for (const { actual, expected, refs } of rendered) { expect(actual).toBe(expected); @@ -345,7 +345,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate const root = page.locator('.root-mark'); await expect(root.locator('svg .root-shape')).toHaveCount(1); await expect(root.locator('.root-shape')).toHaveAttribute('d', 'M31 4H41L17 60H7Z'); // The slash is geometry, not a font glyph. - await expect(page.locator(selectedContent)).toHaveAttribute('inert', ''); + await expect(page.locator('.document-pane[data-reveal]')).toHaveAttribute('inert', ''); const initial = await root.boundingBox(); expect(initial).not.toBeNull(); expect(Math.abs(initial!.x + initial!.width / 2 - page.viewportSize()!.width / 2)).toBeLessThan(2); @@ -429,7 +429,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate spineOpacity: Number(getComputedStyle(first.querySelector('.branch-spine')!).opacity), trunkOpacity: Number(getComputedStyle(document.querySelector('.trunk-line')!).opacity), labelOpacity: Number(getComputedStyle(document.querySelector('.demo-node:not(.root-mark)')!).opacity), - articleOpacity: Number(getComputedStyle(document.querySelector('[data-selected-content]')!).opacity), + articleOpacity: Number(getComputedStyle(document.querySelector('.document-pane[data-reveal]')!).opacity), sceneTransform: getComputedStyle(document.querySelector('.tree-scene')!).transform, rowTransforms: [...document.querySelectorAll('.node-row, .demo-node')].map((row) => getComputedStyle(row).transform), }; @@ -458,7 +458,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate await page.clock.runFor(800); await complete(page); await expect(root).toHaveCount(1); - await expect(page.locator(selectedContent)).not.toHaveAttribute('inert'); + await expect(page.locator('.document-pane[data-reveal]')).not.toHaveAttribute('inert'); await expect(page.locator('[data-glitching]')).toHaveCount(0); await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); await expect(page.locator('.branch-lines')).toHaveCount(0); @@ -472,7 +472,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate const grid = await page.evaluate(() => { const root = document.querySelector('.root-mark')!.getBoundingClientRect(); const prefix = document.querySelector('.branch-prefix')!; - const cell = prefix.getBoundingClientRect().width / 4; + const cell = prefix.getBoundingClientRect().width / prefix.dataset.prefix!.length; const frame = document.querySelector('.landing-content')!.getBoundingClientRect(); const rows = [...document.querySelectorAll('.node-row:not(.root-slot):not([hidden])')].map((row) => row.getBoundingClientRect()); const nodes = [...document.querySelectorAll('.node-row:not([hidden]) .demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect()); @@ -511,7 +511,7 @@ for (const time of [1500, 2600]) { for (const row of await page.locator('.node-row').all()) await expect(row).toHaveCSS('transform', 'none'); await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none'); await expect(page.locator('.site-header')).toHaveCSS('opacity', '1'); - await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── '); + await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); await expect(page.locator('.intro-art')).toBeHidden(); await page.clock.runFor(4000); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); @@ -533,7 +533,7 @@ test('skipping an active node glitch restores exact filenames without altering d await page.keyboard.press('Escape'); await complete(page); await expect(page.locator('[data-glitching]')).toHaveCount(0); - for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path.split('/').at(-1)!); + for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path === '/book' ? 'book/' : path.split('/').at(-1)!); await page.clock.runFor(2000); expect(await page.locator(selectedContent).innerHTML()).toBe(html); await page.locator('.root-mark').hover(); @@ -564,22 +564,32 @@ test('landing contains only the real filename tree and selected canonical conten for (const path of documentPaths) { const node = nodeAt(page, path); await expect(node).toHaveAttribute('role', 'treeitem'); - await expect(node).toHaveAttribute('aria-level', path.startsWith('/draft/') ? '3' : '2'); + await expect(node).toHaveAttribute('aria-level', treeParents[path] === '/book' ? '3' : '4'); await expect(node.locator('.node-label')).toHaveText(path.split('/').at(-1)!); expect(await node.evaluate((node) => node.tagName)).toBe('A'); expect(await node.evaluate((node) => (node as HTMLAnchorElement).href)).toBe(bookURL(path, testInfo.project.use.baseURL!)); - await expect(node).not.toHaveAttribute('aria-expanded'); + if (chapterSections[path]) await expect(node).toHaveAttribute('aria-expanded', 'true'); + else await expect(node).not.toHaveAttribute('aria-expanded'); } - for (const path of ['/', '/draft']) { + for (const path of ['/', '/book']) { const folder = nodeAt(page, path); - expect(await folder.evaluate((node) => node.tagName)).toBe('BUTTON'); + expect(await folder.evaluate((node) => node.tagName)).toBe('A'); + await expect(folder).toHaveAttribute('href', new URL(path === '/' ? './' : 'book/', testInfo.project.use.baseURL!).pathname); await expect(folder).toHaveAttribute('aria-expanded', 'true'); await expect(folder).toHaveAttribute('aria-level', path === '/' ? '1' : '2'); + if (path === '/book') { + await expect(folder.locator('.node-label')).toHaveText('book/'); + const toggle = page.locator('.node-toggle[data-toggle-path="/book"]'); + await expect(toggle).toHaveText('−'); + expect(await toggle.evaluate((button) => button.tagName)).toBe('BUTTON'); + expect(await toggle.evaluate((button) => button.parentElement === document.querySelector('.demo-node[data-path="/book"]')!.parentElement)).toBe(true); + await expect(folder.locator('button')).toHaveCount(0); + } } + await expect(page.locator('.demo-node[data-path^="/draft"], .landing a[href*="/draft/"]')).toHaveCount(0); expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.every((node) => - node.textContent === node.querySelector('.node-label')!.textContent + (node.querySelector('.node-indicator')?.textContent ?? '') - && [...node.children].every((child) => child.matches('.node-label, .node-indicator')) - && [...node.querySelectorAll('.node-indicator')].every((indicator) => /^[+−-]$/.test(indicator.textContent!)) + node.textContent === node.querySelector('.node-label')!.textContent + && [...node.children].every((child) => child.matches('.node-label')) ))).toBe(true); const payload = page.locator('script#landing-documents'); await expect(payload).toHaveCount(1); @@ -592,14 +602,14 @@ test('landing contains only the real filename tree and selected canonical conten const document = stored.find((document) => document.path === path)!; expect(document.href).toBe(new URL(bookURL(path, testInfo.project.use.baseURL!)).pathname); expect(document.title).toBeTruthy(); - expect(document.html).toContain(' document.path === path)!; - expect(folder.html).toBeUndefined(); - expect(folder.title).toBeUndefined(); - expect(folder.href).toBeUndefined(); + expect(folder).not.toHaveProperty('html'); + expect(folder.href).toBe(new URL(path === '/' ? './' : 'book/', testInfo.project.use.baseURL!).pathname); } + await expect(page.locator(stage)).toHaveAttribute('data-current-path', initialPath); await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true'); await expect(page.locator(selectedContent)).toBeVisible(); await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); @@ -607,79 +617,123 @@ test('landing contains only the real filename tree and selected canonical conten await checkGlyphRefs(page); }); -test('each file immediately displays exactly its Book HTML, including links, without fetching or navigating', async ({ page }, testInfo) => { +test('numbered chapters nest visually, fold independently, and retain section URLs', async ({ page }, testInfo) => { + const section = '/1.1-interaction-abstraction.md'; + await page.goto(bookURL(section, testInfo.project.use.baseURL!)); + await complete(page); + const chapter = nodeAt(page, initialPath); + const child = nodeAt(page, section); + await expect(chapter).toHaveAttribute('aria-level', '3'); + await expect(child).toHaveAttribute('aria-level', '4'); + const prefix = child.locator('..').locator('.branch-prefix'); + await expect(prefix).toHaveAttribute('data-prefix', ' │ ├─ '); + await expect(nodeAt(page, '/1.4-concepts-and-scope.md').locator('..').locator('.branch-prefix')).toHaveAttribute('data-prefix', ' │ └─ '); + const before = await page.locator(selectedContent).innerHTML(); + await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click(); + for (const path of chapterSections[initialPath]) await expect(nodeAt(page, path)).toBeHidden(); + await expect(nodeAt(page, '/2-core-model.md')).toBeVisible(); + await expect(page).toHaveURL(bookURL(section, testInfo.project.use.baseURL!)); + expect(await page.locator(selectedContent).innerHTML()).toBe(before); + await expect(child).toHaveAttribute('aria-current', 'page'); + await checkGlyphRefs(page); + await page.keyboard.press('ArrowRight'); + await expect(child).toBeVisible(); + await page.keyboard.press('ArrowRight'); + await expect(child).toBeFocused(); + await page.keyboard.press('ArrowLeft'); + await expect(chapter).toBeFocused(); + await page.keyboard.press('ArrowLeft'); + await expect(child).toBeHidden(); + // Other navigation preserves the collapsed chapter; returning to a child reveals it. + await nodeAt(page, '/2-core-model.md').click(); + await expect(nodeAt(page, '/2-core-model.md')).toHaveAttribute('aria-current', 'page'); + await expect(chapter).toHaveAttribute('aria-expanded', 'false'); + await page.goBack(); + await expect(child).toHaveAttribute('aria-current', 'page'); + await expect(chapter).toHaveAttribute('aria-expanded', 'true'); + await expect(child).toBeVisible(); + await expect(child).toHaveAttribute('href', new URL(bookURL(section, testInfo.project.use.baseURL!)).pathname); + await checkGlyphRefs(page); +}); + +test('each file navigates to exactly its Book HTML and title, including links', async ({ page }, testInfo) => { await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('./'); await complete(page); const book = await page.context().newPage(); - const bodies = new Map(); + const documents = new Map(); for (const path of documentPaths) { await book.goto(bookURL(path, testInfo.project.use.baseURL!)); - bodies.set(path, await book.locator('article.prose').innerHTML()); + documents.set(path, { html: await book.locator('article.prose').innerHTML(), title: await book.title() }); } await book.close(); - const landingURL = page.url(); - expect(await page.locator(selectedContent).innerHTML()).toBe(bodies.get(initialPath)); - const requests: string[] = []; - page.on('request', (request) => requests.push(request.url())); + expect(await page.locator(selectedContent).innerHTML()).toBe(documents.get(initialPath)!.html); for (const path of documentPaths) { - // Observe the body in the same task as the click, not after waiting for a read/reveal. - const displayed = await nodeAt(page, path).evaluate((node) => { - (node as HTMLElement).click(); - const article = document.querySelector('[data-selected-content]')!; - return { html: article.innerHTML, hidden: article.hidden }; - }); - expect(displayed).toEqual({ html: bodies.get(path), hidden: false }); - await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true'); - expect(page.url()).toBe(landingURL); - } - expect(requests).toEqual([]); - for (const path of ['/draft', '/']) { await nodeAt(page, path).click(); - await expect(page.locator(selectedContent)).toBeHidden(); - expect(await page.locator(selectedContent).innerHTML()).toBe(''); + await expect(page).toHaveURL(bookURL(path, testInfo.project.use.baseURL!)); + await expect(page.locator(stage)).toHaveAttribute('data-current-path', path); + await expect(page).toHaveTitle(documents.get(path)!.title); + await expect.poll(() => page.locator(selectedContent).innerHTML()).toBe(documents.get(path)!.html); + await expect(page.locator('article.prose')).toHaveCount(1); + await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true'); + await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page'); + await expect(page.locator('.demo-node[aria-current="page"]')).toHaveCount(1); + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); + await expect(page.locator('html')).not.toHaveAttribute('data-landing'); } }); -test('file selection and folder collapse work with keyboard navigation in the scrollable viewport', async ({ page }) => { +test('keyboard arrows only move focus or fold; Enter and Space activate route links', async ({ page }, testInfo) => { await armed(page); await page.keyboard.press('Escape'); await complete(page); + const originalURL = page.url(); + const originalHTML = await page.locator(selectedContent).innerHTML(); + const unchanged = async () => { + expect(page.url()).toBe(originalURL); + expect(await page.locator(selectedContent).innerHTML()).toBe(originalHTML); + await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true'); + }; await expect(nodeAt(page, initialPath)).toBeFocused(); await page.keyboard.press('ArrowDown'); - await expect(nodeAt(page, '/2-core-model.md')).toBeFocused(); - await expect(nodeAt(page, '/2-core-model.md')).toHaveAttribute('aria-selected', 'true'); - await expect(page.locator(selectedContent + ' h1')).toHaveText('2. コアモデル'); - const draft = nodeAt(page, '/draft'); - await draft.focus(); + await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused(); + await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toHaveAttribute('aria-selected', 'false'); + await unchanged(); + const book = nodeAt(page, '/book'); + await book.focus(); await page.keyboard.press('ArrowLeft'); - await expect(draft).toHaveAttribute('aria-expanded', 'false'); - for (const path of documentPaths.filter((path) => path.startsWith('/draft/'))) await expect(nodeAt(page, path)).toBeHidden(); - await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount - 5); - await expect(page.locator(selectedContent)).toBeHidden(); - expect(await page.locator(selectedContent).innerHTML()).toBe(''); + await expect(book).toHaveAttribute('aria-expanded', 'false'); + for (const path of documentPaths) await expect(nodeAt(page, path)).toBeHidden(); + await expect(page.getByRole('treeitem')).toHaveCount(2); + await unchanged(); await checkGlyphRefs(page); await page.keyboard.press('ArrowRight'); - await expect(draft).toHaveAttribute('aria-expanded', 'true'); + await expect(book).toHaveAttribute('aria-expanded', 'true'); await page.keyboard.press('ArrowRight'); - await expect(nodeAt(page, documentPaths[10])).toBeFocused(); - await expect(page.locator(selectedContent)).toBeVisible(); + await expect(nodeAt(page, initialPath)).toBeFocused(); + await unchanged(); + await page.keyboard.press('ArrowLeft'); // Fold the chapter before moving to its parent. + await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-expanded', 'false'); await page.keyboard.press('ArrowLeft'); - await expect(draft).toBeFocused(); - await expect(page.locator(selectedContent)).toBeHidden(); + await expect(book).toBeFocused(); + await unchanged(); + await nodeAt(page, initialPath).focus(); + await page.keyboard.press('ArrowRight'); // Restore all descendants for root collapse/reopen. await page.keyboard.press('Home'); const root = nodeAt(page, '/'); await expect(root).toBeFocused(); await page.keyboard.press('ArrowLeft'); await expect(page.getByRole('treeitem')).toHaveCount(1); expect((await trunkGeometry(page)).length).toBe(0); + await unchanged(); await page.keyboard.press('ArrowRight'); await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount); await checkGlyphRefs(page); await page.keyboard.press('End'); - const finalLeaf = nodeAt(page, documentPaths.at(-1)!); + const finalPath = documentPaths.at(-1)!; + const finalLeaf = nodeAt(page, finalPath); await expect(finalLeaf).toBeFocused(); - await expect(finalLeaf).toHaveAttribute('aria-selected', 'true'); + await expect(finalLeaf).toHaveAttribute('aria-selected', 'false'); const scroll = await page.locator('.tree-viewport').evaluate((viewport) => { const node = document.activeElement!.getBoundingClientRect(), box = viewport.getBoundingClientRect(); return { top: viewport.scrollTop, needsScroll: viewport.scrollHeight > viewport.clientHeight, inside: node.top >= box.top && node.bottom <= box.bottom && node.left >= box.left && node.right <= box.right }; @@ -687,18 +741,21 @@ test('file selection and folder collapse work with keyboard navigation in the sc if (scroll.needsScroll) expect(scroll.top).toBeGreaterThan(0); expect(scroll.inside).toBe(true); await page.keyboard.press('ArrowUp'); - await expect(nodeAt(page, '/draft/typescript-api-model.md')).toBeFocused(); + const previousPath = '/5-transports.md'; + await expect(nodeAt(page, previousPath)).toBeFocused(); await page.keyboard.press('ArrowDown'); await expect(finalLeaf).toBeFocused(); + await unchanged(); await expect(page.locator('.demo-node[tabindex="0"]')).toHaveCount(1); await page.keyboard.press('Enter'); - await expect(finalLeaf).toHaveAttribute('aria-selected', 'true'); + await expect(page).toHaveURL(bookURL(finalPath, testInfo.project.use.baseURL!)); + await expect(finalLeaf).toHaveAttribute('aria-current', 'page'); await expect(page.locator(selectedContent)).toBeVisible(); + await nodeAt(page, previousPath).focus(); await page.keyboard.press('Space'); - await expect(finalLeaf).toHaveAttribute('aria-selected', 'true'); + await expect(page).toHaveURL(bookURL(previousPath, testInfo.project.use.baseURL!)); + await expect(nodeAt(page, previousPath)).toHaveAttribute('aria-current', 'page'); await expect(page.locator(selectedContent)).toBeVisible(); - await page.keyboard.press('Tab'); - await expect(page.locator(selectedContent)).toBeFocused(); }); test.describe('glyph trunk regressions', () => { @@ -789,19 +846,19 @@ test.describe('glyph trunk regressions', () => { return trunk.length; }; const expanded = await checkEnd('expanded'); - const draft = nodeAt(page, '/draft'); - await draft.focus(); + const book = nodeAt(page, '/book'); + await book.focus(); await page.keyboard.press('ArrowLeft'); - await expect(draft).toHaveAttribute('aria-expanded', 'false'); - // Draft is the last root child: its descendants never extend the first-column spine. - expect(await checkEnd('draft')).toBe(expanded); + await expect(book).toHaveAttribute('aria-expanded', 'false'); + // Book is the sole root child: its descendants never extend the first-column spine. + expect(await checkEnd('book')).toBe(expanded); await page.keyboard.press('Home'); await page.keyboard.press('ArrowLeft'); await expect(page.getByRole('treeitem')).toHaveCount(1); await checkEnd('root'); await page.keyboard.press('ArrowRight'); expect(await checkEnd('root-reopened')).toBe(expanded); - await draft.focus(); + await book.focus(); await page.keyboard.press('ArrowRight'); expect(await checkEnd('all-reopened')).toBe(expanded); }); @@ -824,6 +881,9 @@ test.describe('JavaScript-free glyph tree', () => { await node.click(); await expect(page).toHaveURL(bookURL(path, testInfo.project.use.baseURL!)); await expect(page.locator('article.prose h1')).toBeVisible(); + await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); + await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page'); + await expect(page.locator(stage)).toHaveAttribute('data-current-path', path); if (path === initialPath) expect(await page.locator('article.prose').innerHTML()).toBe(initialHTML); } }); @@ -845,7 +905,9 @@ test.describe('JavaScript-free glyph tree', () => { expect(trunk.gapRatio).toBeCloseTo(1 / 14, 5); await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('no-js-glyph-tree.png') }); await page.getByRole('link', { name: 'Book', exact: true }).click(); - await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで'); + await expect(page).toHaveURL(/\/book\/$/); + await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page'); + await expect(page.locator('.landing a[href$="/book/1-concept/"]').last()).toBeVisible(); }); }); @@ -876,13 +938,15 @@ test('blocked animation module fails open to readable content', async ({ page }) await expect(page.locator('html')).not.toHaveAttribute('data-landing', { timeout: 6000 }); await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト'); await expect(page.locator(selectedContent)).toBeVisible(); - await nodeAt(page, '/draft/rust-api-model.md').click(); - await expect(page).toHaveURL(/\/book\/draft\/rust-api-model\/$/); + await nodeAt(page, '/2-core-model.md').click(); + await expect(page).toHaveURL(/\/book\/2-core-model\/$/); await expect(page.locator('article.prose h1')).toBeVisible(); await page.goto('./'); await expect(page.locator(selectedContent)).toBeVisible(); await page.getByRole('link', { name: 'Book', exact: true }).click(); - await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで'); + await expect(page).toHaveURL(/\/book\/$/); + await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page'); + await expect(page.locator('.landing a[href$="/book/1-concept/"]').last()).toBeVisible(); }); test('touch swipe starts the uninterrupted timeline', async ({ page }, testInfo) => { diff --git a/site/tests/browser/routing.spec.ts b/site/tests/browser/routing.spec.ts new file mode 100644 index 0000000..dd816fc --- /dev/null +++ b/site/tests/browser/routing.spec.ts @@ -0,0 +1,607 @@ +import { expect, test, type Page } from '@playwright/test'; +import { load } from 'cheerio'; +import { bookURL, documentPaths, draftPaths, treeNodeCount, treePaths } from './documents'; + +test.use({ javaScriptEnabled: true }); + +const stage = '[data-landing-stage]'; +const article = 'article.selected-content.prose[data-selected-content]'; +const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`); +const toggle = (page: Page) => page.locator('button.node-toggle[data-toggle-path="/book"]'); +const urlFor = (path: string, base: string) => path === '/' ? new URL('./', base).href + : path === '/book' ? new URL('book/', base).href : bookURL(path, base); + +async function settled(page: Page, path: string) { + await expect(page.locator(stage)).toHaveAttribute('data-current-path', path); + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); + await expect(page.locator('html')).not.toHaveAttribute('data-landing'); + await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page'); + await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.demo-node[aria-current="page"]')).toHaveCount(1); +} + +// Catch even a transient intro state, rather than merely waiting for it to finish. +function recordIntroStates() { + const states: string[] = []; + (window as Window & { introStates?: string[] }).introStates = states; + new MutationObserver((mutations) => { + for (const mutation of mutations) { + const target = mutation.target as Element; + const values = [mutation.oldValue, target.getAttribute(mutation.attributeName!)]; + for (const value of values) { + if (value && (mutation.attributeName === 'data-landing' || ['armed', 'flow'].includes(value))) states.push(value); + } + } + }).observe(document, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['data-landing', 'data-phase'] }); +} + +async function expectNoIntroStates(page: Page) { + expect(await page.evaluate(() => (window as Window & { introStates?: string[] }).introStates)).toEqual([]); +} + +async function expectMetadata(page: Page, expectedURL: string) { + const title = await page.title(); + expect(title).toBeTruthy(); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute('content', title); + const description = await page.locator('meta[name="description"]').getAttribute('content'); + expect(description).toBeTruthy(); + await expect(page.locator('meta[property="og:description"]')).toHaveAttribute('content', description!); + const canonical = page.locator('link[rel="canonical"]'); + if (process.env.SITE_URL) { + await expect(canonical).toHaveCount(1); + await expect(canonical).toHaveAttribute('href', new URL(new URL(expectedURL).pathname, process.env.SITE_URL).href); + } else { + await expect(canonical).toHaveCount(0); + } +} + +test('all 14 document responses contain the current article and metadata-only public tree', async ({ request }, testInfo) => { + expect(documentPaths).toHaveLength(14); + expect(treeNodeCount).toBe(16); + for (const path of documentPaths) { + const url = bookURL(path, testInfo.project.use.baseURL!); + const response = await request.get(url); + expect(response.ok(), path).toBe(true); + const $ = load(await response.text()); + expect($('.demo-node').length, path).toBe(treeNodeCount); + expect($(stage).attr('data-current-path')).toBe(path); + expect($(article).length).toBe(1); + expect($('article.prose').length).toBe(1); + expect($(article).find('h1').text()).toBeTruthy(); + expect($(`.demo-node[data-path="${path}"]`).attr('aria-current')).toBe('page'); + expect($(`.demo-node[data-path="${path}"]`).attr('href')).toBe(new URL(url).pathname); + expect($('.sidebar, .mobile-chapters, .mobile-toc, .page-toc, .pagination').length).toBe(0); + const payload = $('#landing-documents'); + expect(payload.length).toBe(1); + const serialized = payload.text(); + expect(serialized).not.toContain('<'); + const nodes = JSON.parse(serialized) as Record[]; + expect(nodes.map((node) => node.path)).toEqual(treePaths); + for (const node of nodes) { + expect(node).not.toHaveProperty('html'); + expect(node).not.toHaveProperty('body'); + expect(node.href).toBe(new URL(urlFor(node.path as string, testInfo.project.use.baseURL!)).pathname); + } + expect($('title').text()).toContain($(article).find('h1').first().text()); + expect($('meta[property="og:title"]').attr('content')).toBe($('title').text()); + expect($('meta[name="description"]').attr('content')).toBeTruthy(); + if (process.env.SITE_URL) { + expect($('link[rel="canonical"]').attr('href')).toBe(new URL(new URL(url).pathname, process.env.SITE_URL).href); + } else { + expect($('link[rel="canonical"]').length).toBe(0); + } + expect($('.demo-node[data-path^="/draft"], a[href*="/draft/"], .draft-notice').length).toBe(0); + expect(serialized).not.toContain('/draft'); + } +}); + +test('draft directory and all five formerly public draft documents return 404 without publishing content', async ({ request }, testInfo) => { + const base = testInfo.project.use.baseURL!; + expect(draftPaths).toHaveLength(5); + const missing = await request.get(new URL('missing-public-document/', base).href); + const notFound = load(await missing.text()); + expect(missing.status()).toBe(404); + for (const url of [new URL('book/draft/', base).href, ...draftPaths.map((path) => bookURL(path, base))]) { + const response = await request.get(url); + expect(response.status(), url).toBe(404); + const $ = load(await response.text()); + expect($('.demo-node').length).toBe(treeNodeCount); + expect($('.demo-node[data-path^="/draft"], a[href*="/draft/"], .draft-notice').length).toBe(0); + expect($(article).length).toBe(1); + expect($(article).html()).toBe(notFound(article).html()); + expect($(article).find('h1').text()).toBe('このページは見つかりません。'); + expect($(stage).attr('data-current-path') ?? '').not.toMatch(/^\/draft/); + const nodes = JSON.parse($('#landing-documents').text()) as { path: string; html?: string; body?: string }[]; + expect(nodes.map((node) => node.path)).toEqual(treePaths); + for (const node of nodes) { + expect(node).not.toHaveProperty('html'); + expect(node).not.toHaveProperty('body'); + } + } +}); + +test('direct documents never arm the intro; history and reload restore route body and metadata', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + const first = '/1-concept.md', second = '/2-core-model.md'; + await page.addInitScript(recordIntroStates); + await page.goto(bookURL(first, base)); + await settled(page, first); + const firstHTML = await page.locator(article).innerHTML(); + const firstTitle = await page.title(); + await expectMetadata(page, bookURL(first, base)); + const pane = await page.locator('.tree-pane').elementHandle(); + const oldStage = await page.locator(stage).elementHandle(); + const oldArticle = await page.locator(article).elementHandle(); + await nodeAt(page, second).click(); + await expect(page).toHaveURL(bookURL(second, base)); + await settled(page, second); + const secondHTML = await page.locator(article).innerHTML(); + const secondTitle = await page.title(); + expect(secondHTML).not.toBe(firstHTML); + expect(secondTitle).not.toBe(firstTitle); + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); + expect(await oldStage!.evaluate((el) => el === document.querySelector('[data-landing-stage]'))).toBe(false); + expect(await oldArticle!.evaluate((el) => el === document.querySelector('article.prose'))).toBe(false); + await expectMetadata(page, bookURL(second, base)); + await page.goBack(); + await expect(page).toHaveURL(bookURL(first, base)); + await settled(page, first); + expect(await page.locator(article).innerHTML()).toBe(firstHTML); + await expect(page).toHaveTitle(firstTitle); + await expectMetadata(page, bookURL(first, base)); + await page.goForward(); + await settled(page, second); + expect(await page.locator(article).innerHTML()).toBe(secondHTML); + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); + await expectMetadata(page, bookURL(second, base)); + await expectNoIntroStates(page); + await page.reload(); + await settled(page, second); + expect(await page.locator(article).innerHTML()).toBe(secondHTML); + await expect(page).toHaveTitle(secondTitle); + await expectMetadata(page, bookURL(second, base)); + await expectNoIntroStates(page); +}); + +test('modified arrow keys remain unconsumed and do not move tree focus or fold directories', async ({ page }, testInfo) => { + const path = '/3-protocol.md'; + await page.goto(bookURL(path, testInfo.project.use.baseURL!)); + await settled(page, path); + const originalURL = page.url(); + const originalHTML = await page.locator(article).innerHTML(); + const book = nodeAt(page, '/book'); + await book.focus(); + // Synthetic cancelable events test shortcut ownership without depending on + // headless Chrome implementing native Alt+Arrow history shortcuts. + for (const modifier of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'] as const) { + for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) { + const result = await book.evaluate((el, { key, modifier }) => { + const event = new KeyboardEvent('keydown', { key, [modifier]: true, bubbles: true, cancelable: true }); + return { uncanceled: el.dispatchEvent(event), defaultPrevented: event.defaultPrevented }; + }, { key, modifier }); + expect(result, `${modifier}+${key}`).toEqual({ uncanceled: true, defaultPrevented: false }); + await expect(book).toBeFocused(); + await expect(book).toHaveAttribute('aria-expanded', 'true'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); + expect(page.url()).toBe(originalURL); + await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page'); + expect(await page.locator(article).innerHTML()).toBe(originalHTML); + } + } + // Positive controls: the handler must still own unmodified tree navigation. + await page.keyboard.press('ArrowLeft'); + await expect(book).toHaveAttribute('aria-expanded', 'false'); + await page.keyboard.press('ArrowRight'); + await expect(book).toHaveAttribute('aria-expanded', 'true'); + await page.keyboard.press('ArrowDown'); + await expect(nodeAt(page, '/1-concept.md')).toBeFocused(); + expect(page.url()).toBe(originalURL); +}); + +test('history restores nested article scroll per entry, including repeated visits to the same URL', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + const first = '/3-protocol.md', second = '/4.2-text-view.md'; + if (testInfo.project.name === 'mobile') { + // Natural mobile reading scroll belongs to Astro's window restoration. + // Force only this test's article to overflow, before page-load restoration + // runs on each new document; desktop exercises the unmodified layout. + await page.addInitScript(() => { + const constrainArticle = () => { + const el = document.querySelector('[data-selected-content]'); + if (el) { el.style.maxHeight = '240px'; el.style.overflow = 'auto'; } + }; + document.addEventListener('astro:after-swap', constrainArticle); + document.addEventListener('astro:page-load', constrainArticle); + }); + } + const setPosition = async (fraction: number) => { + const position = await page.locator(article).evaluate((el, fraction) => { + const maximum = el.scrollHeight - el.clientHeight; + el.scrollTop = Math.floor(maximum * fraction); + return { maximum, top: el.scrollTop }; + }, fraction); + expect(position.maximum).toBeGreaterThan(100); + expect(position.top).toBeGreaterThan(0); + return position.top; + }; + const expectPosition = async (top: number) => { + await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(top); + }; + const navigate = async (path: string) => { + await nodeAt(page, path).click(); + await expect(page).toHaveURL(bookURL(path, base)); + await settled(page, path); + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + }; + await page.goto(bookURL(first, base)); + await settled(page, first); + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + const pane = await page.locator('.tree-pane').elementHandle(); + const firstVisit = await setPosition(.2); + await navigate(second); + await expectPosition(0); + const secondVisit = await setPosition(.3); + await navigate(first); + await expectPosition(0); // A new history entry must not reuse this URL's earlier position. + const firstRevisit = await setPosition(.6); + expect(firstRevisit).not.toBe(firstVisit); + await navigate(second); + await expectPosition(0); + const secondRevisit = await setPosition(.7); + expect(secondRevisit).not.toBe(secondVisit); + for (const [path, top] of [[first, firstRevisit], [second, secondVisit], [first, firstVisit]] as const) { + await page.goBack(); + await expect(page).toHaveURL(bookURL(path, base)); + await settled(page, path); + await expectPosition(top); + } + for (const [path, top] of [[second, secondVisit], [first, firstRevisit], [second, secondRevisit]] as const) { + await page.goForward(); + await expect(page).toHaveURL(bookURL(path, base)); + await settled(page, path); + await expectPosition(top); + } + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); +}); + +test('directory anchors navigate independently from the sibling plus/minus toggle', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + const path = '/2-core-model.md'; + await page.goto(bookURL(path, base)); + await settled(page, path); + const html = await page.locator(article).innerHTML(); + const title = await page.title(); + await expect(nodeAt(page, '/book')).toHaveAttribute('href', new URL('book/', base).pathname); + await toggle(page).click(); + await expect(toggle(page)).toHaveText('+'); + await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); + await expect(nodeAt(page, path)).toBeHidden(); + expect(page.url()).toBe(bookURL(path, base)); + expect(await page.locator(article).innerHTML()).toBe(html); + await expect(page).toHaveTitle(title); + await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true'); + await toggle(page).click(); + await expect(toggle(page)).toHaveText('−'); + await expect(nodeAt(page, path)).toBeVisible(); + await nodeAt(page, '/book').click(); + await expect(page).toHaveURL(new URL('book/', base).href); + await settled(page, '/book'); + await expect(page.locator('.landing a:not(.demo-node)[href*="/book/"]')).toHaveCount(14); + for (const document of documentPaths) { + await expect(page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(document, base)).pathname}"]`)).toBeVisible(); + } + await nodeAt(page, '/').focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(base); + await settled(page, '/1-concept.md'); + await expect(page.locator(article + ' h1')).toHaveText('1. コンセプト'); + await expect(page.locator('.sidebar, .mobile-chapters, .mobile-toc, .page-toc, .pagination')).toHaveCount(0); +}); + +test('persisted tree retains scroll and collapse, expanding only an ancestor needed by selection', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + const first = '/1-concept.md', second = '/2-core-model.md', section = '/4.1-ai-tools.md'; + await page.goto(bookURL(first, base)); + await settled(page, first); + const pane = await page.locator('.tree-pane').elementHandle(); + const viewport = await page.locator('.tree-viewport').elementHandle(); + const root = await nodeAt(page, '/').elementHandle(); + await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click(); + await page.locator('.tree-viewport').evaluate((el) => { + (el as HTMLElement).style.height = '120px'; + el.scrollTop = 48; + }); + const scroll = await page.locator('.tree-viewport').evaluate((el) => ({ top: el.scrollTop, left: el.scrollLeft })); + expect(scroll.top).toBeGreaterThan(0); + // Programmatic native anchor activation avoids Playwright scrolling the target into view first. + await nodeAt(page, second).evaluate((el) => (el as HTMLAnchorElement).click()); + await settled(page, second); + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); + expect(await viewport!.evaluate((el) => el === document.querySelector('.tree-viewport'))).toBe(true); + expect(await root!.evaluate((el) => el === document.querySelector('.root-mark'))).toBe(true); + expect(await page.locator('.tree-viewport').evaluate((el) => ({ top: el.scrollTop, left: el.scrollLeft }))).toEqual(scroll); + await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); + const secondHTML = await page.locator(article).innerHTML(); + await nodeAt(page, '/').focus(); + await page.keyboard.press('ArrowLeft'); + await expect(page.getByRole('treeitem')).toHaveCount(1); + expect(page.url()).toBe(bookURL(second, base)); + expect(await page.locator(article).innerHTML()).toBe(secondHTML); + await page.getByRole('link', { name: 'Book', exact: true }).click(); + await settled(page, '/book'); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); + await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); + await page.goBack(); + await settled(page, second); + await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); + await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); + await page.goBack(); + await settled(page, first); + await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); + // A hidden file still has a real link; selecting it must reveal its collapsed ancestor. + await nodeAt(page, section).evaluate((el) => (el as HTMLAnchorElement).click()); + await settled(page, section); + await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'true'); + await expect(nodeAt(page, section)).toBeVisible(); + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); +}); + +test('internal Markdown cross-page and fragment links use the same router and current tree', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + await page.goto(bookURL('/3-protocol.md', base)); + await settled(page, '/3-protocol.md'); + // Draft references in authored Markdown must not remain navigable after publication filtering. + await expect(page.locator(article + ' a[href*="draft"]')).toHaveCount(0); + const pane = await page.locator('.tree-pane').elementHandle(); + const link = page.locator(article).getByRole('link', { name: '4.2. Text View', exact: true }).first(); + const href = await link.getAttribute('href'); + expect(href).toContain('/book/4.2-text-view/#'); + await link.click(); + await expect(page).toHaveURL(new URL(href!, page.url()).href); + await settled(page, '/4.2-text-view.md'); + const fragment = await page.evaluate(() => { + const target = document.getElementById(decodeURIComponent(location.hash.slice(1))); + return { exists: Boolean(target), inArticle: Boolean(target?.closest('article.prose')), top: target?.getBoundingClientRect().top }; + }); + expect(fragment.exists).toBe(true); + expect(fragment.inArticle).toBe(true); + await expect.poll(() => page.evaluate(() => document.getElementById(decodeURIComponent(location.hash.slice(1)))!.getBoundingClientRect().top)).toBeLessThan(page.viewportSize()!.height); + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); + await expectMetadata(page, bookURL('/4.2-text-view.md', base)); + const currentArticle = await page.locator(article).elementHandle(); + const local = page.locator(article).getByRole('link', { name: '名前と文字列', exact: true }).first(); + const localHref = await local.getAttribute('href'); + expect(localHref).toContain('#'); + const localURL = new URL(localHref!, page.url()).href; + await local.click(); + await expect(page).toHaveURL(localURL); + await settled(page, '/4.2-text-view.md'); + await expect.poll(() => page.evaluate(() => { + const target = document.getElementById(decodeURIComponent(location.hash.slice(1)))!; + const box = target.getBoundingClientRect(); + return box.bottom > 0 && box.top < innerHeight; + })).toBe(true); + expect(await currentArticle!.evaluate((el) => el === document.querySelector('article.prose'))).toBe(true); + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); +}); + +test('modifier and target=_blank file links retain native new-tab behavior', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + await page.goto(bookURL('/1-concept.md', base)); + await settled(page, '/1-concept.md'); + const originalURL = page.url(); + const next = nodeAt(page, '/2-core-model.md'); + const [modified] = await Promise.all([ + page.context().waitForEvent('page'), + next.click({ modifiers: ['ControlOrMeta'] }), + ]); + await modified.waitForLoadState(); + await expect(modified).toHaveURL(bookURL('/2-core-model.md', base)); + expect(page.url()).toBe(originalURL); + await modified.close(); + await next.evaluate((el) => el.setAttribute('target', '_blank')); + const [blank] = await Promise.all([page.context().waitForEvent('page'), next.click()]); + await blank.waitForLoadState(); + await expect(blank).toHaveURL(bookURL('/2-core-model.md', base)); + expect(page.url()).toBe(originalURL); + await settled(page, '/1-concept.md'); + await blank.close(); +}); + +test('rapid navigation settles on the last file and repeated swaps do not duplicate handlers', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(bookURL('/1-concept.md', base)); + await settled(page, '/1-concept.md'); + const pane = await page.locator('.tree-pane').elementHandle(); + await page.evaluate(() => { + for (const path of ['/2-core-model.md', '/3-protocol.md', '/4-client.md']) { + document.querySelector(`.demo-node[data-path="${path}"]`)!.click(); + } + }); + await expect(page).toHaveURL(bookURL('/4-client.md', base)); + await settled(page, '/4-client.md'); + await expect(page.locator(article + ' h1')).toHaveText('4. クライアント'); + for (const path of ['/1-concept.md', '/2-core-model.md', '/1-concept.md', '/2-core-model.md']) { + await nodeAt(page, path).click(); + await settled(page, path); + } + await nodeAt(page, '/1-concept.md').focus(); + const currentURL = page.url(); + await page.keyboard.press('ArrowDown'); + await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused(); + expect(page.url()).toBe(currentURL); + await toggle(page).click(); + await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); + await expect(toggle(page)).toHaveText('+'); + await toggle(page).click(); + await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true'); + await expect(toggle(page)).toHaveText('−'); + // One activation must add one history entry, not one entry per prior page-load handler. + await nodeAt(page, '/3-protocol.md').click(); + await settled(page, '/3-protocol.md'); + await page.goBack(); + await expect(page).toHaveURL(currentURL); + await settled(page, '/2-core-model.md'); + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); + await expect(page.locator('.tree-pane')).toHaveCount(1); + await expect(page.locator('#landing-documents')).toHaveCount(1); + expect(errors).toEqual([]); +}); + +test('client return home skips intro, while a hard reload home may arm it again', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + await page.goto('./'); + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); + await page.keyboard.press('Escape'); + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); + await page.evaluate(recordIntroStates); + const pane = await page.locator('.tree-pane').elementHandle(); + await nodeAt(page, '/2-core-model.md').click(); + await settled(page, '/2-core-model.md'); + await page.locator('.brand').click(); + await expect(page).toHaveURL(base); + await settled(page, '/1-concept.md'); + await expect(page.locator('.intro-skip')).toBeHidden(); + await expect(page.locator(article + ' h1')).toHaveText('1. コンセプト'); + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); + // The book directory has its own route; the resting slash returns to HOME. + await nodeAt(page, '/book').click(); + await expect(page).toHaveURL(new URL('book/', base).href); + await settled(page, '/book'); + await nodeAt(page, '/').click(); + await expect(page).toHaveURL(base); + await settled(page, '/1-concept.md'); + await page.goBack(); + await expect(page).toHaveURL(new URL('book/', base).href); + await settled(page, '/book'); + await page.goBack(); + await expect(page).toHaveURL(base); + await settled(page, '/1-concept.md'); + await expectNoIntroStates(page); + await page.reload(); + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); +}); + +for (const action of ['skip-click', 'escape', 'root-click', 'key'] as const) { + test(`slow-font boot ${action} works before font readiness without losing the initial input`, async ({ page }, testInfo) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }); + // Freeze application timers so neither the 2.5s font fallback nor the boot + // fail-open timeout can make an ignored control appear to have worked. + await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') }); + await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z')); + await page.addInitScript(() => { + const load = document.fonts.load.bind(document.fonts); + document.fonts.load = (font, text) => { + if (font === '14px "Lyth Mono"') { + (window as Window & { introFontWaitStarted?: boolean }).introFontWaitStarted = true; + } + return load(font, text); + }; + }); + let releaseFont!: () => void; + const fontGate = new Promise((resolve) => { releaseFont = resolve; }); + let requestedFont!: () => void; + const fontRequested = new Promise((resolve) => { requestedFont = resolve; }); + let released = false; + await page.route('**/fonts/LythMono-Regular.woff2', async (route) => { + requestedFont(); + await fontGate; + await route.continue(); + }); + try { + // Avoid waiting for full page/font readiness; observe initLanding reaching + // its font wait so the input is sent after boot handlers are installed. + await page.goto('./', { waitUntil: 'domcontentloaded' }); + await fontRequested; + await expect.poll(() => page.evaluate(() => (window as Window & { introFontWaitStarted?: boolean }).introFontWaitStarted)).toBe(true); + await expect(page.locator('html')).toHaveAttribute('data-landing', 'boot'); + await expect(page.locator('.demo-tree')).not.toHaveAttribute('data-enhanced'); + expect(await page.evaluate(() => document.fonts.check('14px "Lyth Mono"'))).toBe(false); + const beforeInput = await page.evaluate(() => Date.now()); + expect(released).toBe(false); + if (action === 'skip-click') await page.locator('.intro-skip').click({ timeout: 1000 }); + if (action === 'escape') await page.keyboard.press('Escape'); + if (action === 'root-click') await nodeAt(page, '/').click({ timeout: 1000 }); + if (action === 'key') await page.keyboard.press('a'); + expect(page.url()).toBe(testInfo.project.use.baseURL!); + const skipping = action === 'skip-click' || action === 'escape'; + if (skipping) { + await expect(page.locator('html')).not.toHaveAttribute('data-landing', { timeout: 1000 }); + await expect(page.locator(article)).toBeVisible({ timeout: 1000 }); + await expect(page.locator('.intro-skip')).toBeHidden({ timeout: 1000 }); + } else { + // Boot input queues playback; it must not activate the root's HOME link. + await expect(page.locator('html')).toHaveAttribute('data-landing', 'boot'); + } + expect(await page.evaluate(() => Date.now()) - beforeInput).toBeLessThan(2500); + expect(await page.evaluate(() => document.fonts.check('14px "Lyth Mono"'))).toBe(false); + expect(released).toBe(false); + released = true; + releaseFont(); + if (skipping) { + await settled(page, '/1-concept.md'); + await page.clock.runFor(4000); + await settled(page, '/1-concept.md'); // Font completion must not replay an already-skipped intro. + } else { + // No second click/key: the input received during boot must start the timeline. + await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing'); + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow'); + expect(page.url()).toBe(testInfo.project.use.baseURL!); + await page.clock.runFor(4000); + await settled(page, '/1-concept.md'); + } + expect(page.url()).toBe(testInfo.project.use.baseURL!); + } finally { + releaseFont(); + await page.unrouteAll({ behavior: 'wait' }); + } + }); +} + +test.describe('JavaScript-free directory and error routes', () => { + test.use({ javaScriptEnabled: false }); + + test('directory index and 404 keep the public tree with ordinary usable anchors', async ({ page }, testInfo) => { + const base = testInfo.project.use.baseURL!; + await page.goto(new URL('book/', base).href); + await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/book'); + await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); + await expect(page.locator('html')).not.toHaveAttribute('data-landing'); + await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page'); + await expect(page.locator('.landing a:not(.demo-node)[href*="/book/"]')).toHaveCount(14); + for (const path of documentPaths) { + await expect(page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(path, base)).pathname}"]`)).toBeVisible(); + } + await expect(page.locator('.demo-node[data-path^="/draft"], a[href*="/draft/"]')).toHaveCount(0); + const target = '/1.1-interaction-abstraction.md'; + await page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(target, base)).pathname}"]`).click(); + await expect(page).toHaveURL(bookURL(target, base)); + await expect(page.locator(article + ' h1')).toBeVisible(); + await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); + const response = await page.goto(new URL('missing-no-js-route/', base).href); + expect(response!.status()).toBe(404); + await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); + await expect(page.locator('html')).not.toHaveAttribute('data-landing'); + await nodeAt(page, '/book').click(); + await expect(page).toHaveURL(new URL('book/', base).href); + await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/book'); + await nodeAt(page, '/').click(); + await expect(page).toHaveURL(base); + await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/1-concept.md'); + }); +}); + +test('404 uses the shared tree and its normal links recover to documents', async ({ page }, testInfo) => { + const response = await page.goto('missing-tree-route/'); + expect(response!.status()).toBe(404); + await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); + await expect(page.locator('html')).not.toHaveAttribute('data-landing'); + await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); + const pane = await page.locator('.tree-pane').elementHandle(); + await nodeAt(page, '/1-concept.md').click(); + await expect(page).toHaveURL(bookURL('/1-concept.md', testInfo.project.use.baseURL!)); + await settled(page, '/1-concept.md'); + expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); +}); diff --git a/site/tests/browser/site.spec.ts b/site/tests/browser/site.spec.ts index 6a9c9f5..4bf5c3c 100644 --- a/site/tests/browser/site.spec.ts +++ b/site/tests/browser/site.spec.ts @@ -1,12 +1,13 @@ import { expect, test } from '@playwright/test'; +import { bookURL, documentPaths, treeNodeCount } from './documents'; -test('home and book are usable without client JavaScript', async ({ page }, testInfo) => { +test('home and book share the usable tree without client JavaScript', async ({ page }, testInfo) => { const failures: string[] = []; page.on('response', (response) => { if (response.status() >= 400) failures.push(response.url()); }); await page.goto('./'); await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── '); + await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); const root = await page.locator('.root-mark').boundingBox(); const spine = await page.locator('.branch-vector').first().evaluate((element) => { const svg = element as SVGSVGElement; @@ -19,24 +20,30 @@ test('home and book are usable without client JavaScript', async ({ page }, test expect(Math.abs(root!.y + root!.height - spine.y)).toBeLessThan(1); await page.screenshot({ path: testInfo.outputPath('home.png'), fullPage: true }); await page.getByRole('link', { name: 'Book', exact: true }).click(); - await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで'); - await page.getByRole('link', { name: '第1章から読む' }).click(); - await expect(page.getByRole('heading', { level: 1 })).toHaveText('1. コンセプト'); - if (testInfo.project.name === 'mobile') { - await page.locator('.mobile-chapters summary').click(); - await page.locator('.mobile-chapters').getByRole('link', { name: '3. Protocol', exact: true }).click(); - } else { - await page.locator('.sidebar').getByRole('link', { name: '3. Protocol', exact: true }).click(); + await expect(page).toHaveURL(new URL('book/', testInfo.project.use.baseURL!).href); + await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-current-path', '/book'); + await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); + await expect(page.locator('.sidebar, .mobile-chapters, .mobile-toc, .page-toc, .pagination')).toHaveCount(0); + await expect(page.getByText('世界の見方から、', { exact: false })).toHaveCount(0); + // The directory index lists all 14 physical documents, independent of logical chapter nesting. + await expect(page.locator('.landing a:not(.demo-node)[href*="/book/"]')).toHaveCount(documentPaths.length); + for (const path of documentPaths) { + await expect(page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(path, testInfo.project.use.baseURL!)).pathname}"]`)).toBeVisible(); } - await expect(page.getByRole('heading', { level: 1 })).toHaveText('3. Protocol'); + await expect(page.locator('.demo-node[data-path^="/draft"], .landing a[href*="/draft/"]')).toHaveCount(0); + await page.locator('.landing a:not(.demo-node)[href$="/book/1-concept/"]').click(); + await expect(page.locator('article.prose h1')).toHaveText('1. コンセプト'); + await page.locator('.demo-node[data-path="/3-protocol.md"]').click(); + await expect(page.locator('article.prose h1')).toHaveText('3. Protocol'); + await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); await page.screenshot({ path: testInfo.outputPath('book.png'), fullPage: false }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); // Cross-chapter Japanese fragment links must reach the matching heading. - await page.locator('article').getByRole('link', { name: '4.2. Text View' }).first().click(); - await expect(page.getByRole('heading', { level: 1 })).toHaveText('4.2. Text View'); + await page.locator('article.prose').getByRole('link', { name: '4.2. Text View' }).first().click(); + await expect(page.locator('article.prose h1')).toHaveText('4.2. Text View'); await expect(page).toHaveURL(/4\.2-text-view\/#/); - await page.goto('book/draft/filesystem-discovery/'); - await expect(page.locator('.draft-notice')).toContainText('Draft'); + await expect(page.locator('a[href*="/draft/"]')).toHaveCount(0); + await expect(page.locator('.draft-notice')).toHaveCount(0); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); expect(failures).toEqual([]); }); diff --git a/site/tests/document-links.test.mjs b/site/tests/document-links.test.mjs index e40b1f8..afdbd34 100644 --- a/site/tests/document-links.test.mjs +++ b/site/tests/document-links.test.mjs @@ -4,9 +4,11 @@ import test from 'node:test'; import documentLinks, { contentsRoot, documentHref, normalizeBase } from '../plugins/document-links.mjs'; const source = path.join(contentsRoot, '3-protocol.md'); -test('canonical, draft and cross-directory links keep their fragments', () => { +test('published links keep fragments and draft targets stay unpublished', () => { assert.equal(documentHref('4.2-text-view.md#参照表記', source), '/book/4.2-text-view/#参照表記'); - assert.equal(documentHref('draft/unresolved-issues.md', source), '/book/draft/unresolved-issues/'); + assert.equal(documentHref('draft/unresolved-issues.md', source), null); + assert.equal(documentHref('./draft/unresolved-issues.md?from=book#detail', source, '/wip'), null); + assert.equal(documentHref('%64raft/unresolved-issues.md', source), null); assert.equal(documentHref('../3-protocol.md#value', path.join(contentsRoot, 'draft/rust-api-model.md')), '/book/3-protocol/#value'); }); test('base prefix and query are preserved', () => { @@ -22,6 +24,22 @@ test('invalid base and links escaping contents are rejected', () => { for (const base of ['relative', '//example.test', '/foo?x', '/../x']) assert.throws(() => normalizeBase(base)); assert.throws(() => documentHref('../secret.md', source)); }); +test('draft links render only their text without changing source code examples', () => { + const text = { type: 'text', value: '未解決事項' }; + const emphasis = { type: 'emphasis', children: [text] }; + const code = { type: 'code', value: '[example](draft/unresolved-issues.md)' }; + const tree = { type: 'root', children: [ + { type: 'paragraph', children: [ + { type: 'link', url: 'draft/unresolved-issues.md', children: [emphasis] }, + { type: 'linkReference', identifier: 'draft-ref', referenceType: 'full', children: [text] }, + ] }, + { type: 'definition', identifier: 'draft-ref', url: 'draft/unresolved-issues.md#detail' }, + code, + ] }; + documentLinks()(tree, { path: source }); + assert.deepEqual(tree.children, [{ type: 'paragraph', children: [emphasis, text] }, code]); +}); + test('remark handles reference links but leaves code examples unchanged', () => { const tree = { type: 'root', children: [ { type: 'link', url: '4-client.md', children: [] }, diff --git a/site/tests/landing-glyphs.test.mjs b/site/tests/landing-glyphs.test.mjs index 9750ea6..876a126 100644 --- a/site/tests/landing-glyphs.test.mjs +++ b/site/tests/landing-glyphs.test.mjs @@ -53,7 +53,7 @@ test('Lyth Mono paths retain exact advance, row bounds and stroke dimensions', ( test('cell layout bypasses shaping, preserves spaces and fails for missing glyphs', () => { close(box(glyphPath(' │')).x1, box(glyphPath('│')).x1 + 4 * cellWidth); close(box(glyphPath('── ')).x2, 2 * cellWidth); - for (const prefix of ['├── ', '│ ├── ', '│ └── ', '│ └── ', ' └── ']) { + for (const prefix of ['├─ ', '│ ├─ ', '│ └─ ', ' └─ ', '├ ', '│ ├ ', '│ └ ', '│ └ ', ' └ ', '├── ', '│ ├── ', '│ └── ', '│ └── ', ' └── ']) { const path = glyphPath(prefix); assert.ok(path.length > 0); assert.ok(!path.includes('NaN')); diff --git a/site/tests/landing-tree.test.mjs b/site/tests/landing-tree.test.mjs index a6fd963..642b9bc 100644 --- a/site/tests/landing-tree.test.mjs +++ b/site/tests/landing-tree.test.mjs @@ -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: '', 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);