diff --git a/bun.lock b/bun.lock index 37833d0..eca2ecb 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,9 @@ "@astrojs/markdown-remark": "^7.3.0", "@astrojs/rss": "^4.0.19", "astro": "^7.4.3", + "fontkitten": "^1.0.3", "remark-breaks": "^4.0.0", + "sharp": "^0.35.4", }, "devDependencies": { "@astrojs/sitemap": "^3.7.4", diff --git a/package.json b/package.json index e2d2201..e2d57d9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "@astrojs/markdown-remark": "^7.3.0", "@astrojs/rss": "^4.0.19", "astro": "^7.4.3", - "remark-breaks": "^4.0.0" + "fontkitten": "^1.0.3", + "remark-breaks": "^4.0.0", + "sharp": "^0.35.4" }, "scripts": { "test:documents": "node --test tests/*.test.mjs", diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index f157bd1..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/public/og-default.jpg b/public/og-default.jpg deleted file mode 100644 index d86e036..0000000 Binary files a/public/og-default.jpg and /dev/null differ diff --git a/scripts/subset-fonts.py b/scripts/subset-fonts.py index 1ef1ca0..bc145fd 100644 --- a/scripts/subset-fonts.py +++ b/scripts/subset-fonts.py @@ -1,6 +1,7 @@ """Make the site's self-hosted fonts in src/fonts/ from the original font files. -- DejaVu Sans Mono 2.37, Book: the text of everything but articles and About. +- DejaVu Sans Mono 2.37, Book: the text of everything but articles and About. The build also draws the share images + and the favicon from it (src/lib/mono.ts), so a character missing from it shows as a box in a share image. - Newsreader 1.003, regular and italic: the text of articles and About. Its weight is fixed at 400 and its optical size axis (6 to 72) is kept, as Google Fonts serves it to the prototype. diff --git a/src/components/Favicons.astro b/src/components/Favicons.astro new file mode 100644 index 0000000..823cd14 --- /dev/null +++ b/src/components/Favicons.astro @@ -0,0 +1,8 @@ +--- +// The favicon links, drawn at build time by src/lib/favicon.ts: the SVG for the browsers that take one, favicon.ico +// for the others (its sizes keep Chrome on the SVG), and the icon iPhones and iPads put on the home screen. +--- + + + + diff --git a/src/data/projects.ts b/src/data/projects.ts new file mode 100644 index 0000000..08a9da5 --- /dev/null +++ b/src/data/projects.ts @@ -0,0 +1,7 @@ +// The projects the home shows, one entry each. For now only what the share image's project count needs; the home and +// article pages (#31, #32) add each project's details. +export const projects = [ + { slug: 'voice-todos', name: 'Voice Todos' }, + { slug: 'agent-sdks', name: 'Agent SDKs for the Rest of Us' }, + { slug: 'data-lineage', name: 'Data Lineage' }, +]; diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 21f7a94..dd7dfc5 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -2,15 +2,18 @@ import '../styles/global.css'; import '../styles/legacy.css'; import Frame from '../components/Frame.astro'; +import Favicons from '../components/Favicons.astro'; import monoFont from '../fonts/dejavu-sans-mono.woff2?url'; +import { SITE_SHARE, type Share } from '../lib/share'; -// Accept props for meta tags +// Accept props for meta tags. `share` is the page's share image, the site's unless it has its own. const { title = 'meaningfool', description = 'Personal site of Josselin Perrus, product manager in Paris', - ogImage = new URL('/og-default.jpg', Astro.site).toString(), + share = SITE_SHARE as Share, type = 'website' } = Astro.props; +const ogImage = new URL(share.path, Astro.site).toString(); // Use Astro's built-in URL handling for canonical URLs const canonicalURL = new URL(Astro.url.pathname, Astro.site); @@ -22,7 +25,7 @@ const canonical = canonicalURL.toString(); - + {title} @@ -35,6 +38,9 @@ const canonical = canonicalURL.toString(); + + + @@ -43,6 +49,7 @@ const canonical = canonicalURL.toString(); + diff --git a/src/lib/favicon.ts b/src/lib/favicon.ts new file mode 100644 index 0000000..6913f57 --- /dev/null +++ b/src/lib/favicon.ts @@ -0,0 +1,73 @@ +// The favicon, the paper tile: "m." in the mono font on the panel colour, inside a 1px border, the "m" in ink and the +// full stop in the accent colour. The design and its values are in docs/redesign/paper/README.md. +// +// The glyphs have a line height equal to their size and -0.12em of letter-spacing, and are centred in the tile with +// some space kept under them. Each size has its own values. +// +// The small sizes are kept sharp the way a font's hinting would: the glyphs move by less than half a pixel so the "m" +// starts on a pixel and stands on one, and the full stop, a plain rectangle in DejaVu Sans Mono, is fitted to whole +// pixels, at least 2px a side so it still shows as a square at 16px. +import sharp from 'sharp'; +import { baseline, charWidth, inkBox, textPath, type TextStyle } from './mono'; + +const PANEL = '#f3f3f0'; +const BORDER = '#dcdcd6'; +const INK = '#151515'; +const ACCENT = '#b64b00'; + +// Tile size: the glyphs' size and the space under them, in px. The 180px tile, the apple-touch-icon, is the design's +// 144px tile (108px glyphs, 14px under them) scaled by 1.25. +const TILES: Record = { + 16: { glyphs: 12, under: 1 }, + 32: { glyphs: 24, under: 3 }, + 180: { glyphs: 135, under: 17.5 }, +}; + +/** The tile as an SVG, `size` px wide, with the glyphs converted to paths. */ +export function tileSvg(size: number): string { + const { glyphs, under } = TILES[size]; + const style: TextStyle = { size: glyphs, tracking: -0.12 }; + const m = inkBox('m'); + const ink = (size - 2 * charWidth(style)) / 2 + m.minX * glyphs; // where the "m" starts, centred + const x = Math.round(ink) - m.minX * glyphs; + const y = Math.round(baseline((size - under - glyphs) / 2, glyphs, glyphs)); + + const stop = inkBox('.'); + const width = Math.max(2, Math.round((stop.maxX - stop.minX) * glyphs)); + const height = Math.max(2, Math.round((stop.maxY - stop.minY) * glyphs)); + const centre = x + charWidth(style) + ((stop.minX + stop.maxX) / 2) * glyphs; + const left = Math.round(centre - width / 2); + return ( + `` + + `` + + `` + + `` + + `` + + '' + ); +} + +/** The tile as a PNG. */ +export function tilePng(size: number): Promise { + return sharp(Buffer.from(tileSvg(size))).ensureAlpha().png({ compressionLevel: 9 }).toBuffer(); +} + +/** An .ico file holding the tile at each size, each as a PNG, which every browser reads. */ +export async function tileIco(sizes: number[]): Promise { + const images = await Promise.all(sizes.map(tilePng)); + const header = Buffer.alloc(6 + 16 * sizes.length); + header.writeUInt16LE(1, 2); // an icon + header.writeUInt16LE(sizes.length, 4); + let offset = header.length; + sizes.forEach((size, i) => { + const entry = 6 + 16 * i; + header.writeUInt8(size % 256, entry); // width and height, 0 meaning 256 + header.writeUInt8(size % 256, entry + 1); + header.writeUInt16LE(1, entry + 4); // colour planes + header.writeUInt16LE(32, entry + 6); // bits per pixel + header.writeUInt32LE(images[i].length, entry + 8); + header.writeUInt32LE(offset, entry + 12); + offset += images[i].length; + }); + return Buffer.concat([header, ...images]); +} diff --git a/src/lib/mono.ts b/src/lib/mono.ts new file mode 100644 index 0000000..82c8860 --- /dev/null +++ b/src/lib/mono.ts @@ -0,0 +1,89 @@ +// DejaVu Sans Mono as SVG paths, for the images the build draws: the share images and the favicon. They use the +// site's own subset (src/fonts/), so they show the same letters as the pages, and as paths they need no font where +// they are shown. Text is laid out as a browser lays it out: every character takes the same width plus the +// letter-spacing, lines break at spaces and after hyphens, and a line box puts its baseline where CSS puts it. +import { create, type Font } from 'fontkitten'; +import fontData from '../fonts/dejavu-sans-mono.woff2?inline'; + +const font = create(Buffer.from(fontData.slice(fontData.indexOf(',') + 1), 'base64')) as Font; +const EM = font.unitsPerEm; +const ASCENT = font.ascent / EM; +const DESCENT = -font.descent / EM; +const ADVANCE = font.glyphForCodePoint(0x6d).advanceWidth / EM; // 0.602em: in a mono font, every character's width + +const COMMANDS = { moveTo: 'M', lineTo: 'L', quadraticCurveTo: 'Q', bezierCurveTo: 'C', closePath: 'Z' }; + +export interface TextStyle { + size: number; // font size, in px + tracking?: number; // letter-spacing, in em +} + +const chars = (text: string) => Array.from(text); +const num = (value: number) => String(Math.round(value * 100) / 100); + +/** The width one character takes, letter-spacing included. */ +export function charWidth({ size, tracking = 0 }: TextStyle): number { + return size * (ADVANCE + tracking); +} + +/** The width of a line of text. As in Chrome, the last character keeps its letter-spacing. */ +export function textWidth(text: string, style: TextStyle): number { + return chars(text).length * charWidth(style); +} + +/** Where CSS puts the baseline in a line box `lineHeight` tall whose top is at `top`. */ +export function baseline(top: number, lineHeight: number, size: number): number { + return top + (lineHeight - (ASCENT + DESCENT) * size) / 2 + ASCENT * size; +} + +/** A character's ink, in em from its origin on the baseline, y going up. */ +export function inkBox(char: string): { minX: number; minY: number; maxX: number; maxY: number } { + const { minX, minY, maxX, maxY } = font.glyphForCodePoint(char.codePointAt(0)!).bbox; + return { minX: minX / EM, minY: minY / EM, maxX: maxX / EM, maxY: maxY / EM }; +} + +/** SVG path data for one line of text, starting at `x` on the baseline `y`. */ +export function textPath(text: string, x: number, y: number, style: TextStyle): string { + const scale = style.size / EM; + const step = charWidth(style); + const missing = new Set(); + let d = ''; + chars(text).forEach((char, i) => { + const glyph = font.glyphForCodePoint(char.codePointAt(0)!); + if (glyph.id === 0 && char.trim()) missing.add(char); + const left = x + i * step; + for (const { command, args } of glyph.path.commands) { + d += COMMANDS[command] + args.map((v, j) => num(j % 2 ? y - v * scale : left + v * scale)).join(' '); + } + }); + if (missing.size) { + // The subset only holds Latin, punctuation, arrows and box drawing (scripts/subset-fonts.py). + console.warn(`[mono] DejaVu Sans Mono's subset has no ${[...missing].join(' ')}, drawn as a box in "${text}"`); + } + return d; +} + +/** + * Break text into lines at most `maxWidth` wide, as a browser does: at spaces and after a hyphen between letters, + * the space at the end of a line not counting. A word longer than a line breaks where it reaches the end. + */ +export function wrap(text: string, style: TextStyle, maxWidth: number): string[] { + const max = Math.max(1, Math.floor(maxWidth / charWidth(style) + 1e-9)); + const fits = (line: string) => chars(line.trimEnd()).length <= max; + const pieces = text.replace(/\s+/g, ' ').trim().split(/(?<= )|(?<=[\p{L}\p{N}]-)(?=[\p{L}\p{N}])/u); + const lines: string[] = []; + let line = ''; + for (let piece of pieces) { + if (line && !fits(line + piece)) { + lines.push(line.trimEnd()); + line = ''; + } + while (!fits(piece)) { + lines.push(chars(piece).slice(0, max).join('')); + piece = chars(piece).slice(max).join(''); + } + line += piece; + } + if (line.trim()) lines.push(line.trimEnd()); + return lines; +} diff --git a/src/lib/reading-time.mjs b/src/lib/reading-time.mjs new file mode 100644 index 0000000..0249c6e --- /dev/null +++ b/src/lib/reading-time.mjs @@ -0,0 +1,11 @@ +// Reading time, as the redesign shows it next to a post's date. + +/** + * Minutes to read a post: the words of its Markdown body at 235 a minute, rounded up, and at least 1. This gives the + * prototype's reading times (docs/redesign/prototype/maps.js) for all 18 of its articles, as any rate from 231 to 236 + * does. + */ +export function readingMinutes(markdown = '') { + const words = markdown.split(/\s+/).filter(Boolean).length; + return Math.max(1, Math.ceil(words / 235)); +} diff --git a/src/lib/share-images.ts b/src/lib/share-images.ts new file mode 100644 index 0000000..ab34b22 --- /dev/null +++ b/src/lib/share-images.ts @@ -0,0 +1,118 @@ +// The share images, 1200×630 PNGs drawn at build time: one per article and one for Home and About. The design and its +// values are in docs/redesign/paper/README.md. All the text is DejaVu Sans Mono, drawn as paths (src/lib/mono.ts). +// +// The frame is white, padded 64px at the top, 72px at the sides and 56px at the bottom, with three rows spread from top +// to bottom: the wordmark and a label tab, the middle, and a line over two pieces of text. +import sharp from 'sharp'; +import { baseline, charWidth, textPath, textWidth, wrap, type TextStyle } from './mono'; + +const WIDTH = 1200; +const HEIGHT = 630; +const TOP = 64; +const SIDE = 72; +const BOTTOM = 56; +const RIGHT = WIDTH - SIDE; + +// The colours of src/styles/tokens.css. +const PAPER = '#ffffff'; +const INK = '#151515'; +const MUTED = '#686862'; +const LINE = '#e5e5e2'; +const TINT = '#fff9f4'; +const ACCENT = '#b64b00'; + +interface Block { + height: number; + draw: (top: number) => string; +} + +const rect = (x: number, y: number, width: number, height: number, fill: string) => + ``; +const path = (d: string, fill: string) => (d ? `` : ''); + +/** One line of text in a line box `lineHeight` tall whose top is at `top`, from `x`. */ +const line = (text: string, x: number, top: number, lineHeight: number, style: TextStyle, fill: string) => + path(textPath(text, x, baseline(top, lineHeight, style.size), style), fill); + +/** Lines of text, one under the other. */ +function lines(texts: string[], top: number, lineHeight: number, style: TextStyle, fill: string): string { + return texts.map((text, i) => line(text, SIDE, top + i * lineHeight, lineHeight, style, fill)).join(''); +} + +function frame(label: string, middle: Block, left: string, right: string): string { + // Top row, its items centred on each other. Left, the wordmark: mono 36px/36px, -0.055em, the dot in the accent + // colour. Right, the label tab: mono 20px/28px on the tint, padding 10px 20px 9px, with a 3px accent line under it. + const mark: TextStyle = { size: 36, tracking: -0.055 }; + const tab: TextStyle = { size: 20 }; + const tabWidth = textWidth(label, tab) + 40; + const tabHeight = 10 + 28 + 9 + 3; + const markTop = TOP + (tabHeight - 36) / 2; + const tabLeft = RIGHT - tabWidth; + const top = + line('meaningfool', SIDE, markTop, 36, mark, INK) + + line('.', SIDE + 11 * charWidth(mark), markTop, 36, mark, ACCENT) + + rect(tabLeft, TOP, tabWidth, tabHeight - 3, TINT) + + rect(tabLeft, TOP + tabHeight - 3, tabWidth, 3, ACCENT) + + line(label, tabLeft + 20, TOP + 10, 28, tab, INK); + + // Bottom row: a 2px line, 24px of space, then mono 22px/30px, ink on the left and muted on the right. + const foot: TextStyle = { size: 22 }; + const footTop = HEIGHT - BOTTOM - (2 + 24 + 30); + const bottom = + rect(SIDE, footTop, RIGHT - SIDE, 2, LINE) + + line(left, SIDE, footTop + 26, 30, foot, INK) + + line(right, RIGHT - textWidth(right, foot), footTop + 26, 30, foot, MUTED); + + // The middle, with as much space above as below. + const space = footTop - (TOP + tabHeight); + const centre = middle.draw(TOP + tabHeight + (space - middle.height) / 2); + + return ( + `` + + rect(0, 0, WIDTH, HEIGHT, PAPER) + + top + + centre + + bottom + + '' + ); +} + +const png = (svg: string) => sharp(Buffer.from(svg)).removeAlpha().png({ compressionLevel: 9 }).toBuffer(); + +/** + * An article's image: "Article" on the tab, the title in mono 68px/80px, -0.05em, at most 1020px wide, and the name, + * date and reading time at the bottom. A title longer than four lines gets smaller until it fits in four. + */ +function articleImageSvg({ title, meta }: { title: string; meta: string }): string { + let style: TextStyle = { size: 68, tracking: -0.05 }; + let texts = wrap(title, style, 1020); + while (texts.length > 4 && style.size > 36) { + style = { ...style, size: style.size - 4 }; + texts = wrap(title, style, 1020); + } + if (texts.length > 4) texts = [...texts.slice(0, 3), `${texts[3].slice(0, -1)}…`]; + const lineHeight = Math.round((style.size * 80) / 68); + const middle = { + height: texts.length * lineHeight, + draw: (top: number) => lines(texts, top, lineHeight, style, INK), + }; + return frame('Article', middle, 'Josselin Perrus', meta); +} + +/** + * The image of Home and About: "Projects · Writing" on the tab, the name in mono 84px/92px, -0.05em, and 20px under it + * the home's intro in mono 28px/42px, -0.02em, muted, at most 920px wide. At the bottom, the site and its counts. + */ +function siteImageSvg({ intro, counts }: { intro: string; counts: string }): string { + const name: TextStyle = { size: 84, tracking: -0.05 }; + const lede: TextStyle = { size: 28, tracking: -0.02 }; + const texts = wrap(intro, lede, 920); + const middle = { + height: 92 + 20 + texts.length * 42, + draw: (top: number) => line('Josselin Perrus', SIDE, top, 92, name, INK) + lines(texts, top + 112, 42, lede, MUTED), + }; + return frame('Projects · Writing', middle, 'meaningfool.net', counts); +} + +export const articleImage = (text: { title: string; meta: string }) => png(articleImageSvg(text)); +export const siteImage = (text: { intro: string; counts: string }) => png(siteImageSvg(text)); diff --git a/src/lib/share.ts b/src/lib/share.ts new file mode 100644 index 0000000..fea66b4 --- /dev/null +++ b/src/lib/share.ts @@ -0,0 +1,52 @@ +// What the share images say and where they are served, for the endpoints that draw them (src/lib/share-images.ts) +// and the pages whose og:image they are. +import type { CollectionEntry } from 'astro:content'; +import { isDailyLog } from './posts.mjs'; +import { readingMinutes } from './reading-time.mjs'; +import { projects } from '../data/projects'; + +type Post = CollectionEntry<'writing'>; + +export interface Share { + path: string; + alt: string; +} + +/** The home's intro, which the share image of Home and About repeats under the name. */ +export const HOME_INTRO = + "I'm a product manager building AI products with AI. My projects are an opportunity to test ideas and learn in public."; + +/** The share image of Home and About. The activity logs, which have none of their own, use it too. */ +export const SITE_SHARE: Share = { path: '/share.png', alt: `Josselin Perrus. ${HOME_INTRO}` }; + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; + +/** "May 27, 2026": the date in the content is a day, which UTC keeps whatever the build machine's time zone. */ +export function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }); +} + +/** The articles, not the activity logs. */ +export const articlesOnly = (posts: Post[]) => posts.filter((post) => !isDailyLog(post)); + +/** "18 articles · 3 projects", for the share image of Home and About. */ +export function siteCounts(posts: Post[]): string { + return `${plural(articlesOnly(posts).length, 'article')} · ${plural(projects.length, 'project')}`; +} + +/** An article's date and reading time, as its share image shows them: "May 27, 2026 · 3 min read". */ +export function articleMeta(article: Post): { date: string; minutes: number; line: string } { + const date = formatDate(article.data.date); + const minutes = readingMinutes(article.body); + return { date, minutes, line: `${date} · ${minutes} min read` }; +} + +/** A post's share image: its own for an article, the site's for an activity log. */ +export function postShare(post: Post): Share { + if (isDailyLog(post)) return SITE_SHARE; + const { date, minutes } = articleMeta(post); + return { + path: `/articles/${post.id}/share.png`, + alt: `${post.data.title}. Josselin Perrus, ${date}, ${minutes} min read.`, + }; +} diff --git a/src/pages/404.astro b/src/pages/404.astro index 6e3d663..72baa37 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -2,6 +2,7 @@ import '../styles/global.css'; import Frame from '../components/Frame.astro'; import NotFound from '../components/NotFound.astro'; +import Favicons from '../components/Favicons.astro'; import monoFont from '../fonts/dejavu-sans-mono.woff2?url'; --- @@ -12,7 +13,7 @@ import monoFont from '../fonts/dejavu-sans-mono.woff2?url'; 404 - Page Not Found | meaningfool - + diff --git a/src/pages/apple-touch-icon.png.ts b/src/pages/apple-touch-icon.png.ts new file mode 100644 index 0000000..c53523c --- /dev/null +++ b/src/pages/apple-touch-icon.png.ts @@ -0,0 +1,6 @@ +// The 180px icon that iPhones and iPads put on the home screen. +import type { APIRoute } from 'astro'; +import { tilePng } from '../lib/favicon'; + +export const GET: APIRoute = async () => + new Response(new Uint8Array(await tilePng(180)), { headers: { 'Content-Type': 'image/png' } }); diff --git a/src/pages/articles/[slug].astro b/src/pages/articles/[slug].astro index be96275..7d71e55 100644 --- a/src/pages/articles/[slug].astro +++ b/src/pages/articles/[slug].astro @@ -3,6 +3,7 @@ import type { GetStaticPaths } from 'astro'; import { getCollection, render, type CollectionEntry } from 'astro:content'; import Layout from '../../layouts/Layout.astro'; import JsonLd from '../../components/JsonLd.astro'; +import { postShare } from '../../lib/share'; export const getStaticPaths: GetStaticPaths = async () => { const articles = await getCollection('writing'); @@ -14,6 +15,7 @@ export const getStaticPaths: GetStaticPaths = async () => { const article = Astro.props as CollectionEntry<'writing'>; const { Content } = await render(article); +const share = postShare(article); // JSON-LD structured data for BlogPosting const canonical = new URL(Astro.url.pathname, Astro.site).toString(); @@ -27,7 +29,7 @@ const articleSchema = { "@type": "BlogPosting", "headline": article.data.title, "description": article.data.description || article.data.title, - "image": new URL('/og-default.jpg', Astro.site).toString(), + "image": new URL(share.path, Astro.site).toString(), "author": author, "publisher": author, "datePublished": article.data.date.toISOString(), @@ -41,6 +43,7 @@ const articleSchema = { title={`${article.data.title} | meaningfool`} description={article.data.description || article.data.title} type="article" + share={share} >
diff --git a/src/pages/articles/[slug]/share.png.ts b/src/pages/articles/[slug]/share.png.ts new file mode 100644 index 0000000..45467cd --- /dev/null +++ b/src/pages/articles/[slug]/share.png.ts @@ -0,0 +1,14 @@ +// Each article's share image, /articles//share.png: its title, date and reading time. +import type { APIRoute, GetStaticPaths } from 'astro'; +import { getCollection, type CollectionEntry } from 'astro:content'; +import { articleImage } from '../../../lib/share-images'; +import { articleMeta, articlesOnly } from '../../../lib/share'; + +export const getStaticPaths: GetStaticPaths = async () => + articlesOnly(await getCollection('writing')).map((article) => ({ params: { slug: article.id }, props: { article } })); + +export const GET: APIRoute = async ({ props }) => { + const { article } = props as { article: CollectionEntry<'writing'> }; + const image = await articleImage({ title: article.data.title, meta: articleMeta(article).line }); + return new Response(new Uint8Array(image), { headers: { 'Content-Type': 'image/png' } }); +}; diff --git a/src/pages/documents.astro b/src/pages/documents.astro index beaa3ed..44c9d39 100644 --- a/src/pages/documents.astro +++ b/src/pages/documents.astro @@ -2,6 +2,7 @@ import '../styles/tokens.css'; import Frame from '../components/Frame.astro'; import NotFound from '../components/NotFound.astro'; +import Favicons from '../components/Favicons.astro'; --- @@ -16,7 +17,7 @@ import NotFound from '../components/NotFound.astro'; content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self'; frame-src blob:; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'" /> 404 - Page Not Found | meaningfool - +