feat: add Astro reference site and organize book contents
Reference site / build (push) Canceled after 0s
Reference site / build (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
name: Reference site
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- name: Install
|
||||
working-directory: site
|
||||
run: npm ci
|
||||
- name: Check and build
|
||||
working-directory: site
|
||||
run: npm run verify
|
||||
- name: Browser smoke tests
|
||||
working-directory: site
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
npm run test:browser
|
||||
- name: Verify subpath deployment
|
||||
working-directory: site
|
||||
run: |
|
||||
SITE_BASE=/reference SITE_URL=https://example.test npm run verify
|
||||
SITE_BASE=/reference npm run test:browser
|
||||
- name: Build deployment artifact
|
||||
working-directory: site
|
||||
env:
|
||||
SITE_URL: ${{ vars.SITE_URL }}
|
||||
SITE_BASE: ${{ vars.SITE_BASE }}
|
||||
run: npm run build && npm run check:links
|
||||
# Gitea uses the v3 artifact protocol, not GitHub's v4 artifact service.
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wip-reference-site
|
||||
path: site/dist/
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,8 @@
|
||||
site/node_modules/
|
||||
site/dist/
|
||||
site/.astro/
|
||||
site/test-results/
|
||||
site/playwright-report/
|
||||
site/.env
|
||||
site/.env.*
|
||||
!site/.env.example
|
||||
@@ -0,0 +1,72 @@
|
||||
# WIP Reference
|
||||
|
||||
Web Interface Protocolの仕様文書と、その文書を読むための静的サイトです。
|
||||
|
||||
## 構成
|
||||
|
||||
- [`contents/`](contents/) — 仕様の正本。番号付きMarkdownを章順に読みます。
|
||||
- [`contents/draft/`](contents/draft/) — API設計・投影方針などの草案。正本とは区別します。
|
||||
- [`site/`](site/) — AstroによるトップページとBook。文書のコピーは持たず、`contents/**/*.md`をビルド時に読み込みます。
|
||||
|
||||
[第1章:コンセプト](contents/1-concept.md) / [Core Model](contents/2-core-model.md) / [Protocol](contents/3-protocol.md) / [Client](contents/4-client.md) / [WIP over HTTP](contents/5.1-wip-over-http.md)
|
||||
|
||||
以前の直下Markdownと`draft/`は`contents/`へ移動しました。文書の内容・ファイル名・相互の相対リンクは維持しています。外部からraw Markdownを参照する場合はパスへ`contents/`を追加してください。
|
||||
|
||||
## 開発・検証
|
||||
|
||||
Node.js 22.12以上(CIは24)、npmを使用します。
|
||||
|
||||
```sh
|
||||
cd site
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
開発サーバーは通常 `http://localhost:4321/` です。
|
||||
|
||||
```sh
|
||||
npm run verify # unit tests → Astro/TypeScript check → production build → link checks
|
||||
npm run preview
|
||||
```
|
||||
|
||||
`/`がトップページ、`/book/`が目次です。各章は`/book/3-protocol/`のように元のファイル名を保持します。草案は`/book/draft/rust-api-model/`などに分離し、草案である旨を表示します。サイドバー、章内目次、前後の章へのリンクはsourceから生成します。
|
||||
|
||||
ブラウザーでデスクトップ/モバイルの表示とナビゲーションを検証する場合(事前にbuildが必要):
|
||||
|
||||
```sh
|
||||
npx playwright install chromium
|
||||
npm run test:browser
|
||||
# インストール済みChromeを使う場合:CHROME_PATH=/path/to/chrome npm run test:browser
|
||||
```
|
||||
|
||||
テストはクライアントJavaScriptを無効にして実行し、スクリーンショットを`site/test-results/`へ保存します。サブパスbuildの検証時は同じ`SITE_BASE`を設定してください。
|
||||
|
||||
## 文書の編集
|
||||
|
||||
文書は`contents/`だけを編集します。各文書の先頭の`#`見出しをタイトルに使用するため、frontmatterは不要です。番号付き文書は番号順、草案は別グループで表示します。
|
||||
|
||||
Markdown上の相対`.md`リンクはリポジトリ上でもそのまま読めます。サイト生成時にremark pluginがBookのURLへ変換し、queryとfragmentを維持します。コードブロックや外部URLは変換しません。`npm run check:links`は生成ページ、文書の欠落、重複ID、内部リンク・画像・CSS/JSと見出しanchorを検証します。
|
||||
|
||||
## サイト公開
|
||||
|
||||
静的出力の`site/dist/`を公開します。SSR、常駐Nodeサーバー、データベースは不要です。ホスティング先や公開ドメインはまだ固定していません。**このリポジトリへのpushだけでインターネット上へ自動デプロイされる設定ではありません。**
|
||||
|
||||
```sh
|
||||
cd site
|
||||
npm ci
|
||||
SITE_URL=https://your-domain.example npm run verify
|
||||
```
|
||||
|
||||
サブパスへ公開する場合:
|
||||
|
||||
```sh
|
||||
SITE_URL=https://your-domain.example SITE_BASE=/wip-reference npm run verify
|
||||
```
|
||||
|
||||
- `SITE_URL`:公開サイトのorigin(例:`https://your-domain.example`)。path prefixは`SITE_BASE`へ分離します。設定時にcanonical URLを生成し、未指定時に仮ドメインは埋め込みません。
|
||||
- `SITE_BASE`:公開path prefix。既定は`/`。サブパスの場合は、そのprefixへ`dist/`の内容を配置します。
|
||||
- 本番ビルドはリポジトリ全体をcheckoutし、作業directoryを`site/`、build commandを`npm ci && npm run verify`、output directoryを`site/dist/`(作業directory基準では`dist/`)にします。
|
||||
- Static hostではディレクトリの`index.html`を配信し、未知のURLへ`404.html`を返す設定を使用します。SPA fallbackは不要です。
|
||||
- 文書は静的HTML、コードの色付けはビルド時のShiki、モバイルの目次はHTMLの`details`です。閲覧にクライアント側JavaScriptを必要としません。
|
||||
|
||||
Gitea Actionsの`.gitea/workflows/site.yml`で、通常ビルドとサブパスビルドを検証し、公開用の`wip-reference-site` artifactを作成します。GiteaのActionsと対応runnerが必要です。Repository variablesの`SITE_URL`/`SITE_BASE`を設定すると成果物へ反映します。ホスティングへのアップロードは、その公開先を決めてから別途接続してください。
|
||||
@@ -0,0 +1,4 @@
|
||||
# Optional production URL (canonical links). No placeholder domain is published.
|
||||
# SITE_URL=https://your-domain.example
|
||||
# Optional path prefix when not deployed at the origin root.
|
||||
# SITE_BASE=/wip-reference
|
||||
@@ -0,0 +1,24 @@
|
||||
import './plugins/environment.mjs';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import { unified } from '@astrojs/markdown-remark';
|
||||
import documentLinks, { normalizeBase } from './plugins/document-links.mjs';
|
||||
|
||||
const base = normalizeBase(process.env.SITE_BASE || '/');
|
||||
const site = process.env.SITE_URL || undefined;
|
||||
if (site) {
|
||||
const url = new URL(site);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
||||
throw new Error('SITE_URL must be an http(s) origin; use SITE_BASE for a path prefix');
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
site,
|
||||
base,
|
||||
trailingSlash: 'always',
|
||||
markdown: {
|
||||
processor: unified({ remarkPlugins: [[documentLinks, { base }]], smartypants: false }),
|
||||
shikiConfig: { theme: 'github-light', langAlias: { wip: 'text' } },
|
||||
},
|
||||
});
|
||||
Generated
+6992
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "wip-reference-site",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"check": "astro check",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"build": "astro build",
|
||||
"check:links": "node scripts/check-links.mjs",
|
||||
"verify": "npm run test && npm run check && npm run build && npm run check:links",
|
||||
"test:browser": "playwright test",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "7.3.2",
|
||||
"astro": "7.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "0.9.10",
|
||||
"@playwright/test": "1.64.0",
|
||||
"@types/node": "24.19.1",
|
||||
"cheerio": "1.2.0",
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import './plugins/environment.mjs';
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const base = process.env.SITE_BASE?.replace(/\/$/, '') || '';
|
||||
export default defineConfig({
|
||||
testDir: './tests/browser',
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:4321${base}/`,
|
||||
javaScriptEnabled: false,
|
||||
launchOptions: { executablePath: process.env.CHROME_PATH },
|
||||
},
|
||||
projects: [
|
||||
{ name: 'desktop', use: { viewport: { width: 1440, height: 1000 } } },
|
||||
{ name: 'mobile', use: { viewport: { width: 390, height: 844 } } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run preview -- --host 127.0.0.1 --port 4321',
|
||||
url: `http://127.0.0.1:4321${base}/`,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const contentsRoot = fileURLToPath(new URL('../../contents/', import.meta.url));
|
||||
|
||||
export function normalizeBase(value = '/') {
|
||||
if (!/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]*\/?$/.test(value)) {
|
||||
throw new Error('SITE_BASE must be / or an absolute path of URL-safe segments');
|
||||
}
|
||||
return value === '/' ? '/' : `/${value.split('/').filter(Boolean).join('/')}/`;
|
||||
}
|
||||
|
||||
/** Rewrite Markdown document links, not code, fragments, or external URLs. */
|
||||
export function documentHref(href, sourceFile, base = '/') {
|
||||
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(href)) return href;
|
||||
const match = href.match(/^([^?#]+\.md)([?#].*)?$/i);
|
||||
if (!match) return href;
|
||||
const filename = path.resolve(path.dirname(sourceFile), decodeURIComponent(match[1]));
|
||||
const relative = path.relative(contentsRoot, filename);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`Markdown link outside contents/: ${href} in ${sourceFile}`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// CLI environment wins; use the same optional .env file for Astro and link checks.
|
||||
const file = fileURLToPath(new URL('../.env', import.meta.url));
|
||||
if (existsSync(file)) loadEnvFile(file);
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#123c39"/><path d="m12 18 8 28 12-18 12 18 8-28" fill="none" stroke="#c8edbc" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,73 @@
|
||||
import '../plugins/environment.mjs';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { load } from 'cheerio';
|
||||
import { normalizeBase } from '../plugins/document-links.mjs';
|
||||
|
||||
const root = path.resolve(fileURLToPath(new URL('../dist/', import.meta.url)));
|
||||
const base = normalizeBase(process.env.SITE_BASE || '/');
|
||||
const origin = 'https://local.invalid';
|
||||
const failures = [];
|
||||
const cache = new Map();
|
||||
async function files(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (await Promise.all(entries.map((entry) => entry.isDirectory() ? files(path.join(directory, entry.name)) : path.join(directory, entry.name)))).flat();
|
||||
}
|
||||
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'));
|
||||
for (const filename of htmlFiles) {
|
||||
const relative = path.relative(root, filename).split(path.sep).join('/');
|
||||
const pagePath = base + relative.replace(/index\.html$/, '');
|
||||
const $ = await document(filename);
|
||||
const ids = new Set();
|
||||
$('[id]').each((_, element) => {
|
||||
const id = $(element).attr('id');
|
||||
if (ids.has(id)) failures.push(`${relative}: duplicate id ${id}`);
|
||||
ids.add(id);
|
||||
});
|
||||
if ($('h1').length !== 1) failures.push(`${relative}: expected one h1`);
|
||||
if ($('main#main').length !== 1) failures.push(`${relative}: missing main landmark`);
|
||||
for (const element of $('a[href], link[href], script[src], img[src]').toArray()) {
|
||||
const raw = $(element).attr('href') ?? $(element).attr('src');
|
||||
if (!raw || /^(mailto:|tel:|data:)/.test(raw)) continue;
|
||||
const url = new URL(raw, origin + pagePath);
|
||||
if (url.origin !== origin) continue;
|
||||
if (!url.pathname.startsWith(base)) {
|
||||
failures.push(`${relative}: link escapes site base: ${raw}`);
|
||||
continue;
|
||||
}
|
||||
let target = path.resolve(root, decodeURIComponent(url.pathname.slice(base.length)));
|
||||
if (target !== root && !target.startsWith(root + path.sep)) throw new Error(`Path escape: ${raw}`);
|
||||
try {
|
||||
if ((await stat(target)).isDirectory()) target = path.join(target, 'index.html');
|
||||
await stat(target);
|
||||
if (url.hash && target.endsWith('.html')) {
|
||||
const dest = await document(target);
|
||||
const fragment = decodeURIComponent(url.hash.slice(1));
|
||||
if (!dest('[id]').toArray().some((node) => dest(node).attr('id') === fragment)) {
|
||||
failures.push(`${relative}: missing anchor ${raw}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`${relative}: broken link ${raw} (${error.code ?? error.message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every source document must have its own generated page (including drafts).
|
||||
const contents = fileURLToPath(new URL('../../contents/', import.meta.url));
|
||||
const markdown = (await files(contents)).filter((file) => file.endsWith('.md'));
|
||||
for (const file of markdown) {
|
||||
const slug = path.relative(contents, file).replace(/\.md$/, '');
|
||||
const output = path.join(root, 'book', slug, 'index.html');
|
||||
try { await stat(output); } catch { failures.push(`Missing generated document: ${slug}`); }
|
||||
}
|
||||
if (failures.length) {
|
||||
console.error(failures.join('\n'));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Checked ${htmlFiles.length} HTML pages, ${markdown.length} source documents, local links/assets and fragment targets (base ${base}).`);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import { bookHref, title, type Document } from '../lib/book';
|
||||
interface Props { documents: Document[]; current?: string; }
|
||||
const { documents, current } = Astro.props;
|
||||
const chapters = documents.filter((entry) => !entry.id.startsWith('draft/'));
|
||||
const drafts = documents.filter((entry) => entry.id.startsWith('draft/'));
|
||||
---
|
||||
<nav class="chapter-nav" aria-label="Bookの章">
|
||||
<p class="nav-label">REFERENCE / 正本</p>
|
||||
<ol>
|
||||
{chapters.map((entry) => <li class:list={{ subchapter: /^\d+\./.test(entry.id) }}><a href={bookHref(entry.id)} aria-current={current === entry.id ? 'page' : undefined}>{title(entry)}</a></li>)}
|
||||
</ol>
|
||||
<p class="nav-label draft-label">DRAFTS / 草案・検討</p>
|
||||
<ul>{drafts.map((entry) => <li><a href={bookHref(entry.id)} aria-current={current === entry.id ? 'page' : undefined}>{title(entry)}</a></li>)}</ul>
|
||||
</nav>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
const book = defineCollection({
|
||||
loader: glob({
|
||||
base: '../contents',
|
||||
pattern: '**/*.md',
|
||||
// Keep source filenames stable, including chapter numbers and draft paths.
|
||||
generateId: ({ entry }) => entry.replace(/\.md$/, ''),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { book };
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import { href, repository } from '../lib/book';
|
||||
interface Props { title?: string; description?: string; book?: boolean; }
|
||||
const { title = 'Web Interface Protocol', description = 'デジタル環境を、発見・観測・操作できるWorldspaceへ。WIPのコンセプトと仕様を読む。', book = false } = Astro.props;
|
||||
const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} · WIP</title>
|
||||
<meta name="description" content={description} />
|
||||
{canonical && <link rel="canonical" href={canonical} />}
|
||||
<meta property="og:title" content={`${title} · WIP`} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#123c39" />
|
||||
<link rel="icon" type="image/svg+xml" href={href('favicon.svg')} />
|
||||
</head>
|
||||
<body>
|
||||
<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<span class="brand-caption">Web Interface Protocol</span></span></a>
|
||||
<nav aria-label="メインナビゲーション">
|
||||
<a href={href()} aria-current={!book ? 'page' : undefined}>Home</a>
|
||||
<a href={href('book/')} aria-current={book ? 'true' : undefined}>Book</a>
|
||||
<a href={repository}>Source <span aria-hidden="true">↗</span></a>
|
||||
</nav>
|
||||
</header>
|
||||
<slot />
|
||||
<footer class="site-footer"><span>WIP / Web Interface Protocol</span><span>文書を正本に、ひとつのWorldspaceを。</span><a href={repository}>ソースと変更履歴 ↗</a></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
|
||||
export type Document = CollectionEntry<'book'>;
|
||||
export const repository = 'https://gitea.hareworks.net/Hare/wip-reference';
|
||||
|
||||
export function href(path = ''): string {
|
||||
return `${import.meta.env.BASE_URL.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
|
||||
}
|
||||
export function bookHref(id: string): string {
|
||||
return href(`book/${id.split('/').map(encodeURIComponent).join('/')}/`);
|
||||
}
|
||||
export function title(document: Document): string {
|
||||
return document.body?.match(/^#\s+(.+)$/m)?.[1] ?? document.id;
|
||||
}
|
||||
export function isDraft(document: Document): boolean {
|
||||
return document.id.startsWith('draft/');
|
||||
}
|
||||
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 }));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import Site from '../layouts/Site.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>
|
||||
</Site>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
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';
|
||||
export async function getStaticPaths() {
|
||||
return (await documents()).map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
|
||||
}
|
||||
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);
|
||||
---
|
||||
<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>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
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);
|
||||
---
|
||||
<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>
|
||||
</Site>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import Site from '../layouts/Site.astro';
|
||||
import { href, bookHref } from '../lib/book';
|
||||
const interactions = [
|
||||
{ name: 'observe', label: '世界を観測する', text: 'pathからObjectと、指定した深さまでのindexableな子Objectを取得する。' },
|
||||
{ name: 'fetch_interface', label: 'できることを知る', text: 'Interfaceの宣言を取得し、Operationの引数・結果・用途を理解する。' },
|
||||
{ name: 'call_operation', label: '対象を操作する', text: '対象とInterfaceを明示して、観測した条件のもとでOperationを実行する。' },
|
||||
];
|
||||
---
|
||||
<Site>
|
||||
<main id="main">
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow"><span class="status-dot" aria-hidden="true"></span> WEB INTERFACE PROTOCOL</p>
|
||||
<h1>ツールの一覧から、<br /><em>探索できる世界へ。</em></h1>
|
||||
<p class="lead">Filesystemも、Issueも、Databaseも。<br />WIPはデジタル環境を、発見・観測・操作できる<br class="desktop-break" />ひとつの<strong>Worldspace</strong>として公開するProtocolです。</p>
|
||||
<div class="hero-actions"><a class="button primary" href={href('book/')}>Bookを読む <span aria-hidden="true">→</span></a><a class="text-link" href={bookHref('1-concept')}>コンセプトから知る ↗</a></div>
|
||||
<p class="hero-note">One worldspace. Three core interactions.</p>
|
||||
</div>
|
||||
<div class="world-card" aria-label="Worldspaceの例">
|
||||
<div class="card-top"><span class="status-dot" aria-hidden="true"></span><span>WORLDSPACE</span><span class="card-coordinate">/</span></div>
|
||||
<div class="world-tree"><div class="tree-root">/ <span>あなたに公開された世界</span></div><ul><li><span class="tree-symbol">◇</span> repository <small>source & history</small></li><li><span class="tree-symbol">◇</span> issues <small>query & resolve</small><ul><li><span class="tree-symbol">◈</span> 123 <span class="tree-tag">Object</span></li></ul></li><li><span class="tree-symbol">◇</span> knowledge <small>search & discover</small></li></ul></div>
|
||||
<div class="operation-example"><span class="code-label">SELECT AN OPERATION</span><code>/::issue.lifecycle#close</code><span class="example-note">参照を見つけ、意味を理解し、明示的に呼び出す。</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="interaction-section" aria-labelledby="interactions">
|
||||
<div class="section-heading"><p class="eyebrow">A SMALL COMMON LANGUAGE</p><h2 id="interactions">共通の約束は、小さく。</h2><p>環境ごとの巨大なAPIではなく、探索と実行のための3つのCore interaction。</p></div>
|
||||
<div class="interaction-grid">{interactions.map((item, index) => <a class="interaction-card" href={bookHref('3-protocol')}><span class="step">0{index + 1}</span><code>{item.name}</code><h3>{item.label}</h3><p>{item.text}</p><span class="card-arrow" aria-hidden="true">↗</span></a>)}</div>
|
||||
</section>
|
||||
<section class="principles" aria-labelledby="principles">
|
||||
<div><p class="eyebrow">BOUNDARIES, NOT A BIGGER API</p><h2 id="principles">見えるものと、<br />扱えることを分ける。</h2></div>
|
||||
<div class="principle-list"><div><h3>ObjectとInterface</h3><p>Objectは公開情報とInterface参照を持つ。Operationの型や説明は、共有できるDescriptorとして分離する。</p></div><div><h3>発見と保持</h3><p>結果から得たentryは、必要なときに観測する。ClientのKnown Spaceは過去の観測であり、Hostの状態のreplicaではない。</p></div><div><h3>意味論と通信</h3><p>Coreはtransportから独立。標準HTTP bindingが、同じ契約をHTTPとJSONへ対応付ける。</p></div></div>
|
||||
</section>
|
||||
<section class="reading-cta"><p class="eyebrow">START READING</p><h2>仕様の全体像を、Bookで。</h2><p>コンセプトからCore Model、Client、HTTP bindingまで。<br />正本と草案を分け、章を辿って読めます。</p><a class="button primary" href={href('book/')}>目次を開く →</a></section>
|
||||
</main>
|
||||
</Site>
|
||||
@@ -0,0 +1,192 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #f7f7f2;
|
||||
--surface: #fff;
|
||||
--ink: #202f2c;
|
||||
--muted: #596762;
|
||||
--line: #dce1d8;
|
||||
--green: #123c39;
|
||||
--accent: #d8ecc9;
|
||||
--soft: #edf1e8;
|
||||
--mono: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
font-family: 'Noto Sans JP', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
a { color: inherit; text-underline-offset: .22em; }
|
||||
a:hover { color: #226c51; }
|
||||
:focus-visible { outline: 3px solid #33846c; outline-offset: 4px; border-radius: 3px; }
|
||||
::selection { background: var(--accent); color: var(--green); }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1, h2, h3 { line-height: 1.5; letter-spacing: -.035em; }
|
||||
h1 { font-size: clamp(2rem, 3.6vw, 3.8rem); font-weight: 800; }
|
||||
h2 { font-size: clamp(1.65rem, 2.5vw, 2.5rem); }
|
||||
code, pre { font-family: var(--mono); }
|
||||
.skip-link { position: fixed; top: -6rem; left: 1rem; z-index: 100; padding: .7rem 1rem; background: var(--green); color: white; }
|
||||
.skip-link:focus { top: 1rem; color: white; }
|
||||
.site-header { min-height: 90px; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem max(5vw, calc((100vw - 1400px)/2)); border-bottom: 1px solid var(--line); background: var(--paper); }
|
||||
.brand { display: flex; align-items: center; gap: .8rem; text-decoration: none; font-size: 1.45rem; font-weight: 800; line-height: 1.3; letter-spacing: -.04em; }
|
||||
.brand-mark { display: grid; place-items: center; background: var(--green); color: var(--accent); width: 42px; height: 42px; border-radius: 10px; font: 800 1.4rem var(--mono); }
|
||||
.brand-caption { display: block; font: 500 .65rem/1.6 var(--mono); letter-spacing: .01em; color: var(--muted); }
|
||||
.site-header nav { display: flex; gap: 1.8rem; align-items: center; font-size: .85rem; font-weight: 600; }
|
||||
.site-header nav a { text-decoration: none; padding: .45rem 0; }
|
||||
.site-header nav a[aria-current] { border-bottom: 2px solid var(--green); }
|
||||
.eyebrow { font: 600 .72rem/1.6 var(--mono); color: var(--muted); letter-spacing: .14em; margin-bottom: 1.5rem; }
|
||||
.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #73ab72; margin-right: .6rem; }
|
||||
.lead { color: var(--muted); line-height: 2; font-size: 1.05rem; }
|
||||
.button { display: inline-flex; align-items: center; gap: 2rem; padding: .9rem 1.4rem; border-radius: 5px; text-decoration: none; font-size: .88rem; font-weight: 600; }
|
||||
.primary { background: var(--green); color: white; border: 1px solid var(--green); }
|
||||
.primary:hover { background: #23534a; color: white; }
|
||||
.text-link { font-size: .85rem; font-weight: 600; }
|
||||
.hero { display: grid; grid-template-columns: 1.25fr 1fr; gap: 5vw; max-width: 1280px; margin: auto; padding: 6rem 3rem 5rem; align-items: center; }
|
||||
.hero h1 { margin-bottom: 1.7rem; }
|
||||
.hero h1 em { font-style: normal; color: var(--green); }
|
||||
.hero-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 1.5rem; margin: 2.3rem 0 1.2rem; }
|
||||
.hero-note { color: var(--muted); font: .68rem var(--mono); letter-spacing: .02em; }
|
||||
.world-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 20px 45px -30px #183c393d; overflow: hidden; }
|
||||
.card-top { display: flex; align-items: center; gap: .2rem; padding: 1rem 1.4rem; border-bottom: 1px solid var(--line); font: .68rem var(--mono); color: var(--muted); letter-spacing: .1em; }
|
||||
.card-coordinate { margin-left: auto; }
|
||||
.world-tree { padding: 1.6rem 1.8rem 1rem; font: .86rem/2.5 var(--mono); }
|
||||
.tree-root { color: var(--green); font-weight: 700; }
|
||||
.tree-root > span { margin-left: .5rem; font: .68rem system-ui; color: var(--muted); font-weight: 400; }
|
||||
.world-tree ul { list-style: none; padding-left: 1.15rem; margin: .4rem 0 0 .35rem; border-left: 1px solid var(--line); }
|
||||
.world-tree li { position: relative; }
|
||||
.world-tree li::before { content: ''; position: absolute; left: -1.15rem; top: 1.2rem; width: .7rem; border-top: 1px solid var(--line); }
|
||||
.world-tree small { font-size: .62rem; color: var(--muted); margin-left: .5rem; }
|
||||
.tree-symbol { color: #618868; margin-right: .3rem; }
|
||||
.tree-tag { background: var(--soft); border-radius: 3px; padding: .15rem .4rem; font-size: .6rem; color: #31573b; }
|
||||
.operation-example { margin: 1rem; padding: 1.1rem 1rem; background: var(--green); color: #eff4ed; border-radius: 6px; overflow-wrap: anywhere; }
|
||||
.code-label { display: block; color: #bed8c7; font: .6rem var(--mono); letter-spacing: .12em; margin-bottom: .7rem; }
|
||||
.operation-example code { font-size: .8rem; }
|
||||
.example-note { display: block; color: #d2dfd4; font-size: .62rem; margin-top: .7rem; }
|
||||
.interaction-section { max-width: 1280px; margin: auto; padding: 3rem 3rem 5rem; }
|
||||
.section-heading { margin-bottom: 2.3rem; }
|
||||
.section-heading .eyebrow { margin-bottom: .7rem; }
|
||||
.section-heading h2 { margin-bottom: .7rem; }
|
||||
.section-heading p:last-child { color: var(--muted); font-size: .9rem; }
|
||||
.interaction-grid { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--line); border-radius: 8px; background: var(--surface); overflow: hidden; }
|
||||
.interaction-card { padding: 1.8rem; text-decoration: none; position: relative; }
|
||||
.interaction-card + .interaction-card { border-left: 1px solid var(--line); }
|
||||
.interaction-card:hover { background: #f0f5ed; color: var(--ink); }
|
||||
.step { display: block; font: .7rem var(--mono); color: var(--muted); margin-bottom: 2rem; }
|
||||
.interaction-card code { color: var(--green); font-weight: 600; font-size: .92rem; }
|
||||
.interaction-card h3 { font-size: 1rem; margin: .8rem 0; }
|
||||
.interaction-card p { color: var(--muted); font-size: .83rem; margin-bottom: 1rem; }
|
||||
.card-arrow { position: absolute; right: 1.5rem; top: 1.4rem; color: var(--muted); }
|
||||
.principles { display: grid; grid-template-columns: 1fr 1fr; gap: 4rem; max-width: 1184px; padding: 3.5rem 0; margin: 0 auto; border-top: 1px solid var(--line); }
|
||||
.principle-list > div + div { border-top: 1px solid var(--line); padding-top: 1.2rem; }
|
||||
.principle-list h3 { font-size: 1rem; margin-bottom: .5rem; }
|
||||
.principle-list p { color: var(--muted); font-size: .88rem; }
|
||||
.reading-cta { padding: 4.5rem 1.5rem; margin-top: 2rem; text-align: center; background: var(--soft); border-block: 1px solid var(--line); }
|
||||
.reading-cta h2 { margin-bottom: 1rem; }
|
||||
.reading-cta p:not(.eyebrow) { color: var(--muted); font-size: .9rem; }
|
||||
.site-footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; padding: 2rem 5vw; font-size: .7rem; color: var(--muted); }
|
||||
.site-footer > span:first-child { font-family: var(--mono); }
|
||||
.book-shell { display: grid; grid-template-columns: 255px minmax(0, 1fr) 215px; max-width: 1480px; margin: auto; padding: 0 1.5rem; gap: 2.5rem; }
|
||||
.sidebar, .page-toc { position: sticky; top: 1rem; align-self: start; max-height: calc(100vh - 2rem); overflow-y: auto; padding: 2rem 0; scrollbar-width: thin; }
|
||||
.nav-label { font: 600 .63rem var(--mono); letter-spacing: .1em; color: var(--muted); margin: .5rem .7rem 1rem; }
|
||||
.chapter-nav ul, .chapter-nav ol, .page-toc ul { list-style: none; padding: 0; margin: 0; }
|
||||
.chapter-nav a { display: block; padding: .6rem .7rem; text-decoration: none; font-size: .77rem; line-height: 1.6; border-radius: 4px; color: var(--muted); }
|
||||
.chapter-nav a:hover { background: var(--soft); color: var(--green); }
|
||||
.chapter-nav a[aria-current] { background: var(--green); color: white; }
|
||||
.chapter-nav .subchapter { padding-left: .7rem; }
|
||||
.draft-label { margin-top: 2.5rem; }
|
||||
.book-content { min-width: 0; padding: 3rem 0 4rem; }
|
||||
.book-index { grid-template-columns: 255px minmax(0, 820px); }
|
||||
.book-index h1 { font-size: clamp(2rem, 3vw, 3rem); }
|
||||
.book-index .lead { max-width: 40rem; }
|
||||
.index-section { margin: 3.5rem 0; }
|
||||
.index-section h2 { font-size: 1.5rem; }
|
||||
.count { display: inline-block; margin-left: .8rem; font: .65rem var(--mono); color: var(--muted); letter-spacing: 0; }
|
||||
.chapter-list { list-style: none; padding: 0; border-top: 1px solid var(--line); }
|
||||
.chapter-list a { display: flex; gap: 1rem; justify-content: space-between; align-items: center; padding: 1.05rem .4rem; border-bottom: 1px solid var(--line); text-decoration: none; font-size: .95rem; }
|
||||
.chapter-list a:hover { background: var(--soft); }
|
||||
.badge { background: #efe8d8; padding: .12rem .5rem; border-radius: 3px; color: #715324; font: .65rem var(--mono); }
|
||||
.document-meta { display: flex; align-items: baseline; flex-wrap: wrap; gap: .5rem; margin-bottom: 2rem; color: var(--muted); font-size: .72rem; }
|
||||
.document-meta a { text-decoration: none; }
|
||||
.source-link { margin-left: auto; }
|
||||
.draft-notice { background: #f4efdf; border-left: 3px solid #a88131; border-radius: 3px; padding: .9rem 1.1rem; margin-bottom: 2rem; font-size: .8rem; color: #5c461e; }
|
||||
.draft-notice p { margin: .3rem 0 0; }
|
||||
.prose { font-size: .95rem; line-height: 1.95; overflow-wrap: anywhere; }
|
||||
.prose h1 { font-size: clamp(1.7rem, 2.7vw, 2.6rem); margin-bottom: 2rem; }
|
||||
.prose h2 { font-size: 1.5rem; padding-top: 1rem; margin: 2.8rem 0 1rem; border-top: 1px solid var(--line); }
|
||||
.prose h3 { font-size: 1.13rem; margin: 2rem 0 .8rem; }
|
||||
.prose h4 { font-size: 1rem; }
|
||||
.prose :is(h1,h2,h3,h4) { scroll-margin-top: 1.5rem; }
|
||||
.prose a { color: #246449; }
|
||||
.prose p, .prose ul, .prose ol { margin-bottom: 1.2rem; }
|
||||
.prose ul, .prose ol { padding-left: 1.5rem; }
|
||||
.prose li + li { margin-top: .35rem; }
|
||||
.prose :not(pre) > code { background: #e9eee6; padding: .15em .35em; border-radius: 3px; font-size: .86em; color: #284c3a; }
|
||||
.prose pre { max-width: 100%; overflow-x: auto; padding: 1.25rem; border: 1px solid var(--line); border-radius: 6px; font-size: .78rem; line-height: 1.7; }
|
||||
.prose pre code { background: none; }
|
||||
.prose table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: .82rem; margin: 1.5rem 0; }
|
||||
.prose th, .prose td { border: 1px solid var(--line); padding: .65rem .8rem; text-align: left; vertical-align: top; min-width: 100px; }
|
||||
.prose th { background: var(--soft); font-weight: 600; }
|
||||
.prose blockquote { margin: 1rem 0; padding: .5rem 1.2rem; border-left: 3px solid #8ca58a; color: var(--muted); }
|
||||
.prose blockquote p:last-child { margin-bottom: 0; }
|
||||
.prose img { max-width: 100%; height: auto; }
|
||||
.page-toc { padding-top: 3rem; }
|
||||
.page-toc a { display: block; text-decoration: none; font-size: .68rem; color: var(--muted); padding: .3rem .7rem; border-left: 1px solid var(--line); }
|
||||
.page-toc a:hover { color: var(--green); border-color: var(--green); }
|
||||
.page-toc .nested a { padding-left: 1.3rem; font-size: .64rem; }
|
||||
.pagination { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 4rem; border-top: 1px solid var(--line); padding-top: 1.5rem; }
|
||||
.pagination a { display: flex; flex-direction: column; text-decoration: none; gap: .5rem; padding: .7rem; border: 1px solid var(--line); border-radius: 5px; }
|
||||
.pagination small { color: var(--muted); font-size: .7rem; }
|
||||
.pagination span { font-size: .85rem; }
|
||||
.mobile-chapters, .mobile-toc { display: none; }
|
||||
.muted { color: var(--muted); }
|
||||
.not-found { max-width: 900px; margin: auto; padding: 6rem 2rem; min-height: 65vh; }
|
||||
@media (min-width: 1500px) { .hero { gap: 5rem; } }
|
||||
@media (max-width: 1200px) {
|
||||
.hero { gap: 2rem; padding-inline: 2rem; }
|
||||
.principles { margin-inline: 3rem; }
|
||||
.book-shell { grid-template-columns: 230px minmax(0, 1fr); gap: 2rem; }
|
||||
.page-toc { display: none; }
|
||||
.mobile-toc { display: block; border: 1px solid var(--line); border-radius: 5px; padding: .6rem 1rem; margin: 0 0 2rem; font-size: .8rem; }
|
||||
.mobile-toc ul { padding-left: 1rem; }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.site-header { min-height: 75px; padding-inline: 1.25rem; }
|
||||
.site-header nav { gap: 1rem; font-size: .8rem; }
|
||||
.brand-caption { display: none; }
|
||||
.hero { grid-template-columns: 1fr; padding: 3.5rem 1.5rem 2rem; }
|
||||
.hero h1 { font-size: clamp(2rem, 6vw, 3.5rem); }
|
||||
.world-card { max-width: 520px; width: 100%; margin: 1rem auto; }
|
||||
.interaction-section { padding: 2rem 1.5rem; }
|
||||
.interaction-grid { grid-template-columns: 1fr; }
|
||||
.interaction-card + .interaction-card { border-left: 0; border-top: 1px solid var(--line); }
|
||||
.step { margin-bottom: 1rem; }
|
||||
.principles { grid-template-columns: 1fr; gap: 1rem; margin: 1rem 1.5rem; padding: 2rem 0; }
|
||||
.reading-cta { margin-top: 0; padding-block: 3rem; }
|
||||
.book-shell { display: block; padding-inline: 1.25rem; }
|
||||
.sidebar { display: none; }
|
||||
.book-content { padding-top: 2rem; }
|
||||
.mobile-chapters { display: block; padding: .6rem 1rem; border: 1px solid var(--line); border-radius: 5px; margin-bottom: 1rem; font-size: .8rem; }
|
||||
.mobile-chapters nav { margin-top: 1rem; }
|
||||
summary { cursor: pointer; }
|
||||
.prose { font-size: .9rem; }
|
||||
.prose h2 { font-size: 1.3rem; }
|
||||
.site-footer { flex-direction: column; }
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.brand-mark { width: 32px; height: 32px; }
|
||||
.site-header nav { gap: .65rem; }
|
||||
.hero-actions { gap: 1rem; }
|
||||
.desktop-break { display: none; }
|
||||
.world-tree { padding-inline: 1rem; }
|
||||
.world-tree small { display: none; }
|
||||
}
|
||||
@media print {
|
||||
.site-header, .site-footer, .sidebar, .page-toc, .mobile-toc, .mobile-chapters, .pagination, .source-link { display: none; }
|
||||
.book-shell { display: block; padding: 0; }
|
||||
.prose { font-size: 10pt; }
|
||||
.prose pre, .prose table { white-space: pre-wrap; overflow: visible; }
|
||||
body { background: white; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('home and book are usable 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.getByRole('heading', { level: 1 })).toContainText('探索できる世界へ');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
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.getByRole('heading', { level: 1 })).toHaveText('3. Protocol');
|
||||
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 expect(page).toHaveURL(/4\.2-text-view\/#/);
|
||||
await page.goto('book/draft/filesystem-discovery/');
|
||||
await expect(page.locator('.draft-notice')).toContainText('Draft');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
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', () => {
|
||||
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('../3-protocol.md#value', path.join(contentsRoot, 'draft/rust-api-model.md')), '/book/3-protocol/#value');
|
||||
});
|
||||
test('base prefix and query are preserved', () => {
|
||||
assert.equal(normalizeBase('/reference'), '/reference/');
|
||||
assert.equal(documentHref('4-client.md?from=book#known-space', source, '/reference'), '/reference/book/4-client/?from=book#known-space');
|
||||
});
|
||||
test('external URLs, local anchors and non-Markdown assets are untouched', () => {
|
||||
for (const url of ['https://example.test/file.md', '//example.test/file.md', '#名前と文字列', 'mailto:user@example.test', './image.svg']) {
|
||||
assert.equal(documentHref(url, source), url);
|
||||
}
|
||||
});
|
||||
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('remark handles reference links but leaves code examples unchanged', () => {
|
||||
const tree = { type: 'root', children: [
|
||||
{ type: 'link', url: '4-client.md', children: [] },
|
||||
{ type: 'definition', url: '4-client.md#known-space' },
|
||||
{ type: 'code', value: '[example](4-client.md)' },
|
||||
] };
|
||||
documentLinks() (tree, { path: source });
|
||||
assert.equal(tree.children[0].url, '/book/4-client/');
|
||||
assert.equal(tree.children[1].url, '/book/4-client/#known-space');
|
||||
assert.equal(tree.children[2].value, '[example](4-client.md)');
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user