diff --git a/scripts/fetch-tutorials.ts b/scripts/fetch-tutorials.ts index 3baea6b9f..6f68b5b48 100644 --- a/scripts/fetch-tutorials.ts +++ b/scripts/fetch-tutorials.ts @@ -5,6 +5,7 @@ import { performance } from 'node:perf_hooks' import { hugoFrontmatterStringify as yamlStringify } from './lib/hugo-yaml.js' import { flushDimensionsCache, populateImageDimensions, exportDimensionsForHugo } from './parsers/image-dimensions.js' import { composeTutorial } from './parsers/compose.js' +import { extractFrontmatter } from './parsers/frontmatter.js' import { DirectiveParseError } from './parsers/directives.js' import './parsers/directives/register-all.js' // side-effect: registers all content-reuse directives import { findUnexpandedDirectives } from './parsers/guard-unexpanded-directives.js' @@ -1226,8 +1227,8 @@ async function main() { console.log(`[snippets] fetched ${snippetMap.size} snippet(s) from ${uniqueRepos.length} repo(s)`) } - // #directive-xref-validation: existence catalog for the `:tutorial` and - // `::requires` directives. Both validate their slug against ctx.catalogBySlug + // #directive-xref-validation: existence + metadata catalog for the `:tutorial` + // and `::requires` directives. Both validate their slug against ctx.catalogBySlug // and throw DirectiveParseError (→ fatal composeFailures) when it's present but // the slug is missing; when it's undefined they fail-open. We enable the fatal // path ONLY when discovery came from the authoritative GitHub tier, because the @@ -1235,19 +1236,52 @@ async function main() { // the filter never shrinks allTutorials). A disk/hana/rest fallback could be a // stale/partial set and would throw false positives, so we pass undefined there. // - // Value carries slug-as-title (existence is the load-bearing check; there is no - // pre-compose source of real titles for ALL slugs) + time 0. `::requires` reads - // entry.title into its card; a real title is a nice-to-have, not required to - // catch a dead cross-ref. Keyed lowercase to match the directives' slug casing. + // Value carries the REAL title + time, read from each tutorial's frontmatter in + // the on-disk cache (every .md is already written to CACHE_DIR by this + // point — the :help prefetch loop above reads the same files). `::requires` + // renders entry.title/entry.time into its card and `:tutorial` uses entry.title + // as the link label; without real values they showed the raw slug + "0 min". + // Fail-open to slug/0 when the file or field is missing (existence still holds). let catalogBySlug: Map | undefined if (discoverySource === 'github') { catalogBySlug = new Map() + // #3091 follow-up: the CAP /build/catalog carries a real title for EVERY + // tutorial slug (not just the ones composed this run). On a slug-targeted + // rebuild the referenced tutorial's own .md is NOT fetched into + // CACHE_DIR, so the per-file lookup below falls back to slug-as-title and the + // xref/prereq card renders a raw slug. Seed a slug→title map from the cached + // catalog first; the on-disk frontmatter (which also has `time`) overrides it + // when present. loadCapCache() is already read at the fingerprint step above, + // so this is a cheap disk read with no extra network. + const catalogTitleBySlug = new Map() + try { + for (const m of (loadCapCache()?.tutorialMetas ?? [])) { + if (m.title) catalogTitleBySlug.set(m.slug.toLowerCase(), m.title) + } + } catch { /* no cache yet — per-file + slug fallback still hold */ } for (const t of allTutorials) { - catalogBySlug.set(t.slug.toLowerCase(), { title: t.slug, time: 0 }) + const slugLc = t.slug.toLowerCase() + let title = catalogTitleBySlug.get(slugLc) ?? t.slug + let time = 0 + const cacheFile = join(CACHE_DIR, `${t.slug}.md`) + if (existsSync(cacheFile)) { + try { + const fm = extractFrontmatter(readFileSync(cacheFile, 'utf-8')) + // title is a top-level field on FrontmatterResult; time lives on the + // nested `frontmatter` object (coerced to a number by extractFrontmatter). + if (fm.title) title = fm.title + const t = fm.frontmatter?.time + if (typeof t === 'number' && Number.isFinite(t)) time = t + } catch { + // Corrupt/unreadable cache entry — keep catalog/slug fallback (existence + // check is what the fatal guard depends on; metadata is best-effort). + } + } + catalogBySlug.set(slugLc, { title, time }) } - console.log(`[xref] slug-existence catalog enabled (${catalogBySlug.size} slugs; dead :tutorial/::requires refs now fail the build)`) + console.log(`[xref] slug catalog enabled (${catalogBySlug.size} slugs; ${catalogTitleBySlug.size} titles from CAP catalog, frontmatter overrides where cached; dead :tutorial/::requires refs now fail the build)`) } else { - console.log(`[xref] slug-existence catalog DISABLED — discovery source '${discoverySource ?? 'cache'}' is not authoritative (fail-open on cross-ref validation)`) + console.log(`[xref] slug catalog DISABLED — discovery source '${discoverySource ?? 'cache'}' is not authoritative (fail-open on cross-ref validation)`) } // Build-time safety net (#directive-rollout): names of directives that SHOULD diff --git a/scripts/parsers/__tests__/cap-catalog-title.test.ts b/scripts/parsers/__tests__/cap-catalog-title.test.ts new file mode 100644 index 000000000..800a52587 --- /dev/null +++ b/scripts/parsers/__tests__/cap-catalog-title.test.ts @@ -0,0 +1,43 @@ +// scripts/parsers/__tests__/cap-catalog-title.test.ts +// #3091 follow-up: fetchBuildCatalog must carry each tutorial's `title` into +// tutorialMetas. fetch-tutorials.ts seeds the xref/prereq catalogBySlug from +// these titles so :tutorial / ::requires cross-references render a real title +// (not the raw slug) even on slug-targeted rebuilds, where the referenced +// tutorial's own .md is never fetched into CACHE_DIR. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { fetchBuildCatalog } from '../cap.js'; + +const CATALOG = { + missions: [], + hierarchies: [], + standaloneGroups: [], + categories: [], + tutorials: [ + { slug: 'hana-cloud-mission-trial-1', title: 'Understand How the SAP HANA Cloud Free Tier Service can be Used', categorySlugs: [] }, + { slug: 'no-title-slug', categorySlugs: ['cat-a'] }, // title absent → omitted, not slug-filled + ], + featured: [], +}; + +afterEach(() => vi.restoreAllMocks()); + +describe('fetchBuildCatalog title capture (#3091)', () => { + it('threads tutorials[].title into tutorialMetas', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => CATALOG, + }))); + + const res = await fetchBuildCatalog('http://localhost:4004'); + const bySlug = new Map(res.tutorialMetas.map(m => [m.slug, m])); + + expect(bySlug.get('hana-cloud-mission-trial-1')?.title) + .toBe('Understand How the SAP HANA Cloud Free Tier Service can be Used'); + // A tutorial with no title must NOT get a fabricated one — the slug-as-title + // fallback lives in fetch-tutorials.ts, not here; here title stays undefined. + expect(bySlug.get('no-title-slug')?.title).toBeUndefined(); + expect(bySlug.get('no-title-slug')?.categorySlugs).toEqual(['cat-a']); + }); +}); diff --git a/scripts/parsers/cap.ts b/scripts/parsers/cap.ts index 5670e3406..bd3b0b046 100644 --- a/scripts/parsers/cap.ts +++ b/scripts/parsers/cap.ts @@ -80,12 +80,16 @@ export async function fetchBuildCatalog(baseUrl: string): Promise<{ hierarchies: MissionHierarchy[] standaloneGroups?: StandaloneGroup[] categories?: CategoryMeta[] - tutorials?: Array<{ slug: string; categorySlugs?: string[] }> + tutorials?: Array<{ slug: string; title?: string; categorySlugs?: string[] }> featured?: BrowseFeaturedEntry[] } const tutorialMetas: CatalogTutorialMeta[] = (data.tutorials ?? []).map(t => ({ slug: t.slug, categorySlugs: t.categorySlugs ?? [], + // #3091 follow-up: carry the real title so the xref/prereq catalog can label + // :tutorial / ::requires links with it even on slug-targeted rebuilds (where + // the referenced tutorial's own .md is not fetched into CACHE_DIR). + ...(typeof t.title === 'string' && t.title ? { title: t.title } : {}), })) return { missions: data.missions, diff --git a/scripts/parsers/types.ts b/scripts/parsers/types.ts index 8f685c843..89f8648f5 100644 --- a/scripts/parsers/types.ts +++ b/scripts/parsers/types.ts @@ -414,6 +414,7 @@ export interface CategoryMeta { export interface CatalogTutorialMeta { slug: string categorySlugs: string[] + title?: string } // Full CodeCheckSpec — used by the publish pipeline. NEVER ship to the diff --git a/scripts/sync-approuter-css.cjs b/scripts/sync-approuter-css.cjs index e33cdc688..f375bbd98 100644 --- a/scripts/sync-approuter-css.cjs +++ b/scripts/sync-approuter-css.cjs @@ -127,6 +127,43 @@ async function main() { const base = approuterUrl.replace(/\/+$/, ''); const pages = page ? [page] : DEFAULT_PAGES; + // 0) PREFER SOURCE WHEN THE DEPLOY ALREADY SHIPPED IT (#3091 follow-up). + // #2920's sync-from-approuter exists to avoid baking a hash the approuter + // does NOT serve (→ 404 / unstyled). But it has a failure mode: a CSS-only + // SOURCE change ships on a deploy (build:css rotates the fingerprinted file, + // and the new file IS served) yet NEVER reaches HANA pages — every rebuild + // re-pins the OLD hash it scrapes from the (still-stale) served pages, a + // self-perpetuating loop (observed 2026-11-10: console line-wrap CSS fix + // deployed as 863da9b0… but all pages kept referencing 9dbb07c7…). + // + // Break it WITHOUT reintroducing drift: build from source, then verify the + // approuter actually SERVES the source-built hash (200). If it does, the + // deploy already shipped these exact bytes — baking the source hash is safe + // (no 404) AND propagates the new CSS to republished pages. If it does not + // (source is ahead of the deploy — the pre-#2920 drift the guard blocks), + // fall through to the sync-from-approuter path below unchanged. + try { + buildFromSource(); + const srcBytes = fs.readFileSync(outFile); + const srcHash = crypto.createHash('sha256').update(srcBytes).digest('hex'); + const srcUrl = `/css/sap-fundamental.${srcHash}.css`; + const probe = await fetchWithRetry(base + srcUrl, { asBuffer: true }); + if (probe.ok && probe.body) { + const servedHash = crypto.createHash('sha256').update(probe.body).digest('hex'); + if (servedHash === srcHash) { + log(`source CSS hash ${srcHash.slice(0, 12)} IS served by the approuter → keeping source build (deploy already shipped it; new CSS will propagate to republished pages)`); + return 0; + } + // Served bytes at the source URL don't match (edge-mangled / corrupt) — + // don't trust it; fall through to the canonical discover-and-pin path. + log(`source hash ${srcHash.slice(0, 12)} returned 200 but bytes mismatch — falling through to approuter sync`); + } else { + log(`approuter does NOT serve source hash ${srcHash.slice(0, 12)} (HTTP ${probe.status}) → source is ahead of the deploy; syncing from approuter to avoid drift`); + } + } catch (e) { + log(`source pre-build/probe failed (${e && e.message ? e.message : e}); syncing from approuter`); + } + // 1) Discover the served sap-fundamental href from a rendered page. let href = ''; for (const p of pages) {