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);

)}