diff --git a/package.json b/package.json index 41b0897..b6b5e64 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "@markdoc/markdoc": "^0.4.0", "astro": "^7.0.4", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "sharp": "^0.35.4" }, "devDependencies": { "@astrojs/check": "^0.9.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b2d627..efb356c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: react-dom: specifier: ^19.2.8 version: 19.3.0(react@19.3.0) + sharp: + specifier: ^0.35.4 + version: 0.35.4 devDependencies: '@astrojs/check': specifier: ^0.9.10 @@ -4135,8 +4138,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: @@ -6572,7 +6574,6 @@ snapshots: '@img/sharp-win32-arm64': 0.35.4 '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 - optional: true shebang-command@2.0.0: dependencies: diff --git a/src/assets/content/articles/the-crucible/Screenshot 2026-09-15 at 10.21.17.png b/src/assets/content/articles/the-crucible/Screenshot 2026-09-15 at 10.21.17.png deleted file mode 100644 index 9b834df..0000000 Binary files a/src/assets/content/articles/the-crucible/Screenshot 2026-09-15 at 10.21.17.png and /dev/null differ diff --git a/src/assets/content/articles/the-crucible/summary/cover/image.png b/src/assets/content/articles/the-crucible/summary/cover/image.png deleted file mode 100644 index 7184e19..0000000 Binary files a/src/assets/content/articles/the-crucible/summary/cover/image.png and /dev/null differ diff --git a/src/components/dev/DevBanner.astro b/src/components/dev/DevBanner.astro index 09cfd08..23951e3 100644 --- a/src/components/dev/DevBanner.astro +++ b/src/components/dev/DevBanner.astro @@ -14,9 +14,9 @@ type Props = { const { collection, id, status, diagnostics = [] } = Astro.props const NOTICE: Partial> = { - draft: 'Draft – not build in production', + draft: 'Draft – not built in production', unlisted: 'Unlisted – has page, but appears in no list', - archived: 'Archived - has page, but appears in no list', + archived: 'Archived – has page, but appears in no list', } const site = import.meta.env.DEV ? await getSite() : null diff --git a/src/components/markdoc/Node.astro b/src/components/markdoc/Node.astro index 6c593d7..5e2cac6 100644 --- a/src/components/markdoc/Node.astro +++ b/src/components/markdoc/Node.astro @@ -1,8 +1,18 @@ --- +import type { AstroComponentFactory } from 'astro/runtime/server/index.js' + import Markdoc, { type RenderableTreeNode } from '@markdoc/markdoc' +import ContentImage from '@/components/media/ContentImage.astro' + +import Figure from '../media/Figure.astro' + type Props = { node: RenderableTreeNode } +const COMPONENTS: Record = { + Figure, + Image: ContentImage, +} const { node } = Astro.props const Element = Markdoc.Tag.isTag(node) ? node.name : null --- diff --git a/src/components/media/ContentImage.astro b/src/components/media/ContentImage.astro new file mode 100644 index 0000000..b8298ec --- /dev/null +++ b/src/components/media/ContentImage.astro @@ -0,0 +1,20 @@ +--- +import { Image } from 'astro:assets' + +import { resolveImage } from '@/lib/images/resolve' + +type Props = { + src: string + alt: string +} + +const { src, alt } = Astro.props +const image = resolveImage(src) +if (!image && import.meta.env.PROD) throw new Error(`Image not found: ${src}`) +--- + +{image ? ( + {alt} +) : ( + Missing image: {src} +)} diff --git a/src/components/media/Figure.astro b/src/components/media/Figure.astro new file mode 100644 index 0000000..210706f --- /dev/null +++ b/src/components/media/Figure.astro @@ -0,0 +1,25 @@ +--- +import type { FigureSize } from '@/lib/constants/figure' + +import ContentImage from './ContentImage.astro' + +type Props = { + image: string + alt: string + caption?: string + credit?: string + size?: FigureSize +} + +const { image, alt, caption, credit, size = 'inline' } = Astro.props +--- + +
+ + {(caption || credit) && ( +
+ {caption && {caption}} + {credit && {credit}} +
+ )} +
diff --git a/src/content/general/articles/the-crucible.mdoc b/src/content/general/articles/the-crucible.mdoc index 57b6058..930f79c 100644 --- a/src/content/general/articles/the-crucible.mdoc +++ b/src/content/general/articles/the-crucible.mdoc @@ -7,14 +7,10 @@ summary: excerpt: >- Where we explore the crucible – a materialistic alchemist Worldbuilding Framework - cover: - image: /src/assets/content/articles/the-crucible/summary/cover/image.png - alt: Just a test - caption: Test - credit: By ME + cover: {} dates: published: 2026-09-21T12:33:00.000Z tags: - the-crucible --- -![](/src/assets/content/articles/the-crucible/Screenshot%202026-09-15%20at%2010.21.17.png) +hmm, do you even sharp? diff --git a/src/keystatic/blocks/general/figure.ts b/src/keystatic/blocks/general/figure.ts new file mode 100644 index 0000000..93bde77 --- /dev/null +++ b/src/keystatic/blocks/general/figure.ts @@ -0,0 +1,31 @@ +import { fields } from '@keystatic/core' +import { block } from '@keystatic/core/content-components' + +import { FIGURE_SIZES } from '@/lib/constants/figure' +import { assets } from '@/lib/images/storage' + +export const figure = (collection: string) => + block({ + label: 'Figure', + description: 'An image with alt text, and optionally a caption and credit.', + schema: { + image: fields.image({ + label: 'Image', + ...assets(collection), + validation: { + isRequired: true, + }, + }), + alt: fields.text({ + label: 'Caption', + }), + credit: fields.text({ + label: 'Credit', + }), + size: fields.select({ + label: 'Size', + options: FIGURE_SIZES, + defaultValue: 'inline', + }), + }, + }) diff --git a/src/keystatic/blocks/general/index.ts b/src/keystatic/blocks/general/index.ts new file mode 100644 index 0000000..4739de9 --- /dev/null +++ b/src/keystatic/blocks/general/index.ts @@ -0,0 +1,5 @@ +import { figure } from './figure' + +export const generalBlocks = (collection: string) => ({ + figure: figure(collection), +}) diff --git a/src/keystatic/fields/content/content.ts b/src/keystatic/fields/content/content.ts index 7b8ef5a..c4a843f 100644 --- a/src/keystatic/fields/content/content.ts +++ b/src/keystatic/fields/content/content.ts @@ -1,6 +1,7 @@ import { fields } from '@keystatic/core' -import { assets } from '@/lib/content/assets' +import { generalBlocks } from '@/keystatic/blocks/general' +import { assets } from '@/lib/images/storage' export const content = (collection: string) => fields.markdoc({ @@ -9,4 +10,5 @@ export const content = (collection: string) => heading: [2, 3, 4], image: assets(collection), }, + components: generalBlocks(collection), }) diff --git a/src/keystatic/fields/content/cover.ts b/src/keystatic/fields/content/cover.ts index 7e2bb61..d454dbd 100644 --- a/src/keystatic/fields/content/cover.ts +++ b/src/keystatic/fields/content/cover.ts @@ -1,6 +1,6 @@ import { fields } from '@keystatic/core' -import { assets } from '@/lib/content/assets' +import { assets } from '@/lib/images/storage' export const cover = (collection: string) => fields.object( diff --git a/src/layouts/ArticleLayout.astro b/src/layouts/general/ArticleLayout.astro similarity index 65% rename from src/layouts/ArticleLayout.astro rename to src/layouts/general/ArticleLayout.astro index 3ad5783..0637311 100644 --- a/src/layouts/ArticleLayout.astro +++ b/src/layouts/general/ArticleLayout.astro @@ -1,11 +1,11 @@ --- import DevBanner from '@/components/dev/DevBanner.astro' import Node from '@/components/markdoc/Node.astro' +import Figure from '@/components/media/Figure.astro' +import BaseLayout from '@/layouts/BaseLayout.astro' import type { Rendered } from '@/lib/markdoc/types' import type { Article } from '@/lib/site/types' -import BaseLayout from './BaseLayout.astro' - type Props = { article: Article rendered: Rendered @@ -23,6 +23,15 @@ const { article, rendered } = Astro.props />

{article.title}

+ {article.summary.cover.image && ( +
+ )}
diff --git a/src/layouts/PageLayout.astro b/src/layouts/general/PageLayout.astro similarity index 100% rename from src/layouts/PageLayout.astro rename to src/layouts/general/PageLayout.astro diff --git a/src/layouts/TagLayout.astro b/src/layouts/general/TagLayout.astro similarity index 100% rename from src/layouts/TagLayout.astro rename to src/layouts/general/TagLayout.astro diff --git a/src/lib/constants/figure.ts b/src/lib/constants/figure.ts new file mode 100644 index 0000000..59a965c --- /dev/null +++ b/src/lib/constants/figure.ts @@ -0,0 +1,16 @@ +export const FIGURE_SIZES = [ + { + label: 'Inline', + value: 'inline', + }, + { + label: 'Wide', + value: 'wide', + }, + { + label: 'Full', + value: 'full', + }, +] + +export type FigureSize = (typeof FIGURE_SIZES)[number]['value'] diff --git a/src/lib/content/assets.ts b/src/lib/content/assets.ts deleted file mode 100644 index c847422..0000000 --- a/src/lib/content/assets.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const ASSET_ROOT = 'src/assets/content' - -export const assets = (collection: string) => ({ - directory: `${ASSET_ROOT}/${collection}`, - publicPath: `/${ASSET_ROOT}/${collection}/`, -}) diff --git a/src/lib/images/resolve.ts b/src/lib/images/resolve.ts new file mode 100644 index 0000000..5fb5ffe --- /dev/null +++ b/src/lib/images/resolve.ts @@ -0,0 +1,8 @@ +import type { ImageMetadata } from 'astro' + +const images = import.meta.glob( + '/src/assets/content/**/*.{png,jpg,jpeg,webp,avif,gif,svg,PNG,JPG,JPEG,WEBP,AVIF,GIF,SVG}', + { eager: true, import: 'default' }, +) + +export const resolveImage = (src: string): ImageMetadata | undefined => images[decodeURI(src)] diff --git a/src/lib/images/storage.ts b/src/lib/images/storage.ts new file mode 100644 index 0000000..5568d52 --- /dev/null +++ b/src/lib/images/storage.ts @@ -0,0 +1,15 @@ +import { slugify } from '../content/slug' + +export const ASSET_ROOT = 'src/assets/content' + +export function cleanFilename(filename: string): string { + const dot = filename.lastIndexOf('.') + if (dot <= 0) return slugify(filename) + return `${slugify(filename.slice(0, dot)) || 'image'}${filename.slice(dot).toLowerCase()}` +} + +export const assets = (collection: string) => ({ + directory: `${ASSET_ROOT}/${collection}`, + publicPath: `/${ASSET_ROOT}/${collection}/`, + transformFilename: cleanFilename, +}) diff --git a/src/lib/markdoc/index.ts b/src/lib/markdoc/index.ts index aeb4c1a..a08a1c4 100644 --- a/src/lib/markdoc/index.ts +++ b/src/lib/markdoc/index.ts @@ -2,6 +2,7 @@ import Markdoc from '@markdoc/markdoc' import { createDiagnostics } from '../diagnostics' import { createNodes } from './nodes' +import { tags } from './tags' import type { NodeState, Renderable, Rendered } from './types' const LEVEL = { @@ -18,6 +19,7 @@ export async function render({ collection, id, content }: Renderable): Promise { return new Tag('div', { class: 'table-scroll', tabindex: 0 }, [table]) }, }, + image: { + ...Markdoc.nodes.image, + render: 'Image', + validate: (node) => + node.attributes.alt + ? [] + : [ + { + id: 'image-alt', + level: 'warning', + message: 'An image has no alt text', + }, + ], + }, } } diff --git a/src/lib/markdoc/tags.ts b/src/lib/markdoc/tags.ts new file mode 100644 index 0000000..9b45716 --- /dev/null +++ b/src/lib/markdoc/tags.ts @@ -0,0 +1,12 @@ +import { fields } from '@keystatic/core' + +import { generalBlocks } from '@/keystatic/blocks/general' + +export const tags = fields.markdoc.createMarkdocConfig({ + components: generalBlocks('articles'), + render: { + tags: { + figure: 'Figure', + }, + }, +}).tags diff --git a/src/pages/[...path].astro b/src/pages/[...path].astro index feabaf9..a9af333 100644 --- a/src/pages/[...path].astro +++ b/src/pages/[...path].astro @@ -1,8 +1,8 @@ --- import type { GetStaticPaths } from 'astro' -import ArticleLayout from '@/layouts/ArticleLayout.astro' -import PageLayout from '@/layouts/PageLayout.astro' +import ArticleLayout from '@/layouts/general/ArticleLayout.astro' +import PageLayout from '@/layouts/general/PageLayout.astro' import { assertNoErrors } from '@/lib/diagnostics/report' import { render } from '@/lib/markdoc' import { getSite } from '@/lib/site'