Co-locate images with their articles/authors instead of a shared folder (AI)
resolveMediaUrl() now resolves frontmatter image/avatar filenames relative to the referencing .md file's own directory instead of a shared cmjs/pictures/ folder, which is removed along with PICTURES_URL. fetchAuthorProfile() now returns its own file path so avatars resolve the same way. Migrates the existing hello-world example's preview image into its article directory. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
This commit is contained in:
1 parent
c05a85b21b
commit
e58a5e1336
6 files changed
+32
-26
No files matched your search
@@ -47,7 +47,7 @@ cmjs/
|
||||
│ ├── toc.css # Auto-generated table of contents
|
||||
│ └── author-profile.css # Author profile card (avatar, bio, social links)
|
||||
├── js/
|
||||
│ ├── config.js # BASE_URL, MARKED_URL, PICTURES_URL (all via import.meta.url)
|
||||
│ ├── config.js # BASE_URL, MARKED_URL (all via import.meta.url)
|
||||
│ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser
|
||||
│ ├── templates.js # loadTemplates(), templates cache
|
||||
│ ├── discovery.js # discoverArticles(), fetchJson(), article cache
|
||||
@@ -66,14 +66,15 @@ cmjs/
|
||||
│ └── author-profile.html # Author profile card layout
|
||||
├── blog-config/
|
||||
│ └── tags.json # Tag id → human-readable label registry — see "Tag labels" below
|
||||
├── articles/
|
||||
│ └── author/ # One directory per author
|
||||
│ ├── author.md # Author profile (optional)
|
||||
│ └── slug/ # One directory per post
|
||||
│ ├── slug.md # Article content + frontmatter
|
||||
│ ├── tag-id.tag # Zero or more empty tag/category markers
|
||||
│ └── DD-MM-YYYY.date # Publish date marker (exactly one)
|
||||
└── pictures/ # Article images (referenced via image: frontmatter field)
|
||||
└── articles/
|
||||
└── author/ # One directory per author
|
||||
├── author.md # Author profile (optional)
|
||||
├── avatar.jpg # Optional — referenced by author.md's `avatar:` field
|
||||
└── slug/ # One directory per post
|
||||
├── slug.md # Article content + frontmatter
|
||||
├── cover.jpg # Optional — referenced by slug.md's `image:` field
|
||||
├── tag-id.tag # Zero or more empty tag/category markers
|
||||
└── DD-MM-YYYY.date # Publish date marker (exactly one)
|
||||
```
|
||||
|
||||
## Adding an article
|
||||
@@ -88,8 +89,11 @@ cmjs/
|
||||
treated as the article's "year"/category, shown as a badge on each card/article linking to
|
||||
`#category/expa-YYYY`; every other tag id is a normal topic tag, shown as a pill linking to
|
||||
`#tag/tag-id`.
|
||||
5. Add a frontmatter block at the top of the Markdown file
|
||||
6. Write your content — it will appear in the list automatically
|
||||
5. Optionally add a cover image beside it too — `cmjs/articles/author/my-article/photo.jpg` —
|
||||
and reference it from frontmatter with `image: photo.jpg`. Images live next to the article
|
||||
that uses them, not in a shared folder.
|
||||
6. Add a frontmatter block at the top of the Markdown file
|
||||
7. Write your content — it will appear in the list automatically
|
||||
|
||||
`author` is derived from the directory path — no frontmatter field needed for it. Publish date,
|
||||
tags, and category are derived from the `.date`/`.tag` marker files, not frontmatter.
|
||||
@@ -128,7 +132,7 @@ Your markdown content here...
|
||||
|-------|----------|-------|
|
||||
| `title` | yes | Shown in the list and article header |
|
||||
| `description` | no | Short summary shown under the title in the list |
|
||||
| `image` | no | Filename of the preview image in the `pictures/` directory (e.g. `photo.jpg`); shown as card thumbnail and article hero |
|
||||
| `image` | no | Filename of an image file placed in this same post directory (e.g. `photo.jpg`); shown as card thumbnail and article hero |
|
||||
| `draft` | no | Set to `true`, `yes`, or `1` to hide from the list |
|
||||
|
||||
Reading time is computed automatically from the article word count — no frontmatter field
|
||||
@@ -164,7 +168,7 @@ Short bio rendered as markdown.
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `title` | Display name shown in the profile card. Falls back to the author directory name. |
|
||||
| `avatar` | Filename in `pictures/` — displayed as a circular thumbnail. |
|
||||
| `avatar` | Filename of an image file placed in this same author directory — displayed as a circular thumbnail. |
|
||||
| `github` / `twitter` / `linkedin` / `mastodon` / `website` | Any subset of these renders as pill links below the bio. |
|
||||
|
||||
The body text below the frontmatter is rendered as markdown and shown as the bio.
|
||||
|
||||
File renamed without changes.
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
@@ -6,6 +6,3 @@ export const BASE_URL = new URL('../', import.meta.url).href;
|
||||
|
||||
// Path to the marked.js vendor script (lives one level above cmjs/)
|
||||
export const MARKED_URL = new URL('../../js/marked.min.js', import.meta.url).href;
|
||||
|
||||
// Base URL of the pictures directory — prefix article image filenames with this
|
||||
export const PICTURES_URL = new URL('../pictures/', import.meta.url).href;
|
||||
+14
-8
@@ -1,5 +1,5 @@
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
import { BASE_URL, PICTURES_URL } from './config.js';
|
||||
import { BASE_URL } from './config.js';
|
||||
import { mapWithConcurrency } from './concurrency.js';
|
||||
import { getCacheEntry, setCacheEntry, pruneCache } from './cache.js';
|
||||
import { t } from './i18n.js';
|
||||
@@ -58,6 +58,11 @@ export async function fetchJson(url) {
|
||||
throw new Error(`Expected a directory listing at ${url}`);
|
||||
}
|
||||
|
||||
// Images live alongside whatever references them — an article's cover/inline
|
||||
// images sit in its own slug directory, an author's avatar in their own
|
||||
// author directory — so `articlePath` (the referencing .md file's own URL)
|
||||
// is the resolution base, not a shared folder. A bare filename in
|
||||
// frontmatter therefore always means "next to this file."
|
||||
export function resolveMediaUrl(value, articlePath = '') {
|
||||
if (!value) return '';
|
||||
let path = String(value).trim();
|
||||
@@ -65,15 +70,15 @@ export function resolveMediaUrl(value, articlePath = '') {
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
try {
|
||||
const url = new URL(path);
|
||||
// Legacy WordPress-migrated content sometimes still carries an
|
||||
// absolute upload URL — reduce it to a bare filename so it
|
||||
// resolves against the article's own directory like anything
|
||||
// else, assuming the file was migrated alongside it.
|
||||
if (url.pathname.includes('/wp-content/uploads/')) path = url.pathname.split('/wp-content/uploads/')[1];
|
||||
else return path;
|
||||
} catch { return path; }
|
||||
}
|
||||
if (path.startsWith('media/') && articlePath) return new URL(path, articlePath).href;
|
||||
path = path.replace(/^.*\/wp-content\/uploads\//i, '')
|
||||
.replace(/^\/?(?:blog\/media|cmjs\/pictures|pictures)\//i, '')
|
||||
.replace(/^\.\.\/pictures\//i, '');
|
||||
return new URL(path.replace(/^\//, ''), PICTURES_URL).href;
|
||||
return new URL(path.replace(/^\//, ''), articlePath || BASE_URL).href;
|
||||
}
|
||||
|
||||
let tagCache = null;
|
||||
@@ -243,10 +248,11 @@ export async function findArticle(hash) {
|
||||
}
|
||||
|
||||
export async function fetchAuthorProfile(author) {
|
||||
const path = `${BASE_URL}articles/${encodeURIComponent(author)}/${encodeURIComponent(author)}.md`;
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}articles/${encodeURIComponent(author)}/${encodeURIComponent(author)}.md`);
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) return null;
|
||||
const { meta, content } = parseFrontmatter(await response.text());
|
||||
return { author, ...meta, content };
|
||||
return { author, ...meta, content, path };
|
||||
} catch { return null; }
|
||||
}
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
import { templates } from './templates.js';
|
||||
import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, getCrawlStats, resolveMediaUrl } from './discovery.js';
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
import { PICTURES_URL } from './config.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -253,7 +252,7 @@ function makeSocialLink(href, label) {
|
||||
function renderAuthorProfile(profile) {
|
||||
const element = document.importNode(templates.authorProfile, true);
|
||||
const field = name => element.querySelector(`[data-field="${name}"]`);
|
||||
setField(field('avatar'), profile.avatar, (image, value) => { image.src = `${PICTURES_URL}${value}`; image.alt = profile.title || profile.author; });
|
||||
setField(field('avatar'), profile.avatar, (image, value) => { image.src = resolveMediaUrl(value, profile.path); image.alt = profile.title || profile.author; });
|
||||
field('name').textContent = profile.title || profile.author;
|
||||
const bio = field('bio');
|
||||
if (profile.content?.trim()) bio.innerHTML = marked.parse(profile.content); else bio.remove();
|
||||
|
||||
Whitespace-only changes.
Reference in new issue
Block a user