apsis-book/frontend/src/pages/Document.svelte

149 lines
5.1 KiB
Svelte

<script lang="ts">
/**
* One concept document.
*
* The body is HTML rendered by the backend, which escapes every text node
* and emits only the markup it generated, so rendering it here does not
* introduce an injection path. Internal links are intercepted and handled
* by the router.
*
* The document carries a form per embedded language. The form matching
* the reader's language is shown, with the English form as the fallback
* when a translation is absent.
*/
import type { Document, DocumentForm } from '../lib/types';
import { navigate } from '../lib/router';
import { language, setLanguage, tr, type Language } from '../lib/i18n';
interface Props {
document: Document | null;
id: string | null;
}
let { document, id }: Props = $props();
/** The current language, read so that the form re-renders on a switch. */
let lang = $derived($language);
/** The form for the reader's language, falling back to English. */
const form = $derived.by((): DocumentForm | null => {
if (!document) return null;
const preferred = document.forms.find((entry) => entry.language === lang);
if (preferred) return preferred;
return document.forms.find((entry) => entry.language === 'en') ?? document.forms[0] ?? null;
});
/** True when the reader's language has no form of its own. */
const translationMissing = $derived(
!!document && !!form && form.language !== lang,
);
/** True when a form in the other language is available to switch to. */
const otherLanguage = $derived.by((): Language | null => {
if (!document || !form) return null;
const other: Language = form.language === 'zh' ? 'en' : 'zh';
return document.forms.some((entry) => entry.language === other) ? other : null;
});
/** Handles a click inside the body. */
function onClick(event: MouseEvent) {
const target = event.target as HTMLElement | null;
const anchor = target?.closest('a');
if (!anchor) return;
const concept = anchor.getAttribute('data-concept');
if (concept) {
event.preventDefault();
navigate(`/docs/${concept}`);
return;
}
const href = anchor.getAttribute('href');
if (href && href.startsWith('#')) {
event.preventDefault();
const anchorId = href.slice(1);
const element = window.document.getElementById(anchorId);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}
/** Sets the language; the router keeps the reader on the same document. */
function switchLanguage(next: Language) {
setLanguage(next);
}
</script>
{#if !id}
<p class="empty">{tr('docs.noSelection', lang)}</p>
{:else if !document || !form}
<div class="notice error">
<p>{tr('docs.notFound', lang)} <code>{id}</code>。</p>
<p><a href="#/docs">{tr('docs.back', lang)}</a></p>
</div>
{:else}
<div class="doc-layout" class:no-toc={form.headings.length === 0}>
<article class="doc-body">
<p class="breadcrumb">
<a href="#/docs">{tr('docs.breadcrumb', lang)}</a> / {document.chapter}
</p>
<h1>{form.title}</h1>
{#if form.summary}
<p style="color: var(--text-muted); font-size: 1.05rem;">{form.summary}</p>
{/if}
<div>
{#each document.tags as tag}
<span class="tag">{tag}</span>
{/each}
{#if otherLanguage}
<button
class="ghost"
type="button"
style="padding: 0.05rem 0.6rem; font-size: 0.75rem;"
onclick={() => switchLanguage(otherLanguage)}>{tr('docs.otherLanguage', lang)}</button>
{/if}
</div>
{#if translationMissing}
<div class="notice muted">
<p>{tr('docs.translationMissing', lang)}</p>
</div>
{/if}
{#if document.prerequisites.length > 0}
<div class="notice">
<strong>{tr('docs.readFirst', lang)}</strong>
{#each document.prerequisites as prerequisite, index}
{#if index > 0}, {/if}
<a href={`#/docs/${prerequisite}`}>{prerequisite}</a>
{/each}
</div>
{/if}
<!-- The HTML is generated by the backend from authored Markdown and
every text node is escaped there. The handler only intercepts
clicks on the generated links. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="doc-content" role="document" onclick={onClick}>
{@html form.html}
</div>
</article>
{#if form.headings.length > 0}
<aside class="doc-toc">
<h2 style="font-size: 0.72rem;">{tr('docs.onThisPage', lang)}</h2>
<ul style="list-style: none; padding-left: 0;">
{#each form.headings as heading}
<li style="margin: 0.2rem 0;">
<a href={`#${heading.anchor}`} style="color: var(--text-muted); font-size: 0.85rem;">
{heading.text}
</a>
</li>
{/each}
</ul>
</aside>
{/if}
</div>
{/if}