feat: add persistent book navigation and public-only pages
This commit is contained in:
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
---
|
||||
<h1>{path === '/' ? 'Book' : `${path.slice(1)}/`}</h1>
|
||||
<ul class="directory-index">
|
||||
{children.map((node) => <li><a href={node.href ?? href(`book${node.path}/`)}>{node.title ?? `${node.name}/`}</a></li>)}
|
||||
</ul>
|
||||
@@ -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;
|
||||
---
|
||||
<section class="landing" aria-label="WIP" data-landing-stage>
|
||||
<TreeGlyphs />
|
||||
<section class="landing" aria-label="WIP" data-landing-stage data-current-path={currentPath} data-phase="complete">
|
||||
<div class="intro-art" aria-hidden="true">
|
||||
<svg class="orbit" viewBox="-300 -300 600 600" focusable="false">
|
||||
<defs><path id="welcome-orbit" d="M0 -180 C99 -180 180 -99 180 0 C180 99 99 180 0 180 C-99 180 -180 99 -180 0 C-180 -99 -99 -180 0 -180" /></defs>
|
||||
@@ -22,25 +24,26 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1)
|
||||
</div>
|
||||
<button class="intro-skip" type="button" aria-label="アニメーションをスキップ">Skip ↗</button>
|
||||
<div class="landing-content explorer">
|
||||
<div class="tree-pane">
|
||||
<div class="tree-pane" transition:persist="document-tree">
|
||||
<TreeGlyphs />
|
||||
<div class="tree-viewport">
|
||||
<div class="demo-tree" role="tree" aria-label="contents">
|
||||
<div class="tree-scene">
|
||||
{objects.map((object, index) => {
|
||||
const root = object.path === '/';
|
||||
const children = childrenOf(objects, object.path);
|
||||
const Tag = object.href ? 'a' : 'button';
|
||||
return <div class:list={['node-row', { 'root-slot': root }]}>
|
||||
{!root && <TreePrefix prefix={treePrefix(object, objects)} id={index} />}
|
||||
<Tag
|
||||
href={object.href} type={object.href ? undefined : 'button'}
|
||||
<a
|
||||
href={object.href ?? folderHref(object.path)}
|
||||
role="treeitem" class:list={['demo-node', { 'root-mark': root }]}
|
||||
data-path={object.path} aria-label={root ? 'ルート /' : object.name}
|
||||
aria-level={object.depth} aria-expanded={children.length ? true : undefined}
|
||||
aria-setsize={root ? 1 : childrenOf(objects, object.parent!).length}
|
||||
aria-posinset={root ? 1 : childrenOf(objects, object.parent!).findIndex((item) => 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 ? <svg class="root-vector" viewBox="0 0 48 64" aria-hidden="true" focusable="false">
|
||||
<g class="slash-art">
|
||||
@@ -59,19 +62,25 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1)
|
||||
<defs><mask id="trunk-growth-mask" maskUnits="userSpaceOnUse" x={-stemWidth} y="0" width={stemWidth * 2} height="10000" mask-type="alpha"><rect class="trunk-reveal" x={-stemWidth} y="0" width={stemWidth * 2} height={trunkLength} fill="white" /></mask></defs>
|
||||
<g mask="url(#trunk-growth-mask)"><path class="trunk-line" d={trunkPath} transform={`scale(1 ${trunkLength})`} /></g>
|
||||
</g>
|
||||
</svg> : <><span class="node-label">{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">−</span>}</>}
|
||||
</Tag>
|
||||
</svg> : <span class="node-label">{object.name}</span>}
|
||||
</a>
|
||||
{!root && children.length > 0 && <button class="node-toggle node-indicator" type="button" data-toggle-path={object.path} aria-label={`${object.name}を開閉`} aria-expanded="true" tabindex="-1">−</button>}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<article class="selected-content prose" data-selected-content data-reveal aria-label={initialObject.title} tabindex="0" set:html={initialObject.html} />
|
||||
<div class="document-pane" data-reveal>
|
||||
{draft && <aside class="draft-notice"><strong>Draft / 検討中の文書</strong><p>このページは草案です。確定したProtocolの契約は正本を参照してください。</p></aside>}
|
||||
<article class="selected-content prose" data-selected-content aria-label={label ?? current?.title ?? currentPath} tabindex="0">
|
||||
<slot><Fragment set:html={initialContent} /></slot>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="landing-documents" set:html={serializeTree(objects)} />
|
||||
</section>
|
||||
<script>
|
||||
import { initLanding } from '../scripts/landing';
|
||||
initLanding();
|
||||
import { setupExplorerNavigation } from '../scripts/landing';
|
||||
setupExplorerNavigation();
|
||||
</script>
|
||||
|
||||
@@ -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$/, ''),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<html lang="ja" transition:animate="none">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
{landing && <script is:inline>
|
||||
// Progressive enhancement: a missing/blocked module must never strand the page.
|
||||
if (!matchMedia('(prefers-reduced-motion: reduce)').matches && !location.hash) {
|
||||
<ClientRouter fallback="swap" />
|
||||
<script is:inline define:vars={{ intro }}>
|
||||
// Only a fresh visit to the landing route plays the introduction.
|
||||
// The window flag survives client navigation, but not a full reload.
|
||||
if (intro && !window.__wipVisited && !matchMedia('(prefers-reduced-motion: reduce)').matches && !location.hash) {
|
||||
document.documentElement.dataset.landing = 'boot';
|
||||
setTimeout(() => {
|
||||
if (document.documentElement.dataset.landing === 'boot') delete document.documentElement.dataset.landing;
|
||||
}, 3500);
|
||||
}
|
||||
</script>}
|
||||
window.__wipVisited = true;
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} · WIP</title>
|
||||
<meta name="description" content={description} />
|
||||
@@ -32,7 +36,7 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
|
||||
<body class:list={{ 'landing-page': landing }}>
|
||||
<a class="skip-link" href="#main">本文へスキップ</a>
|
||||
<header class="site-header">
|
||||
<a class="brand" href={href()} aria-label="WIP トップページ"><span class="brand-mark">W</span><span>WIP{!landing && <span class="brand-caption">Web Interface Protocol</span>}</span></a>
|
||||
<a class="brand" href={href()} aria-label="WIP トップページ"><span class="brand-mark">W</span><span>WIP{!landing && <span class="brand-caption">Worldspace Interface Protocol</span>}</span></a>
|
||||
<nav aria-label="メインナビゲーション">
|
||||
{!landing && <a href={href()} aria-current={!book ? 'page' : undefined}>Home</a>}
|
||||
<a href={href('book/')} aria-current={book ? 'true' : undefined}>Book</a>
|
||||
@@ -40,6 +44,6 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
|
||||
</nav>
|
||||
</header>
|
||||
<slot />
|
||||
{!landing && <footer class="site-footer"><span>WIP / Web Interface Protocol</span><span>文書を正本に、ひとつのWorldspaceを。</span><a href={repository}>ソースと変更履歴 ↗</a></footer>}
|
||||
{!landing && <footer class="site-footer"><span>WIP / Worldspace Interface Protocol</span><span>文書を正本に、ひとつのWorldspaceを。</span><a href={repository}>ソースと変更履歴 ↗</a></footer>}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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<Document[]> {
|
||||
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 }));
|
||||
}
|
||||
|
||||
@@ -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<string>): 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;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
---
|
||||
import Site from '../layouts/Site.astro';
|
||||
import Landing from '../components/Landing.astro';
|
||||
import { href } from '../lib/book';
|
||||
---
|
||||
<Site title="ページが見つかりません">
|
||||
<main id="main" class="not-found"><p class="eyebrow">404 / NOT FOUND</p><h1>このページは見つかりません。</h1><p>Bookの目次から、お探しの章へ移動できます。</p><a class="button primary" href={href('book/')}>Bookの目次へ →</a></main>
|
||||
<main id="main"><Landing currentPath="" label="ページが見つかりません">
|
||||
<p>404 / NOT FOUND</p><h1>このページは見つかりません。</h1><p>Bookの目次から、お探しの章へ移動できます。</p><a href={href('book/')}>Bookの目次へ →</a>
|
||||
</Landing></main>
|
||||
</Site>
|
||||
|
||||
@@ -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)}/`;
|
||||
---
|
||||
<Site title={title(entry)} description={`${title(entry)} — WIP ${draft ? '設計草案' : 'リファレンス'}`} book>
|
||||
<div class="book-shell">
|
||||
<aside class="sidebar"><ChapterNav documents={entries} current={entry.id} /></aside>
|
||||
<main id="main" class="book-content document">
|
||||
<div class="document-meta"><a href={href('book/')}>Book</a><span>/ {draft ? 'Drafts' : 'Reference'}</span><a class="source-link" href={sourceHref(entry)}>Markdown ↗</a></div>
|
||||
<details class="mobile-chapters"><summary>章を選ぶ</summary><ChapterNav documents={entries} current={entry.id} /></details>
|
||||
{draft && <aside class="draft-notice"><strong>Draft / 検討中の文書</strong><p>このページは草案です。確定したProtocolの契約は正本を参照してください。</p></aside>}
|
||||
{toc.length > 0 && <details class="mobile-toc"><summary>このページの目次</summary><ul>{toc.map((heading) => <li><a href={`#${heading.slug}`}>{heading.text}</a></li>)}</ul></details>}
|
||||
<article class="prose"><Content /></article>
|
||||
<nav class="pagination" aria-label="前後の章">
|
||||
{previous ? <a href={bookHref(previous.id)}><small>← 前の{draft ? '草案' : '章'}</small><span>{title(previous)}</span></a> : <span />}
|
||||
{next ? <a href={bookHref(next.id)}><small>次の{draft ? '草案' : '章'} →</small><span>{title(next)}</span></a> : <a href={href('book/')}><small>目次に戻る →</small><span>Book</span></a>}
|
||||
</nav>
|
||||
</main>
|
||||
<aside class="page-toc"><nav aria-label="このページの目次"><p class="nav-label">ON THIS PAGE</p><ul>{toc.map((heading) => <li class:list={{ nested: heading.depth === 3 }}><a href={`#${heading.slug}`}>{heading.text}</a></li>)}</ul></nav></aside>
|
||||
</div>
|
||||
<Site title={label} book>
|
||||
<main id="main"><Landing currentPath={path} label={label} draft={entry && isDraft(entry)}>
|
||||
{Content ? <Content /> : <DirectoryIndex path={path} />}
|
||||
</Landing></main>
|
||||
</Site>
|
||||
|
||||
@@ -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';
|
||||
---
|
||||
<Site title="Book" book>
|
||||
<div class="book-shell book-index">
|
||||
<aside class="sidebar"><ChapterNav documents={entries} /></aside>
|
||||
<main id="main" class="book-content">
|
||||
<p class="eyebrow">THE WIP BOOK</p>
|
||||
<h1>世界の見方から、<br />通信の約束まで。</h1>
|
||||
<p class="lead">WIPのコンセプト、共通モデル、ClientとHostの契約を、ひとつの流れで読むためのリファレンスです。</p>
|
||||
<a class="button primary" href={bookHref(chapters[0].id)}>第1章から読む <span aria-hidden="true">→</span></a>
|
||||
<section class="index-section" aria-labelledby="reference">
|
||||
<h2 id="reference">仕様を読む <span class="count">{chapters.length} chapters</span></h2>
|
||||
<ol class="chapter-list">{chapters.map((entry) => <li><a href={bookHref(entry.id)}><span>{title(entry)}</span><span aria-hidden="true">↗</span></a></li>)}</ol>
|
||||
</section>
|
||||
<section class="index-section" aria-labelledby="drafts">
|
||||
<p class="eyebrow">WORK IN PROGRESS</p>
|
||||
<h2 id="drafts">草案と未解決事項</h2>
|
||||
<p>API設計や投影方針を検討する文書です。正本とは分けて掲載し、確定したProtocolの契約と混同しません。</p>
|
||||
<ul class="chapter-list">{drafts.map((entry) => <li><a href={bookHref(entry.id)}><span>{title(entry)}</span><span class="badge">Draft</span></a></li>)}</ul>
|
||||
</section>
|
||||
<p class="muted"><a href={href()}>← WIPについて</a></p>
|
||||
</main>
|
||||
</div>
|
||||
<main id="main"><Landing currentPath="/book" label="Book"><DirectoryIndex /></Landing></main>
|
||||
</Site>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Site from '../layouts/Site.astro';
|
||||
import Landing from '../components/Landing.astro';
|
||||
---
|
||||
<Site landing>
|
||||
<Site landing intro>
|
||||
<main id="main">
|
||||
<Landing />
|
||||
</main>
|
||||
|
||||
+126
-44
@@ -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<HTMLElement>('[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<HTMLElement>('[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 = <T extends Element = HTMLElement>(selector: string) => stage.querySelector<T>(selector)!;
|
||||
const root = get<HTMLButtonElement>('.root-mark');
|
||||
const root = get<HTMLAnchorElement>('.root-mark');
|
||||
const slashArt = get<SVGGElement>('.slash-art');
|
||||
const restingSlashScale = Number(getComputedStyle(slashArt).getPropertyValue('--slash-rest-scale'));
|
||||
const trunk = get<SVGPathElement>('.trunk-line');
|
||||
@@ -63,13 +89,25 @@ export async function initLanding() {
|
||||
const orbitPath = get<SVGPathElement>('#welcome-orbit');
|
||||
const textPath = get<SVGTextPathElement>('.orbit textPath');
|
||||
const skip = get<HTMLButtonElement>('.intro-skip');
|
||||
const result = get('[data-selected-content]');
|
||||
const blocked = [...document.querySelectorAll<HTMLElement>('.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<string>();
|
||||
// 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<HTMLElement>('.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<HTMLElement>('.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<HTMLButtonElement>('[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<string, { top: number; left: number }>();
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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))); }
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
'/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<string, string> = 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;
|
||||
|
||||
@@ -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<SVGSVGElement>('.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);
|
||||
|
||||
@@ -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<HTMLElement>('.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<HTMLElement>('.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<SVGUseElement>('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<HTMLElement>('.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<HTMLElement>('.node-row:not(.root-slot):not([hidden])')].map((row) => row.getBoundingClientRect());
|
||||
const nodes = [...document.querySelectorAll<HTMLElement>('.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('<h1');
|
||||
expect(document).not.toHaveProperty('html');
|
||||
}
|
||||
for (const path of ['/', '/draft']) {
|
||||
for (const path of ['/', '/book']) {
|
||||
const folder = stored.find((document) => 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<string, string>();
|
||||
const documents = new Map<string, { html: string; title: string }>();
|
||||
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<HTMLElement>('[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) => {
|
||||
|
||||
@@ -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<string, unknown>[];
|
||||
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<HTMLElement>('[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<HTMLAnchorElement>(`.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<void>((resolve) => { releaseFont = resolve; });
|
||||
let requestedFont!: () => void;
|
||||
const fontRequested = new Promise<void>((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);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { contentsRoot } from '../plugins/document-links.mjs';
|
||||
import { documentTree, childrenOf, visibleNodes, treePrefix, serializeTree } from '../src/lib/landing-tree.ts';
|
||||
import { documentTree, chapterTree, bookTree, childrenOf, visibleNodes, treePrefix, serializeTree } from '../src/lib/landing-tree.ts';
|
||||
|
||||
async function sourceFiles(directory, prefix = '') {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
@@ -37,10 +37,72 @@ test('tree mirrors only real content files and their directory parents', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('existing titles, hrefs and content pass through without added descriptions or operations', () => {
|
||||
test('chapter display nests existing numbered sections while preserving paths and URLs', () => {
|
||||
const display = chapterTree(documents);
|
||||
for (const [chapter, sections] of [
|
||||
['/1-concept.md', ['/1.1-interaction-abstraction.md', '/1.2-existing-abstractions.md', '/1.3-design-principles.md', '/1.4-concepts-and-scope.md']],
|
||||
['/2-core-model.md', ['/2.1-object-tree-schema.md', '/2.2-operation-and-interface.md']],
|
||||
['/4-client.md', ['/4.1-ai-tools.md', '/4.2-text-view.md']],
|
||||
['/5-transports.md', ['/5.1-wip-over-http.md']],
|
||||
]) {
|
||||
assert.deepEqual(childrenOf(display, chapter).map((node) => node.path), sections);
|
||||
for (const section of sections) {
|
||||
const node = display.find((node) => node.path === section);
|
||||
assert.equal(node.depth, 3);
|
||||
assert.equal(treePrefix(node, display).length, 6);
|
||||
assert.ok(!visibleNodes(display, new Set([chapter])).includes(node));
|
||||
}
|
||||
}
|
||||
for (const node of display) {
|
||||
const original = nodes.find((candidate) => candidate.path === node.path);
|
||||
assert.equal(node.href, original.href);
|
||||
assert.equal(node.name, original.name);
|
||||
assert.equal(node.title, original.title);
|
||||
}
|
||||
assert.deepEqual(childrenOf(display, '/draft'), childrenOf(nodes, '/draft'));
|
||||
// The directory index still lists the physical files, not the display grouping.
|
||||
assert.equal(nodes.find((node) => node.path === '/1.1-interaction-abstraction.md').parent, '/');
|
||||
});
|
||||
|
||||
test('chapter grouping supports deeper numbering and never invents or crosses directory parents', () => {
|
||||
const source = ['1.2.3-leaf', '1.2-section', '1-chapter', '10-other', '10.1-section', '3.1-orphan', 'draft/1.1-independent']
|
||||
.map((id) => ({ id, title: id, href: `/book/${id}/` }));
|
||||
const display = chapterTree(source);
|
||||
const at = (id) => display.find((node) => node.path === `/${id}.md`);
|
||||
assert.equal(at('1.2-section').parent, '/1-chapter.md');
|
||||
assert.equal(at('1.2.3-leaf').parent, '/1.2-section.md');
|
||||
assert.equal(at('1.2.3-leaf').depth, 4);
|
||||
assert.equal(at('10.1-section').parent, '/10-other.md');
|
||||
assert.equal(at('3.1-orphan').parent, '/');
|
||||
assert.equal(at('draft/1.1-independent').parent, '/draft');
|
||||
assert.ok(display.indexOf(at('1-chapter')) < display.indexOf(at('1.2-section')));
|
||||
assert.ok(display.indexOf(at('1.2-section')) < display.indexOf(at('1.2.3-leaf')));
|
||||
});
|
||||
|
||||
test('public book container wraps the numbered chapters without changing document identity', () => {
|
||||
const published = documents.filter((document) => !document.id.startsWith('draft/'));
|
||||
const display = bookTree(published);
|
||||
assert.deepEqual(childrenOf(display, '/').map((node) => node.path), ['/book']);
|
||||
assert.equal(display[1].name, 'book/');
|
||||
assert.equal(display[1].depth, 2);
|
||||
assert.equal(display.length, published.length + 2);
|
||||
assert.ok(!display.some((node) => node.path.startsWith('/draft')));
|
||||
assert.deepEqual(childrenOf(display, '/book').map((node) => node.path), ['/1-concept.md', '/2-core-model.md', '/3-protocol.md', '/4-client.md', '/5-transports.md']);
|
||||
const section = display.find((node) => node.path === '/1.1-interaction-abstraction.md');
|
||||
assert.equal(section.parent, '/1-concept.md');
|
||||
assert.equal(section.depth, 4);
|
||||
assert.equal(treePrefix(section, display), ' │ ├─ ');
|
||||
assert.deepEqual(visibleNodes(display, new Set(['/book'])), display.slice(0, 2));
|
||||
for (const node of display.filter((node) => node.href)) {
|
||||
assert.equal(node.href, nodes.find((original) => original.path === node.path).href);
|
||||
}
|
||||
});
|
||||
|
||||
test('existing titles and hrefs pass through without embedding document bodies', () => {
|
||||
for (const document of documents) {
|
||||
const node = nodes.find((node) => node.path === '/' + document.id + '.md');
|
||||
assert.equal(node.html, document.html);
|
||||
assert.equal('html' in node, false);
|
||||
assert.equal('body' in node, false);
|
||||
assert.equal(node.title, document.title);
|
||||
assert.equal(node.href, document.href);
|
||||
assert.equal('description' in node, false);
|
||||
@@ -61,14 +123,14 @@ test('collapse hides descendants only and joins follow the visible tree', () =>
|
||||
for (const shown of [nodes, collapsed]) {
|
||||
for (const node of shown) {
|
||||
const prefix = treePrefix(node, nodes, shown);
|
||||
assert.equal(prefix.length, (node.depth - 1) * 4);
|
||||
if (node.parent) assert.ok(prefix.endsWith(childrenOf(shown, node.parent).at(-1) === node ? '└── ' : '├── '));
|
||||
assert.equal(prefix.length, (node.depth - 1) * 3);
|
||||
if (node.parent) assert.ok(prefix.endsWith(childrenOf(shown, node.parent).at(-1) === node ? '└─ ' : '├─ '));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('embedded data cannot close its JSON script and roundtrips exact content', () => {
|
||||
const input = documentTree([{ id: 'source', title: '<title>', href: '/book/source/', html: '</script><script>alert(1)</script>\u2028&' }]);
|
||||
test('embedded metadata cannot close its JSON script and roundtrips exactly', () => {
|
||||
const input = documentTree([{ id: 'source', title: '</script><script>alert(1)</script>\u2028&', href: '/book/source/' }]);
|
||||
const serialized = serializeTree(input);
|
||||
assert.equal(serialized.includes('<'), false);
|
||||
assert.deepEqual(JSON.parse(serialized), input);
|
||||
|
||||
Reference in New Issue
Block a user