From 3bc8cea40d11901c7fcd5b0ce2c29be62d062186 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Fri, 12 Jun 2026 12:18:00 +0200 Subject: [PATCH 01/26] feat(seo): core SEO model, resolver, JSON-LD, robots generation New @core/seo engine module: persisted schemas (SeoMetadata, SiteSeoSettings), two-stage title resolver shared by publisher/admin/server, JSON-LD builders (WebSite/Organization/Article/BreadcrumbList) with safe serialization, robots.txt generation with AI-crawler group controls, health indicators, and pixel-width length meters. Co-Authored-By: Claude Fable 5 --- .../no-core-barrel-deep-imports.test.ts | 2 + src/core/seo/__tests__/health.test.ts | 42 ++++ src/core/seo/__tests__/jsonLd.test.ts | 117 +++++++++++ src/core/seo/__tests__/resolve.test.ts | 174 ++++++++++++++++ src/core/seo/__tests__/robots.test.ts | 51 +++++ src/core/seo/aiCrawlers.ts | 26 +++ src/core/seo/health.ts | 62 ++++++ src/core/seo/index.ts | 59 ++++++ src/core/seo/jsonLd.ts | 121 +++++++++++ src/core/seo/lengthMeter.ts | 50 +++++ src/core/seo/resolve.ts | 196 ++++++++++++++++++ src/core/seo/robots.ts | 41 ++++ src/core/seo/schema.ts | 124 +++++++++++ 13 files changed, 1065 insertions(+) create mode 100644 src/core/seo/__tests__/health.test.ts create mode 100644 src/core/seo/__tests__/jsonLd.test.ts create mode 100644 src/core/seo/__tests__/resolve.test.ts create mode 100644 src/core/seo/__tests__/robots.test.ts create mode 100644 src/core/seo/aiCrawlers.ts create mode 100644 src/core/seo/health.ts create mode 100644 src/core/seo/index.ts create mode 100644 src/core/seo/jsonLd.ts create mode 100644 src/core/seo/lengthMeter.ts create mode 100644 src/core/seo/resolve.ts create mode 100644 src/core/seo/robots.ts create mode 100644 src/core/seo/schema.ts diff --git a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts index 6ee37600c..8cf047c6d 100644 --- a/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts +++ b/src/__tests__/architecture/no-core-barrel-deep-imports.test.ts @@ -10,6 +10,7 @@ * - `@core/framework` * - `@core/framework-schema` * - `@core/fonts` + * - `@core/seo` * * Per the barrel convention (CLAUDE.md → "Barrel imports"): everything OUTSIDE * a module imports through its barrel; files INSIDE the module import each @@ -36,6 +37,7 @@ const BARRELLED_MODULES = [ 'framework', 'framework-schema', 'fonts', + 'seo', ] // Scan production + test sources in both the app and the server. diff --git a/src/core/seo/__tests__/health.test.ts b/src/core/seo/__tests__/health.test.ts new file mode 100644 index 000000000..89b3bc542 --- /dev/null +++ b/src/core/seo/__tests__/health.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test' +import { computeSeoHealth } from '../health' +import { resolveSeoMetadata } from '../resolve' +import { approxPixelWidth, meterZone, TITLE_PIXEL_BUDGET } from '../lengthMeter' + +const BASE = { siteName: 'Acme', routeKind: 'page' as const, routePath: '/about' } + +describe('computeSeoHealth', () => { + test('healthy target has zero issues', () => { + const target = { + title: 'Nice short title', + description: 'A reasonable description for the page.', + ogImage: '/img.png', + ogImageAlt: 'An image', + } + const health = computeSeoHealth(target, resolveSeoMetadata({ ...BASE, target })) + expect(health).toEqual({ + title: 'ok', + description: 'ok', + image: 'ok', + indexable: true, + issueCount: 0, + }) + }) + + test('flags missing description, image, alt, and noindex', () => { + const target = { title: 'T', noindex: true, ogImage: '/img.png' } + const health = computeSeoHealth(target, resolveSeoMetadata({ ...BASE, target })) + expect(health.description).toBe('missing') + expect(health.image).toBe('missingAlt') + expect(health.indexable).toBe(false) + expect(health.issueCount).toBe(3) + }) + + test('flags over-budget title as long', () => { + const longTitle = 'Wide MMMM Words '.repeat(8) + expect(meterZone(approxPixelWidth(longTitle), TITLE_PIXEL_BUDGET)).toBe('over') + const target = { title: longTitle } + const health = computeSeoHealth(target, resolveSeoMetadata({ ...BASE, target })) + expect(health.title).toBe('long') + }) +}) diff --git a/src/core/seo/__tests__/jsonLd.test.ts b/src/core/seo/__tests__/jsonLd.test.ts new file mode 100644 index 000000000..d5beda730 --- /dev/null +++ b/src/core/seo/__tests__/jsonLd.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test' +import { buildJsonLdEntities, serializeJsonLd } from '../jsonLd' +import { resolveSeoMetadata } from '../resolve' + +const ORIGIN = 'https://acme.com' + +function resolvedFor(routePath: string, kind: 'page' | 'row', extra = {}) { + return resolveSeoMetadata({ + siteName: 'Acme', + routeKind: kind, + routePath, + origin: ORIGIN, + baseTitle: 'Hello', + ...extra, + }) +} + +describe('buildJsonLdEntities', () => { + test('homepage emits WebSite, and Organization when configured', () => { + const entities = buildJsonLdEntities(resolvedFor('/', 'page'), { + kind: 'page', + routePath: '/', + origin: ORIGIN, + siteName: 'Acme', + organization: { name: 'Acme Inc', logoUrl: 'https://acme.com/logo.png' }, + }) + const types = entities.map((e) => e['@type']) + expect(types).toContain('WebSite') + expect(types).toContain('Organization') + const org = entities.find((e) => e['@type'] === 'Organization')! + expect(org.logo).toBe('https://acme.com/logo.png') + }) + + test('homepage without organization emits WebSite only', () => { + const entities = buildJsonLdEntities(resolvedFor('/', 'page'), { + kind: 'page', + routePath: '/', + origin: ORIGIN, + siteName: 'Acme', + }) + expect(entities.map((e) => e['@type'])).toEqual(['WebSite']) + }) + + test('row routes emit Article with dates and BreadcrumbList when deep', () => { + const resolved = resolvedFor('/posts/hello', 'row', { + publishedAt: '2026-06-01T00:00:00Z', + updatedAt: '2026-06-02T00:00:00Z', + }) + const entities = buildJsonLdEntities(resolved, { + kind: 'row', + routePath: '/posts/hello', + origin: ORIGIN, + siteName: 'Acme', + }) + const types = entities.map((e) => e['@type']) + expect(types).toContain('Article') + expect(types).toContain('BreadcrumbList') + const article = entities.find((e) => e['@type'] === 'Article')! + expect(article.headline).toBe('Hello') + expect(article.datePublished).toBe('2026-06-01T00:00:00Z') + expect(article.dateModified).toBe('2026-06-02T00:00:00Z') + const breadcrumbs = entities.find((e) => e['@type'] === 'BreadcrumbList')! + const items = breadcrumbs.itemListElement as { item: string; position: number }[] + expect(items).toHaveLength(3) + expect(items[2]!.item).toBe('https://acme.com/posts/hello') + }) + + test('single-segment page emits no breadcrumbs', () => { + const entities = buildJsonLdEntities(resolvedFor('/about', 'page'), { + kind: 'page', + routePath: '/about', + origin: ORIGIN, + siteName: 'Acme', + }) + expect(entities.map((e) => e['@type'])).not.toContain('BreadcrumbList') + }) + + test('noindex targets emit nothing', () => { + const resolved = resolvedFor('/posts/hello', 'row', { target: { noindex: true } }) + expect( + buildJsonLdEntities(resolved, { + kind: 'row', + routePath: '/posts/hello', + origin: ORIGIN, + siteName: 'Acme', + }), + ).toEqual([]) + }) + + test('origin-dependent entities are omitted without an origin', () => { + const resolved = resolveSeoMetadata({ + siteName: 'Acme', + routeKind: 'page', + routePath: '/', + baseTitle: 'Home', + }) + const entities = buildJsonLdEntities(resolved, { + kind: 'page', + routePath: '/', + siteName: 'Acme', + organization: { name: 'Acme Inc' }, + }) + expect(entities).toEqual([]) + }) +}) + +describe('serializeJsonLd', () => { + test('escapes script terminators and comment openers', () => { + const out = serializeJsonLd({ name: 'x', note: '