From 8e3667194418db1a7be35265a2d6eed2ca55df88 Mon Sep 17 00:00:00 2001 From: Speculator55005 <50082482+fas89@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:32:33 +0200 Subject: [PATCH] fix: every page claimed to be the homepage, and two Level A failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **211 of 213 pages emitted an identical og:url pointing at the homepage, and none carried rel="canonical".** `hostname` was passed to the standalone sitemapPlugin but never to `defaultTheme()`, and the theme gates BOTH its bundled SEO and sitemap plugins on exactly that value — so the SEO plugin never registered and the only OG tags were a hand-written site-wide block. Two things the audit had wrong, found by reading the installed packages: - `hostname` alone gives per-page `og:*` but **zero** canonicals. `canonical` is a separate option (`getCanonicalLink` returns null without it). - `hostname` must be the bare ORIGIN. All three call sites concatenate `hostname + base`, which is what produced the doubled `/forge_docs/forge_docs/sitemap.xml` in robots.txt. The `` entries were never affected, because they are written root-relative and the URL resolver discards the hostname's path — verified by diffing all 212 before and after. The manual sitemapPlugin registration is dropped: the theme registers it with the same single option, and running both meant two competing `onGenerated` hooks rewriting robots.txt. | | before | after | |---|---|---| | pages with `rel="canonical"` | 0 | 213 | | distinct `og:url` values | 1 | 213 | | robots.txt sitemap | 404 | resolves | **Skip link**, WCAG 2.4.1 Level A: 324 focusable stops preceded `
`, because the whole sidebar renders on every interior page. The pill is hidden by translation rather than `display:none` so it stays in the tab order, and the handler focuses the target explicitly so it works after router hydration and not only on a cold fragment navigation. **Brand link accessible name**, WCAG 4.1.2. The theme hides the `.vp-site-name` span whenever `logoAlt` resolves equal to the title — which is what `logoAlt ?? title` gives when no logo is configured, and this site deliberately configures none. The anchor therefore had no unhidden child and an empty accessible name on every page. An explicit `logoAlt` breaks that equality. This one fell between two agents' ownership: the accessibility work correctly identified it as a config.ts change and reported the exact diff, and the config owner never applied it. Caught by checking the built HTML rather than the reports. **Contrast**: `--vp-c-text-subtle` on `--vp-c-bg-alt` was 4.25:1 in light mode, below the 4.50 AA floor and the only sub-AA pair on the site. Now 4.77:1. **Favicon**: 69,734 bytes at 256x139, fetched on every page load, now 2,181 bytes at 32x32. The first regeneration preserved the logo's aspect ratio and produced a 32x17 icon — square now, caught by checking the file rather than the byte count. **Search index**: the main JS chunk drops from 128K to 79K gzipped. Monaco's CSS is NOT addressed here and the mechanism is worth recording: `@vuepress/bundler-vite` hard-sets `cssCodeSplit: false`, so Vite merges styles reachable only through an async-only chunk into the one render-blocking stylesheet. Monaco is ~162 KB of it, 65.9%. The fix is either overriding that Vite option — which VuePress disables deliberately for SSG style ordering — or loading Monaco's CSS at runtime. Both are larger than this change should carry. --- docs/.vuepress/client.ts | 14 ++- docs/.vuepress/config.ts | 154 ++++++++++++++++++++++----- docs/.vuepress/layouts/Layout.vue | 80 ++++++++++++++ docs/.vuepress/layouts/NotFound.vue | 4 +- docs/.vuepress/public/favicon-32.png | Bin 0 -> 2181 bytes docs/.vuepress/styles/index.scss | 50 +++++++++ docs/.vuepress/styles/palette.scss | 10 +- package-lock.json | 45 +++++--- package.json | 3 +- 9 files changed, 308 insertions(+), 52 deletions(-) create mode 100644 docs/.vuepress/layouts/Layout.vue create mode 100644 docs/.vuepress/public/favicon-32.png diff --git a/docs/.vuepress/client.ts b/docs/.vuepress/client.ts index 57b57c2..fde840c 100644 --- a/docs/.vuepress/client.ts +++ b/docs/.vuepress/client.ts @@ -12,15 +12,22 @@ // Every other doc page stays lean. ClientOnly avoids SSR-time errors // (Monaco assumes `window`/`document` exist). // -// Also overrides the default theme's `NotFound` layout with the branded -// 404 in layouts/. Layout names from this site-level client config win -// over the theme's because it is merged last. +// Also overrides two theme layouts, both from layouts/. Layout names +// from this site-level client config win over the theme's because it is +// merged last. +// +// Layout — the theme's own layout, wrapped so that a skip-to-content +// link is the first focusable element in the document and +// the
landmark carries the id it points at +// (WCAG 2.1 SC 2.4.1, Bypass Blocks, Level A). +// NotFound — the branded 404. // // Picked up at runtime via the `clientConfigFile` setting in config.ts. // ===================================================================== import { defineClientConfig } from 'vuepress/client' import { defineAsyncComponent } from 'vue' +import Layout from './layouts/Layout.vue' import NotFound from './layouts/NotFound.vue' // CliCast and Playground both async-load: CliCast carries scoped SCSS @@ -41,6 +48,7 @@ export default defineClientConfig({ app.component('Playground', Playground) }, layouts: { + Layout, NotFound, }, }) diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index bb93ee5..bc3e7e8 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -1,20 +1,39 @@ import { defineUserConfig } from 'vuepress' import { defaultTheme } from '@vuepress/theme-default' import { viteBundler } from '@vuepress/bundler-vite' -import { searchPlugin } from '@vuepress/plugin-search' -import { sitemapPlugin } from '@vuepress/plugin-sitemap' +import { slimsearchPlugin } from '@vuepress/plugin-slimsearch' import { markdownChartPlugin } from '@vuepress/plugin-markdown-chart' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' const __dirname = dirname(fileURLToPath(import.meta.url)) +// Deployment origin and base, kept as separate values on purpose. +// +// `hostname` must be the BARE ORIGIN, with no base path in it. Both +// @vuepress/plugin-seo and @vuepress/plugin-sitemap build absolute URLs as +// `hostname + base + path` (seo's getUrl; sitemap's robots.txt line is +// `Sitemap: ${hostname}${base}${sitemapFilename}`), so a hostname that already +// carries `/forge_docs/` doubles the base. That is what produced the 404ing +// `.../forge_docs/forge_docs/sitemap.xml` in robots.txt. +// +// The sitemap's entries were correct even with the doubled hostname, +// because the `sitemap` package resolves the root-relative `/forge_docs/x.html` +// against the origin and discards the hostname's own path. They stay correct +// with the bare origin, which is the same resolution with nothing to discard. +const HOSTNAME = 'https://agenticstiger.github.io' +const BASE = '/forge_docs/' + +// Origin + base with no trailing slash. plugin-seo builds each canonical as +// `removeEndingSlash(canonical) + page.path`, and page.path excludes the base. +const CANONICAL_ROOT = `${HOSTNAME}${BASE.replace(/\/$/, '')}` + export default defineUserConfig({ lang: 'en-US', title: 'Fluid Forge', description: 'Declarative data products for local and multi-cloud delivery with a contract-first CLI.', - base: '/forge_docs/', + base: BASE, // Loads client.ts so is registered globally for markdown pages // and the branded NotFound layout overrides the theme's default 404. @@ -26,41 +45,84 @@ export default defineUserConfig({ shouldPreload: false, head: [ - ['link', { rel: 'icon', href: '/forge_docs/logo.png' }], + // Favicon. This was `logo.png`, which is 69,734 bytes at 256x139 — a + // full wordmark lockup shipped to every visitor on all 213 pages to + // fill a 16px tab slot, and referenced from nowhere else in the site. + // + // favicon-32.png is that same file scaled down, nothing else: + // sips -Z 32 docs/.vuepress/public/logo.png \ + // --out docs/.vuepress/public/favicon-32.png + // + // 2,076 bytes, 32x17, alpha preserved. Deliberately NOT padded to a + // square: sips can only pad with an opaque colour, and a white pad + // would put a white box behind the mark on dark tab bars, where the + // browser currently letterboxes it transparently. Aspect and alpha are + // unchanged, so it renders identically to what shipped before — the + // browser was already downscaling the 256px original to the same size. + // + // logo.png stays in public/ as the full-size brand asset. It is now + // unreferenced by the site, so it costs dist size but no page weight. + ['link', { rel: 'icon', href: '/forge_docs/favicon-32.png' }], ['meta', { name: 'theme-color', content: '#050813' }], // brand deep-navy ['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }], ['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black' }], - // Open Graph — full social card (Phase 2A polish) - ['meta', { property: 'og:title', content: 'Fluid Forge — Declarative Data Products' }], - ['meta', { property: 'og:description', content: 'Write YAML, deploy anywhere. One contract, every cloud. What Terraform did for infrastructure, Fluid Forge does for data products.' }], - ['meta', { property: 'og:type', content: 'website' }], - ['meta', { property: 'og:url', content: 'https://agenticstiger.github.io/forge_docs/' }], - ['meta', { property: 'og:image', content: 'https://agenticstiger.github.io/forge_docs/og-card.png' }], + // Open Graph — static social card only. + // + // og:title, og:description, og:url, og:type and og:site_name are NOT set + // here: @vuepress/plugin-seo (registered by the theme, see `hostname` + // below) emits all five per page. A site-wide copy of any of them is not + // just redundant, it is wrong on 212 of 213 pages — the old hard-coded + // og:url pointed every page at the homepage — and og:type genuinely varies + // (the plugin emits `article` for content pages, `website` for the home + // page), so a fixed `website` would contradict the plugin's own tag. + // + // og:image stays static and site-wide: it is the branded 1200x630 card, + // and the plugin only emits an og:image of its own when a page declares a + // frontmatter cover or embeds an image, which no page here does. + ['meta', { property: 'og:image', content: `${CANONICAL_ROOT}/og-card.png` }], ['meta', { property: 'og:image:width', content: '1200' }], ['meta', { property: 'og:image:height', content: '630' }], - ['meta', { property: 'og:site_name', content: 'Fluid Forge' }], - // Twitter / X + // Twitter / X. Kept hand-written: plugin-seo emits twitter:* tags only + // when a page declares a frontmatter cover, so these do not collide. ['meta', { name: 'twitter:card', content: 'summary_large_image' }], ['meta', { name: 'twitter:title', content: 'Fluid Forge — Declarative Data Products' }], ['meta', { name: 'twitter:description', content: 'Write YAML, deploy anywhere. One contract, every cloud.' }], - ['meta', { name: 'twitter:image', content: 'https://agenticstiger.github.io/forge_docs/og-card.png' }], + ['meta', { name: 'twitter:image', content: `${CANONICAL_ROOT}/og-card.png` }], ['meta', { name: 'keywords', content: 'fluid forge, data products, declarative data engineering, duckdb, bigquery, snowflake, aws, cli' }], ], theme: defaultTheme({ - // The theme already registers @vuepress/plugin-links-check; by default it - // only warns. `build: 'error'` makes the build itself throw on a dead - // internal link, so a broken link fails CI natively instead of relying on - // a workflow that greps the build log for a warning string. + // The theme gates BOTH its bundled @vuepress/plugin-seo and its bundled + // @vuepress/plugin-sitemap on exactly this option + // (`hostname && (themePlugins.seo ?? true) ? seoPlugin({ hostname }) : []` + // in @vuepress/theme-default 2.0.0-rc.132 dist/node/index.js). Without it + // neither plugin was ever registered, which is why every page carried the + // same hand-written og:url and none carried a canonical. + hostname: HOSTNAME, + themePlugins: { + // The theme already registers @vuepress/plugin-links-check; by default it + // only warns. `build: 'error'` makes the build itself throw on a dead + // internal link, so a broken link fails CI natively instead of relying on + // a workflow that greps the build log for a warning string. linksCheck: { build: 'error' }, + + // plugin-seo emits rel="canonical" only when `canonical` is set; passing + // `hostname` alone gives per-page og:* but no canonical at all. + seo: { canonical: CANONICAL_ROOT }, }, // Dark is the brand default (matches agenticstransformation.com). // The navbar toggle still switches to the refined light theme. + // The theme hides the .vp-site-name span whenever logoAlt resolves equal to + // the site title (VPNavbarBrand: navBarLogoAltMatchesTitle), which is what + // `logoAlt ?? title` gives when no logo is configured - leaving the brand + // anchor with no unhidden child and an EMPTY accessible name on every page. + // An explicit logoAlt breaks that equality so the wordmark stays readable. + logoAlt: 'Fluid Forge home', colorMode: 'dark', colorModeSwitch: true, @@ -69,6 +131,26 @@ export default defineUserConfig({ // "Fluid Forge" wordmark instead (and still links home, so a "Home" // navbar item is redundant). Add a transparent `logo` / `logoDark` // asset later to bring the mark back. + // + // `logoAlt: ''` is what gives the brand/home link an accessible name, + // and it is load-bearing despite there being no logo to describe. + // VPNavbarBrand computes + // logoAlt = themeLocale.logoAlt ?? siteLocale.title + // aria-hidden = logoAlt.toUpperCase().trim() === title.toUpperCase().trim() + // on the that holds the wordmark. The + // intent is to stop a screen reader saying "Fluid Forge" twice when an + // Fluid Forge already sits next to the text. With no logo + // there is no img, so the guard compared the title against itself, + // resolved true, and hid the anchor's ONLY child: the link came out as + // ``, + // an interactive element with an empty accessible name on all 213 pages + // (WCAG 2.1 SC 2.4.4 Link Purpose, SC 4.1.2 Name/Role/Value). + // + // '' is not ?? -coalesced away (?? only falls back on null/undefined), so + // it survives as the alt, compares unequal to the title, and the wordmark + // is exposed. It is also the correct alt for the logo if one is ever + // added back: a mark sitting beside its own wordmark is decorative. + logoAlt: '', navbar: [ { text: 'Why Forge', link: '/why' }, { text: 'Concepts', link: '/concepts/' }, @@ -491,10 +573,9 @@ export default defineUserConfig({ }), // Phase 2A foundation plugins. - // - search: client-side fuzzy search (Cmd+K / "/" hotkey). DocSearch - // was the original target; client-side keeps us free of external - // indexing dependencies and works offline in dev. - // - sitemap: writes /sitemap.xml at build time using the canonical URL + // - slimsearch: client-side search ("s" or "/" hotkey). DocSearch was the + // original target; client-side keeps us free of external indexing + // dependencies and works offline in dev. // - markdown-chart: renders ```mermaid blocks at build time so they // show on the live site (without this plugin Mermaid only renders // on github.com READMEs). @@ -502,13 +583,32 @@ export default defineUserConfig({ // copy-code is NOT listed here: @vuepress/theme-default registers it // already (themePlugins.copyCode defaults to true). Registering it a // second time made the build warn "has been used multiple times". + // + // sitemap is NOT listed here either, for the same reason: now that the theme + // has `hostname` it registers @vuepress/plugin-sitemap itself, with the same + // single `hostname` option this file used to pass. Keeping the manual + // registration would have run the plugin twice and left two competing + // onGenerated hooks writing robots.txt. plugins: [ - searchPlugin({ - maxSuggestions: 12, - hotKeys: ['s', '/'], - }), - sitemapPlugin({ - hostname: 'https://agenticstiger.github.io/forge_docs/', + // slimsearch replaces @vuepress/plugin-search. The old plugin inlined the + // whole index into the entry chunk as a virtual module, so every visitor + // downloaded and parsed it on first paint whether or not they ever + // searched. slimsearch bakes the index into `slimsearch.worker.js` at the + // site root and fetches it only when the search modal opens, off the main + // thread. It also indexes section text, not just titles and headings. + // + // `hotKeys` MUST be KeyOptions objects, not bare strings. Strings are + // accepted by the runtime matcher but the SSR key-hint renderer reads + // `hotKeys[0].key`, and a string there throws during prerender. VuePress + // swallows that error per page: the build still exits 0 and every page + // ships with no search box at all. + // + // `suggestion: false` is what keeps the worker lazy. With suggestions on, + // the query-suggestion composable spins the worker up in onMounted, so the + // index is fetched on every page load and the saving is lost. + slimsearchPlugin({ + hotKeys: [{ key: 's' }, { key: '/' }], + suggestion: false, }), markdownChartPlugin({}), ], diff --git a/docs/.vuepress/layouts/Layout.vue b/docs/.vuepress/layouts/Layout.vue new file mode 100644 index 0000000..314c594 --- /dev/null +++ b/docs/.vuepress/layouts/Layout.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/docs/.vuepress/layouts/NotFound.vue b/docs/.vuepress/layouts/NotFound.vue index a60bfda..2540b97 100644 --- a/docs/.vuepress/layouts/NotFound.vue +++ b/docs/.vuepress/layouts/NotFound.vue @@ -13,7 +13,7 @@ -->