First commit

This commit is contained in:
2026-09-30 01:49:35 +02:00
commit 6edd03aace
109 changed files with 27020 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
/**
* The languages an article can be written in: an ISO 639-1 code with the
* name of the language in its own script, as the editor's picker lists them.
*/
export const LANGUAGES: ReadonlyArray<{ code: string; name: string }> = [
{ code: 'en', name: 'English' },
{ code: 'fr', name: 'Français' },
{ code: 'de', name: 'Deutsch' },
{ code: 'es', name: 'Español' },
{ code: 'ca', name: 'Català' },
{ code: 'pt', name: 'Português' },
{ code: 'it', name: 'Italiano' },
{ code: 'nl', name: 'Nederlands' },
{ code: 'pl', name: 'Polski' },
{ code: 'ru', name: 'Русский' },
{ code: 'uk', name: 'Українська' },
{ code: 'tr', name: 'Türkçe' },
{ code: 'ar', name: 'العربية' },
{ code: 'fa', name: 'فارسی' },
{ code: 'hi', name: 'हिन्दी' },
{ code: 'zh', name: '中文' },
{ code: 'ja', name: '日本語' },
{ code: 'ko', name: '한국어' },
]
export const DEFAULT_LANGUAGE = 'en'
/** The language an article is written in; articles predating the field are English. */
export function articleLanguage(article: { language?: string }): string {
return article.language?.trim().toLowerCase() || DEFAULT_LANGUAGE
}
/** Display name for a code — the code itself in upper case if it is unfamiliar. */
export function languageName(code: string): string {
return LANGUAGES.find((l) => l.code === code)?.name ?? code.toUpperCase()
}
/** The first language no version of an article has been written in yet. */
export function nextFreeLanguage(taken: readonly string[]): string {
const takenCodes = taken.map((c) => c.trim().toLowerCase())
return LANGUAGES.find((l) => !takenCodes.includes(l.code))?.code ?? DEFAULT_LANGUAGE
}

View File

@@ -0,0 +1,175 @@
/**
* Markdown -> HTML rendering with LaTeX support.
*
* Pipeline: markdown-it (html disabled for safety) + markdown-it-texmath
* (KaTeX for $...$ and $$...$$) + highlight.js for fenced code blocks.
* The result is sanitized with DOMPurify (isomorphic, works during SSR).
*/
import MarkdownIt from 'markdown-it'
import texmath from 'markdown-it-texmath'
import katex from 'katex'
import hljs from 'highlight.js/lib/common'
import DOMPurify from 'isomorphic-dompurify'
import type { StateInline, Token } from 'markdown-it'
export interface TocEntry {
id: string
text: string
level: number
}
export interface RenderedMarkdown {
html: string
toc: TocEntry[]
}
const md = new MarkdownIt({
html: false, // escape raw HTML in articles — safety first
linkify: true,
breaks: false,
highlight(code: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value
} catch {
/* fall through to plain rendering */
}
}
return ''
},
})
md.use(texmath, {
engine: katex,
delimiters: 'dollars',
katexOptions: { throwOnError: false, strict: false },
macros: {
'\\R': '\\mathbb{R}',
'\\N': '\\mathbb{N}',
'\\Z': '\\mathbb{Z}',
'\\C': '\\mathbb{C}',
'\\Q': '\\mathbb{Q}',
},
})
/** Convert a title to a URL slug — kept in sync with the backend slugify(). */
function slugify(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
/** Turn heading text into a URL fragment id (kept in sync with slugs). */
function slugifyFragment(text: string): string {
return slugify(text) || 'section'
}
/**
* Wiki links: `[[Article Title]]` or `[[Article Title|display text]]`
* become links to /wiki/<slug>. Registered before the `link` rule so
* code spans, emphasis and TeX ($...$) keep their precedence.
*/
function wikilinkRule(state: StateInline, silent: boolean): boolean {
const src = state.src
const start = state.pos
// must open with [[ but not [[[
if (src.charCodeAt(start) !== 0x5b || src.charCodeAt(start + 1) !== 0x5b) return false
if (src.charCodeAt(start + 2) === 0x5b) return false
// find the closing ]] — bail on a nested [[ or end of the inline content
let end = -1
for (let p = start + 2; p < src.length - 1; p++) {
if (src.charCodeAt(p) === 0x5b && src.charCodeAt(p + 1) === 0x5b) return false
if (src.charCodeAt(p) === 0x5d && src.charCodeAt(p + 1) === 0x5d) {
end = p
break
}
}
if (end === -1) return false
const inner = src.slice(start + 2, end)
if (!inner.trim() || inner.includes('\n')) return false
const bar = inner.indexOf('|')
const target = (bar === -1 ? inner : inner.slice(0, bar)).trim()
const slug = slugify(target)
if (!slug) return false
if (silent) {
// markdown-it requires a matching rule to consume its span, even silently
state.pos = end + 2
return true
}
const label = (bar === -1 ? '' : inner.slice(bar + 1)).trim() || target
const open = state.push('link_open', 'a', 1)
open.attrs = [['href', `/wiki/${slug}`], ['class', 'wikilink']]
const text = state.push('text', '', 0)
text.content = label
state.push('link_close', 'a', -1)
state.pos = end + 2
return true
}
md.inline.ruler.before('link', 'wikilink', wikilinkRule)
/** Best-effort plain text of an inline token (for TOC labels). */
function inlineText(inline: Token): string {
let out = ''
for (const child of inline.children ?? []) {
if (child.type === 'text' || child.type === 'code_inline') out += child.content
else if (child.type === 'math_inline') out += child.content
else if (child.type === 'image') {
const alt = child.attrs?.find((a: string[]) => a[0] === 'alt')
if (alt) out += alt[1]
}
}
return out.replace(/\s+/g, ' ').trim()
}
const sanitizeConfig = {
USE_PROFILES: { html: true, mathMl: true, svg: true },
ADD_ATTR: ['target', 'class', 'style', 'aria-hidden', 'encoding'],
}
/**
* Parse markdown once: assign heading ids (for the TOC), collect the table
* of contents, render to HTML and sanitize it.
*
* `namespace` prefixes the heading ids (the reader passes the article slug),
* so that the articles open side by side never hand the document two
* headings with the same id.
*/
export function renderMarkdown(source: string, namespace?: string): RenderedMarkdown {
const src = source ?? ''
const tokens = md.parse(src, {})
const toc: TocEntry[] = []
const usedIds = new Set<string>()
const prefix = namespace ? `${slugify(namespace)}--` : ''
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]!
if (token.type !== 'heading_open') continue
const level = Number(token.tag.slice(1))
const inline = tokens[i + 1]
const text = inline?.type === 'inline' ? inlineText(inline) : ''
if (!text) continue
let id = slugifyFragment(text)
let n = 2
while (usedIds.has(id)) id = `${slugifyFragment(text)}-${n++}`
usedIds.add(id)
token.attrSet('id', `${prefix}${id}`)
if (level >= 2 && level <= 3) toc.push({ id: `${prefix}${id}`, text, level })
}
const raw = md.renderer.render(tokens, md.options, {})
const html = DOMPurify.sanitize(raw, sanitizeConfig) as unknown as string
return { html, toc }
}

View File

@@ -0,0 +1,57 @@
/* Route-shape helpers for the reader experience.
An article route is a page of the open book drawn by ArticleBookStack —
pages laid out side by side, older ones parked with their spines showing —
while the article edit page and the new-article page remain plain,
full-height pages. An article lives at /wiki/<slug>; one of its language
versions at /wiki/<lang>/<slug>, e.g. /wiki/fr/bayes-theorem. */
const READER_PATH = /^\/wiki\/([^/]+?)\/?$/
const VERSION_READER_PATH = /^\/wiki\/([a-z]{2})\/([^/]+?)\/?$/
const WIKI_SUB_PATH = /^\/wiki\/[^/]+\/[^/]+/
/** What a reader address names: the article's identity, and which language
* version the address asks for (null for the article's own version). */
export interface ReaderLocation {
identity: string
lang: string | null
}
export function parseReaderPath(path: string): ReaderLocation | null {
const version = VERSION_READER_PATH.exec(path)
// /wiki/<x>/edit is the editor whatever <x> is — never a version address
if (version && version[2]!.toLowerCase() !== 'edit') {
return { identity: decodeURIComponent(version[2]!), lang: version[1]! }
}
const plain = READER_PATH.exec(path)
if (plain) return { identity: decodeURIComponent(plain[1]!), lang: null }
return null
}
export function isReaderPath(path: string): boolean {
return parseReaderPath(path) !== null
}
export function wikiSlugFromPath(path: string): string | null {
return parseReaderPath(path)?.identity ?? null
}
/**
* The address of one language version: the canonical document lives at the
* article's own URL, translations at /wiki/<lang>/<identity>.
*/
export function versionHref(identity: string, version: { language?: string; slug: string }): string {
if (version.slug === identity) return `/wiki/${encodeURIComponent(identity)}`
return `/wiki/${encodeURIComponent(version.language ?? 'en')}/${encodeURIComponent(identity)}`
}
/**
* The "reading surface": the reader itself plus the edit/new-article pages —
* the pages that belong to the open book. The stack of open pages survives the
* whole session either way; this marks the pages outside it (the list, the
* graph, home), which are the places worth returning to once the last page is
* closed.
*/
export function isReadingSurface(path: string): boolean {
return isReaderPath(path) || WIKI_SUB_PATH.test(path) || path === '/new'
}