From 50fa1ff9818c97a750433b99fab76c1710fa4413 Mon Sep 17 00:00:00 2001 From: Sanskar Date: Sat, 7 Nov 2026 20:12:41 +0530 Subject: [PATCH] refactor(site): consolidate SEO, sitemap and live checks under Scarp routing --- SCARP-DOCS-ENGINE.md | 2 ++ docs/.vitepress/config.mts | 2 +- docs/.vitepress/site-meta.ts | 53 ----------------------------------- scripts/check-site-build.ts | 12 ++++++-- scripts/lib/live-site.ts | 13 +++++---- scripts/lib/sitemap-policy.ts | 2 +- scripts/scarp-site/build.ts | 8 ++---- scripts/scarp-site/routes.ts | 5 ++++ scripts/scarp-site/seo.ts | 24 ++++++++++++++++ scripts/scarp-site/test.ts | 2 +- scripts/test-live-site.ts | 10 +++---- scripts/test-site-meta.ts | 11 ++++++-- scripts/test-sitemap.ts | 4 +-- tsconfig.json | 1 - 14 files changed, 68 insertions(+), 81 deletions(-) delete mode 100644 docs/.vitepress/site-meta.ts create mode 100644 scripts/scarp-site/seo.ts diff --git a/SCARP-DOCS-ENGINE.md b/SCARP-DOCS-ENGINE.md index a582241e..be8262f8 100644 --- a/SCARP-DOCS-ENGINE.md +++ b/SCARP-DOCS-ENGINE.md @@ -47,4 +47,6 @@ The renderer escapes unreviewed raw Markdown HTML. It explicitly recognizes only Source validators now resolve every Markdown link, English/Hindi partner, frontmatter action and published release from the **independent Scarp route and release contracts**; they do not read the obsolete VitePress navigation configuration. Full generated-site, sitemap and browser checks remain separate gates. This is one more prerequisite for deleting the unused legacy website sources. +The independently authored SEO module is now the single source of canonical/hreflang/Open Graph tags for generated pages and legacy compatibility. Sitemap, live Pages smoke tests and build checks resolve all URLs through the Scarp-owned route contract; smoke tests also cover the latest published compiler release in both languages. The obsolete VitePress site-meta helper is no longer needed. + The Scarp-owned renderer is now the production-build candidate. The VitePress and vue-tsc npm dependencies have been removed; old theme/config source fixtures and some evidence checks remain temporarily to protect compiler and bilingual regression coverage. None of these remaining files render or serve Pages. Remove them after live Pages, offline archive, and release CI have independently confirmed the new output. Track completion in Issue #70. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e2ac8ba4..b5b96805 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,5 +1,5 @@ import { defineConfig } from 'vitepress' -import { pageSeoHead } from './site-meta' +import { pageSeoHead } from '../../scripts/scarp-site/seo' import { localSearchOptions } from './local-search' /** Scarp source-language grammar; deliberately limited to implemented syntax. */ diff --git a/docs/.vitepress/site-meta.ts b/docs/.vitepress/site-meta.ts deleted file mode 100644 index 9a2f6670..00000000 --- a/docs/.vitepress/site-meta.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Pure, side-effect-free URL helpers for Scarp Docs' paired English/Hindi pages. - * The same helpers power generated page metadata and post-build assertions. - */ -export const SITE_ORIGIN = 'https://scarp-lang.github.io' as const -export const DOCS_BASE = '/scarp-docs/' as const - -export type DocsLocale = 'en' | 'hi' -export interface PairedUrls { - canonical: string - english: string - hindi: string - locale: DocsLocale -} - -const isPage = (path: string): boolean => - path.endsWith('.md') && !path.startsWith('/') && - !/[\\?#:]/.test(path) && !path.split('/').some(segment => segment === '..' || segment === '.') - -/** Map a source Markdown path to the public clean URL, without .md/.html. */ -export function urlForPage(relativePath: string): string { - if (!isPage(relativePath)) throw new TypeError(`Invalid documentation source path: ${relativePath}`) - const route = relativePath.replace(/\.md$/, '').replace(/(^|\/)index$/, '$1') - const encoded = route.split('/').filter(Boolean).map(encodeURIComponent).join('/') - return `${SITE_ORIGIN}${DOCS_BASE}${encoded}${route.endsWith('/') && encoded ? '/' : ''}` -} - -/** Both locales have matching paths, enforced by npm run check:i18n. */ -export function pairedUrls(relativePath: string): PairedUrls { - const isHindi = relativePath.startsWith('hi/') - const englishPath = isHindi ? relativePath.slice(3) : relativePath - const hindiPath = `hi/${englishPath}` - return { - canonical: urlForPage(relativePath), - english: urlForPage(englishPath), - hindi: urlForPage(hindiPath), - locale: isHindi ? 'hi' : 'en' - } -} - -/** VitePress HeadConfig tuple; explicit attributes preserve SEO language semantics. */ -export function pageSeoHead(relativePath: string): Array<[string, Record]> { - if (relativePath === '404.md') return [] - const { canonical, english, hindi, locale } = pairedUrls(relativePath) - return [ - ['link', { rel: 'canonical', href: canonical }], - ['link', { rel: 'alternate', hreflang: 'en', href: english }], - ['link', { rel: 'alternate', hreflang: 'hi', href: hindi }], - ['link', { rel: 'alternate', hreflang: 'x-default', href: english }], - ['meta', { property: 'og:url', content: canonical }], - ['meta', { property: 'og:locale', content: locale === 'hi' ? 'hi_IN' : 'en_US' }] - ] -} diff --git a/scripts/check-site-build.ts b/scripts/check-site-build.ts index c0d0406d..ad705b46 100644 --- a/scripts/check-site-build.ts +++ b/scripts/check-site-build.ts @@ -2,7 +2,8 @@ import assert from 'node:assert/strict' import { existsSync, readFileSync } from 'node:fs' import { join, resolve, relative } from 'node:path' import { fileURLToPath } from 'node:url' -import { pairedUrls } from '../docs/.vitepress/site-meta.ts' +import { pairedRoutes } from './scarp-site/routes.ts' +import { pageSeoHead } from './scarp-site/seo.ts' import { documentationPages } from './lib/docs-pages.ts' import { validateSitemap } from './lib/sitemap-policy.ts' @@ -24,7 +25,7 @@ for (const path of pages) { const rendered = join(dist, path.replace(/\.md$/, '.html')) if (!existsSync(rendered)) { failures.push(`${path}: no generated HTML at ${relative(root, rendered)}`); continue } const html = readFileSync(rendered, 'utf8') - const expected = pairedUrls(path) + const expected = pairedRoutes(path) const links = headTags(html, 'link') const canonical = links.filter(attrs => attrs.rel === 'canonical') if (canonical.length !== 1 || canonical[0].href !== expected.canonical) { @@ -36,7 +37,12 @@ for (const path of pages) { } } const meta = headTags(html, 'meta') - if (!meta.some(attrs => attrs.property === 'og:url' && attrs.content === expected.canonical)) failures.push(`${path}: missing og:url`) + for (const [tag, attrs] of pageSeoHead(path)) { + const actual = tag === 'link' ? links : meta + if (actual.filter(found => Object.entries(attrs).every(([key,value]) => found[key] === value)).length !== 1) { + failures.push(`${path}: missing/duplicate ${tag} SEO metadata ${JSON.stringify(attrs)}`) + } + } const lang = path.startsWith('hi/') ? 'hi-IN' : 'en-US' if (!new RegExp(`]*\\blang="${lang}"`, 'i').test(html)) failures.push(`${path}: expected `) checked++ diff --git a/scripts/lib/live-site.ts b/scripts/lib/live-site.ts index e327d80e..5388b8a5 100644 --- a/scripts/lib/live-site.ts +++ b/scripts/lib/live-site.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' -import { DOCS_BASE, SITE_ORIGIN, pairedUrls, urlForPage } from '../../docs/.vitepress/site-meta.ts' +import { BASE, ORIGIN, pairedRoutes, urlForPage } from '../scarp-site/routes.ts' import { validateSitemap } from './sitemap-policy.ts' +import { LATEST_RELEASE } from './command-finder.ts' /** * Exercises real public GitHub Pages URLs after deployment, not just the @@ -16,6 +17,8 @@ export const LIVE_PAGE_SOURCES = [ 'hi/guide/installation-alpha6.md', 'releases/v0.0.6-alpha.1.md', 'hi/releases/v0.0.6-alpha.1.md', + `releases/${LATEST_RELEASE}.md`, + `hi/releases/${LATEST_RELEASE}.md`, 'tutorials/project-symbol-navigation-alpha6.md', 'hi/tutorials/project-symbol-navigation-alpha6.md', 'community/social.md', @@ -43,7 +46,7 @@ async function request(fetcher: SiteFetcher, url: string): Promise { const response = await fetcher(url) assert.equal(response.status, 200, url + ': expected HTTP 200, got ' + response.status) // Do not accept a successful redirect to an unrelated host. - if (response.url) assert.equal(new URL(response.url).origin, SITE_ORIGIN, url + ': unexpected redirect origin') + if (response.url) assert.equal(new URL(response.url).origin, ORIGIN, url + ': unexpected redirect origin') return response } @@ -54,7 +57,7 @@ export async function verifyLiveSite(fetcher: SiteFetcher, sitemapSources: reado const response = await request(fetcher, url) assert.match(response.headers.get('content-type') ?? '', /text\/html/i, url + ': expected HTML') const html = await response.text() - const expected = pairedUrls(source) + const expected = pairedRoutes(source) const lang = source.startsWith('hi/') ? 'hi-IN' : 'en-US' assert.match(html, new RegExp(']*\\blang="' + lang + '"', 'i'), url + ': wrong HTML locale') const links = tags(html, 'link') @@ -74,9 +77,9 @@ export async function verifyLiveSite(fetcher: SiteFetcher, sitemapSources: reado const styles = assets.filter(path => path.endsWith('.css')) assert.ok(scripts.length, 'English homepage is missing its versioned JavaScript bundle') assert.ok(styles.length, 'English homepage is missing its versioned CSS bundle') - for (const asset of [scripts[0], styles[0]]) await request(fetcher, SITE_ORIGIN + asset) + for (const asset of [scripts[0], styles[0]]) await request(fetcher, ORIGIN + asset) - const sitemap = SITE_ORIGIN + DOCS_BASE + 'sitemap.xml' + const sitemap = ORIGIN + BASE + 'sitemap.xml' const sitemapResponse = await request(fetcher, sitemap) const xml = await sitemapResponse.text() const checked = validateSitemap(xml, sitemapSources) diff --git a/scripts/lib/sitemap-policy.ts b/scripts/lib/sitemap-policy.ts index f8d84605..c60cc3ea 100644 --- a/scripts/lib/sitemap-policy.ts +++ b/scripts/lib/sitemap-policy.ts @@ -1,4 +1,4 @@ -import { urlForPage } from '../../docs/.vitepress/site-meta.ts' +import { urlForPage } from '../scarp-site/routes.ts' /** * Validate the complete generated sitemap against all English/Hindi source diff --git a/scripts/scarp-site/build.ts b/scripts/scarp-site/build.ts index d84a043c..831c88f5 100644 --- a/scripts/scarp-site/build.ts +++ b/scripts/scarp-site/build.ts @@ -8,6 +8,7 @@ import { commandAdvice, FINDER_TEXT, RELEASES, TASKS } from '../lib/command-find import { escapeHtml, localLink, htmlPath, pairedRoutes, BASE } from './routes.ts' import { renderMarkdown, type RenderedPage, type HomeAction, type HomeFeature } from './markdown.ts' import { currentPublishedCompilerTag, currentCompilerReleaseRoute } from './current-release.ts' +import { renderSeoHead } from './seo.ts' const root=resolve(fileURLToPath(new URL('../..',import.meta.url))) const docsRoot=join(root,'docs') @@ -117,12 +118,7 @@ function htmlDocument(view:PageView, views:PageView[], currentTag:string):string ''+ ''+ ''+escapeHtml(title)+''+ - ''+ - ''+ - ''+ - ''+ - ''+ - ''+ + renderSeoHead(source)+ ''+ ''+ ''+ diff --git a/scripts/scarp-site/routes.ts b/scripts/scarp-site/routes.ts index c44aa23d..052e6026 100644 --- a/scripts/scarp-site/routes.ts +++ b/scripts/scarp-site/routes.ts @@ -28,6 +28,11 @@ export function htmlPath(source: string): string { return source.slice(0, -3) + '.html' } +/** The independent renderer, sitemap and live-site checks share a single URL contract. */ +export function urlForPage(source: string): string { + return ORIGIN + sourceRoute(source) +} + export function pairedRoutes(source: string): { locale: Locale; route: string; canonical: string; english: string; hindi: string } { diff --git a/scripts/scarp-site/seo.ts b/scripts/scarp-site/seo.ts new file mode 100644 index 00000000..d79fd9c9 --- /dev/null +++ b/scripts/scarp-site/seo.ts @@ -0,0 +1,24 @@ +import { escapeHtml, pairedRoutes } from './routes.ts' + +/** Framework-independent metadata. Do not infer the locale from rendered text. */ +export function pageSeoHead(source: string): Array<[string, Record]> { + if (source === '404.md') return [] + const { canonical, english, hindi, locale } = pairedRoutes(source) + return [ + ['link', { rel: 'canonical', href: canonical }], + ['link', { rel: 'alternate', hreflang: 'en', href: english }], + ['link', { rel: 'alternate', hreflang: 'hi', href: hindi }], + ['link', { rel: 'alternate', hreflang: 'x-default', href: english }], + ['meta', { property: 'og:url', content: canonical }], + ['meta', { property: 'og:locale', content: locale === 'hi' ? 'hi_IN' : 'en_US' }] + ] +} + +/** Escapes all attributes instead of joining untrusted URLs into HTML. */ +export function renderSeoHead(source: string): string { + return pageSeoHead(source).map(([tag, attrs]) => + '<' + tag + ' ' + Object.entries(attrs) + .map(([key, value]) => key + '="' + escapeHtml(value) + '"') + .join(' ') + '/>' + ).join('') +} diff --git a/scripts/scarp-site/test.ts b/scripts/scarp-site/test.ts index c7816a03..7439d3eb 100644 --- a/scripts/scarp-site/test.ts +++ b/scripts/scarp-site/test.ts @@ -6,7 +6,7 @@ import { buildSite, previewRoot } from './build.ts' import { renderMarkdown, splitFrontMatter } from './markdown.ts' import { escapeHtml, localLink, htmlPath, pairedRoutes, sourceRoute } from './routes.ts' import { documentationPages } from '../lib/docs-pages.ts' -import { urlForPage } from '../../docs/.vitepress/site-meta.ts' +import { urlForPage } from './routes.ts' import { validateSitemap } from '../lib/sitemap-policy.ts' import { LATEST_RELEASE } from '../lib/command-finder.ts' import { currentPublishedCompilerTag, currentCompilerReleaseRoute } from './current-release.ts' diff --git a/scripts/test-live-site.ts b/scripts/test-live-site.ts index 8dd4063e..00a8c2c6 100644 --- a/scripts/test-live-site.ts +++ b/scripts/test-live-site.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict' -import { DOCS_BASE, SITE_ORIGIN, pairedUrls, urlForPage } from '../docs/.vitepress/site-meta.ts' +import { BASE, ORIGIN, pairedRoutes, urlForPage } from './scarp-site/routes.ts' import { LIVE_PAGE_SOURCES, verifyLiveSite, type SiteFetcher } from './lib/live-site.ts' const htmlRouteByUrl = new Map(LIVE_PAGE_SOURCES.map(source => [urlForPage(source), source])) function fakeSite(options: { wrongCanonical?: boolean; missingHindi?: boolean; missingInstaller?: boolean; missingCss?: boolean; missingSitemapPage?: boolean; duplicateSitemapPage?: boolean } = {}): SiteFetcher { return async url => { - if (url === SITE_ORIGIN + DOCS_BASE + 'sitemap.xml') { + if (url === ORIGIN + BASE + 'sitemap.xml') { const sourcePages = options.missingSitemapPage ? LIVE_PAGE_SOURCES.filter(source => source !== 'hi/community/social.md') : [...LIVE_PAGE_SOURCES] @@ -18,7 +18,7 @@ function fakeSite(options: { wrongCanonical?: boolean; missingHindi?: boolean; m { status: 200, headers: { 'content-type': 'application/xml' } } ) } - if (url.startsWith(SITE_ORIGIN + DOCS_BASE + 'assets/')) { + if (url.startsWith(ORIGIN + BASE + 'assets/')) { return new Response('asset', { status: 200, headers: { 'content-type': 'application/octet-stream' } }) } const source = htmlRouteByUrl.get(url) @@ -29,7 +29,7 @@ function fakeSite(options: { wrongCanonical?: boolean; missingHindi?: boolean; m if (options.missingInstaller && source === 'hi/guide/installation-alpha6.md') { return new Response('Not found', { status: 404 }) } - const links = pairedUrls(source) + const links = pairedRoutes(source) const lang = source.startsWith('hi/') ? 'hi-IN' : 'en-US' const canonical = options.wrongCanonical && source === 'guide/learning-path.md' ? links.hindi : links.canonical @@ -50,7 +50,7 @@ function fakeSite(options: { wrongCanonical?: boolean; missingHindi?: boolean; m const healthy = await verifyLiveSite(fakeSite(), LIVE_PAGE_SOURCES) assert.equal(healthy.pages, LIVE_PAGE_SOURCES.length) assert.equal(healthy.assets, 2) -assert.equal(healthy.sitemap, SITE_ORIGIN + DOCS_BASE + 'sitemap.xml') +assert.equal(healthy.sitemap, ORIGIN + BASE + 'sitemap.xml') assert.equal(healthy.sitemapPages, LIVE_PAGE_SOURCES.length) await assert.rejects(() => verifyLiveSite(fakeSite({ wrongCanonical: true }), LIVE_PAGE_SOURCES), /canonical mismatch/) await assert.rejects(() => verifyLiveSite(fakeSite({ missingHindi: true }), LIVE_PAGE_SOURCES), /HTTP 200, got 404/) diff --git a/scripts/test-site-meta.ts b/scripts/test-site-meta.ts index ac5e6239..364f5e9b 100644 --- a/scripts/test-site-meta.ts +++ b/scripts/test-site-meta.ts @@ -1,8 +1,9 @@ import assert from 'node:assert/strict' -import { pairedUrls, pageSeoHead, urlForPage } from '../docs/.vitepress/site-meta.ts' +import { pairedRoutes, urlForPage } from './scarp-site/routes.ts' +import { pageSeoHead, renderSeoHead } from './scarp-site/seo.ts' -const en = pairedUrls('guide/first-program.md') -const hi = pairedUrls('hi/guide/first-program.md') +const en = pairedRoutes('guide/first-program.md') +const hi = pairedRoutes('hi/guide/first-program.md') assert.equal(en.english, 'https://scarp-lang.github.io/scarp-docs/guide/first-program') assert.equal(en.hindi, 'https://scarp-lang.github.io/scarp-docs/hi/guide/first-program') assert.equal(en.canonical, en.english) @@ -23,4 +24,8 @@ assert.deepEqual(enHead.filter(([tag, props]) => tag === 'link' && props.rel === assert.deepEqual(hiHead.filter(([tag, props]) => tag === 'link' && props.rel === 'alternate').map(([, props]) => props.hreflang), ['en', 'hi', 'x-default']) assert.equal(hiHead.find(([tag, props]) => tag === 'meta' && props.property === 'og:locale')?.[1].content, 'hi_IN') assert.equal(pageSeoHead('404.md').length, 0) +assert.match(renderSeoHead('hi/index.md'), //) +assert.match(renderSeoHead('guide/first-program.md'), //) +assert.ok(!renderSeoHead('hi/index.md').includes(' index === indexEn ? url.replace('scarp-lang.github.io', 'example.invalid') : url)), /Unexpected sitemap URL/) -mustReject('wrong VitePress base', +mustReject('wrong GitHub Pages base', sitemap(urls.map((url, index) => index === indexHi ? url.replace('/scarp-docs/', '/other-docs/') : url)), /Unexpected sitemap URL/) mustReject('HTML extension', diff --git a/tsconfig.json b/tsconfig.json index 454077fb..c146fdaa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,6 @@ }, "include": [ "scripts/**/*.ts", - "docs/.vitepress/site-meta.ts", "docs/.vitepress/local-search.ts", "docs/.vitepress/theme/lib/**/*.ts" ]