diff --git a/.claude/skills/creating-blog-posts/SKILL.md b/.claude/skills/creating-blog-posts/SKILL.md new file mode 100644 index 0000000..9128ee8 --- /dev/null +++ b/.claude/skills/creating-blog-posts/SKILL.md @@ -0,0 +1,224 @@ +--- +name: creating-blog-posts +description: >- + Use when creating, adding, scaffolding, or publishing a new blog post in this + repo (oz-blog / posts.oztamir.com, "0xZ") — even if the user only says "add a + post", "write up X as a post", "draft a new article", or "put this on the + blog". Covers the exact on-disk mechanics: the folder-per-post layout, the + frontmatter schema, how images/video/captions/tags/embeds work, and how to + verify the build. For the PROSE itself (voice, structure, tone), defer to the + oz-skills:blog-post-writer skill — this skill is the companion that gets that + draft into the repo correctly. Trigger this whenever a new post file needs to + exist in src/content/posts, not just when "skill" is mentioned. +--- + +# Creating a blog post in this repo + +This blog is a static **Astro** site (`posts.oztamir.com`). Posts are plain +Markdown, authored in this repo (and editable in Obsidian via VaultCMS). This +skill is the mechanics of getting a correct, building post onto disk. It does +**not** write the prose for you. + +## Division of labor: this skill vs. the writing skill + +- **`oz-skills:blog-post-writer`** owns the *content*: Oz's first-person + tinkerer voice, the interview-first workflow, section structure, headline. + If the user wants help *writing* the post (turning a project/experiment into + prose), invoke that skill first — it produces the body text. +- **This skill** owns the *container*: where the file goes, what frontmatter it + needs, how to reference images/video, and how to confirm it builds. Use it to + place a draft (whether the writer skill produced it, the user pasted it, or + you're scaffolding a stub) into the repo correctly. + +A common flow: run `oz-skills:blog-post-writer` to get the Markdown body, then +use this skill to create the folder, frontmatter, and assets and verify the build. + +## Where a post lives + +One folder per post, with the post body always named `index.md`: + +``` +src/content/posts//index.md ← the post; folder name IS the URL slug +src/content/posts//cover.png ← co-located images (any name) +src/content/posts//demo.mp4 ← co-located video + its poster +``` + +- The **folder name is the slug and the URL**: `src/content/posts/my-post/` → + `https://posts.oztamir.com/my-post/` (root-level, trailing slash — NOT `/posts/...`). +- Choose a slug that's lowercase, hyphenated, and stable — it's load-bearing for + SEO and inbound links. Don't rename an existing post's folder without a redirect. +- Everything a post needs (text + images + video) lives in its folder. The build + optimizes images and (via the `copy-post-media` integration) copies videos to + `dist//`. You never put post assets in `public/`. + +## Frontmatter + +The schema is `src/content.config.ts` (read it if unsure). Required: `title` +and `date`. Everything else is optional. Use this as the starting template: + +```yaml +--- +title: "My New Post" +date: 2026-07-01 +description: "One-line summary shown on cards and as the meta description." +image: cover.png # co-located filename; omit/empty for a generated OG card +imageAlt: "What the cover shows" +tags: + - Home Assistant # display-name strings; tags[0] is the primary tag + - automation +author: "oz" +featured: false +draft: false +--- +``` + +Field notes (where the obvious guess is wrong): + +- **`date`** is required and is a real date — `2026-07-01` or an ISO datetime. + Ordering and the displayed date use `date` (there's also an optional + `updatedDate`, which is intentionally NOT shown in the UI). +- **Never write `updatedDate: ""`** (or any date field as an empty string). The + schema coerces dates, and `""` becomes an Invalid Date and **breaks the build**. + Omit a date field entirely rather than leaving it blank. +- **`tags`** are plain strings (the display name), not objects. `tags[0]` is the + primary tag (shown on cards + OpenGraph). The tag-page URL is derived by + `slugify(name)` (`src/utils/slug.ts`), so "Home Assistant" → `/tag/home-assistant/`. + Reuse existing tag spellings where possible — check other posts so you don't + fork "AI" vs "ai". +- **`image`** is a co-located filename (e.g. `cover.png`), not a path. If a post + has no `image`, a 1200×630 OG card is generated for it at build — that's fine + and often preferable for text-only posts. +- **`author`** defaults to `oz`; keep it `"oz"`. An empty string drops the post + from the `/author/oz/` archive (the default only applies when the key is absent). +- **`draft: true` does NOT currently hide a post** — there's no draft filter in + the routes yet, so a `draft: true` post still builds and publishes. Treat + `draft` as a marker only; if a post genuinely shouldn't go live, don't commit + it (or ask before relying on `draft` to hide it). +- SEO overrides (`metaTitle`, `metaDescription`, `ogTitle`/`ogDescription`, + `ogImage`, `twitter*`, `canonicalUrl`) are all optional — only add one to + override a specific default. + +## Writing the body (Markdown conventions) + +The body is plain Markdown. Custom syntax is handled by remark plugins in +`src/plugins/` — author the source exactly as below and it renders to the +established look. + +**Images** — co-locate the file, reference it by **basename**: + +```markdown +![Alt text describing the image](wiring-diagram.png) +*An optional caption goes on the very next line, in italics.* +``` + +- The caption MUST be the italic line **immediately after** the image, with **no + blank line between** — that's how the caption plugin attaches it as a + `
`. A blank line breaks the association. +- A caption may contain a link: `*Source: [esp32io](https://esp32io.com/…)*`. +- For a wide (full-bleed) image, append `{wide}` to the image line: + `![Architecture](diagram.png){wide}`. +- Always use Markdown images (`![]()`). A raw HTML `` + pointing at a co-located file will **404** — only Markdown image nodes go + through Astro's optimizer. GIFs are preserved; other formats become WebP. + +**Video** — co-locate the `.mp4` (+ an optional poster image) and embed it with +Obsidian wiki-embed syntax on its own line: + +```markdown +![[demo.mp4|poster=demo-poster.jpg|title=Short description of the clip]] +``` + +- Attributes: `poster=`, `title=`, and `autoplay` (implies muted + + loop, for silent GIF-style clips). Reference files by basename; they're + copied into `dist//` at build by the `copy-post-media` integration. A + video that isn't embedded won't be copied/served. + +**Tweets / Instagram** — there's no native syntax yet; paste the platform's +embed HTML (the `