From 3e2b66d5e77d472cae0391d9aac0ba5f77bc660a Mon Sep 17 00:00:00 2001
From: Seungjun Jeong
Date: Mon, 12 Oct 2026 21:03:43 +0900
Subject: [PATCH] feat(seo): enable production indexing and structured metadata
---
.github/workflows/ci.yml | 7 +
astro.config.mjs | 11 +-
package.json | 1 +
scripts/verify-security-guards.mjs | 10 +-
scripts/verify-seo.mjs | 287 +++++++++++++++++++++++++++++
src/layouts/ArticleLayout.astro | 4 +
src/layouts/BaseLayout.astro | 47 ++++-
src/pages/article-template.astro | 1 +
src/pages/blog/index.astro | 5 +-
src/pages/index.astro | 5 +-
src/pages/profile.astro | 4 +-
src/pages/robots.txt.ts | 4 +-
src/pages/tags/[slug].astro | 5 +-
worker/seo.mjs | 171 +++++++++++++++++
worker/site.mjs | 201 +++++++-------------
wrangler.jsonc | 6 +
16 files changed, 619 insertions(+), 150 deletions(-)
create mode 100644 scripts/verify-seo.mjs
create mode 100644 worker/seo.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a5c76cc..de2e96b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,8 +48,15 @@ jobs:
pnpm verify:content
- name: Build static site
+ env:
+ SITE_URL: https://seungjun.sh
+ MEDIA_BASE_URL: https://media.seungjun.sh
+ ALLOW_INDEXING: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'true' || 'false' }}
run: pnpm build
+ - name: Verify SEO and preview indexing guards
+ run: pnpm verify:seo
+
- name: Verify D1 search, retired paths and security guards
run: |
pnpm verify:search
diff --git a/astro.config.mjs b/astro.config.mjs
index 5825873..be5df4f 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -82,7 +82,16 @@ export default defineConfig({
rehypePlugins: [rehypeContent, highlight],
}),
}),
- ...(configuredSite ? [sitemap()] : []),
+ ...(configuredSite
+ ? [
+ sitemap({
+ filter: (page) =>
+ !/\/(?:login|write|admin|article-template|collection-template|list-template|404)(?:\/|$)/.test(
+ new URL(page).pathname
+ ),
+ }),
+ ]
+ : []),
],
markdown: {
syntaxHighlight: false,
diff --git a/package.json b/package.json
index 30ee004..c6cb2e9 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"node": ">=22.12.0"
},
"scripts": {
+ "verify:seo": "node scripts/verify-seo.mjs",
"dev": "astro dev --host 127.0.0.1 --force",
"check": "astro check",
"lint": "eslint .",
diff --git a/scripts/verify-security-guards.mjs b/scripts/verify-security-guards.mjs
index 8899f8a..3ccb76a 100644
--- a/scripts/verify-security-guards.mjs
+++ b/scripts/verify-security-guards.mjs
@@ -8,6 +8,7 @@ const { Request, Response, ReadableStream, TextEncoder } = globalThis;
const require = createRequire(import.meta.url);
const wranglerRequire = createRequire(require.resolve('wrangler/package.json'));
const { Miniflare, convertV4MiniflareOptions } = wranglerRequire('miniflare');
+const { build } = wranglerRequire('esbuild');
const limit = 10 * 1024 * 1024;
let puts = 0;
const env = {
@@ -160,10 +161,17 @@ for (const size of [4, limit]) {
assert.equal(puts, 2);
// Use Wrangler's installed Workers runtime for real HTMLRewriter behavior.
+const siteBundle = await build({
+ entryPoints: [new URL('../worker/site.mjs', import.meta.url).pathname],
+ bundle: true,
+ write: false,
+ format: 'esm',
+ platform: 'browser',
+});
const runtime = new Miniflare(
convertV4MiniflareOptions({
modules: true,
- scriptPath: new URL('../worker/site.mjs', import.meta.url).pathname,
+ script: siteBundle.outputFiles[0].text,
compatibilityDate: '2026-09-16',
bindings: { SITE_ORIGIN: env.SITE_ORIGIN },
serviceBindings: {
diff --git a/scripts/verify-seo.mjs b/scripts/verify-seo.mjs
new file mode 100644
index 0000000..f48e771
--- /dev/null
+++ b/scripts/verify-seo.mjs
@@ -0,0 +1,287 @@
+import assert from 'node:assert/strict';
+import console from 'node:console';
+import { readFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { URL } from 'node:url';
+import { load } from 'cheerio';
+import { canIndex, productionOrigin, serializeJsonLd } from '../worker/seo.mjs';
+
+const { Response } = globalThis;
+
+const require = createRequire(import.meta.url);
+const wranglerRequire = createRequire(require.resolve('wrangler/package.json'));
+const { Miniflare, convertV4MiniflareOptions } = wranglerRequire('miniflare');
+const { build } = wranglerRequire('esbuild');
+const bundle = await build({
+ entryPoints: [new URL('../worker/site.mjs', import.meta.url).pathname],
+ bundle: true,
+ write: false,
+ format: 'esm',
+ platform: 'browser',
+});
+const file = (path) =>
+ readFileSync(new URL(`../dist/${path}`, import.meta.url), 'utf8');
+const post = {
+ slug: 'synthetic-seo',
+ title: '
개발 글',
+ description: '합성 설명 & "따옴표"',
+ published_at: '2026-01-01T00:00:00.000Z',
+ updated_at: '2026-02-01T00:00:00.000Z',
+ thumbnail: 'https://example.com/article.png',
+ tags: [],
+ headings: [],
+ rendered_html: '합성 본문
',
+};
+const series = {
+ id: 'series',
+ name: '합성 시리즈',
+ slug: 'synthetic-series',
+ post_count: 1,
+ description: '시리즈 설명',
+ latest_at: post.published_at,
+};
+const tag = { name: 'Synthetic', post_count: 1 };
+let cmsStatus = 200;
+const assets = {
+ '/': 'index.html',
+ '/profile/': 'profile/index.html',
+ '/article-template/': 'article-template/index.html',
+ '/collection-template/': 'collection-template/index.html',
+ '/list-template/': 'list-template/index.html',
+ '/series/': 'series/index.html',
+ '/tags/': 'tags/index.html',
+ '/login/': 'login/index.html',
+};
+async function cms(request) {
+ if (cmsStatus !== 200) return new Response('{}', { status: cmsStatus });
+ const path = new URL(request.url).pathname;
+ if (path === '/posts/synthetic-seo') return Response.json(post);
+ if (path === '/posts')
+ return Response.json({ entries: [], nextCursor: null });
+ if (path === '/series') return Response.json({ entries: [series] });
+ if (path === '/tags') return Response.json({ entries: [tag] });
+ if (path === '/stats') return Response.json({ posts: 0 });
+ if (path === '/sitemap-entries') return Response.json({ entries: [post] });
+ return new Response('{}', { status: 404 });
+}
+const runtime = (origin, allowed) =>
+ new Miniflare(
+ convertV4MiniflareOptions({
+ modules: true,
+ script: bundle.outputFiles[0].text,
+ compatibilityDate: '2026-09-16',
+ bindings: {
+ CONTENT_READS: 'true',
+ SITE_ORIGIN: origin,
+ ALLOW_INDEXING: allowed,
+ },
+ serviceBindings: {
+ CMS: cms,
+ ASSETS: async (request) => {
+ const path = new URL(request.url).pathname;
+ return assets[path]
+ ? new Response(file(assets[path]), {
+ headers: { 'Content-Type': 'text/html' },
+ })
+ : new Response('Not found', { status: 404 });
+ },
+ },
+ })
+ );
+
+assert.equal(canIndex(productionOrigin, 'true'), true);
+assert.equal(canIndex(productionOrigin, 'false'), false);
+assert.equal(canIndex('https://preview.example', 'true'), false);
+assert.equal(
+ serializeJsonLd({ value: '' }).includes(''),
+ false
+);
+const builtHome = load(file('index.html'));
+const builtAllows = builtHome('meta[name="robots"]')
+ .attr('content')
+ .startsWith('index,');
+assert.match(
+ file('robots.txt'),
+ builtAllows
+ ? /Allow: \/\nSitemap: https:\/\/seungjun\.sh\/sitemap-index\.xml/
+ : /Disallow: \/\n/
+);
+if (builtAllows) {
+ assert.equal(
+ builtHome('link[rel="canonical"]').attr('href'),
+ productionOrigin + '/'
+ );
+ assert.equal(
+ JSON.parse(builtHome('script[data-seo]').text())['@graph'][0]['@type'],
+ 'WebSite'
+ );
+ assert.equal(
+ JSON.parse(load(file('profile/index.html'))('script[data-seo]').text())[
+ '@graph'
+ ][0]['@type'],
+ 'ProfilePage'
+ );
+}
+const staticSitemap = file('sitemap-0.xml');
+assert.doesNotMatch(
+ staticSitemap,
+ /\/(?:login|write|admin|article-template|collection-template|list-template|404)\//
+);
+
+const active = runtime(productionOrigin, 'true');
+const blocked = runtime(productionOrigin, 'false');
+const stageOrigin = 'https://devlog-site-stage.seungjun-jeong10.workers.dev';
+const stage = runtime(stageOrigin, 'true');
+try {
+ for (const [instance, origin, allowed] of [
+ [active, productionOrigin, true],
+ [blocked, productionOrigin, false],
+ [stage, stageOrigin, false],
+ [active, 'https://preview.example', false],
+ ]) {
+ const robots = await instance.dispatchFetch(origin + '/robots.txt');
+ assert.equal(robots.status, 200);
+ assert.match(
+ await robots.text(),
+ allowed ? /Allow: \/\nSitemap:/ : /Disallow: \/\n/
+ );
+ for (const path of [
+ '/',
+ '/blog/',
+ '/blog/synthetic-seo/',
+ '/series/',
+ '/series/synthetic-series/',
+ '/tags/',
+ '/tags/synthetic/',
+ '/profile/',
+ ]) {
+ const response = await instance.dispatchFetch(origin + path);
+ assert.equal(response.status, 200, path);
+ const $ = load(await response.text());
+ assert.equal(
+ $('meta[name="robots"]').attr('content'),
+ allowed ? 'index,follow,max-image-preview:large' : 'noindex,nofollow',
+ path
+ );
+ assert.equal($('link[rel="canonical"]').length, allowed ? 1 : 0, path);
+ assert.equal($('script[data-seo]').length, allowed ? 1 : 0, path);
+ if (allowed) {
+ assert.equal(
+ $('link[rel="canonical"]').attr('href'),
+ productionOrigin + path
+ );
+ const graph = JSON.parse($('script[data-seo]').text())['@graph'];
+ assert.ok(graph.length > 0);
+ if (path === '/blog/synthetic-seo/') {
+ const article = graph.find((item) => item['@type'] === 'BlogPosting');
+ assert.equal(article.headline, post.title);
+ assert.equal(article.datePublished, post.published_at);
+ assert.equal(article.dateModified, post.updated_at);
+ assert.deepEqual(article.image, [post.thumbnail]);
+ assert.equal(article.author.name, '정승준');
+ assert.equal($('title').text(), post.title + ' · seungjun.sh');
+ assert.equal(
+ $('img[onerror]').length,
+ 0,
+ 'Metadata must never become HTML'
+ );
+ assert.equal(
+ $('meta[name="description"]').attr('content'),
+ post.description
+ );
+ assert.equal(
+ $('meta[property="og:image"]').attr('content'),
+ post.thumbnail
+ );
+ assert.equal($('a[rel="author"]').attr('href'), '/profile/');
+ }
+ }
+ }
+ }
+ const login = await active.dispatchFetch(productionOrigin + '/login/');
+ const alias = load(
+ await (
+ await active.dispatchFetch(productionOrigin + '/blog/%73ynthetic-seo/')
+ ).text()
+ );
+ assert.equal(
+ alias('link[rel="canonical"]').attr('href'),
+ productionOrigin + '/blog/synthetic-seo/'
+ );
+ for (const path of ['/index.html', '/profile', '/profile/index.html']) {
+ const response = await stage.dispatchFetch(stageOrigin + path, {
+ redirect: 'manual',
+ });
+ assert.equal(response.status, 301);
+ assert.equal(new URL(response.headers.get('Location')).origin, stageOrigin);
+ }
+ assert.equal(
+ load(await login.text())('meta[name="robots"]').attr('content'),
+ 'noindex, nofollow'
+ );
+ assert.equal(login.headers.get('X-Frame-Options'), 'DENY');
+ assert.equal(
+ (
+ await active.dispatchFetch(productionOrigin + '/write/', {
+ redirect: 'manual',
+ })
+ ).status,
+ 302
+ );
+ for (const path of [
+ '/article-template/',
+ '/collection-template/',
+ '/list-template/',
+ ])
+ assert.equal(
+ (await active.dispatchFetch(productionOrigin + path)).status,
+ 404
+ );
+ const sitemap = await (
+ await active.dispatchFetch(productionOrigin + '/sitemap.xml')
+ ).text();
+ assert.match(sitemap, /synthetic-seo/);
+ assert.match(sitemap, /2026-02-01T00:00:00.000Z/);
+ assert.doesNotMatch(sitemap, /\/login\/|\/write\/|\/admin\/|template/);
+ const $xml = load(sitemap, { xml: true });
+ assert.equal(
+ $xml('url')
+ .filter((_, el) => $xml(el).find('loc').text().includes('/series/'))
+ .find('lastmod').length,
+ 0,
+ 'Do not present a publication date as a collection modification date'
+ );
+ post.thumbnail = null;
+ post.updated_at = null;
+ const withoutImage = load(
+ await (
+ await active.dispatchFetch(productionOrigin + '/blog/synthetic-seo/')
+ ).text()
+ );
+ const article = JSON.parse(withoutImage('script[data-seo]').text())[
+ '@graph'
+ ].find((item) => item['@type'] === 'BlogPosting');
+ assert.equal(article.image, undefined);
+ assert.equal(article.dateModified, undefined);
+ assert.equal(
+ withoutImage('meta[property="og:image"]').attr('content'),
+ productionOrigin + '/blog-og.png'
+ );
+ for (const status of [404, 500]) {
+ cmsStatus = status;
+ assert.equal(
+ (await active.dispatchFetch(productionOrigin + '/blog/synthetic-seo/'))
+ .status,
+ status === 404 ? 404 : 503
+ );
+ assert.equal(
+ (await active.dispatchFetch(productionOrigin + '/sitemap.xml')).status,
+ 503
+ );
+ }
+ console.log(
+ 'PASS: production SEO, stage/preview blocks, canonical/schema metadata, script escaping, author, sitemap and private/error routes'
+ );
+} finally {
+ await Promise.all([active.dispose(), blocked.dispose(), stage.dispose()]);
+}
diff --git a/src/layouts/ArticleLayout.astro b/src/layouts/ArticleLayout.astro
index 1a00a3f..a593ee4 100644
--- a/src/layouts/ArticleLayout.astro
+++ b/src/layouts/ArticleLayout.astro
@@ -37,6 +37,7 @@ const mine = tagsFor(post);
seriesId={group?.id}
postId={post.id}
publishedAt={post.data.publishedAt}
+ updatedAt={post.data.updatedAt}
image={imageUrl(post.data.thumbnail)}
>
@@ -57,6 +58,9 @@ const mine = tagsFor(post);
)}
+
+ 정승준
+