Topics — the curated subscription layer: 18-value controlled vocabulary in src/lib/topics.ts, enum-enforced by the content schema on finds and reviews so unattended runs can't drift it. /topics/ index + /topics/<t>/ pages with finds first-class alongside reviews, per-topic RSS feeds that include finds (unlike category/tag feeds), "By topic" section on /feeds/, Topics footer link and llms.txt section, dev-shim coverage for the new feed URLs. daily-finds and build-review skills now classify at capture/graduation time; "giftable" is a tag, never a topic. Per-source indexes: /sources/<slug>/ lists every review and find from a source (catalog ∪ names in content, so retired sources resolve); find/review colophons link "(more from this source)"; /sources/ cards link "everything from this source →"; sub-3-item pages noindexed. Claude-Session: https://claude.ai/code/session_01WZaczDJjL3xZ3u5spsN5AL
159 lines
5.6 KiB
JavaScript
159 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) });
|
|
}
|
|
});
|
|
},
|
|
};
|
|
|
|
// With trailingSlash 'always', the dev server only matches dynamic-route
|
|
// endpoints (/tags/<t>/rss.xml, /categories/<c>/rss.xml) when the request
|
|
// has a trailing slash; the built site serves them as plain files without
|
|
// one. Rewrite in dev so the same URLs work in both.
|
|
const feedSlashShim = {
|
|
name: 'feed-trailing-slash-dev-shim',
|
|
apply: 'serve',
|
|
configureServer(server) {
|
|
server.middlewares.use((req, _res, next) => {
|
|
if (/^\/(tags|categories|topics)\/[^/]+\/rss\.xml$/.test(req.url)) req.url += '/';
|
|
next();
|
|
});
|
|
},
|
|
};
|
|
|
|
export default defineConfig({
|
|
site: 'https://unique.rzen.dev',
|
|
trailingSlash: 'always',
|
|
build: { format: 'directory' },
|
|
vite: { plugins: [editFindTagApi, setPickApi, feedSlashShim] },
|
|
integrations: [
|
|
mdx(),
|
|
sitemap({
|
|
filter: (page) => !EXCLUDED_PATHS.has(page),
|
|
changefreq: 'weekly',
|
|
}),
|
|
],
|
|
});
|