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
Empty file added .nojekyll
Empty file.
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 2 additions & 1 deletion _template.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>workman.tech — {{title}}</title>
<title>{{title}} · workman.tech</title>
<meta name="description" content="{{description}}" />
<link rel="canonical" href="{{canonical_url}}" />
<meta property="og:type" content="article" />
Expand Down Expand Up @@ -33,6 +33,7 @@
/>
<link rel="manifest" href="/images/favicon/site.webmanifest" />
<link href="/css/style.css" rel="stylesheet" />
{{json_ld}}
</head>

<body>
Expand Down
6 changes: 3 additions & 3 deletions _theme_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>workman.tech — {{theme_display}}</title>
<title>{{theme_display}} · workman.tech</title>
<meta name="description" content="{{description}}" />
<link rel="canonical" href="{{canonical_url}}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="workman.tech — {{theme_display}}" />
<meta property="og:title" content="{{theme_display}} · workman.tech" />
<meta property="og:description" content="{{description}}" />
<meta property="og:url" content="{{canonical_url}}" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="workman.tech — {{theme_display}}" />
<meta name="twitter:title" content="{{theme_display}} · workman.tech" />
<meta name="twitter:description" content="{{description}}" />
<link
rel="apple-touch-icon"
Expand Down
93 changes: 92 additions & 1 deletion build.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ const POSTS_DIR = path.join(ROOT, "_posts");
const POST_TEMPLATE_PATH = path.join(ROOT, "_template.html");
const THEME_TEMPLATE_PATH = path.join(ROOT, "_theme_template.html");
const INDEX_PATH = path.join(ROOT, "index.html");
const SITEMAP_PATH = path.join(ROOT, "sitemap.xml");
const GENERATED_MARKER = "<!-- generated by build.js -->";
const POSTS_START = "<!-- posts:start -->";
const POSTS_END = "<!-- posts:end -->";
const SITE_ORIGIN = "https://workman.tech";
// Stable @id anchors for the structured-data entity graph. The full Person and
// WebSite nodes are defined once on the homepage (index.html); posts reference
// them by these ids so the whole site resolves to one author entity.
const PERSON_ID = `${SITE_ORIGIN}/#person`;
const WEBSITE_ID = `${SITE_ORIGIN}/#website`;
const DEFAULT_DESCRIPTION =
"Ryan Workman's corner of the web. Senior full-stack dev (~10 years, mostly Rails and JS, Turing grad) writing about what he's building and learning.";

Expand Down Expand Up @@ -334,6 +340,43 @@ function partLabel(post, inSeries) {
: "";
}

// Serialize an object as a JSON-LD <script> block. JSON embedded in <script>
// must not contain a literal </script>, so we escape <, >, & to their \uXXXX
// forms after stringifying. This is deliberately NOT escapeHtml — that would
// corrupt the JSON (e.g. turn " into &quot;); JSON.stringify already handles
// escaping of the values themselves.
function jsonLdScript(obj) {
const json = JSON.stringify(obj, null, 2)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026");
return `<script type="application/ld+json">\n${json}\n</script>`;
}

// 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);
Expand All @@ -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,
});
}
Expand Down Expand Up @@ -455,6 +499,51 @@ async function regenerateIndex(byTheme) {
}
}

// ---------- sitemap ----------

// Build sitemap.xml: the homepage, every theme landing page, and every post.
// <lastmod> 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>${lastmod}</lastmod>` : "";
return ` <url>\n <loc>${escapeHtml(loc)}</loc>${lastmodLine}\n </url>`;
})
.join("\n");
return (
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
`${body}\n` +
`</urlset>\n`
);
}

// ---------- cleaning ----------

// Reserved top-level entries that we must never treat as generated theme
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -577,6 +667,7 @@ async function build() {
}

await regenerateIndex(byTheme);
fs.writeFileSync(SITEMAP_PATH, renderSitemap(byTheme));

const themeCount = byTheme.size;
console.log(
Expand Down
35 changes: 33 additions & 2 deletions engineering/2026-06-03-the-repo-is-the-tracker.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>
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
</title>
<meta
name="description"
Expand Down Expand Up @@ -57,6 +57,37 @@
/>
<link rel="manifest" href="/images/favicon/site.webmanifest" />
<link href="/css/style.css" rel="stylesheet" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "the repo is the tracker: solo dev project management in the time of ai",
"description": "When AI agents do the building, the slow part stops being code and becomes coherent intent. Here's the in-repo project management system I built to keep a fast-moving solo project from drifting, and the ideas behind it that transfer to any team.",
"datePublished": "2026-06-03",
"dateModified": "2026-06-03",
"url": "https://workman.tech/engineering/2026-06-03-the-repo-is-the-tracker.html",
"mainEntityOfPage": "https://workman.tech/engineering/2026-06-03-the-repo-is-the-tracker.html",
"inLanguage": "en",
"keywords": [
"ai",
"claude-code",
"agentic-development",
"project-management",
"solo-dev"
],
"isPartOf": {
"@id": "https://workman.tech/#website"
},
"author": {
"@type": "Person",
"@id": "https://workman.tech/#person",
"name": "Ryan Workman"
},
"publisher": {
"@id": "https://workman.tech/#person"
}
}
</script>
</head>

<body>
Expand Down
36 changes: 34 additions & 2 deletions engineering/2026-06-12-building-an-oracle.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>
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
</title>
<meta
name="description"
Expand Down Expand Up @@ -57,6 +57,38 @@
/>
<link rel="manifest" href="/images/favicon/site.webmanifest" />
<link href="/css/style.css" rel="stylesheet" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "building an oracle: a knowledge base of how my team actually reviews code",
"description": "I build knowledge-base engines for a living, so I pointed one at my own team. It mined a thousand PR discussions into the judgment behind our code reviews, and now it reviews our PRs and learns from whether we take its advice. Here's how the Table22 Oracle works, and how I built it in an afternoon.",
"datePublished": "2026-06-12",
"dateModified": "2026-06-12",
"url": "https://workman.tech/engineering/2026-06-12-building-an-oracle.html",
"mainEntityOfPage": "https://workman.tech/engineering/2026-06-12-building-an-oracle.html",
"inLanguage": "en",
"keywords": [
"ai",
"code-review",
"knowledge-base",
"claude-code",
"agentic-development",
"engineering-judgment"
],
"isPartOf": {
"@id": "https://workman.tech/#website"
},
"author": {
"@type": "Person",
"@id": "https://workman.tech/#person",
"name": "Ryan Workman"
},
"publisher": {
"@id": "https://workman.tech/#person"
}
}
</script>
</head>

<body>
Expand Down
6 changes: 3 additions & 3 deletions engineering/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>workman.tech — Engineering</title>
<title>Engineering · workman.tech</title>
<meta name="description" content="Engineering writing by Ryan Workman" />
<link rel="canonical" href="https://workman.tech/engineering/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="workman.tech — Engineering" />
<meta property="og:title" content="Engineering · workman.tech" />
<meta
property="og:description"
content="Engineering writing by Ryan Workman"
/>
<meta property="og:url" content="https://workman.tech/engineering/" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="workman.tech — Engineering" />
<meta name="twitter:title" content="Engineering · workman.tech" />
<meta
name="twitter:description"
content="Engineering writing by Ryan Workman"
Expand Down
35 changes: 35 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,41 @@
/>
<link rel="manifest" href="/images/favicon/site.webmanifest" />
<link href="/css/style.css" rel="stylesheet" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": "https://workman.tech/#person",
"name": "Ryan Workman",
"url": "https://workman.tech/",
"description": "Senior full-stack engineer (~10 years, mostly Ruby on Rails and JavaScript) writing about software development and project management",
"sameAs": [
"https://github.com/rdavid1099",
"https://www.linkedin.com/in/ryan-workman/"
],
"knowsAbout": [
"AI engineering",
"Claude Code",
"Ruby on Rails",
"React",
"TypeScript",
"Full-stack web development"
]
},
{
"@type": "WebSite",
"@id": "https://workman.tech/#website",
"name": "workman.tech",
"url": "https://workman.tech/",
"description": "Ryan Workman's corner of the web. Senior full-stack dev (~10 years, mostly Rails and JS, Turing grad) writing about what he's building and learning.",
"inLanguage": "en",
"publisher": { "@id": "https://workman.tech/#person" }
}
]
}
</script>
</head>

<body>
Expand Down
17 changes: 17 additions & 0 deletions robots.txt
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading