Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions bestax-migrate/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ the component library), and `scripts/pack-manifest.mjs` resolves any remaining
left in `devDependencies` only with the pack hooks present. It does **not**
check which section `@allxsmith/bestax-bulma` sits in — re-adding it as a
plain-semver runtime dependency passes CI, so that one is on review. The skill lives at repo-root
`skills/bestax-migrate/` and is deliberately **not** bundled into create-bestax (existing
sites only) — keep `create-bestax/scripts/sync-skills.mjs` untouched. When the mapping
`skills/bestax-migrate/`. Whether it bundles into create-bestax is contested: the original
policy was existing-sites-only, but `sync-skills.mjs` has shipped it since #345 — see #385
and don't change either side ahead of that call. When the mapping
gains or loses coverage, update `skills/bestax-migrate/references/` and the docs migration
guide in the same PR.
11 changes: 8 additions & 3 deletions create-bestax/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ path (`-y` + flags, no TTY) must never hang or regress (#192).

## Sync rules (this package re-ships other parts of the repo)

- `pnpm build` and `prepack` run `scripts/sync-skills.mjs`, which copies the repo-root
`skills/` into the package. **Never edit the bundled copy** — change `skills/` at the repo
root; the build re-syncs.
- `pnpm build` and `prepack` run `scripts/sync-skills.mjs`, which copies **the skills named
in its hardcoded `SKILLS` allowlist** — not the whole repo-root `skills/` dir — into the
package. An unlisted skill silently doesn't bundle; a delisted one silently vanishes (the
script empties the destination first, and its success line reports the array length either
way); no CI check compares the list against `skills/*`. Bundling is a per-skill policy
decision (see #385) — when adding a skill, decide it explicitly and keep the allowlist,
`skills/README.md`, and `docs/docs/skills/intro.md` in agreement. **Never edit the bundled
copy** — change `skills/` at the repo root; the build re-syncs.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- The `CLAUDE_MD` template in `constants.ts` is what every generated app tells its AI agents.
When library conventions, skills, or the canonical docs entrypoint change (#203), check
whether this template must change too.
Expand Down
4 changes: 3 additions & 1 deletion docs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ it, so a novel non-standard `package.json` key and extra release churn weren't w
are required — without them MDX treats it as literal text, not a code block. The component
derives the command back out of the fence and throws during the prerender if the round trip
isn't exact, so a non-canonical fence (`npm install foo`, odd spacing) fails the build rather
than rendering three tabs derived from something the page never showed.
than rendering three tabs derived from something the page never showed. Docs-tree pages
only — a syndicated blog post must not use it (JSX reaches dev.to as raw text); see the
Syndication section of `blog/CLAUDE.md`.

- **`.md` files here render JSX**, because `markdown.format` defaults to `mdx` and
`docusaurus.config.js` does not override it. That is load-bearing but easy to miss: setting
Expand Down
36 changes: 29 additions & 7 deletions docs/blog/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,31 @@ version bump) and the intro of the first State of React edition: candid, plain,

## Post conventions (all posts)

- **File naming:** `docs/blog/YYYY-MM-DD-slug.md`, or the folder form `YYYY-MM-DD-slug/index.md`
when the post ships images. The date prefix is the publish date; there is no `date:`
frontmatter field. If a PR merges after the date in its filename, rename before merging.
- **File naming:** `docs/blog/YYYY-MM-DD-slug.md`. The date prefix is the publish date; there
is no `date:` frontmatter field. If a PR merges after the date in its filename, rename
before merging. A post that will syndicate stays a **flat `.md` even when it ships images**
(assets go to `docs/static/img/<slug>*.{svg,png}`): `devto-preprocessor.js` writes
`build/.devto-publish/<basename>`, so a folder post emits a colliding `index.md`, and only
`/img/`-rooted markdown images rewrite cleanly. The folder form `YYYY-MM-DD-slug/index.md`
is for posts that will never syndicate (State of React uses it, with
`publish_to_devto: false`).
- **Frontmatter:** `slug`, `title` (quote it when it contains a colon), `authors: [asmith]`
(the only entry in `authors.yml`), and inline `tags: [...]`. `onInlineTags: 'ignore'` means a
new tag needs no `tags.yml` entry; add one only for a custom label/permalink/description.
dev.to syndication and cover images are opt-in per post — see the two sections below.
A post with `slug:` sets `canonical_url: https://bestax.io/blog/<slug>`; older date-path
canonicals predate this — don't copy them.
- **The fold:** `<!-- truncate -->` goes after a 1–3 sentence hook (the config warns when it's
missing). A leading `:::info` admonition sits above the fold when the post needs one.
- **Live examples:** ` ```tsx live ` fences. Every library export plus `React`, `useState`, and
`useEffect` is already in scope (`docs/src/theme/CodeBlock/index.js` spreads the whole
package into react-live; import lines are stripped anyway). No inline `style={{}}`; use
`Block`/helper props, and space children with `m*`/`p*` (there is no `gap` helper).
- **Links:** internal links are absolute (`/docs/...`, `/blog/...`); `onBrokenLinks: 'throw'`
build-validates every one.
build-validates every one. The LLM artifacts (`/llms.txt`, `/llms-full.txt`, and every
page's `.md` twin) are generated files, not routes — link them fully qualified
(`https://bestax.io/llms.txt`); a root-relative form fails the build's link check, and the
dev.to rewrite only covers `/docs` and `/blog`.
- **Verify:** `pnpm format`, then `pnpm exec turbo run build --filter=@allxsmith/bestax-docs`,
then `pnpm format:check`. Commits and PR titles use the non-releasing `docs` type.

Expand All @@ -83,9 +93,12 @@ stays a manual act — the build only generates the files.
are rewritten to `https://bestax.io/...`. Fenced code blocks are never touched.
- Keep internal links root-relative in the source — the rewrite handles dev.to, and
`onBrokenLinks: 'throw'` keeps build-validating them.
- The rewrites cover markdown syntax only, so a syndicated post's visible cover must be a
markdown image (`![…](/img/…)`), not a JSX `<img>` (which would reach dev.to unrewritten
and broken).
- The rewrites cover markdown syntax only, so a syndicated post is **plain markdown
throughout**: no JSX components (they reach dev.to as raw text) and no `:::` admonitions.
That includes the docs-tree `<PackageManagerTabs>` convention — install commands in a
syndicated post use a plain fence (most aren't pnpm-derivable anyway) — and the visible
cover, which must be a markdown image (`![…](/img/…)`), not a JSX `<img>`. (State of React
editions may use JSX because they set `publish_to_devto: false`.)

Medium is manual end to end: there is no Medium plugin. Publish on bestax.io first, then use
Medium's import-a-story flow on the live `https://bestax.io/blog/<slug>` URL so Medium records
Expand All @@ -106,6 +119,15 @@ Any post can ship a cover, not just State of React editions; _The Floor Is React
- **Frontmatter:** `image:` and `cover_image:` both point at the **PNG** (rooted `/img/...`
path); `og:image` and dev.to need a raster.
- **Body:** the visible banner at the very top renders the **SVG**, crisp at any width.
- **Section images** (optional, for flagship posts): each in-body image follows the same
1200×630 contract and rasterizer, stems `docs/static/img/<slug>-<topic>.{svg,png}`. Only
the top banner embeds the SVG; every other in-body image embeds the **PNG** (dev.to and
Medium handle rasters reliably).
- **Alt text** is a long, literal description of the pixel-art scene (see the v5 banner),
not a caption; mirror it into the SVG's `aria-label`.
- Covers can be hand-authored or scripted against `docs/scripts/pixel-cover-lib.mjs` (the
5×7 pixel font, house palette, and bevel/starfield helpers behind the existing covers);
either way `rasterize:cover` is the gate.

The rest of this file is the **runbook for the recurring component-comparison series** so each
edition is turnkey.
Expand Down
284 changes: 284 additions & 0 deletions docs/scripts/pixel-cover-lib.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
/**
* Reusable core for the blog's pixel-art covers (the house style established by
* the v5-one-css-story and fighting-ai-training-bias covers): a 5x7 pixel font,
* the shared palette, and rect-based drawing helpers that emit pure-<rect> SVG
* with shape-rendering="crispEdges".
*
* This module draws nothing by itself — a per-post script composes scenes with
* it and writes the SVG masters into docs/static/img/. The output contract is
* enforced by scripts/rasterize-cover.mjs (1200x630, explicit width/height, an
* opaque full-bleed background rect painted first); Canvas#svg() satisfies it
* by construction.
*
* Usage sketch:
* import { Canvas, PALETTE as P, textW, starfield } from './pixel-cover-lib.mjs';
* const c = new Canvas();
* starfield(c, 41, 24);
* c.textShadow('HELLO', 90, 60, 12, P.yellow);
* writeFileSync('static/img/my-post.svg', c.svg('Hello, drawn as pixel art: ...'));
* // then: pnpm --filter @allxsmith/bestax-docs rasterize:cover static/img/my-post.svg
*/

export const COVER_WIDTH = 1200;
export const COVER_HEIGHT = 630;

// Palette lifted from docs/static/img/v5-one-css-story.svg so new covers stay
// in the established look. bg is the mandatory full-bleed background.
export const PALETTE = {
bg: '#071a24',
star1: '#ffffff',
star2: '#7fc7c9',
panel: '#26455a',
panelLt: '#3f6b82',
panelDk: '#142c3a',
inner: '#0b2530',
line: '#1a3648',
teal: '#00d1b2',
tealLt: '#4de8cc',
tealDk: '#009e85',
yellow: '#ffd24a',
yellowDk: '#c79a1f',
steel: '#7fb3bd',
steelDk: '#4f7c8a',
muted: '#8b97b0',
white: '#f4f7ff',
shadow: '#04303a',
gray: '#5c6f85',
grayLt: '#7c8ea3',
grayDk: '#3d4d63',
paper: '#e8eef6',
paperDk: '#b9c6d8',
};

/**
* Deterministic PRNG (LCG). Cover generation must be reproducible, so scene
* scripts seed this instead of calling Math.random().
*/
export function lcg(seed) {
let s = seed >>> 0;
return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296;
}

// 5x7 pixel font, rows top->bottom, '1' = filled. Lowercase descenders
// (g j p q y) carry 9 rows; everything else 7. Coverage: A-Z a-z 0-9 . - _ / :
// and space. Unknown characters render as a space.
export const FONT = {
A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
W: ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
a: ['00000', '00000', '01110', '00001', '01111', '10001', '01111'],
b: ['10000', '10000', '11110', '10001', '10001', '10001', '11110'],
c: ['00000', '00000', '01111', '10000', '10000', '10000', '01111'],
d: ['00001', '00001', '01111', '10001', '10001', '10001', '01111'],
e: ['00000', '00000', '01110', '10001', '11111', '10000', '01110'],
f: ['00110', '01000', '11110', '01000', '01000', '01000', '01000'],
g: [
'00000',
'00000',
'01111',
'10001',
'10001',
'01111',
'00001',
'00001',
'01110',
],
h: ['10000', '10000', '11110', '10001', '10001', '10001', '10001'],
i: ['00100', '00000', '01100', '00100', '00100', '00100', '01110'],
j: [
'00010',
'00000',
'00110',
'00010',
'00010',
'00010',
'00010',
'10010',
'01100',
],
k: ['10000', '10000', '10010', '10100', '11000', '10100', '10010'],
l: ['01100', '00100', '00100', '00100', '00100', '00100', '01110'],
m: ['00000', '00000', '11010', '10101', '10101', '10101', '10101'],
n: ['00000', '00000', '11110', '10001', '10001', '10001', '10001'],
o: ['00000', '00000', '01110', '10001', '10001', '10001', '01110'],
p: [
'00000',
'00000',
'11110',
'10001',
'10001',
'11110',
'10000',
'10000',
'10000',
],
q: [
'00000',
'00000',
'01111',
'10001',
'10001',
'01111',
'00001',
'00001',
'00001',
],
r: ['00000', '00000', '10110', '11001', '10000', '10000', '10000'],
s: ['00000', '00000', '01111', '10000', '01110', '00001', '11110'],
t: ['01000', '01000', '11110', '01000', '01000', '01001', '00110'],
u: ['00000', '00000', '10001', '10001', '10001', '10011', '01101'],
v: ['00000', '00000', '10001', '10001', '10001', '01010', '00100'],
w: ['00000', '00000', '10001', '10101', '10101', '10101', '01010'],
x: ['00000', '00000', '10001', '01010', '00100', '01010', '10001'],
y: [
'00000',
'00000',
'10001',
'10001',
'10001',
'01111',
'00001',
'00001',
'01110',
],
z: ['00000', '00000', '11111', '00010', '00100', '01000', '11111'],
0: ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
2: ['01110', '10001', '00001', '00110', '01000', '10000', '11111'],
3: ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
5: ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
6: ['01110', '10000', '10000', '11110', '10001', '10001', '01110'],
7: ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
9: ['01110', '10001', '10001', '01111', '00001', '00001', '01110'],
'.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'],
'-': ['00000', '00000', '00000', '01110', '00000', '00000', '00000'],
_: ['00000', '00000', '00000', '00000', '00000', '00000', '11111'],
'/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'],
':': ['00000', '01100', '01100', '00000', '01100', '01100', '00000'],
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
};

/** Rendered width of a string at pixel scale s (glyphs advance 6*s, minus the trailing gap). */
export const textW = (str, s) => str.length * 6 * s - s;

/**
* Accumulates <rect> elements and emits the final cover SVG. All drawing is
* axis-aligned rects so crispEdges stays honest; keep coordinates on a 3px or
* 6px grid like the existing covers.
*/
export class Canvas {
constructor() {
this.parts = [];
}

/** Append a raw SVG fragment (e.g. a <g filter="url(#glow)"> wrapper). */
raw(s) {
this.parts.push(s);
}

rect(x, y, w, h, fill, opacity) {
if (w <= 0 || h <= 0) return;
const o = opacity !== undefined ? ` opacity="${opacity}"` : '';
this.parts.push(
`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"${o}/>`
);
}

/** Bevelled box: base fill, light top+left edge, dark bottom+right edge. */
bevel(x, y, w, h, base, lt, dk, e = 6) {
this.rect(x, y, w, h, base);
this.rect(x, y, w, e, lt);
this.rect(x, y, e, h, lt);
this.rect(x, y + h - e, w, e, dk);
this.rect(x + w - e, y, e, h, dk);
}

/**
* Pixel text; (x, y) is the top-left of the 7-row glyph box, s is the pixel
* size. Horizontal runs are merged into single rects to keep files small.
*/
text(str, x, y, s, fill, opacity) {
for (let i = 0; i < str.length; i++) {
const g = FONT[str[i]] ?? FONT[' '];
const gx = x + i * 6 * s;
for (let r = 0; r < g.length; r++) {
const row = g[r];
let c = 0;
while (c < 5) {
if (row[c] === '1') {
let run = 1;
while (c + run < 5 && row[c + run] === '1') run++;
this.rect(gx + c * s, y + r * s, run * s, s, fill, opacity);
c += run;
} else c++;
}
}
}
}

/** Headline text with the house drop shadow (PALETTE.shadow offset behind). */
textShadow(str, x, y, s, fill) {
const o = s >= 9 ? 6 : 3;
this.text(str, x + o, y + o, s, PALETTE.shadow);
this.text(str, x, y, s, fill);
}

/**
* Emit the complete SVG. Satisfies the rasterize-cover.mjs contract: literal
* width/height, matching viewBox, opaque full-bleed background rect first.
* Pass the post's alt text as ariaLabel so the two never drift.
*/
svg(ariaLabel) {
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${COVER_WIDTH}" height="${COVER_HEIGHT}" viewBox="0 0 ${COVER_WIDTH} ${COVER_HEIGHT}" role="img" aria-label="${ariaLabel}" shape-rendering="crispEdges">`,
`<defs><filter id="glow" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="4.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter><filter id="softglow" x="-80%" y="-80%" width="260%" height="260%"><feGaussianBlur stdDeviation="26"/></filter></defs>`,
`<rect x="0" y="0" width="${COVER_WIDTH}" height="${COVER_HEIGHT}" fill="${PALETTE.bg}"/>`,
...this.parts,
`</svg>`,
'',
].join('\n');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}

/** Scatter of 6px star pixels on the background, seeded for reproducibility. */
export function starfield(c, seed, n, y0 = 24, y1 = COVER_HEIGHT - 24) {
const rnd = lcg(seed);
for (let i = 0; i < n; i++) {
const x = 24 + Math.floor(rnd() * ((COVER_WIDTH - 48) / 6)) * 6;
const y = y0 + Math.floor(rnd() * ((y1 - y0) / 6)) * 6;
const major = rnd() > 0.55;
c.rect(
x,
y,
6,
6,
major ? PALETTE.star1 : PALETTE.star2,
major ? 0.9 : 0.5
);
}
}
Loading
Loading