diff --git a/astro.config.mjs b/astro.config.mjs index c970c29..9618f7a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,4 +1,6 @@ import { defineConfig } from 'astro/config'; +import { readFile, writeFile, stat } from 'node:fs/promises'; +import path from 'node:path'; import mdx from '@astrojs/mdx'; import sitemap from '@astrojs/sitemap'; @@ -8,10 +10,102 @@ const EXCLUDED_PATHS = new Set([ 'https://unique.rzen.dev/404/', ]); +const FIND_ROOT = path.resolve('src/content/find'); +const VALID_SLUG = /^[a-z0-9][a-z0-9-]*$/; +const VALID_TAG = /^[a-z0-9][a-z0-9-]{0,40}$/i; + +function readBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.setEncoding('utf8'); + req.on('data', (c) => { data += c; if (data.length > 8192) reject(new Error('body too large')); }); + req.on('end', () => resolve(data)); + req.on('error', reject); + }); +} + +function send(res, status, payload) { + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(payload)); +} + +function parseEditorialTags(fm) { + const inline = fm.match(/^editorialTags:\s*\[([^\]]*)\]\s*$/m); + if (inline) { + return inline[1] + .split(',') + .map((s) => s.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean); + } + const list = fm.match(/^editorialTags:\s*\n((?:[ \t]+-[^\n]*\n?)+)/m); + if (list) { + return list[1] + .split('\n') + .map((line) => line.replace(/^[ \t]+-[ \t]*/, '').trim().replace(/^["']|["']$/g, '')) + .filter(Boolean); + } + if (/^editorialTags:\s*\[\s*\]\s*$/m.test(fm) || /^editorialTags:\s*$/m.test(fm)) return []; + return null; // field absent +} + +function stripEditorialTags(fm) { + return fm.replace(/^editorialTags:[^\n]*(?:\n[ \t]+-[^\n]*)*\n?/m, ''); +} + +function formatEditorialTags(tags) { + return `editorialTags: [${tags.map((t) => JSON.stringify(t)).join(', ')}]`; +} + +const editFindTagApi = { + name: 'edit-find-tag-dev-api', + apply: 'serve', + configureServer(server) { + server.middlewares.use('/api/edit-find-tag', async (req, res, next) => { + if (req.method !== 'POST') return next(); + try { + const raw = await readBody(req); + const { slug, tag, mode } = JSON.parse(raw || '{}'); + if (typeof slug !== 'string' || !VALID_SLUG.test(slug)) return send(res, 400, { error: 'invalid slug' }); + if (typeof tag !== 'string' || !VALID_TAG.test(tag)) return send(res, 400, { error: 'invalid tag' }); + + const filePath = path.join(FIND_ROOT, slug, 'index.mdx'); + if (!filePath.startsWith(FIND_ROOT + path.sep)) return send(res, 400, { error: 'path escape' }); + try { await stat(filePath); } catch { return send(res, 404, { error: 'find not found' }); } + + const text = await readFile(filePath, 'utf8'); + const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!m) return send(res, 422, { error: 'no frontmatter' }); + const fm = m[1]; + const body = m[2]; + + const current = parseEditorialTags(fm) ?? []; + const has = current.includes(tag); + let next; + if (mode === 'add') next = has ? current : [...current, tag]; + else if (mode === 'remove') next = current.filter((t) => t !== tag); + else next = has ? current.filter((t) => t !== tag) : [...current, tag]; // toggle + + const stripped = stripEditorialTags(fm).replace(/\n+$/, ''); + const newFm = next.length + ? `${stripped}\n${formatEditorialTags(next)}` + : stripped; + const out = `---\n${newFm}\n---\n${body}`; + await writeFile(filePath, out, 'utf8'); + + return send(res, 200, { slug, tag, editorialTags: next }); + } catch (err) { + return send(res, 500, { error: String(err && err.message ? err.message : err) }); + } + }); + }, +}; + export default defineConfig({ site: 'https://unique.rzen.dev', trailingSlash: 'always', build: { format: 'directory' }, + vite: { plugins: [editFindTagApi] }, integrations: [ mdx(), sitemap({ diff --git a/src/lib/hero.ts b/src/lib/hero.ts index 921bc4e..6bf419b 100644 --- a/src/lib/hero.ts +++ b/src/lib/hero.ts @@ -9,6 +9,7 @@ import hyperkey from '../content/reviews/hyperkey/hero.png'; import ice from '../content/reviews/ice/ice-bar.png'; import macMouseFix from '../content/reviews/mac-mouse-fix/studio-display.png'; import superkey from '../content/reviews/superkey/hero.jpg'; +import timelined from '../content/reviews/timelined/hero.png'; import tot from '../content/reviews/tot/seven-dots.png'; import antinote from '../content/reviews/antinote/swipe.mp4'; @@ -34,6 +35,7 @@ export const heroes: Record = { ice: { kind: 'image', src: ice }, 'mac-mouse-fix': { kind: 'image', src: macMouseFix }, superkey: { kind: 'image', src: superkey }, + timelined: { kind: 'image', src: timelined }, tot: { kind: 'image', src: tot }, antinote: { kind: 'video', src: antinote }, cotypist: { kind: 'video', src: cotypist },