From 6a6c4cbdb4a2291101e0d39a5f4a25c7a90498d7 Mon Sep 17 00:00:00 2001 From: Ryan Workman Date: Tue, 16 Jun 2026 12:59:29 -0600 Subject: [PATCH] Add structured data, sitemap, and SEO hygiene for search indexing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Problem The site had no machine-readable signals for search engines or AI crawlers: no structured data, no sitemap, no robots.txt, and page titles that buried the post name behind the domain prefix. # Solution Added Schema.org JSON-LD structured data across the site. The homepage defines a single `Person` and `WebSite` node (anchored at `/#person` and `/#website`); every post page emits a `BlogPosting` block that references those nodes by `@id`, so the whole site resolves to one author entity in Google's knowledge graph. The `build.js` template pipeline was extended to inject the per-post block automatically via a `{{json_ld}}` placeholder, keeping generated HTML in sync without manual edits. Generated a `sitemap.xml` covering the homepage, all theme landing pages, and every post. Lastmod dates derive exclusively from frontmatter (never build time), so the file is byte-stable between builds and won't produce spurious diffs in CI. Sitemap generation is integrated into the `build` step and the `clean` step removes the file on reset. Added `sitemap.xml` and `images/` to `.prettierignore` since Prettier has no XML parser. Added `robots.txt` allowing all crawlers (including AI/LLM bots) with targeted `Disallow` entries for build scaffolding files that `.nojekyll` now exposes. Flipped all page titles from `workman.tech — Post Title` to `Post Title · workman.tech` so the meaningful content leads in browser tabs and SERP snippets. # Notes The structured data on existing post HTML files was regenerated by running the build rather than hand-edited, so the JSON-LD in those files is canonical output from `renderPostJsonLd`. If a future post's frontmatter changes, re-running `build.js` will update the block automatically. --- .nojekyll | 0 .prettierignore | 3 + _template.html | 3 +- _theme_template.html | 6 +- build.js | 93 ++++++++++++++++++- .../2026-06-03-the-repo-is-the-tracker.html | 35 ++++++- .../2026-06-12-building-an-oracle.html | 36 ++++++- engineering/index.html | 6 +- index.html | 35 +++++++ robots.txt | 17 ++++ ...-24-pokemon-card-scanner-in-one-night.html | 35 ++++++- side-projects/index.html | 6 +- sitemap.xml | 35 +++++++ ...15-ai-didnt-break-the-laws-of-physics.html | 33 ++++++- understanding-clients/index.html | 6 +- 15 files changed, 328 insertions(+), 21 deletions(-) create mode 100644 .nojekyll create mode 100644 robots.txt create mode 100644 sitemap.xml diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/.prettierignore b/.prettierignore index f2fa146..50c462b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,9 @@ node_modules package-lock.json +# Generated by build.js, written pre-formatted (Prettier has no XML parser). +sitemap.xml + # Generated favicon assets (created by an external tool, not hand-maintained). images diff --git a/_template.html b/_template.html index d4c0d66..8a79965 100644 --- a/_template.html +++ b/_template.html @@ -4,7 +4,7 @@ - workman.tech — {{title}} + {{title}} · workman.tech @@ -33,6 +33,7 @@ /> + {{json_ld}} diff --git a/_theme_template.html b/_theme_template.html index 54bdca6..b8efbd0 100644 --- a/_theme_template.html +++ b/_theme_template.html @@ -4,15 +4,15 @@ - workman.tech — {{theme_display}} + {{theme_display}} · workman.tech - + - + block. JSON embedded in , so we escape <, >, & to their \uXXXX +// forms after stringifying. This is deliberately NOT escapeHtml — that would +// corrupt the JSON (e.g. turn " into "); JSON.stringify already handles +// escaping of the values themselves. +function jsonLdScript(obj) { + const json = JSON.stringify(obj, null, 2) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026"); + return ``; +} + +// Per-post BlogPosting structured data. author/publisher reference the Person +// node defined on the homepage by @id, and isPartOf references the WebSite, +// so the whole site resolves to one author entity. Dates come from frontmatter +// only (keeps the build deterministic — same rule as the sitemap). +function renderPostJsonLd(post, description) { + const url = canonicalUrlForPost(post); + const obj = { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: post.title, + description, + datePublished: post.date, + dateModified: post.date, + url, + mainEntityOfPage: url, + inLanguage: "en", + }; + if (post.tags.length > 0) obj.keywords = post.tags; + obj.isPartOf = { "@id": WEBSITE_ID }; + obj.author = { "@type": "Person", "@id": PERSON_ID, name: "Ryan Workman" }; + obj.publisher = { "@id": PERSON_ID }; + return jsonLdScript(obj); +} + function renderPost(template, post) { const description = post.description || DEFAULT_DESCRIPTION; const content = renderPostBody(post); @@ -347,6 +390,7 @@ function renderPost(template, post) { theme_slug: post.themeSlug, tags_display: post.tags.map(escapeHtml).join(", "), theme_and_tags_line: themeAndTagsLine(post), + json_ld: renderPostJsonLd(post, description), content, }); } @@ -455,6 +499,51 @@ async function regenerateIndex(byTheme) { } } +// ---------- sitemap ---------- + +// Build sitemap.xml: the homepage, every theme landing page, and every post. +// is derived only from frontmatter dates (never build time), so the +// output is byte-stable and CI's "git clean after build" check holds. Not run +// through Prettier — Prettier core has no XML parser — so the formatting here +// is the committed formatting. URL order is deterministic: homepage, then +// themes in the byTheme map's (alphabetical) order, each landing page followed +// by its posts. +function renderSitemap(byTheme) { + const entries = []; + let newestOverall = null; + for (const posts of byTheme.values()) { + for (const p of posts) { + if (!newestOverall || p.date > newestOverall) newestOverall = p.date; + } + } + entries.push({ loc: `${SITE_ORIGIN}/`, lastmod: newestOverall }); + for (const [themeSlug, posts] of byTheme) { + let newestInTheme = null; + for (const p of posts) { + if (!newestInTheme || p.date > newestInTheme) newestInTheme = p.date; + } + entries.push({ + loc: canonicalUrlForTheme(themeSlug), + lastmod: newestInTheme, + }); + for (const p of posts) { + entries.push({ loc: canonicalUrlForPost(p), lastmod: p.date }); + } + } + const body = entries + .map(({ loc, lastmod }) => { + const lastmodLine = lastmod ? `\n ${lastmod}` : ""; + return ` \n ${escapeHtml(loc)}${lastmodLine}\n `; + }) + .join("\n"); + return ( + `\n` + + `\n` + + `${body}\n` + + `\n` + ); +} + // ---------- cleaning ---------- // Reserved top-level entries that we must never treat as generated theme @@ -514,7 +603,8 @@ async function cleanGeneratedDirs() { for (const name of generated) { fs.rmSync(path.join(ROOT, name), { recursive: true, force: true }); } - // Also reset the index post-list region. + // Also remove the generated sitemap and reset the index post-list region. + fs.rmSync(SITEMAP_PATH, { force: true }); if (fs.existsSync(INDEX_PATH)) { await regenerateIndex(new Map()); } @@ -577,6 +667,7 @@ async function build() { } await regenerateIndex(byTheme); + fs.writeFileSync(SITEMAP_PATH, renderSitemap(byTheme)); const themeCount = byTheme.size; console.log( diff --git a/engineering/2026-06-03-the-repo-is-the-tracker.html b/engineering/2026-06-03-the-repo-is-the-tracker.html index a6dcec1..84e9862 100644 --- a/engineering/2026-06-03-the-repo-is-the-tracker.html +++ b/engineering/2026-06-03-the-repo-is-the-tracker.html @@ -5,8 +5,8 @@ - workman.tech — the repo is the tracker: solo dev project management in the - time of ai + the repo is the tracker: solo dev project management in the time of ai · + workman.tech + diff --git a/engineering/2026-06-12-building-an-oracle.html b/engineering/2026-06-12-building-an-oracle.html index d68710f..6f458b1 100644 --- a/engineering/2026-06-12-building-an-oracle.html +++ b/engineering/2026-06-12-building-an-oracle.html @@ -5,8 +5,8 @@ - workman.tech — building an oracle: a knowledge base of how my team - actually reviews code + building an oracle: a knowledge base of how my team actually reviews code + · workman.tech + diff --git a/engineering/index.html b/engineering/index.html index 3799e1c..5063d47 100644 --- a/engineering/index.html +++ b/engineering/index.html @@ -4,18 +4,18 @@ - workman.tech — Engineering + Engineering · workman.tech - + - + + diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..0a2ae48 --- /dev/null +++ b/robots.txt @@ -0,0 +1,17 @@ +# workman.tech crawl policy +# Goal: maximize discoverability, including AI/LLM crawlers. No actual content +# is disallowed; the Disallow lines just keep crawlers off the build +# scaffolding that .nojekyll now exposes. Per-bot control (e.g. allow +# Claude-SearchBot but block ClaudeBot training) can be added later; today +# every bot is allowed. + +User-agent: * +Allow: / +Disallow: /_ +Disallow: /build.js +Disallow: /CLAUDE.md +Disallow: /README.md +Disallow: /package.json +Disallow: /package-lock.json + +Sitemap: https://workman.tech/sitemap.xml diff --git a/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.html b/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.html index ac5dcea..0ff21ca 100644 --- a/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.html +++ b/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.html @@ -5,8 +5,8 @@ - workman.tech — i built a pokémon card scanner in one night with claude - code + i built a pokémon card scanner in one night with claude code · + workman.tech + diff --git a/side-projects/index.html b/side-projects/index.html index 9ef1fe1..4c04c58 100644 --- a/side-projects/index.html +++ b/side-projects/index.html @@ -4,18 +4,18 @@ - workman.tech — Side Projects + Side Projects · workman.tech - + - + + + + https://workman.tech/ + 2026-06-15 + + + https://workman.tech/engineering/ + 2026-06-12 + + + https://workman.tech/engineering/2026-06-12-building-an-oracle.html + 2026-06-12 + + + https://workman.tech/engineering/2026-06-03-the-repo-is-the-tracker.html + 2026-06-03 + + + https://workman.tech/side-projects/ + 2026-05-24 + + + https://workman.tech/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.html + 2026-05-24 + + + https://workman.tech/understanding-clients/ + 2026-06-15 + + + https://workman.tech/understanding-clients/2026-06-15-ai-didnt-break-the-laws-of-physics.html + 2026-06-15 + + diff --git a/understanding-clients/2026-06-15-ai-didnt-break-the-laws-of-physics.html b/understanding-clients/2026-06-15-ai-didnt-break-the-laws-of-physics.html index 273fae0..601a2fb 100644 --- a/understanding-clients/2026-06-15-ai-didnt-break-the-laws-of-physics.html +++ b/understanding-clients/2026-06-15-ai-didnt-break-the-laws-of-physics.html @@ -4,7 +4,7 @@ - workman.tech — ai didn't break the laws of physics + ai didn't break the laws of physics · workman.tech + diff --git a/understanding-clients/index.html b/understanding-clients/index.html index b07803f..5a7be35 100644 --- a/understanding-clients/index.html +++ b/understanding-clients/index.html @@ -4,7 +4,7 @@ - workman.tech — understanding clients in the age of ai + understanding clients in the age of ai · workman.tech