diff --git a/README.md b/README.md index a16097b..f9f6a68 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmjs/pictures/hello-world-preview.webp b/cmjs/articles/admin/hello-world/hello-world-preview.webp similarity index 100% rename from cmjs/pictures/hello-world-preview.webp rename to cmjs/articles/admin/hello-world/hello-world-preview.webp diff --git a/cmjs/js/config.js b/cmjs/js/config.js index f440fa3..2ac0ca6 100644 --- a/cmjs/js/config.js +++ b/cmjs/js/config.js @@ -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; diff --git a/cmjs/js/discovery.js b/cmjs/js/discovery.js index a4d123d..170bc87 100644 --- a/cmjs/js/discovery.js +++ b/cmjs/js/discovery.js @@ -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; } } diff --git a/cmjs/js/views.js b/cmjs/js/views.js index f6e0491..0a7b8a6 100644 --- a/cmjs/js/views.js +++ b/cmjs/js/views.js @@ -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(); diff --git a/cmjs/pictures/.gitkeep b/cmjs/pictures/.gitkeep deleted file mode 100644 index e69de29..0000000