Merge branch 'feat/link-files': project links to files in media/
agent-docs / check (push) Successful in 4s
ci / test (push) Successful in 4m34s
ci / image (push) Successful in 35s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-10 03:15:31 +01:00
commit f1fbee76ca
4 files changed
+40 -7

No files matched your search

+3 -2
View File
@@ -21,7 +21,7 @@ Renaming a file breaks old links. Any file with `draft: true` is skipped entirel
use it for anything not yet confirmed. Dates are `YYYY-MM-DD` and must be real calendar days
(`2025-02-30` is rejected).
Media paths (`links.cv`, a project's `image.file`, an item's `media[].file`) are relative to `media/` and must
Media paths (`links.cv`, a project's `image.file` and `links[].file`, an item's `media[].file`) are relative to `media/` and must
name a regular file there: no leading `/` or `\`, no `..`, no folders. Subfolders are fine (`art/page-1.png`).
To show a media file inside a Markdown body, link it by its URL, `/media/<file>`:
`![A page from the comic](/media/art/page-1.png)` (images in bodies load lazily).
@@ -77,8 +77,9 @@ featured: 1 # optional order (1 first); needs a showcase
showcase: compute-queue # optional drawn panel: compute-queue | config-editor | peer-network | rowing-stroke
image: # optional; stands in for the showcase when there is none
{ file: compute-portal.jpg, alt: Screenshot of the portal } # file in media/, an image
links: # optional
links: # optional; each has a url (http/https) or a file in media/
- { label: Source, url: 'https://example.invalid/portal' }
- { label: Report, file: reports/portal.pdf } # served as /media/reports/portal.pdf
ai_parts: # optional breakdown
- { part: Docs, level: some-ai, note: Drafted with a model }
related: [another-project-id] # must be existing, non-draft projects
+1
View File
@@ -15,6 +15,7 @@ Small, specific, independently actionable. Remove only when actually done.
- [ ] Make the Trakt profile public (the build uses no OAuth). If it must stay private, add OAuth in `src/sources/trakt.ts`.
- [ ] DNS cutover and the 2021 site's move to `old.lucaswinther.info`, per `docs/DEPLOY.md`.
- [ ] Redirect the old blog URLs (`blog.lucaswinther.info/<slug>/`) to `/work/writing/<slug>/` (DEPLOY step 7), and decide what stays on the blog host. Post pages link "Read it on the blog →" to the Ghost URL, so that link must change or stop redirecting once the blog redirects.
- [ ] Old report links (`lucaswinther.info/documents/<name>.pdf`, e.g. from a CV or LinkedIn) break at the DNS cutover: the reports now live at `/media/reports/` (lowercase names) and in the archive at `old.lucaswinther.info/documents/`. Redirect one way or the other if anything still points there.
- [ ] Check the gate palette against the approved `mockups/nav-d.html` once more on the live site (left gate light, work dark: kept on purpose, owner to confirm).
- [ ] Reword the 2021 `portfolio` project summary if wanted ("The 2021 version of this portfolio…", derived from its `info.json`).
+14 -5
View File
@@ -169,19 +169,28 @@ function loadInterests(raw: unknown, report: Report): Interest[] {
return out;
}
function linkList(v: unknown, field: string, report: Report): Link[] {
/** Links are `{ label, url }` (http(s)) or `{ label, file }` (a file in media/, served from /media/). */
function linkList(v: unknown, field: string, mediaDir: string, report: Report): Link[] {
if (v === undefined || v === null) return [];
if (!Array.isArray(v)) {
report(field, 'must be a list of { label, url }');
report(field, 'must be a list of { label, url } or { label, file }');
return [];
}
const out: Link[] = [];
v.forEach((l, i) => {
const label = isObj(l) ? str(l.label) : undefined;
const url = isObj(l) ? str(l.url) : undefined;
const file = isObj(l) ? str(l.file) : undefined;
if (!label) report(`${field}[${i}].label`, 'required');
if (!url || !isUrl(url)) report(`${field}[${i}].url`, 'must be an http(s) URL');
if (label && url && isUrl(url)) out.push({ label, url });
if (!url === !file) return report(`${field}[${i}]`, 'needs exactly one of url or file');
if (file) {
const problem = mediaFile(mediaDir, file);
if (problem) return report(`${field}[${i}].file`, problem);
if (label) out.push({ label, url: `/media/${file}` });
return;
}
if (!url || !isUrl(url)) return report(`${field}[${i}].url`, 'must be an http(s) URL');
if (label) out.push({ label, url });
});
return out;
}
@@ -292,7 +301,7 @@ function loadProject(doc: Doc, mediaDir: string, report: Report): Project {
where: required(f.where, 'where', report),
stack: textList(f.stack, 'stack', report),
summary: required(f.summary, 'summary', report),
links: linkList(f.links, 'links', report),
links: linkList(f.links, 'links', mediaDir, report),
aiParts,
related: Array.isArray(f.related) ? (f.related.filter((r) => typeof r === 'string') as string[]) : [],
bodyHtml: html(doc.body),
+22
View File
@@ -155,6 +155,28 @@ describe('loadContent', () => {
expect(ok.errors).toEqual([]);
});
test('a project link can point at a file in media/ instead of a URL', async () => {
const ok = await load({
'projects/p.md': project({ links: [{ label: 'Report', file: 'docs/report.pdf' }, { label: 'Source', url: 'https://example.invalid/s' }] }),
'media/docs/report.pdf': 'x',
});
expect(ok.errors).toEqual([]);
expect(ok.content!.projects[0]!.links).toEqual([
{ label: 'Report', url: '/media/docs/report.pdf' },
{ label: 'Source', url: 'https://example.invalid/s' },
]);
const bad = await load({
'projects/missing.md': project({ links: [{ label: 'Report', file: 'nope.pdf' }] }),
'projects/up.md': project({ links: [{ label: 'Report', file: '../site.yml' }] }),
'projects/both.md': project({ links: [{ label: 'Report', file: 'r.pdf', url: 'https://example.invalid/r' }] }),
'projects/neither.md': project({ links: [{ label: 'Report' }] }),
'media/r.pdf': 'x',
});
expect(bad.errors.map((e) => `${e.file}:${e.field}`).sort()).toEqual([
'projects/both.md:links[0]', 'projects/missing.md:links[0].file', 'projects/neither.md:links[0]', 'projects/up.md:links[0].file',
]);
});
test('dates must be real calendar days', async () => {
const { errors } = await load({
'items/feb.md': item({ date: '2025-02-30' }),