Added definition list block and code block

This commit is contained in:
2026-09-30 11:00:47 +02:00
parent e40463dfd3
commit 9aa43737d2
24 changed files with 492 additions and 53 deletions

View File

@@ -21,7 +21,8 @@
"astro": "^7.0.4", "astro": "^7.0.4",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"sharp": "^0.35.4" "sharp": "^0.35.4",
"shiki": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.10", "@astrojs/check": "^0.9.10",

3
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
sharp: sharp:
specifier: ^0.35.4 specifier: ^0.35.4
version: 0.35.4(@types/node@22.20.4) version: 0.35.4(@types/node@22.20.4)
shiki:
specifier: ^4.4.3
version: 4.4.3
devDependencies: devDependencies:
'@astrojs/check': '@astrojs/check':
specifier: ^0.9.10 specifier: ^0.9.10

View File

@@ -7,6 +7,9 @@ import ContentImage from '@/components/media/ContentImage.astro'
import Figure from '../media/Figure.astro' import Figure from '../media/Figure.astro'
import Callout from './blocks/general/Callout.astro' import Callout from './blocks/general/Callout.astro'
import Code from './blocks/general/Code.astro'
import Definition from './blocks/general/Definitions/Definition.astro'
import DefinitionList from './blocks/general/Definitions/DefinitionList.astro'
import Lead from './blocks/general/Lead.astro' import Lead from './blocks/general/Lead.astro'
import Quote from './blocks/general/Quote.astro' import Quote from './blocks/general/Quote.astro'
import Reveal from './blocks/general/Reveal.astro' import Reveal from './blocks/general/Reveal.astro'
@@ -17,9 +20,12 @@ type Props = { node: RenderableTreeNode | RenderableTreeNode[] }
const COMPONENTS: Record<string, AstroComponentFactory> = { const COMPONENTS: Record<string, AstroComponentFactory> = {
Callout, Callout,
Reveal, Reveal,
Code,
Figure, Figure,
TableWrapper, TableWrapper,
Quote, Quote,
Definition,
DefinitionList,
Lead, Lead,
Image: ContentImage, Image: ContentImage,
} }

View File

@@ -0,0 +1,14 @@
---
type Props = {
code: string
lang?: string
html?: string
}
const { code, lang, html } = Astro.props
---
<div class="code" data-language={lang}>
{lang && <span class="code__lang">{lang}</span>}
{html ? <Fragment set:html={html} /> : <pre tabindex="0"><code>{code}</code></pre>}
</div>

View File

@@ -0,0 +1,23 @@
---
import TermEntry, { type SeeLink } from '@/components/terms/TermEntry.astro'
import type { TermKind } from '@/lib/constants/terms'
import { slugify } from '@/lib/content/slug'
type Props = {
term: string
kind?: TermKind
see?: string
}
const { term, kind = 'entry', see = '' } = Astro.props
const links: SeeLink[] = see
.split(',')
.map((name) => name.trim())
.filter(Boolean)
.map((name) => ({ title: name, href: `#${slugify(name)}` }))
---
<TermEntry id={slugify(term)} title={term} kind={kind} see={links} ink>
<slot />
</TermEntry>

View File

@@ -0,0 +1,3 @@
<dl class="terms terms--cross">
<slot />
</dl>

View File

@@ -7,4 +7,4 @@ type Props = { cover: Article['summary']['cover'] }
const { image, ...rest } = Astro.props.cover const { image, ...rest } = Astro.props.cover
--- ---
{image && <Figure image={image} {...rest} size="full" eager />} {image && <Figure image={image} {...rest} size="wide" eager />}

View File

@@ -0,0 +1,42 @@
---
import { TERM_KINDS, type TermKind } from '@/lib/constants/terms'
export type SeeLink = {
title: string
href: string
}
type Props = {
id: string
title: string
kind: TermKind
see?: SeeLink[]
ink?: boolean
}
const { id, title, kind, see = [], ink } = Astro.props
const label = TERM_KINDS.find((option) => option.value === kind)?.label ?? kind
---
<dt class:list={['term', ink && 'dark']} id={id}>
<span class="term__name">{title}</span>
<span class="term__kind">{label}</span>
</dt>
<dd class="term__body">
<p class="term__definition">
<slot />
</p>
{see.length > 0 && (
<p class="term__see">
<span class="term__see-label">See</span>
{see.map((related) => (
<a class="term__link" href={related.href}>
{related.title}
</a>
))}
</p>
)}
</dd>

View File

@@ -144,8 +144,67 @@ for (const [key, value] of Object.entries(crucible)) {
credit="Dave Damage" credit="Dave Damage"
effect="xeroxed" effect="xeroxed"
ratio="16-9" ratio="16-9"
size="wide" /%} size="inline" /%}
Aliquam erat volutpat. Fusce sed velit at arcu tincidunt vulputate{% sidenote type="example" marker="‡" note="A second sidenote, typed as an example." /%}. Nam vitae lacus a nisl ultrices dictum. Aliquam erat volutpat. Fusce sed velit at arcu tincidunt vulputate{% sidenote type="example" marker="‡" note="A second sidenote, typed as an example." /%}. Nam vitae lacus a nisl ultrices dictum.
Finally, a link to another article: {% xref reference="the-crucible" %}The Crucible{% /xref %}. Finally, a link to another article: {% xref reference="the-crucible" %}The Crucible{% /xref %}.
## Code
```ts
const crucible = { stage: 'prima-materia', heat: 3 }
function temper(work: typeof crucible): 'calcined' | 'raw' {
return work.heat > 5 ? 'calcined' : 'raw'
}
```
A fence with no language, which should render unhighlighted and with no label:
```
Prima materia — the undifferentiated ground.
Nothing is named yet.
```
A long line, which should scroll inside the block rather than widen the article:
```css
.prose :is(h2, h3, h4) + .table-wrapper thead th { border-block-end: var(--border-card) solid var(--color-border-strong); color: inherit; background-color: transparent; }
```
An unknown language, which should render plain and raise a warning in the dev banner:
```brainfuck
+[----->+++<]>+.
```
{% callout type="example" title="Code inside a block" %}
The frame should sit inside the callout body, not fight it:
```js
export const isTag = (node) => Markdoc.Tag.isTag(node)
```
{% /callout %}
## Spoiler
Duis aute irure dolor in reprehenderit, and then {% spoiler %}the prima materia was the vessel all along{% /spoiler %}, which changes the reading of everything above.
A spoiler long enough to wrap across a line break: {% spoiler %}Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua{% /spoiler %}. And text after it.
## Definitions
{% definitions %}
{% definition term="Prima Materia" kind="prime" see="Calcination, Solutio" %}
The undifferentiated ground. Nothing is named yet, which is precisely what makes it workable.
{% /definition %}
{% definition term="Calcination" kind="stage" see="Prima Materia" %}
Burned down to what will not burn.
{% /definition %}
{% definition term="Solutio" kind="stage" %}
Dissolved back into flow, so that forms loosen.
{% /definition %}
{% /definitions %}

View File

@@ -0,0 +1,27 @@
import { fields } from '@keystatic/core'
import { wrapper } from '@keystatic/core/content-components'
import { TERM_KINDS } from '@/lib/constants/terms'
export const definition = wrapper({
label: 'Definition',
description: 'One term and its meaning',
forSpecificLocations: true,
schema: {
term: fields.text({
label: 'Term',
validation: {
isRequired: true,
},
}),
kind: fields.select({
label: 'Kind',
options: TERM_KINDS,
defaultValue: 'entry',
}),
see: fields.text({
label: 'See also',
description: 'Other terms in this list, comma separated',
}),
},
})

View File

@@ -0,0 +1,13 @@
import { repeating } from '@keystatic/core/content-components'
export const definitions = repeating({
label: 'Definitions',
description: 'A list of terms defined for this article only',
children: ['definition'],
validation: {
children: {
min: 1,
},
},
schema: {},
})

View File

@@ -1,21 +1,27 @@
import { callout } from './callout' import { callout } from './callout'
import { definition } from './definition-list/definition'
import { definitions } from './definition-list/definitions'
import { figure } from './figure' import { figure } from './figure'
import { footnote } from './footnote' import { footnote } from './footnote'
import { lead } from './lead' import { lead } from './lead'
import { quote } from './quote' import { quote } from './quote'
import { reveal } from './reveal' import { reveal } from './reveal'
import { sidenote } from './sidenote' import { sidenote } from './sidenote'
import { spoiler } from './spoiler'
import { tableWrapper } from './table-wrapper' import { tableWrapper } from './table-wrapper'
import { xref } from './xref' import { xref } from './xref'
export const generalBlocks = (collection: string) => ({ export const generalBlocks = (collection: string) => ({
figure: figure(collection), figure: figure(collection),
callout, callout,
definition,
definitions,
lead, lead,
footnote, footnote,
sidenote, sidenote,
tableWrapper, tableWrapper,
quote, quote,
reveal, reveal,
spoiler,
xref: xref(), xref: xref(),
}) })

View File

@@ -0,0 +1,15 @@
import { mark } from '@keystatic/core/content-components'
import { eyeOffIcon } from '@keystar/ui/icon/icons/eyeOffIcon'
export const spoiler = mark({
label: 'Spoiler',
icon: eyeOffIcon,
tag: 'span',
schema: {},
style: {
backgroundColor: '#2A2A2A',
color: '#F5F5F5',
padding: '0 2px',
},
})

View File

@@ -0,0 +1,11 @@
export const TERM_KINDS = [
{ label: 'Entry', value: 'entry' },
{ label: 'Prime', value: 'prime' },
{ label: 'Essence', value: 'essence' },
{ label: 'Element', value: 'element' },
{ label: 'Stage', value: 'stage' },
{ label: 'Tier', value: 'tier' },
{ label: 'Premise', value: 'premise' },
] as const
export type TermKind = (typeof TERM_KINDS)[number]['value']

View File

@@ -0,0 +1,38 @@
import { createHighlighter, type Highlighter } from 'shiki'
const THEMES = { light: 'github-light', dark: 'github-dark' } as const
const LANGS = [
'astro',
'bash',
'css',
'diff',
'html',
'ini',
'js',
'json',
'jsonc',
'jsx',
'md',
'python',
'sql',
'ts',
'tsx',
'xml',
'yaml',
]
export type Highlight = (code: string, lang: string) => string | null
let ready: Promise<Highlighter> | null = null
export async function createHighlight(): Promise<Highlight> {
ready ??= createHighlighter({ themes: Object.values(THEMES), langs: LANGS })
const highlighter = await ready
const loaded = new Set(highlighter.getLoadedLanguages())
return (code, lang) =>
loaded.has(lang)
? highlighter.codeToHtml(code, { lang, themes: THEMES, defaultColor: false })
: null
}

View File

@@ -1,6 +1,7 @@
import Markdoc from '@markdoc/markdoc' import Markdoc from '@markdoc/markdoc'
import { createDiagnostics } from '../diagnostics' import { createDiagnostics } from '../diagnostics'
import { createHighlight } from './highlight'
import { createNodes } from './nodes' import { createNodes } from './nodes'
import { createTags } from './tags' import { createTags } from './tags'
import type { NodeState, Renderable, Rendered, ResolveLink } from './types' import type { NodeState, Renderable, Rendered, ResolveLink } from './types'
@@ -21,11 +22,13 @@ export async function render(
ids: new Set(), ids: new Set(),
footnotes: [], footnotes: [],
sidenoteCount: 0, sidenoteCount: 0,
spoilerCount: 0,
markers: new Set(), markers: new Set(),
problems: [], problems: [],
} }
const highlight = await createHighlight()
const config = { const config = {
nodes: createNodes(state), nodes: createNodes(state, highlight),
tags: createTags(state, resolveLink), tags: createTags(state, resolveLink),
} }
const problems = createDiagnostics() const problems = createDiagnostics()

View File

@@ -1,6 +1,7 @@
import Markdoc, { type Node, type Schema } from '@markdoc/markdoc' import Markdoc, { type Node, type Schema } from '@markdoc/markdoc'
import { slugify } from '../content/slug' import { slugify } from '../content/slug'
import type { Highlight } from './highlight'
import type { NodeState } from './types' import type { NodeState } from './types'
const { Tag } = Markdoc const { Tag } = Markdoc
@@ -18,8 +19,27 @@ function uniqueId(text: string, ids: Set<string>): string {
return id return id
} }
export function createNodes(state: NodeState): Record<string, Schema> { export function createNodes(state: NodeState, highlight: Highlight): Record<string, Schema> {
return { return {
fence: {
...Markdoc.nodes.fence,
transform(node) {
/* transformAttributes renames `language` to `data-language`, so the
raw attribute is the honest source. The content carries a trailing
newline that would render as a blank last line. */
const lang = String(node.attributes.language ?? '').trim()
const code = String(node.attributes.content ?? '').replace(/\n$/, '')
const html = lang ? highlight(code, lang) : null
if (lang && !html)
state.problems.push({
level: 'warning',
message: `No highlighting for language "${lang}" — add it to LANGS in highlight.ts.`,
})
return new Tag('Code', { code, lang: lang || undefined, html: html ?? undefined }, [])
},
},
document: { document: {
...Markdoc.nodes.document, ...Markdoc.nodes.document,
transform: (node, config) => node.transformChildren(config), transform: (node, config) => node.transformChildren(config),

View File

@@ -60,6 +60,8 @@ const generated =
components: generalBlocks(ARTICLES), components: generalBlocks(ARTICLES),
render: { render: {
tags: { tags: {
definitions: 'DefinitionList',
definition: 'Definition',
callout: 'Callout', callout: 'Callout',
figure: 'Figure', figure: 'Figure',
tableWrapper: 'TableWrapper', tableWrapper: 'TableWrapper',
@@ -84,6 +86,16 @@ export function createTags(state: NodeState, resolveLink: ResolveLink): Record<s
]) ])
}, },
}, },
spoiler: {
...generated.spoiler,
transform(node, config) {
const id = `spoiler-${(state.spoilerCount += 1)}`
return new Tag('span', { class: 'spoiler' }, [
new Tag('input', { id, class: 'spoiler__toggle', type: 'checkbox' }, []),
new Tag('label', { class: 'spoiler__text', for: id }, node.transformChildren(config)),
])
},
},
sidenote: { sidenote: {
...generated.sidenote, ...generated.sidenote,
transform(node, config) { transform(node, config) {

View File

@@ -23,6 +23,7 @@ export type NodeState = {
ids: Set<string> ids: Set<string>
footnotes: Footnote[] footnotes: Footnote[]
sidenoteCount: number sidenoteCount: number
spoilerCount: number
markers: Set<string> markers: Set<string>
problems: Problem[] problems: Problem[]
} }

View File

@@ -0,0 +1,49 @@
@layer components.blocks {
.code {
@mixin my var(--space-9);
border: var(--border-card) solid var(--color-border-strong);
background-color: var(--color-surface-elevated-1);
}
.code__lang {
@mixin typo_label;
@mixin py var(--size-3);
@mixin px var(--size-7);
@mixin border-b var(--border-card), solid, var(--color-border-strong);
display: block;
font-family: var(--font-mono);
font-size: var(--type--3);
color: var(--color-text-primary);
background-color: var(--color-surface-base);
}
.code pre {
@mixin ma var(--size-0);
@mixin py var(--size-7);
@mixin px var(--size-8);
overflow-x: auto;
border: 0;
tab-size: 2;
background-color: transparent;
}
.code pre code {
@mixin pa var(--size-0);
font: inherit;
background-color: transparent;
}
.code .shiki,
.code .shiki span {
color: var(--shiki-light);
}
.dark .code .shiki,
.dark .code .shiki span {
color: var(--shiki-dark);
}
}

View File

@@ -7,3 +7,6 @@
@import url("footnote.css"); @import url("footnote.css");
@import url("sidenote.css"); @import url("sidenote.css");
@import url("figure.css"); @import url("figure.css");
@import url("code.css");
@import url("terms.css");
@import url("spoiler.css");

View File

@@ -0,0 +1,35 @@
@layer components.blocks {
.spoiler__toggle {
position: absolute;
overflow: hidden;
width: 1px;
height: 1px;
margin: -1px;
white-space: nowrap;
clip-path: inset(50%);
}
.spoiler__text {
@mixin px var(--space-1);
cursor: pointer;
user-select: none;
color: transparent;
background-color: var(--color-text-primary);
}
.spoiler__toggle:checked + .spoiler__text {
cursor: text;
user-select: text;
color: inherit;
background-color: var(--color-surface-elevated-3);
}
.spoiler__toggle:focus-visible + .spoiler__text {
@mixin focus-ring;
}
}

View File

@@ -1,7 +1,5 @@
@layer components.blocks { @layer components.blocks {
.table-wrapper { .table-wrapper {
/* The header treatment. Ink by default: a standalone wrapper is a
citable object. Position overrides these — nothing is authored. */
--table-header-fg: var(--color-text-inverse); --table-header-fg: var(--color-text-inverse);
--table-header-bg: var(--color-surface-inverse); --table-header-bg: var(--color-surface-inverse);
--table-header-rule: 0; --table-header-rule: 0;
@@ -59,11 +57,6 @@
} }
} }
/* Underline header: a table introduced by a heading, or one inside a
block, is part of the argument rather than a reference. A block with
its own surface redeclares --table-header-bg so the sticky first
column on mobile paints the right colour. */
.prose :is(h2, h3, h4) + .table-wrapper, .prose :is(h2, h3, h4) + .table-wrapper,
:is(.callout, .reveal) .table-wrapper { :is(.callout, .reveal) .table-wrapper {
--table-header-fg: inherit; --table-header-fg: inherit;
@@ -72,47 +65,6 @@
--table-header-edge: var(--color-border-strong); --table-header-edge: var(--color-border-strong);
} }
/* === MATRIX ===
A correspondence table: the cells carry hits, not values, so it has to
read as a grid — rules on both axes, everything centred, and a miss
stated rather than left looking like an oversight. */
.table-wrapper--matrix {
& :is(th, td) {
@mixin px var(--space-4);
@mixin border-r var(--border-hairline), solid, var(--color-border-subtle);
text-align: center;
vertical-align: middle;
}
& :is(th, td):last-child {
border-inline-end: 0;
}
/* The first column is the subject; it keeps its reading direction. */
& :is(thead, tbody) :is(th, td):first-child {
@mixin border-r var(--border-card), solid, var(--color-border-strong);
text-align: left;
}
/* Axis labels are short. Let them wrap instead of widening a column. */
& thead th {
text-wrap: balance;
}
/* A hit: the cell itself is the mark. */
& tbody td.is-set {
background-color: var(--color-text-primary);
}
/* A miss, said out loud. */
& tbody td:empty::before {
content: "·";
color: var(--color-text-quiet);
}
}
@media (--bp-mobile) { @media (--bp-mobile) {
.table-wrapper .table-scroll thead th:first-child { .table-wrapper .table-scroll thead th:first-child {
border-inline-end-color: var(--table-header-edge); border-inline-end-color: var(--table-header-edge);

View File

@@ -0,0 +1,103 @@
@layer components.blocks {
.terms {
@mixin ma var(--size-0);
display: grid;
}
.term {
display: flex;
flex-direction: column;
gap: var(--size-8);
justify-content: space-between;
}
.term__name {
@mixin typo_heading-3;
hyphens: auto;
overflow-wrap: break-word;
}
.term__kind {
@mixin typo_label;
font-size: var(--type--2);
color: var(--color-text-quarternary);
}
.term__body {
@mixin ma var(--size-0);
}
.term__definition {
@mixin typo_body;
@mixin ma var(--size-0);
}
.term__see {
@mixin typo_label;
@mixin mt var(--size-8);
@mixin mb var(--size-0);
@mixin pt var(--size-5);
@mixin border-t var(--border-hairline), solid, var(--color-border-subtle);
display: flex;
flex-wrap: wrap;
gap: var(--size-5);
font-size: var(--type--2);
color: var(--color-text-quarternary);
}
.term__see-label::after {
content: " ⟫";
}
.terms .term__link {
color: var(--color-text-primary);
text-decoration: underline;
text-underline-offset: var(--size-2);
&:hover,
&:focus-visible {
color: var(--color-primary);
}
}
.terms--cross {
@mixin my var(--space-9);
grid-template-columns: minmax(0, 11rem) minmax(0, 1fr);
gap: var(--border-card);
border: var(--border-card) solid var(--color-border-strong);
background-color: var(--color-border-strong);
}
.terms--cross .term {
@mixin py var(--size-8);
@mixin px var(--size-9);
color: var(--color-text-primary);
background-color: var(--color-surface-base);
&:not(:first-of-type) {
box-shadow: 0 calc(-1 * var(--border-card)) 0 var(--color-border-normal);
}
}
.terms--cross .term:target {
color: var(--color-text-on-accent);
background-color: var(--color-primary);
}
.terms--cross .term:target .term__kind {
color: var(--color-text-on-accent);
}
.terms--cross .term__body {
@mixin py var(--size-8);
@mixin px var(--size-9);
background-color: var(--color-surface-elevated-1);
}
}