149 lines
5.1 KiB
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}
|