diff --git a/package.json b/package.json index ee83bf99d..85e27981f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "fetch-tutorials": "tsx scripts/fetch-tutorials.ts --target hugo", "fetch-homepage-shelves": "tsx scripts/fetch-homepage-shelves.ts", "fetch-verb-definitions": "tsx scripts/fetch-verb-definitions.ts", + "fetch-tags": "tsx scripts/fetch-tags.ts", "fetch-shelf-definitions": "tsx scripts/fetch-shelf-definitions.ts", "fetch-featured-topics": "tsx scripts/fetch-featured-topics.ts", "fetch-topic-clusters": "tsx scripts/fetch-topic-clusters.ts", @@ -87,7 +88,7 @@ "build:display": "cd app/display-app && npm install && npm run build", "copy-joule-vendor": "node scripts/copy-joule-vendor.mjs", "check-deploy-cap-target": "node scripts/check-deploy-cap-target.cjs", - "build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-verb-definitions && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run fetch-topics-gallery && npm run build:icon-subset && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run check:ui5-single-copy && npx tsx scripts/check-ui5-entry-coverage.ts && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && npm run build:page-fallback && npm run build:whats-new-snapshot && npm run retain:assets && npm run build:publish-island-manifest && npm run build:highlight && npm run build:display && npm run build:sdl", + "build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-verb-definitions && npm run fetch-tags && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run fetch-topics-gallery && npm run build:icon-subset && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run check:ui5-single-copy && npx tsx scripts/check-ui5-entry-coverage.ts && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && npm run build:page-fallback && npm run build:whats-new-snapshot && npm run retain:assets && npm run build:publish-island-manifest && npm run build:highlight && npm run build:display && npm run build:sdl", "build:deploy": "npm run check-deploy-cap-target && npm run build:all", "deploy": "node scripts/deploy-mta.cjs", "build:admin": "npm --prefix app/admin-shell run build", diff --git a/scripts/fetch-tags.ts b/scripts/fetch-tags.ts new file mode 100644 index 000000000..03be3deab --- /dev/null +++ b/scripts/fetch-tags.ts @@ -0,0 +1,22 @@ +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const CAP_BASE = process.env.CAP_BASE_URL || 'http://localhost:4004'; +const OUT_PATH = join('hugo', 'data', 'tags.json'); + +async function main() { + let payload = { tags: [] as string[], buildAt: new Date().toISOString(), error: null as string | null }; + try { + const res = await fetch(`${CAP_BASE}/build/tags`); + if (!res.ok) throw new Error(`status ${res.status}`); + payload = await res.json(); + } catch (err: any) { + payload.error = err.message; + console.warn(`[fetch-tags] WARN: ${err.message} — writing empty payload`); + } + mkdirSync(join('hugo', 'data'), { recursive: true }); + writeFileSync(OUT_PATH, JSON.stringify(payload, null, 2), 'utf-8'); + console.log(`[fetch-tags] wrote ${payload.tags?.length ?? 0} tags to ${OUT_PATH}`); +} + +main().catch(e => { console.error(e); process.exit(1); }); diff --git a/srv/server.js b/srv/server.js index 119a3c4a1..aeb15eaac 100644 --- a/srv/server.js +++ b/srv/server.js @@ -373,6 +373,28 @@ cds.on('bootstrap', (app) => { } }); + // Build-time tag taxonomy feed — consumed by scripts/fetch-tags.ts at build + // time so a downstream CI checker can validate tutorial frontmatter tags + // against the canonical taxonomy. Mirrors /build/verb-definitions above: + // anonymous route (registered before CDS auth), reads direct from the raw + // Tags entity (NOT through AdminService, whose @requires chain would 403 an + // unauthenticated request), 60s Cache-Control, 500s on error. Returns just + // the array of `name` strings (the full `category>value` tag) — all the + // membership check needs. + app.get('/build/tags', async (_req, res) => { + try { + const db = await cds.connect.to('db'); + const rows = await db.run( + SELECT.from('com.sap.developers.ims.Tags').columns('name', 'label').orderBy('name') + ); + res.set('Cache-Control', 'public, max-age=60'); + res.json({ tags: rows.map((r) => r.name), buildAt: new Date().toISOString() }); + } catch (err) { + console.error('[build/tags]', err.message); + res.status(500).json({ error: err.message }); + } + }); + // (#1032) Build-time data for Hugo featured topics carousel — consumed by // scripts/fetch-tutorials.ts at build time. Public, unauthenticated. // Cache-Control 60s (Hugo fetches once per build, not per request). diff --git a/test/unit/srv/build-tags-feed.test.js b/test/unit/srv/build-tags-feed.test.js new file mode 100644 index 000000000..965e44019 --- /dev/null +++ b/test/unit/srv/build-tags-feed.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); + +const TAGS_ENTITY = 'com.sap.developers.ims.Tags'; + +describe('/build/tags tag-taxonomy feed', () => { + let db; + beforeAll(async () => { + db = await cds.connect.to('db'); + }); + + it('returns 200 with a tags array', async () => { + const res = await project.get('/build/tags'); + expect(res.status).toBe(200); + expect(Array.isArray(res.data.tags)).toBe(true); + }); + + it('sets 60s Cache-Control header', async () => { + const res = await project.get('/build/tags'); + expect(res.headers['cache-control']).toBe('public, max-age=60'); + }); + + it('returns buildAt ISO timestamp', async () => { + const res = await project.get('/build/tags'); + expect(res.data.buildAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('returns the name string of each seeded Tags row, ordered by name', async () => { + await db.run(DELETE.from(TAGS_ENTITY)); + await db.run(INSERT.into(TAGS_ENTITY).entries( + { ID: cds.utils.uuid(), name: 'products>sap-hana-cloud', label: 'SAP HANA Cloud', isActualTag: true }, + { ID: cds.utils.uuid(), name: 'products>sap-btp', label: 'SAP BTP', isActualTag: true }, + { ID: cds.utils.uuid(), name: 'topic>cloud', label: 'Cloud', isActualTag: true }, + )); + const res = await project.get('/build/tags'); + expect(res.status).toBe(200); + expect(res.data.tags).toEqual([ + 'products>sap-btp', + 'products>sap-hana-cloud', + 'topic>cloud', + ]); + // Payload is the array of name strings only, not row objects. + expect(res.data.tags.every((t) => typeof t === 'string')).toBe(true); + }); +});