Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions scripts/fetch-tutorials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1226,28 +1227,61 @@ 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
// key set is the FULL discovery slug list (complete even on slug-targeted runs —
// 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 <slug>.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<string, { title: string; time: number }> | 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 <slug>.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<string, string>()
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
Expand Down
43 changes: 43 additions & 0 deletions scripts/parsers/__tests__/cap-catalog-title.test.ts
Original file line number Diff line number Diff line change
@@ -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 <slug>.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']);
});
});
6 changes: 5 additions & 1 deletion scripts/parsers/cap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>.md is not fetched into CACHE_DIR).
...(typeof t.title === 'string' && t.title ? { title: t.title } : {}),
}))
return {
missions: data.missions,
Expand Down
1 change: 1 addition & 0 deletions scripts/parsers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions scripts/sync-approuter-css.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading