From 9aa43737d2592af0947c651da4ff98c3f35bebeb Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 30 Sep 2026 11:00:47 +0200 Subject: [PATCH] Added definition list block and code block --- package.json | 3 +- pnpm-lock.yaml | 3 + src/components/markdoc/Node.astro | 6 + .../markdoc/blocks/general/Code.astro | 14 +++ .../general/Definitions/Definition.astro | 23 ++++ .../general/Definitions/DefinitionList.astro | 3 + src/components/post/Header/Cover.astro | 2 +- src/components/terms/TermEntry.astro | 42 +++++++ .../general/articles/prima-materia.mdoc | 61 ++++++++++- .../general/definition-list/definition.ts | 27 +++++ .../general/definition-list/definitions.ts | 13 +++ src/keystatic/blocks/general/index.ts | 6 + src/keystatic/blocks/general/spoiler.ts | 15 +++ src/lib/constants/terms.ts | 11 ++ src/lib/markdoc/highlight.ts | 38 +++++++ src/lib/markdoc/index.ts | 5 +- src/lib/markdoc/nodes.ts | 22 +++- src/lib/markdoc/tags.ts | 12 ++ src/lib/markdoc/types.ts | 1 + src/styles/components/code.css | 49 +++++++++ src/styles/components/index.css | 3 + src/styles/components/spoiler.css | 35 ++++++ src/styles/components/table-wrapper.css | 48 -------- src/styles/components/terms.css | 103 ++++++++++++++++++ 24 files changed, 492 insertions(+), 53 deletions(-) create mode 100644 src/components/markdoc/blocks/general/Code.astro create mode 100644 src/components/markdoc/blocks/general/Definitions/Definition.astro create mode 100644 src/components/markdoc/blocks/general/Definitions/DefinitionList.astro create mode 100644 src/components/terms/TermEntry.astro create mode 100644 src/keystatic/blocks/general/definition-list/definition.ts create mode 100644 src/keystatic/blocks/general/definition-list/definitions.ts create mode 100644 src/keystatic/blocks/general/spoiler.ts create mode 100644 src/lib/constants/terms.ts create mode 100644 src/lib/markdoc/highlight.ts create mode 100644 src/styles/components/code.css create mode 100644 src/styles/components/spoiler.css create mode 100644 src/styles/components/terms.css diff --git a/package.json b/package.json index c14e55a..a40681b 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "astro": "^7.0.4", "react": "^19.2.8", "react-dom": "^19.2.8", - "sharp": "^0.35.4" + "sharp": "^0.35.4", + "shiki": "^4.4.3" }, "devDependencies": { "@astrojs/check": "^0.9.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad7dc1b..7b5f4f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: sharp: specifier: ^0.35.4 version: 0.35.4(@types/node@22.20.4) + shiki: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@astrojs/check': specifier: ^0.9.10 diff --git a/src/components/markdoc/Node.astro b/src/components/markdoc/Node.astro index 2d78904..dcb6d19 100644 --- a/src/components/markdoc/Node.astro +++ b/src/components/markdoc/Node.astro @@ -7,6 +7,9 @@ import ContentImage from '@/components/media/ContentImage.astro' import Figure from '../media/Figure.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 Quote from './blocks/general/Quote.astro' import Reveal from './blocks/general/Reveal.astro' @@ -17,9 +20,12 @@ type Props = { node: RenderableTreeNode | RenderableTreeNode[] } const COMPONENTS: Record = { Callout, Reveal, + Code, Figure, TableWrapper, Quote, + Definition, + DefinitionList, Lead, Image: ContentImage, } diff --git a/src/components/markdoc/blocks/general/Code.astro b/src/components/markdoc/blocks/general/Code.astro new file mode 100644 index 0000000..01d3b28 --- /dev/null +++ b/src/components/markdoc/blocks/general/Code.astro @@ -0,0 +1,14 @@ +--- +type Props = { + code: string + lang?: string + html?: string +} + +const { code, lang, html } = Astro.props +--- + +
+ {lang && {lang}} + {html ? :
{code}
} +
diff --git a/src/components/markdoc/blocks/general/Definitions/Definition.astro b/src/components/markdoc/blocks/general/Definitions/Definition.astro new file mode 100644 index 0000000..9e24d54 --- /dev/null +++ b/src/components/markdoc/blocks/general/Definitions/Definition.astro @@ -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)}` })) +--- + + + + diff --git a/src/components/markdoc/blocks/general/Definitions/DefinitionList.astro b/src/components/markdoc/blocks/general/Definitions/DefinitionList.astro new file mode 100644 index 0000000..aad9b8f --- /dev/null +++ b/src/components/markdoc/blocks/general/Definitions/DefinitionList.astro @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/components/post/Header/Cover.astro b/src/components/post/Header/Cover.astro index 8d26eb8..aa9c715 100644 --- a/src/components/post/Header/Cover.astro +++ b/src/components/post/Header/Cover.astro @@ -7,4 +7,4 @@ type Props = { cover: Article['summary']['cover'] } const { image, ...rest } = Astro.props.cover --- -{image &&
} +{image &&
} diff --git a/src/components/terms/TermEntry.astro b/src/components/terms/TermEntry.astro new file mode 100644 index 0000000..826c903 --- /dev/null +++ b/src/components/terms/TermEntry.astro @@ -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 +--- + +
+ {title} + {label} +
+ +
+

+ +

+ + {see.length > 0 && ( +

+ See + {see.map((related) => ( + + {related.title} + + ))} +

+ )} +
diff --git a/src/content/general/articles/prima-materia.mdoc b/src/content/general/articles/prima-materia.mdoc index a31c5c8..8f97e39 100644 --- a/src/content/general/articles/prima-materia.mdoc +++ b/src/content/general/articles/prima-materia.mdoc @@ -144,8 +144,67 @@ for (const [key, value] of Object.entries(crucible)) { credit="Dave Damage" effect="xeroxed" 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. 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 %} diff --git a/src/keystatic/blocks/general/definition-list/definition.ts b/src/keystatic/blocks/general/definition-list/definition.ts new file mode 100644 index 0000000..d143974 --- /dev/null +++ b/src/keystatic/blocks/general/definition-list/definition.ts @@ -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', + }), + }, +}) diff --git a/src/keystatic/blocks/general/definition-list/definitions.ts b/src/keystatic/blocks/general/definition-list/definitions.ts new file mode 100644 index 0000000..5207e3c --- /dev/null +++ b/src/keystatic/blocks/general/definition-list/definitions.ts @@ -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: {}, +}) diff --git a/src/keystatic/blocks/general/index.ts b/src/keystatic/blocks/general/index.ts index 15199ef..c439b15 100644 --- a/src/keystatic/blocks/general/index.ts +++ b/src/keystatic/blocks/general/index.ts @@ -1,21 +1,27 @@ import { callout } from './callout' +import { definition } from './definition-list/definition' +import { definitions } from './definition-list/definitions' import { figure } from './figure' import { footnote } from './footnote' import { lead } from './lead' import { quote } from './quote' import { reveal } from './reveal' import { sidenote } from './sidenote' +import { spoiler } from './spoiler' import { tableWrapper } from './table-wrapper' import { xref } from './xref' export const generalBlocks = (collection: string) => ({ figure: figure(collection), callout, + definition, + definitions, lead, footnote, sidenote, tableWrapper, quote, reveal, + spoiler, xref: xref(), }) diff --git a/src/keystatic/blocks/general/spoiler.ts b/src/keystatic/blocks/general/spoiler.ts new file mode 100644 index 0000000..96f6eba --- /dev/null +++ b/src/keystatic/blocks/general/spoiler.ts @@ -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', + }, +}) diff --git a/src/lib/constants/terms.ts b/src/lib/constants/terms.ts new file mode 100644 index 0000000..61c951f --- /dev/null +++ b/src/lib/constants/terms.ts @@ -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'] diff --git a/src/lib/markdoc/highlight.ts b/src/lib/markdoc/highlight.ts new file mode 100644 index 0000000..351931f --- /dev/null +++ b/src/lib/markdoc/highlight.ts @@ -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 | null = null + +export async function createHighlight(): Promise { + 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 +} diff --git a/src/lib/markdoc/index.ts b/src/lib/markdoc/index.ts index fa40517..c2501a0 100644 --- a/src/lib/markdoc/index.ts +++ b/src/lib/markdoc/index.ts @@ -1,6 +1,7 @@ import Markdoc from '@markdoc/markdoc' import { createDiagnostics } from '../diagnostics' +import { createHighlight } from './highlight' import { createNodes } from './nodes' import { createTags } from './tags' import type { NodeState, Renderable, Rendered, ResolveLink } from './types' @@ -21,11 +22,13 @@ export async function render( ids: new Set(), footnotes: [], sidenoteCount: 0, + spoilerCount: 0, markers: new Set(), problems: [], } + const highlight = await createHighlight() const config = { - nodes: createNodes(state), + nodes: createNodes(state, highlight), tags: createTags(state, resolveLink), } const problems = createDiagnostics() diff --git a/src/lib/markdoc/nodes.ts b/src/lib/markdoc/nodes.ts index ab0036a..2fff927 100644 --- a/src/lib/markdoc/nodes.ts +++ b/src/lib/markdoc/nodes.ts @@ -1,6 +1,7 @@ import Markdoc, { type Node, type Schema } from '@markdoc/markdoc' import { slugify } from '../content/slug' +import type { Highlight } from './highlight' import type { NodeState } from './types' const { Tag } = Markdoc @@ -18,8 +19,27 @@ function uniqueId(text: string, ids: Set): string { return id } -export function createNodes(state: NodeState): Record { +export function createNodes(state: NodeState, highlight: Highlight): Record { 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: { ...Markdoc.nodes.document, transform: (node, config) => node.transformChildren(config), diff --git a/src/lib/markdoc/tags.ts b/src/lib/markdoc/tags.ts index 8dc8a61..90e260e 100644 --- a/src/lib/markdoc/tags.ts +++ b/src/lib/markdoc/tags.ts @@ -60,6 +60,8 @@ const generated = components: generalBlocks(ARTICLES), render: { tags: { + definitions: 'DefinitionList', + definition: 'Definition', callout: 'Callout', figure: 'Figure', tableWrapper: 'TableWrapper', @@ -84,6 +86,16 @@ export function createTags(state: NodeState, resolveLink: ResolveLink): Record footnotes: Footnote[] sidenoteCount: number + spoilerCount: number markers: Set problems: Problem[] } diff --git a/src/styles/components/code.css b/src/styles/components/code.css new file mode 100644 index 0000000..60d10cc --- /dev/null +++ b/src/styles/components/code.css @@ -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); + } +} diff --git a/src/styles/components/index.css b/src/styles/components/index.css index a25f18e..43b4433 100644 --- a/src/styles/components/index.css +++ b/src/styles/components/index.css @@ -7,3 +7,6 @@ @import url("footnote.css"); @import url("sidenote.css"); @import url("figure.css"); +@import url("code.css"); +@import url("terms.css"); +@import url("spoiler.css"); diff --git a/src/styles/components/spoiler.css b/src/styles/components/spoiler.css new file mode 100644 index 0000000..427983c --- /dev/null +++ b/src/styles/components/spoiler.css @@ -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; + } +} diff --git a/src/styles/components/table-wrapper.css b/src/styles/components/table-wrapper.css index 1049547..532ade1 100644 --- a/src/styles/components/table-wrapper.css +++ b/src/styles/components/table-wrapper.css @@ -1,7 +1,5 @@ @layer components.blocks { .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-bg: var(--color-surface-inverse); --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, :is(.callout, .reveal) .table-wrapper { --table-header-fg: inherit; @@ -72,47 +65,6 @@ --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) { .table-wrapper .table-scroll thead th:first-child { border-inline-end-color: var(--table-header-edge); diff --git a/src/styles/components/terms.css b/src/styles/components/terms.css new file mode 100644 index 0000000..92d162e --- /dev/null +++ b/src/styles/components/terms.css @@ -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); + } +}