diff --git a/.gitignore b/.gitignore index caecbad..3c86d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/*/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..53fac93 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +src/content/blog/.generated/ +src/app/blog/en/ +src/app/blog/pl/*/ diff --git a/AGENTS.md b/AGENTS.md index 53debd6..0abb7d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `.mdx`, mapped in `bodies.ts`. Pages render at `/work//`. - `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 diff --git a/README.md b/README.md index 1e9072d..d9fed85 100644 --- a/README.md +++ b/README.md @@ -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//` and `/blog/pl//`, 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. diff --git a/e2e/blog.spec.ts b/e2e/blog.spec.ts new file mode 100644 index 0000000..ef656aa --- /dev/null +++ b/e2e/blog.spec.ts @@ -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); + } +}); diff --git a/e2e/site.spec.ts b/e2e/site.spec.ts index 3bc0aec..c4ee6b5 100644 --- a/e2e/site.spec.ts +++ b/e2e/site.spec.ts @@ -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/", @@ -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"}` ); }); } @@ -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() ); }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 22816e4..11608f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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/**", diff --git a/package.json b/package.json index 9ee7918..8347906 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/prepare-blog.mjs b/scripts/prepare-blog.mjs new file mode 100644 index 0000000..d4d8663 --- /dev/null +++ b/scripts/prepare-blog.mjs @@ -0,0 +1,101 @@ +import { mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { dirname, join, resolve, relative, isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; +import { headingsFromMdx } from "../src/lib/headings.ts"; +import { blogPosts } from "../src/content/blog/catalog.ts"; +import { + selectBlogPosts, + readingMinutes, + blogLocales, +} from "../src/content/blog/model.ts"; +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const generated = join(root, "src/content/blog/.generated"); +const previewDir = process.env.BLOG_PREVIEW_DIR; +if (previewDir && process.env.CI) + throw new Error( + "Private blog previews must not run in CI or deployment builds." + ); +let drafts = []; +if (previewDir) { + drafts = JSON.parse( + await readFile(join(resolve(previewDir), "posts.json"), "utf8") + ); + if (drafts.some((p) => p.published || p.publishedAt)) + throw new Error( + "Preview manuscripts must be unpublished and have no release date." + ); +} +const selected = selectBlogPosts( + [...blogPosts, ...drafts], + Boolean(previewDir) +); +const loaded = []; +// Validate every selected body before touching a previous preview. +for (const post of selected) { + const base = drafts.includes(post) + ? resolve(previewDir) + : join(root, "src/content/blog/articles"); + const sources = {}; + for (const lang of blogLocales) { + const file = resolve(base, post.editions[lang].body); + const rel = relative(base, file); + if (rel.startsWith("..") || isAbsolute(rel)) + throw new Error("Body escapes article directory"); + sources[lang] = await readFile(file, "utf8"); + const ids = headingsFromMdx(sources[lang]).map((h) => h.id); + if (ids.some((id) => !id) || new Set(ids).size !== ids.length) + throw new Error(`Duplicate or empty headings: ${post.slug}/${lang}`); + if (!sources[lang].trim()) + throw new Error(`Empty ${lang} body: ${post.slug}`); + } + loaded.push({ post, sources }); +} +let previous = []; +try { + previous = JSON.parse(await readFile(join(generated, "routes.json"), "utf8")); +} catch (e) { + if (e.code !== "ENOENT") throw e; +} +for (const path of previous) { + if (!/^src\/app\/blog\/(en|pl)\/[a-z0-9-]+$/.test(path)) + throw new Error("Invalid generated route manifest"); + await rm(join(root, path), { recursive: true, force: true }); +} +await rm(generated, { recursive: true, force: true }); +await mkdir(generated, { recursive: true }); +const routes = [], + visible = []; +for (const { post, sources } of loaded) { + const entry = { + ...post, + preview: !post.published, + readingMinutes: { + en: readingMinutes(sources.en), + pl: readingMinutes(sources.pl), + }, + }; + visible.push(entry); + for (const lang of blogLocales) { + const route = `src/app/blog/${lang}/${post.slug}`; + routes.push(route); + const bodyName = `${post.slug}.${lang}.mdx`; + await writeFile(join(generated, bodyName), sources[lang]); + await mkdir(join(root, route, "card.jpg"), { recursive: true }); + await writeFile( + join(root, route, "page.tsx"), + `// Generated by prepare-blog; edit the catalog and MDX instead.\nimport Body from "@/content/blog/.generated/${bodyName}";\nimport { BlogArticle, articleMetadata } from "@/components/Blog/BlogArticle";\nexport const metadata = articleMetadata(${JSON.stringify(post.slug)}, "${lang}");\nexport default function Page() { return ; }\n` + ); + await writeFile( + join(root, route, "card.jpg/route.tsx"), + `// Generated by prepare-blog.\nimport { blogCard } from "@/components/Blog/BlogCard";\nexport const dynamic = "force-static";\nexport function GET() { return blogCard(${JSON.stringify(post.slug)}, "${lang}"); }\n` + ); + } +} +await writeFile( + join(generated, "posts.ts"), + `// Generated; no manuscript text enters client code.\nimport type { VisiblePost } from "../types";\nexport const visiblePosts: VisiblePost[] = ${JSON.stringify(visible, null, 2)};\n` +); +await writeFile(join(generated, "routes.json"), JSON.stringify(routes)); +console.log( + `Blog: ${visible.length} article(s), ${routes.length} editions${previewDir ? " — PRIVATE PREVIEW, DO NOT DEPLOY" : " — production"}` +); diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx new file mode 100644 index 0000000..1a5c3e5 --- /dev/null +++ b/src/app/blog/page.tsx @@ -0,0 +1,5 @@ +import { BlogIndex, blogIndexMetadata } from "@/components/Blog/BlogIndex"; +export const metadata = blogIndexMetadata("en"); +export default function Page() { + return ; +} diff --git a/src/app/blog/pl/page.tsx b/src/app/blog/pl/page.tsx new file mode 100644 index 0000000..a93d4fa --- /dev/null +++ b/src/app/blog/pl/page.tsx @@ -0,0 +1,5 @@ +import { BlogIndex, blogIndexMetadata } from "@/components/Blog/BlogIndex"; +export const metadata = blogIndexMetadata("pl"); +export default function Page() { + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7a1e3c6..8dc0a3f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,6 +23,7 @@ import "@/components/FooterPanel/footer.css"; import "@/components/CommandPalette/palette.css"; import "@/components/Prose/prose.css"; import "@/components/CaseStudy/case-study.css"; +import "@/components/Blog/blog.css"; import "@/design/skin-terminal.css"; // The standard skin shares the CV's type system (cv.devthomas.pl): Fraunces diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index a8e4c22..aeae386 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,3 +1,5 @@ +import { visiblePosts } from "@/content/blog/.generated/posts"; +import { blogArticlePath } from "@/content/blog/model"; import type { MetadataRoute } from "next"; import { SITE_URL } from "@/lib/metadata"; import { caseStudyPath, publishedCaseStudies } from "@/content/work/studies"; @@ -8,6 +10,22 @@ export const dynamic = "force-static"; export default function sitemap(): MetadataRoute.Sitemap { return [ + { url: `${SITE_URL}/blog/` }, + { url: `${SITE_URL}/blog/pl/` }, + ...visiblePosts + .filter((post) => !post.preview) + .flatMap((post) => + (["en", "pl"] as const).map((locale) => ({ + url: SITE_URL + blogArticlePath(post.slug, locale), + lastModified: post.updatedAt ?? post.publishedAt, + alternates: { + languages: { + en: SITE_URL + blogArticlePath(post.slug, "en"), + pl: SITE_URL + blogArticlePath(post.slug, "pl"), + }, + }, + })) + ), { url: `${SITE_URL}/`, changeFrequency: "monthly", priority: 1 }, ...publishedCaseStudies.map((study) => ({ url: `${SITE_URL}${caseStudyPath(study.slug)}`, diff --git a/src/commands.ts b/src/commands.ts index 1bd0cce..81a2781 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,3 +1,5 @@ +import { visiblePosts } from "./content/blog/.generated/posts"; +import { blogArticlePath } from "./content/blog/model"; import { profile } from "./content/publicProfile"; import { SITE_URL } from "./lib/metadata"; import { sections } from "./content/navigation"; @@ -62,6 +64,24 @@ export const carBrainCommands = (links: CarBrainLinks | null): Command[] => : []; export const commands: Command[] = [ + { + id: "blog", + title: "Read the blog", + alias: "ls blog/", + keywords: ["writing", "articles", "blog"], + group: "Page", + run: (context) => context.goToPage("/blog/"), + }, + ...visiblePosts.flatMap((post) => + (["en", "pl"] as const).map((locale): Command => ({ + id: `blog-${locale}-${post.slug}`, + title: `${post.editions[locale].title} (${locale.toUpperCase()})`, + alias: `cat blog/${locale}/${post.slug}.mdx`, + keywords: ["article", "blog", ...post.editions[locale].topics], + group: "Page", + run: (context) => context.goToPage(blogArticlePath(post.slug, locale)), + })) + ), { id: "top", title: "Go to top", diff --git a/src/components/Blog/Blog.test.tsx b/src/components/Blog/Blog.test.tsx new file mode 100644 index 0000000..3e9cb0e --- /dev/null +++ b/src/components/Blog/Blog.test.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { expect, test } from "vitest"; +import { ClientArchitecture } from "./ClientArchitecture"; +import { BlogList, BlogLanguages, LatestWriting } from "./BlogList"; +import { BlogIndex, blogIndexMetadata } from "./BlogIndex"; +import { visiblePosts } from "@/content/blog/.generated/posts"; +import { pageMetadata } from "@/lib/metadata"; +test("language switch keeps the same article", () => { + render(); + expect(screen.getByRole("link", { name: "Polski" })).toHaveAttribute( + "aria-current", + "page" + ); + expect(screen.getByRole("link", { name: "English" })).toHaveAttribute( + "href", + expect.stringMatching(/^\/blog\/en\/example\/?$/) + ); +}); +test("empty article list has no placeholder cards", () => { + const { container } = render(); + expect(container.querySelectorAll("li")).toHaveLength(0); +}); +test("latest writing matches the available records and hides when empty", () => { + const { container } = render(); + expect(container.querySelectorAll(".blog-list-item")).toHaveLength( + Math.min(2, visiblePosts.length) + ); + expect(Boolean(container.querySelector("#writing"))).toBe( + visiblePosts.length > 0 + ); +}); +test("Polish index labels its language and has localized metadata", () => { + render(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Artykuły" + ); + expect(screen.getByRole("region")).toHaveAttribute("lang", "pl"); + expect(blogIndexMetadata("pl").alternates).toMatchObject({ + canonical: "/blog/pl/", + languages: { en: "/blog/", pl: "/blog/pl/" }, + }); +}); +test("article metadata preserves independent canonicals and prevents draft indexing", () => { + const m = pageMetadata({ + path: "/blog/pl/example/", + title: "Przykład", + languages: { en: "/blog/en/example/", pl: "/blog/pl/example/" }, + article: { locale: "pl" }, + noIndex: true, + }); + expect(m.alternates?.canonical).toBe("/blog/pl/example/"); + expect(m.openGraph).toMatchObject({ type: "article", locale: "pl_PL" }); + expect(m.robots).toMatchObject({ index: false, follow: false }); +}); + +for (const locale of ["en", "pl"] as const) { + test(`architecture exposes its boundaries and planned data path (${locale})`, () => { + const { container } = render(); + const graphic = screen.getByRole("img"); + expect(graphic).toHaveAccessibleName(/3 /); + expect(graphic).toHaveTextContent("React Native"); + expect(graphic).toHaveTextContent("Next.js"); + expect(graphic).toHaveTextContent("Appwrite"); + expect(graphic.querySelector('[data-edge="planned"]')).toHaveAttribute( + "stroke-dasharray" + ); + expect(graphic.querySelector('[data-edge="mobile"]')).not.toHaveAttribute( + "stroke-dasharray" + ); + expect(container.querySelector('[role="region"]')).toHaveAttribute( + "tabindex", + "0" + ); + }); +} diff --git a/src/components/Blog/BlogArticle.tsx b/src/components/Blog/BlogArticle.tsx new file mode 100644 index 0000000..c964935 --- /dev/null +++ b/src/components/Blog/BlogArticle.tsx @@ -0,0 +1,161 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { ReactNode } from "react"; +import type { MDXContent } from "mdx/types"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { visiblePosts } from "@/content/blog/.generated/posts"; +import { + blogArticlePath, + blogImagePath, + blogIndexPath, + formatBlogDate, +} from "@/content/blog/model"; +import type { BlogLocale } from "@/content/blog/types"; +import { blogLabels } from "@/content/blog/labels"; +import { profile } from "@/content/publicProfile"; +import { publishedCaseStudies, caseStudyPath } from "@/content/work/studies"; +import { pageMetadata, SITE_URL } from "@/lib/metadata"; +import { headingsFromMdx } from "@/lib/headings"; +import { JsonLd } from "@/components/JsonLd/JsonLd"; +import { CaseStudyToc } from "@/components/CaseStudy/CaseStudyToc"; +import { BlogLanguages } from "./BlogList"; +const find = (slug: string) => visiblePosts.find((p) => p.slug === slug); +export function articleMetadata(slug: string, locale: BlogLocale) { + const post = find(slug); + if (!post) return {}; + const edition = post.editions[locale]; + return pageMetadata({ + path: blogArticlePath(slug, locale), + title: edition.title, + description: edition.description, + image: { url: blogImagePath(slug, locale), alt: edition.title }, + languages: { + en: blogArticlePath(slug, "en"), + pl: blogArticlePath(slug, "pl"), + }, + article: { + locale, + publishedAt: post.publishedAt, + updatedAt: post.updatedAt, + }, + noIndex: post.preview, + }); +} +const Unwrapped = ({ children }: { children?: ReactNode }) => <>{children}; +export async function BlogArticle({ + slug, + locale, + Body, +}: { + slug: string; + locale: BlogLocale; + Body: MDXContent; +}) { + const post = find(slug); + if (!post) notFound(); + const t = blogLabels[locale], + edition = post.editions[locale]; + const source = await readFile( + join(process.cwd(), "src/content/blog/.generated", `${slug}.${locale}.mdx`), + "utf8" + ); + const headings = headingsFromMdx(source); + const url = SITE_URL + blogArticlePath(slug, locale); + const related = publishedCaseStudies.filter((s) => + post.relatedWork?.includes(s.slug) + ); + return ( +
+ +
+
+

Blog · {edition.topics.join(" / ")}

+

{edition.title}

+

{edition.description}

+

+ {profile.name} + + {post.publishedAt ? ( + + ) : ( + t.draft + )} + + {post.readingMinutes[locale]} {t.minute} +

+ {post.updatedAt && ( +

+ {t.updated}:{" "} + +

+ )} + +
+
+
+ +
+ {post.preview &&

{t.preview}

} + {post.developmentAsOf && ( + + )} + + {related.length > 0 && ( + + )} + +
+
+
+ ); +} diff --git a/src/components/Blog/BlogCard.tsx b/src/components/Blog/BlogCard.tsx new file mode 100644 index 0000000..6e57614 --- /dev/null +++ b/src/components/Blog/BlogCard.tsx @@ -0,0 +1,55 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { ImageResponse } from "next/og"; +import sharp from "sharp"; +import { visiblePosts } from "@/content/blog/.generated/posts"; +import type { BlogLocale } from "@/content/blog/types"; +// Standard design-system band colours, matching index.css. Font is local. +export async function blogCard(slug: string, locale: BlogLocale) { + const post = visiblePosts.find((p) => p.slug === slug); + if (!post) return new Response("Not found", { status: 404 }); + const font = await readFile( + join(process.cwd(), "src/assets/fonts/Geist-600.ttf") + ); + const image = new ImageResponse( +
+
+ devthomas.pl / blog · {locale.toUpperCase()} +
+
+ {post.editions[locale].title} +
+
Tomasz Stanisz
+
, + { + width: 1200, + height: 630, + fonts: [{ name: "Geist", data: font, weight: 600, style: "normal" }], + } + ); + const jpeg = await sharp(Buffer.from(await image.arrayBuffer())) + .jpeg({ quality: 85 }) + .toBuffer(); + return new Response(new Uint8Array(jpeg), { + headers: { "Content-Type": "image/jpeg" }, + }); +} diff --git a/src/components/Blog/BlogIndex.tsx b/src/components/Blog/BlogIndex.tsx new file mode 100644 index 0000000..cb82296 --- /dev/null +++ b/src/components/Blog/BlogIndex.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import { visiblePosts } from "@/content/blog/.generated/posts"; +import { blogLabels } from "@/content/blog/labels"; +import { blogIndexPath } from "@/content/blog/model"; +import type { BlogLocale } from "@/content/blog/types"; +import { pageMetadata } from "@/lib/metadata"; +import { BlogLanguages, BlogList } from "./BlogList"; +export function blogIndexMetadata(locale: BlogLocale) { + const t = blogLabels[locale]; + return pageMetadata({ + path: blogIndexPath(locale), + title: t.title, + description: t.description, + languages: { en: "/blog/", pl: "/blog/pl/" }, + noIndex: visiblePosts.some((post) => post.preview), + }); +} +export function BlogIndex({ locale }: { locale: BlogLocale }) { + const t = blogLabels[locale]; + return ( +
+
+
+

Blog · Tomasz Stanisz

+

{t.title}

+

{t.description}

+ +
+ {visiblePosts.some((p) => p.preview) && ( +

{t.preview}

+ )} + {visiblePosts.length ? ( + + ) : ( +
+

{t.empty}

+ {t.work} → +
+ )} +
+
+ ); +} diff --git a/src/components/Blog/BlogList.tsx b/src/components/Blog/BlogList.tsx new file mode 100644 index 0000000..c5fb6c2 --- /dev/null +++ b/src/components/Blog/BlogList.tsx @@ -0,0 +1,91 @@ +import Link from "next/link"; +import { visiblePosts } from "@/content/blog/.generated/posts"; +import { + blogArticlePath, + blogIndexPath, + formatBlogDate, +} from "@/content/blog/model"; +import { blogLabels } from "@/content/blog/labels"; +import type { BlogLocale, VisiblePost } from "@/content/blog/types"; +import { SectionBand } from "@/components/SectionBand/SectionBand"; + +export function BlogLanguages({ + locale, + slug, +}: { + locale: BlogLocale; + slug?: string; +}) { + return ( + + ); +} +export function BlogList({ + locale, + posts = visiblePosts, + headingLevel = 2, +}: { + locale: BlogLocale; + posts?: VisiblePost[]; + headingLevel?: 2 | 3; +}) { + const t = blogLabels[locale]; + const Heading = headingLevel === 3 ? "h3" : "h2"; + return ( +
    + {posts.map((post) => ( +
  • +

    + {post.publishedAt ? ( + + ) : ( + t.draft + )} + + {post.readingMinutes[locale]} {t.minute} +

    + + + {post.editions[locale].title} + + +

    + {post.editions[locale].description} +

    + +
  • + ))} +
+ ); +} +export function LatestWriting() { + if (!visiblePosts.length) return null; + return ( + All articles →} + > + + + ); +} diff --git a/src/components/Blog/ClientArchitecture.tsx b/src/components/Blog/ClientArchitecture.tsx new file mode 100644 index 0000000..e98f6fb --- /dev/null +++ b/src/components/Blog/ClientArchitecture.tsx @@ -0,0 +1,478 @@ +import { useId, type ReactNode } from "react"; +import type { BlogLocale } from "@/content/blog/types"; + +const ink = "var(--text, #1c282c)"; +const muted = "var(--text-secondary, #52646a)"; +const accent = "var(--accent, #0f6b75)"; +const surface = "var(--surface, #ffffff)"; +const soft = "var(--accent-soft, #e2efee)"; +const border = "var(--separator, #d4deda)"; + +function Label({ + x, + y, + children, + size = 14, + bold = false, + color = ink, + anchor = "start", +}: { + x: number; + y: number; + children: ReactNode; + size?: number; + bold?: boolean; + color?: string; + anchor?: "start" | "middle" | "end"; +}) { + return ( + + {children} + + ); +} +function Node({ + x, + y, + w, + title, + detail, + accentNode = false, +}: { + x: number; + y: number; + w: number; + title: string; + detail: string; + accentNode?: boolean; +}) { + return ( + + + + + + ); +} +function Diagram({ + locale, + kind, + title, + description, + caption, + height, + children, +}: { + locale: BlogLocale; + kind: string; + title: string; + description: string; + caption: string; + height: number; + children: (marker: string) => ReactNode; +}) { + const id = useId(); + const marker = `${id}-arrow`; + return ( +
+
+ + {title} + {description} + + + + + + + + + + + + + {children(marker)} + +
+
+ {caption}{" "} + {locale === "pl" + ? "Na małym ekranie przewiń schemat w poziomie." + : "On a narrow screen, scroll the diagram horizontally."} +
+
+ ); +} + +export function ClientArchitecture({ locale = "en" }: { locale?: BlogLocale }) { + const pl = locale === "pl"; + const title = pl + ? "3 platformy. 2 klienty. 1 backend." + : "3 platforms. 2 clients. 1 backend."; + return ( + + {(marker) => ( + <> + + + + + {[ + { x: 48, name: "iOS" }, + { x: 176, name: "Android" }, + ].map(({ x, name }) => ( + + + + + + ))} + + + + + + + + + + + + + + + + + + + + {["Auth", "Database", "Storage", "Functions"].map((name, i) => ( + + + + + ))} + + )} + + ); +} + +export function AuthSequence({ locale = "en" }: { locale?: BlogLocale }) { + const pl = locale === "pl"; + const actors = [pl ? "Przeglądarka" : "Browser", "Next.js", "Appwrite"]; + const steps = pl + ? [ + "1. Dane logowania", + "2. Utworzenie sesji", + "3. Sekret sesji", + "4. Cookie HTTP-only", + "5. Kolejne żądanie + cookie", + "6. Klient z sesją użytkownika", + ] + : [ + "1. Sign-in details", + "2. Create session", + "3. Session secret", + "4. HTTP-only cookie", + "5. Next request + cookie", + "6. User-scoped session client", + ]; + return ( + + {(marker) => ( + <> + {actors.map((actor, i) => ( + + + + + + ))} + {[ + { from: 119, to: 359, y: 177 }, + { from: 359, to: 599, y: 227 }, + { from: 599, to: 359, y: 277 }, + { from: 359, to: 119, y: 327 }, + { from: 119, to: 359, y: 388 }, + { from: 359, to: 599, y: 438 }, + ].map((s, i) => ( + + + + + ))} + + + )} + + ); +} diff --git a/src/components/Blog/blog.css b/src/components/Blog/blog.css new file mode 100644 index 0000000..3f185d7 --- /dev/null +++ b/src/components/Blog/blog.css @@ -0,0 +1,173 @@ +/* Uses the existing portfolio tokens; no separate blog theme. */ +.blog-index-header { + max-width: 72ch; + margin-bottom: var(--space-7); +} +.blog-index h1, +.blog-hero h1 { + font-family: var(--font-display); + font-size: clamp(32px, 5vw, 54px); + line-height: 1.12; + letter-spacing: -0.025em; + text-wrap: balance; +} +.blog-hero { + padding: var(--space-7) 0; + border-bottom: 1px solid var(--separator); +} +.blog-hero h1 { + max-width: 24ch; + margin: var(--space-4) 0 var(--space-5); +} +.blog-description { + max-width: 65ch; + color: var(--text-soft); + font-size: 18px; + line-height: 1.65; +} +.blog-meta { + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; +} +.blog-languages { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} +.blog-languages a { + display: inline-flex; + min-height: 44px; + align-items: center; + padding: 8px 14px; + border: 1px solid var(--separator); + border-radius: var(--radius-control); + color: var(--text); + text-decoration: none; + font-size: 14px; +} +.blog-languages a[aria-current="page"] { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--text); + font-weight: 600; +} +.blog-languages a:hover { + border-color: var(--accent); +} +.blog-list { + margin: 0; + padding: 0; + list-style: none; +} +.blog-list-item { + padding: var(--space-6) 0; + border-top: 1px solid var(--separator); + max-width: 80ch; +} +.blog-list-item :is(h2, h3) { + font-family: var(--font-display); + font-size: clamp(24px, 3vw, 32px); + line-height: 1.25; + max-width: 38ch; + margin: 8px 0 12px; +} +.blog-list-item :is(h2, h3) a { + color: var(--text); + text-decoration: none; +} +.blog-list-item :is(h2, h3) a:hover { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 4px; +} +.blog-prose { + width: 100%; + max-width: 72ch; + min-width: 0; + line-height: 1.75; + font-size: 17px; +} +.blog-prose h2 { + scroll-margin-top: calc(var(--header-height) + 24px); +} +.blog-notice { + padding: var(--space-5); + margin: 0 0 var(--space-6); + border-left: 3px solid var(--accent); + background: var(--accent-soft); + color: var(--text); + border-radius: 0 var(--radius-surface) var(--radius-surface) 0; +} +.blog-notice p { + margin: 8px 0 0; +} +.prose pre { + max-width: 100%; + overflow-x: auto; + padding: 20px; + border: 1px solid var(--separator); + border-radius: 12px; + background: var(--surface); + line-height: 1.6; +} +.prose pre code { + padding: 0; + border: 0; + background: none; + overflow-wrap: normal; + white-space: pre; + font-family: var(--font-mono); +} +.blog-prose figure { + margin: 32px 0; +} +.blog-prose figure img, +.blog-prose figure svg { + max-width: 100%; + height: auto; +} +.blog-prose figcaption { + margin-top: 12px; + font-size: 14px; + color: var(--text-secondary); +} +.blog-author { + margin-top: 48px; + padding-top: 24px; + border-top: 1px solid var(--separator); +} +/* One extra nav destination needs the compact menu earlier. */ +@media (min-width: 801px) and (max-width: 1099px) { + .nav-toggle { + display: inline-flex; + } + .nav-cv-menu { + display: block; + } + .site-nav-links { + position: absolute; + top: calc(100% + 8px); + right: 16px; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + width: min(300px, calc(100vw - 32px)); + padding: 16px; + border: 1px solid var(--separator); + border-radius: var(--radius-surface); + background: var(--surface); + box-shadow: 0 10px 30px #0002; + } + .site-nav-links a { + padding: 10px 12px; + } + .nav-skin-menu { + display: none; + } +} +.site-nav-links[hidden] { + display: none; +} diff --git a/src/components/CaseStudy/CaseStudyToc.tsx b/src/components/CaseStudy/CaseStudyToc.tsx index cd07961..b8b4c3e 100644 --- a/src/components/CaseStudy/CaseStudyToc.tsx +++ b/src/components/CaseStudy/CaseStudyToc.tsx @@ -6,7 +6,13 @@ import type { Heading } from "../../lib/headings"; // "On this page": the study's sections, rendered on the server so the list // works without JavaScript. After hydration it marks the section being read. // Only colour and the border colour change, so nothing shifts. -export const CaseStudyToc = ({ headings }: { headings: Heading[] }) => { +export const CaseStudyToc = ({ + headings, + label = "On this page", +}: { + headings: Heading[]; + label?: string; +}) => { const [currentId, setCurrentId] = useState(headings[0]?.id); useEffect(() => { @@ -47,7 +53,7 @@ export const CaseStudyToc = ({ headings }: { headings: Heading[] }) => { return (