Files
unique/astro.config.mjs
rzen 1072e5faf3 site: upgrade Astro 5 → 7
- astro 7.0.7, @astrojs/mdx 7, sitemap 3.7, rss 4.0.19, check 0.9.9
- trailingSlash 'always' → 'ignore': Astro 7 generates prerender paths for
  dynamic extension endpoints (/categories/<c>/rss.xml) with a trailing
  slash under 'always' but builds their route patterns without one, so the
  build fails with "Missing parameter"; directory-format output is
  identical either way. Also removes the now-unneeded dev feed-slash shim.
- compressHTML: true pinned to keep pre-v7 whitespace behavior
- zod 4: import z from 'astro/zod' (astro:content re-export deprecated),
  z.string().url() → z.url()
- npm audit fix (fast-uri, fast-xml-builder, yaml)

Claude-Session: https://claude.ai/code/session_01WZaczDJjL3xZ3u5spsN5AL
2026-07-12 20:40:26 -04:00

153 lines
5.6 KiB
JavaScript

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';
const EXCLUDED_PATHS = new Set([
'https://unique.rzen.dev/grid/',
'https://unique.rzen.dev/index3/',
'https://unique.rzen.dev/404/',
]);
const FIND_ROOT = path.resolve('src/content/find');
const REVIEW_ROOT = path.resolve('src/content/reviews');
const PICK_FILE = path.resolve('src/data/pick.json');
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) });
}
});
},
};
const setPickApi = {
name: 'set-pick-dev-api',
apply: 'serve',
configureServer(server) {
server.middlewares.use('/api/set-pick', async (req, res, next) => {
if (req.method !== 'POST') return next();
try {
const raw = await readBody(req);
const { slug } = JSON.parse(raw || '{}');
if (typeof slug !== 'string' || !VALID_SLUG.test(slug)) return send(res, 400, { error: 'invalid slug' });
const reviewPath = path.join(REVIEW_ROOT, slug, 'index.mdx');
if (!reviewPath.startsWith(REVIEW_ROOT + path.sep)) return send(res, 400, { error: 'path escape' });
try { await stat(reviewPath); } catch { return send(res, 404, { error: 'review not found' }); }
await writeFile(PICK_FILE, JSON.stringify({ hero: slug }, null, 2) + '\n', 'utf8');
return send(res, 200, { slug });
} catch (err) {
return send(res, 500, { error: String(err && err.message ? err.message : err) });
}
});
},
};
export default defineConfig({
site: 'https://unique.rzen.dev',
// 'ignore', not 'always': Astro 7 generates prerender paths for dynamic
// extension endpoints (/categories/<c>/rss.xml) with a trailing slash under
// 'always' but builds their route patterns without one, so the build dies
// with "Missing parameter". Directory-format output is identical either
// way, and 'ignore' also lets the dev server match those endpoints without
// the old feed-trailing-slash shim.
trailingSlash: 'ignore',
// Keep the pre-v7 whitespace behavior; the v7 default 'jsx' collapses
// whitespace between adjacent inline elements.
compressHTML: true,
build: { format: 'directory' },
vite: { plugins: [editFindTagApi, setPickApi] },
integrations: [
mdx(),
sitemap({
filter: (page) => !EXCLUDED_PATHS.has(page),
changefreq: 'weekly',
}),
],
});