Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ yarn-error.log*

.idea
workspace.xml

# Build-generated blog routes and private review previews
/src/content/blog/.generated/
/src/app/blog/en/
/src/app/blog/pl/*/
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
src/content/blog/.generated/
src/app/blog/en/
src/app/blog/pl/*/
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ All of these must pass before a change is done. CI (`.github/actions/check/`) ru
- `src/design/skin-terminal.css` — every rule of the optional Terminal skin.
- `src/content/work/` — case studies: facts and `published` in `studies.ts`, text in `<slug>.mdx`, mapped in `bodies.ts`. Pages render at `/work/<slug>/`.
- `src/content/looks.ts` and `src/app/look/[look]/` — one copy of the home page per skin and theme, each with a preview card rendered at build time from `card.jpg/route.tsx`. Card colours mirror the CSS tokens; keep them in sync when the design changes.
- `e2e/site.spec.ts` — Playwright suite. Add each new route to `PAGES`.
- `src/content/blog/` — typed bilingual registry and approved MDX bodies; `scripts/prepare-blog.mjs` generates ignored static routes and metadata before checks/builds. See README for the private local review workflow. Never copy unreviewed manuscripts into tracked public files or deploy a preview build.
- `e2e/site.spec.ts` and `e2e/blog.spec.ts` — Playwright suites; blog routes are discovered from the generated registry.

## Rules that are easy to break

Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,32 @@ Look at 1440, 768, 390, and 320px, and at both sides of the 801px navigation swi
On 2026-09-29, `https://3d-portfolio.devthomas.pl/` did not resolve (DNS `NXDOMAIN`). The footer does not link to it. GitHub, this portfolio, and the CV remain linked.

Orange's October 2022 start is still the date already published here; it is provisional until that month is confirmed directly.

## Bilingual blog

The blog shares the existing MDX renderer, design tokens, standard/Terminal skins and light/dark themes. `/blog/` is the English index and `/blog/pl/` the Polish index. Article editions use `/blog/en/<slug>/` and `/blog/pl/<slug>/`, with one stable English slug. Language choices never redirect automatically.

Maintain approved articles in `src/content/blog/catalog.ts` and MDX bodies under `src/content/blog/articles/`. The typed registry requires English and Polish titles, descriptions, topics and body references together. Set `publishedAt` to the actual release date, and only set `published: true` when both editions have been reviewed and publication is authorized. `updatedAt` is for substantive later changes. The optional `developmentAsOf` renders a dated development note. Reading time is derived independently from each body.

`npm run prepare:blog` validates the registry and generates concrete static article routes, metadata and localized share-card routes. It runs before dev, build, lint, typecheck and unit tests. Generated files in `src/content/blog/.generated/`, `src/app/blog/en/` and the article subdirectories of `src/app/blog/pl/` are ignored: never edit or commit them. Concrete routes allow an empty initial catalogue without exporting a placeholder article. Run checks sequentially, because preparation replaces those generated files.

The homepage shows up to two published articles between Background and Contact and hides the section when none exist. Both indexes and published editions appear in the command palette and sitemap. The production catalogue includes the approved Car Brain/Appwrite article in English and Polish. It builds normally without private manuscript files or BLOG_PREVIEW_DIR.

### Private local manuscript review

For this sibling career workspace, use `npm run dev:blog -- --port 3012`. It selects the private manuscripts automatically and binds to 127.0.0.1. Open `http://127.0.0.1:3012/blog/` exactly: another project may already use `localhost` on the same port. Stop the previous portfolio dev server before switching modes. Run lint/build/test in a separate checkout or after stopping the preview, because their preparation hooks replace generated routes.

Unreviewed manuscripts do not belong in this public repository. A local-only `BLOG_PREVIEW_DIR` may point to a private directory containing `posts.json` and the referenced EN/PL MDX files. Records must be unpublished and have no release date. The generator rejects this mode in CI. Preview pages have visible draft labels and `noindex`, and are excluded from the sitemap. This is a local review facility, not access control: **never upload a preview build or expose its server publicly**.

```bash
BLOG_PREVIEW_DIR=/absolute/path/to/private/drafts npm run dev
```

For automated preview verification, use the same environment for preparation/build and the following Playwright run. Tests discover the generated article editions. A normal build without `BLOG_PREVIEW_DIR` removes generated private routes and body copies and exports only published records:

```bash
env -u BLOG_PREVIEW_DIR npm run build
npm run test:e2e
```

Article headings must have unique, non-empty generated IDs. Polish characters are normalized for heading anchors. Use `ClientArchitecture` only for the Car Brain article, with `locale="en"` or `locale="pl"`; it distinguishes implemented authentication from planned web data paths. Publication does not enable the independent Car Brain launch card or App Store links.
210 changes: 210 additions & 0 deletions e2e/blog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
import { visiblePosts } from "../src/content/blog/.generated/posts";
import { blogArticlePath } from "../src/content/blog/model";
import { blogLabels } from "../src/content/blog/labels";
const first = visiblePosts[0];
const articlePaths = visiblePosts.flatMap((p) =>
(["en", "pl"] as const).map((locale) => ({
path: blogArticlePath(p.slug, locale),
locale,
post: p,
}))
);
test("unpublished and unknown editions do not export routes", async ({
request,
}) => {
for (const path of ["/blog/de/unknown/", "/blog/en/not-published/"])
expect((await request.get(path)).status()).toBe(404);
});
test("blog navigation works separately from homepage section scrolling", async ({
page,
}) => {
await page.goto("/");
await page.getByRole("link", { name: "Blog", exact: true }).click();
await expect(page).toHaveURL(/\/blog\/$/);
await page.getByRole("link", { name: "Polski", exact: true }).first().click();
await expect(page).toHaveURL(/\/blog\/pl\/$/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Artykuły");
});
for (const { path, locale, post } of articlePaths) {
test(`${path} reads without JS and links its translated edition`, async ({
browser,
request,
}) => {
const context = await browser.newContext({ javaScriptEnabled: false });
const page = await context.newPage();
await page.goto(path);
await expect(page.locator("article.blog-article")).toHaveAttribute(
"lang",
locale
);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
post.editions[locale].title
);
const other = locale === "en" ? "pl" : "en";
await expect(
page.locator(`link[rel="alternate"][hreflang="${other}"]`)
).toHaveAttribute(
"href",
`https://devthomas.pl${blogArticlePath(post.slug, other)}`
);
await page
.getByRole("link", {
name: other === "en" ? "English" : "Polski",
exact: true,
})
.click();
await expect(page).toHaveURL(
new RegExp(blogArticlePath(post.slug, other) + "$")
);
expect((await request.get(`${path}card.jpg`)).status()).toBe(200);
if (post.preview)
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
"content",
/noindex/
);
await context.close();
});
}
for (const skin of ["standard", "terminal"] as const)
for (const theme of ["light", "dark"] as const)
for (const width of [1440, 768, 390, 320]) {
test(`blog layout ${skin} ${theme} ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 });
await page.emulateMedia({ reducedMotion: "reduce" });
const paths = [
"/blog/",
"/blog/pl/",
...(first
? (["en", "pl"] as const).map((locale) =>
blogArticlePath(first.slug, locale)
)
: []),
];
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
for (const path of paths) {
await page.goto(`${path}?skin=${skin}&theme=${theme}`);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth
)
).toBe(true);
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual(
[]
);
if (path.includes(first?.slug ?? "__none__")) {
// Diagram geometry must survive missing component CSS.
for (const selector of [".client-architecture", ".auth-sequence"]) {
const diagram = page.locator(selector);
const graphic = diagram.getByRole("img");
await expect(graphic).toBeVisible();
const bounds = await graphic.boundingBox();
expect(bounds?.width).toBeGreaterThanOrEqual(640);
expect(bounds?.height).toBeLessThan(800);
await expect(diagram.getByRole("region")).toHaveCSS(
"overflow-x",
"auto"
);
await expect(
graphic.locator("path[marker-end]").first()
).not.toHaveCSS("stroke", "none");
}
const locale = path.startsWith("/blog/pl/") ? "pl" : "en";
const toc = page.getByRole("navigation", {
name: blogLabels[locale].toc,
});
for (const link of await toc.getByRole("link").all()) {
const href = await link.getAttribute("href");
expect(await page.locator(href!).count()).toBe(1);
}
}
await page.screenshot({
path: `test-results/blog-${skin}-${theme}-${width}-${path.replaceAll("/", "_")}.png`,
fullPage: true,
});
}
expect(errors).toEqual([]);
});
}

test("blog discovery and keyboard navigation reach the index and both editions", async ({
page,
}) => {
await page.goto("/");
await expect(page.locator("#writing .blog-list-item")).toHaveCount(
Math.min(2, visiblePosts.length)
);
const destinations = [
{ query: "ls blog/", path: "/blog/" },
...articlePaths.map(({ post, locale, path }) => ({
query: `cat blog/${locale}/${post.slug}.mdx`,
path,
})),
];
for (const { query, path } of destinations) {
await page.getByRole("button", { name: "Open command palette" }).click();
await page.getByRole("combobox", { name: "Search commands" }).fill(query);
await page.keyboard.press("Enter");
await expect(page).toHaveURL(new RegExp(path + "$"));
}
if (first) {
const toc = page.getByRole("navigation", { name: blogLabels.pl.toc });
const link = toc.getByRole("link").first();
const href = await link.getAttribute("href");
await link.focus();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(new RegExp(href + "$"));
await expect(page.locator(href!)).toBeInViewport();
}
});

for (const [skin, theme, width] of [
["standard", "light", 1440],
["standard", "light", 390],
["terminal", "dark", 1440],
["terminal", "dark", 320],
] as const) {
test(`blog review screenshots ${skin} ${theme} ${width}`, async ({
page,
}) => {
test.skip(!first, "No article is available in this build");
await page.setViewportSize({ width, height: 1000 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto(
`${blogArticlePath(first.slug, width < 800 ? "pl" : "en")}?skin=${skin}&theme=${theme}`
);
await page.screenshot({
path: `test-results/review-${skin}-${theme}-${width}-header.png`,
});
await page.locator(".client-architecture").screenshot({
path: `test-results/review-${skin}-${theme}-${width}-diagram.png`,
style: ".site-header, .site-header * { visibility: hidden !important; }",
});
});
}

test("article diagrams keep their geometry without external stylesheets", async ({
page,
}) => {
test.skip(!first, "No article in this build");
await page.setViewportSize({ width: 390, height: 900 });
await page.goto(blogArticlePath(first.slug, "pl"));
await page.evaluate(() => {
for (const sheet of Array.from(document.styleSheets)) sheet.disabled = true;
});
for (const selector of [".client-architecture", ".auth-sequence"]) {
const figure = page.locator(selector);
const region = figure.getByRole("region");
const graphic = figure.getByRole("img");
await expect(graphic).toBeVisible();
expect((await graphic.boundingBox())?.width).toBeGreaterThanOrEqual(640);
expect((await graphic.boundingBox())?.height).toBeLessThan(800);
await region.focus();
await page.keyboard.press("ArrowRight");
await expect
.poll(() => region.evaluate((element) => element.scrollLeft))
.toBeGreaterThan(0);
}
});
22 changes: 20 additions & 2 deletions e2e/site.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { test, expect, type Page } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
import sharp from "sharp";
import { visiblePosts } from "../src/content/blog/.generated/posts";
import { blogArticlePath } from "../src/content/blog/model";
import { carBrain } from "../src/content/carBrain";

// Every exported page. Add new routes here as they ship.
const PAGES = [
"/",
"/colophon/",
"/blog/",
"/blog/pl/",
...visiblePosts.flatMap((post) =>
(["en", "pl"] as const).map((locale) => blogArticlePath(post.slug, locale))
),
"/work/orange-cms/",
"/work/orange-e2e-testing/",
"/work/theeventa-mvp/",
Expand Down Expand Up @@ -62,7 +69,7 @@ for (const path of PAGES) {
}
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
"content",
"https://devthomas.pl/og-share.png"
`https://devthomas.pl${path.startsWith("/blog/en/") || /^\/blog\/pl\/[^/]+\/$/.test(path) ? `${path}card.jpg` : "/og-share.png"}`
);
});
}
Expand Down Expand Up @@ -846,7 +853,18 @@ test.describe("search basics", () => {
.map((match) => match[1])
.sort();
expect(locations).toEqual(
PAGES.map((path) => `https://devthomas.pl${path}`).sort()
PAGES.filter(
(path) =>
!visiblePosts.some(
(post) =>
post.preview &&
(["en", "pl"] as const).some(
(locale) => blogArticlePath(post.slug, locale) === path
)
)
)
.map((path) => `https://devthomas.pl${path}`)
.sort()
);
});

Expand Down
3 changes: 3 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export default defineConfig([
...nextTypeScript,
globalIgnores([
".next/**",
"src/content/blog/.generated/**",
"src/app/blog/en/**",
"src/app/blog/pl/*/**",
"out/**",
"next-env.d.ts",
"playwright-report/**",
Expand Down
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"prettier": "prettier --write src e2e *.mjs *.mts *.ts .github",
"prettier:check": "prettier --check src e2e *.mjs *.mts *.ts .github"
"prettier:check": "prettier --check src e2e *.mjs *.mts *.ts .github",
"prepare:blog": "node scripts/prepare-blog.mjs",
"predev": "npm run prepare:blog",
"prebuild": "npm run prepare:blog",
"pretest": "npm run prepare:blog",
"pretypecheck": "npm run prepare:blog",
"prelint": "npm run prepare:blog",
"dev:blog": "BLOG_PREVIEW_DIR=${BLOG_PREVIEW_DIR:-../professional-career/materials/articles/car-brain-appwrite} npm run dev -- --hostname 127.0.0.1"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
Expand Down
Loading
Loading