Merge branch 'feat/link-files': project links to files in media/
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f1fbee76ca
4 files changed
+40
-7
No files matched your search
@@ -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>`:
|
||||
`` (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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
Reference in new issue
Block a user