From c688e1b1f8427d742689e31abe26c4dd18f4232c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 01:44:46 +0000 Subject: [PATCH] ci(content-lint): resolve topic hub slugs instead of exempting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveInternalLink` recognised `//blog/topics//` and returned null for it: the shape was accepted and the slug behind it was never checked, so a body link to a misspelled hub passed the gate and 404ed in production. It was the one internal link shape the validator knew and could not resolve. The term list could not be read because `src/lib/terms.ts` value-imports `./i18n` and `./zhconvert` without extensions, which only Vite resolves. So the taxonomy data moves to `src/lib/term-data.ts`, a module with no imports at all — the property that makes `src/lib/clusters.ts` loadable from this script today — and `terms.ts` consumes it for the labels and lookups the site needs. `termSlugPath` moves with the data and is re-exported, so the nesting rule has one definition that both the route and the gate call. Hub URLs are resolved against what the site actually builds, not against a flat slug set: * nesting — a topic lives at `/` only when it declares a parent, so `governance/ai-agents` spells two real terms and names no page; * content — `getStaticPaths` emits a hub only for terms `getAllUsedTerms` returns, so a declared term nobody has written about (`automation`, `customer-stories`) 404s exactly like a typo, and a term with content in only some locales (`modernization`: en, zh-Hans, zh-Hant) 404s in the rest. The registry keys on published posts whatever `--published` says, because the question a link asks is what production serves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr --- scripts/content-lint.mjs | 153 ++++++++++++++++++++++++++++++++++++++- src/lib/term-data.ts | 119 ++++++++++++++++++++++++++++++ src/lib/terms.ts | 88 +++++----------------- 3 files changed, 286 insertions(+), 74 deletions(-) create mode 100644 src/lib/term-data.ts diff --git a/scripts/content-lint.mjs b/scripts/content-lint.mjs index aaecefb..33dfb88 100644 --- a/scripts/content-lint.mjs +++ b/scripts/content-lint.mjs @@ -15,6 +15,7 @@ const BLOG = path.join(ROOT, 'content', 'blog'); const GLOSSARY = path.join(ROOT, 'content', 'glossary'); const PAGES = path.join(ROOT, 'content', 'pages'); const CLUSTERS_MODULE = path.join(ROOT, 'src', 'lib', 'clusters.ts'); +const TERMS_MODULE = path.join(ROOT, 'src', 'lib', 'term-data.ts'); const args = new Set(argv.slice(2)); const onlyPublished = args.has('--published'); const today = new Date(); @@ -133,6 +134,10 @@ function addIssue(issues, file, data, severity, message) { // * marketing — `src/content-pages/registry.ts`, same fallback rule as the // glossary. // * cluster — `src/lib/clusters.ts`, built for every locale unconditionally. +// * topic hub — `src/lib/term-data.ts` crossed with the published posts. +// `src/pages/[lang]/blog/topics/[...slug].astro` emits a hub +// only for a term some post in that locale carries, so a real +// term nobody has written about 404s exactly like a typo does. /** URL segments under `//` that are pages rather than content slugs. */ const STATIC_ROUTES = new Set([ @@ -196,6 +201,83 @@ async function readClusterSlugs() { } } +/** + * Topic hubs — `//blog/topics//`, one page per term the site + * has content for. Two separate facts decide whether such a URL exists, so the + * registry carries both: + * + * * the term list and its nesting, from `src/lib/term-data.ts`. This script + * imports the module and calls its own `termSlugPath`, so the gate and the + * route cannot disagree about where a hub lives — re-deriving the slug list + * by text-matching a TypeScript file is exactly the tolerant re-parse a gate + * must not do. That module deliberately has no value imports; see its header. + * * which terms have content in which locale. `getStaticPaths` emits a hub + * only for the terms `getAllUsedTerms(locale)` returns, and a topic hub also + * aggregates its children's articles (`articleHasTerm`, src/lib/posts.ts). + * + * Keyed on PUBLISHED posts whatever `--published` says: `astro build` exposes + * published posts only (`shouldExposePost`), so a link asks what production + * serves, not what `astro dev` additionally renders. + */ +async function readTermHubs() { + let module; + try { + module = await import(pathToFileURL(TERMS_MODULE).href); + } catch (error) { + console.error( + `✗ content lint could not read the term list from src/lib/term-data.ts, so ` + + `//blog/topics// links cannot be resolved.\n` + + ` This script imports that module directly, which needs Node type ` + + `stripping (Node 22.18+) and a module with no value imports — an ` + + `extensionless specifier like './i18n' resolves under Vite only.\n` + + ` ${error.message}` + ); + exit(1); + } + const { RAW_TERMS: terms, termSlugPath } = module; + + const usedByLocale = new Map(); + for (const file of await walk(BLOG)) { + let data; + try { + data = yaml.load(splitFrontmatter(await readFile(file, 'utf8'), file).raw) ?? {}; + } catch { + continue; // malformed frontmatter is reported by the main pass + } + if (data.status !== 'published') continue; + const locale = localeFromFile(file); + const used = usedByLocale.get(locale) ?? new Set(); + for (const slug of [ + data.topic, + data.audience, + ...(Array.isArray(data.solutions) ? data.solutions : []), + ...(Array.isArray(data.industries) ? data.industries : []), + ]) { + if (typeof slug === 'string' && slug !== '') used.add(slug); + } + usedByLocale.set(locale, used); + } + + const childSlugs = new Map(); + for (const term of terms) { + if (term.group !== 'topic' || !term.parent) continue; + childSlugs.set(term.parent, [...(childSlugs.get(term.parent) ?? []), term.slug]); + } + + const byPath = new Map(); // hub path -> locales the site builds it in + const pathBySlug = new Map(); // term slug -> its one canonical hub path + for (const term of terms) { + const owned = [term.slug, ...(childSlugs.get(term.slug) ?? [])]; + const locales = new Set(); + for (const [locale, used] of usedByLocale) { + if (owned.some((slug) => used.has(slug))) locales.add(locale); + } + byPath.set(termSlugPath(term), locales); + pathBySlug.set(term.slug, termSlugPath(term)); + } + return { byPath, pathBySlug }; +} + /** * Locale segments the site routes. Derived from the authored trees rather than * hard-coded, so adding a locale needs no edit here: a locale is routable @@ -259,6 +341,71 @@ async function readPublicAssets(dir, prefix = '') { return assets; } +/** + * One `//blog/topics//` hub link. The path is a single + * segment for a top-level term and `/` for a nested topic, which + * is why a flat slug set is not enough to answer this: `governance/automation` + * spells two real terms and names no page. + */ +function resolveTopicHub(locale, segments, hubs) { + const example = [...hubs.byPath] + .filter(([, locales]) => locales.has(locale)) + .map(([hubPath]) => hubPath) + .sort()[0]; + const suggestion = example ? `, e.g. /${locale}/blog/topics/${example}/` : ''; + + if (segments.length === 0) { + return { + severity: 'error', + reason: + `there is no topic index page — the route builds one page per term, so link a ` + + `specific hub${suggestion}`, + }; + } + + const slugPath = segments.join('/'); + const built = hubs.byPath.get(slugPath); + if (!built) { + const leaf = segments[segments.length - 1]; + const canonical = hubs.pathBySlug.get(leaf); + if (canonical) { + return { + severity: 'error', + reason: + `the term "${leaf}" exists but its hub is /${locale}/blog/topics/${canonical}/ — ` + + `a topic is nested under a parent only when it declares one in ` + + `src/lib/term-data.ts, and "${slugPath}" is not a path the route builds`, + }; + } + return { + severity: 'error', + reason: + `no term "${leaf}" in src/lib/term-data.ts, so no hub page exists at this URL — ` + + `fix the slug, or add the term${suggestion}`, + }; + } + + if (built.size === 0) { + return { + severity: 'error', + reason: + `the term "${slugPath}" is declared but no published post carries it, and the hub ` + + `route builds a page only for terms with content, so this URL 404s in every locale`, + }; + } + if (!built.has(locale)) { + return { + severity: 'error', + reason: + `no published post in ${locale} carries the term "${slugPath}", and the hub route ` + + `builds only the locales that have content, so this URL 404s — link ` + + `/${[...built].sort().join('|')}/blog/topics/${slugPath}/, or translate a post ` + + `that carries the term`, + }; + } + return null; +} + /** * Resolve one absolute internal link against the registries. * Returns null when the link resolves, otherwise `{ severity, reason }`. @@ -282,10 +429,7 @@ function resolveInternalLink(href, registries) { if (rest.length === 1 && STATIC_ROUTES.has(rest[0])) return null; if (rest[0] === 'blog') { - // Topic hubs come from `src/lib/terms.ts`, which this script cannot import - // (it value-imports extensionless modules that only Vite resolves), so the - // shape is recognized but the slug is not resolved. Tracked separately. - if (rest[1] === 'topics') return null; + if (rest[1] === 'topics') return resolveTopicHub(locale, rest.slice(2), registries.termHubs); if (rest.length !== 2) { return { severity: 'error', reason: 'not a blog post URL (//blog//)' }; } @@ -382,6 +526,7 @@ const registries = { glossary: await readLocaleTree(GLOSSARY), pages: await readLocaleTree(PAGES), clusters: await readClusterSlugs(), + termHubs: await readTermHubs(), publicAssets: await readPublicAssets(path.join(ROOT, 'public')), }; registries.locales = routableLocales(registries.blog, registries.glossary, registries.pages); diff --git a/src/lib/term-data.ts b/src/lib/term-data.ts new file mode 100644 index 0000000..9095da4 --- /dev/null +++ b/src/lib/term-data.ts @@ -0,0 +1,119 @@ +// The term taxonomy, as data. Single source of truth for hub slugs and nesting. +// +// This module has two consumers, and the second one is the reason it exists +// apart from `./terms`: +// +// * `src/lib/terms.ts` — derives the zh-Hant labels and the lookups the site +// renders with. Runs under Vite. +// * `scripts/content-lint.mjs` — plain Node, resolves the +// `//blog/topics//` links authors write in article bodies. +// +// Node runs a `.ts` file by stripping its types, which erases `import type` but +// leaves value imports to be resolved by Node's own rules — extensionless +// specifiers like `./i18n` are Vite-only and fail with ERR_MODULE_NOT_FOUND. +// `terms.ts` value-imports `./i18n` and `./zhconvert`, so Node cannot load it; +// this module imports nothing and must stay that way. `src/lib/clusters.ts` is +// importable from the same script for the same reason. +// +// ⛔ Adding a value import here breaks the content gate's ability to tell a real +// topic hub from a typo, and it breaks it loudly (`content:lint` exits 1 with +// the module error) rather than silently — but the fix is to keep the import +// out, not to re-derive the slug list by text-matching this file. +// +// One unified taxonomy. Every browse axis (topic / role / solution / industry) +// is just a "term" with a `group`. Hubs all live under one flat URL namespace +// (//blog/topics/); the axis never appears in the URL — it only +// groups terms in the menus. `topic` may nest one level (parent -> child). +// +// Labels are authored in English + Simplified Chinese; Traditional (zh-Hant) +// is auto-derived via OpenCC s2twp in `./terms`, so we never hand-maintain a +// third copy. + +export type TermGroup = 'topic' | 'role' | 'solution' | 'industry'; + +/** The structural half of a term: everything that decides its hub URL. */ +export interface TermIdentity { + slug: string; + group: TermGroup; + parent?: string; // topic-only: slug of the parent topic +} + +/** Authored labels. zh-Hant is derived, never authored, so it is absent here. */ +export interface TermLabels { + en: string; + 'zh-Hans': string; + ja: string; + de: string; + es: string; + fr: string; + ko: string; +} + +export interface RawTerm extends TermIdentity { + label: TermLabels; +} + +export interface RawGroup { + group: TermGroup; + label: TermLabels; + cardinality: 'one' | 'many'; // per article + inMainNav: boolean; // topic = primary header nav; others = filters +} + +export const RAW_GROUPS: RawGroup[] = [ + { group: 'topic', label: { en: 'Topics', 'zh-Hans': '主题', ja: 'トピック', de: 'Themen', es: 'Temas', fr: 'Sujets', ko: '주제' }, cardinality: 'one', inMainNav: true }, + { group: 'solution', label: { en: 'Solutions', 'zh-Hans': '解决方案', ja: 'ソリューション', de: 'Lösungen', es: 'Soluciones', fr: 'Solutions', ko: '솔루션' }, cardinality: 'many', inMainNav: false }, + { group: 'role', label: { en: 'Audience', 'zh-Hans': '受众', ja: '読者', de: 'Zielgruppe', es: 'Audiencia', fr: 'Audience', ko: '대상' }, cardinality: 'one', inMainNav: false }, + { group: 'industry', label: { en: 'Industry', 'zh-Hans': '行业', ja: '業界', de: 'Branche', es: 'Industria', fr: 'Secteur', ko: '산업' }, cardinality: 'many', inMainNav: false }, +]; + +export const RAW_TERMS: RawTerm[] = [ + // — Topics (主题) — primary axis. Horizontal themes. Can nest via `parent`. + { slug: 'ai-agents', group: 'topic', label: { en: 'AI & Agents', 'zh-Hans': 'AI 与智能体', ja: 'AI とエージェント', de: 'AI & Agenten', es: 'AI y agentes', fr: 'AI et agents', ko: 'AI와 에이전트' } }, + { slug: 'app-building', group: 'topic', label: { en: 'App Development', 'zh-Hans': '应用搭建', ja: 'アプリ開発', de: 'App-Entwicklung', es: 'Desarrollo de apps', fr: 'Développement applicatif', ko: '앱 개발' } }, + { slug: 'integration-data', group: 'topic', label: { en: 'Integration & Data', 'zh-Hans': '集成与数据', ja: '連携とデータ', de: 'Integration & Daten', es: 'Integración y datos', fr: 'Intégration et données', ko: '통합과 데이터' } }, + { slug: 'automation', group: 'topic', label: { en: 'Process Automation', 'zh-Hans': '流程自动化', ja: 'プロセス自動化', de: 'Prozessautomatisierung', es: 'Automatización de procesos', fr: 'Automatisation des processus', ko: '프로세스 자동화' } }, + { slug: 'modernization', group: 'topic', label: { en: 'Modernization', 'zh-Hans': '系统现代化', ja: 'モダナイゼーション', de: 'Modernisierung', es: 'Modernización', fr: 'Modernisation', ko: '현대화' } }, + { slug: 'governance', group: 'topic', label: { en: 'Security & Governance', 'zh-Hans': '安全与治理', ja: 'セキュリティとガバナンス', de: 'Sicherheit & Governance', es: 'Seguridad y gobernanza', fr: 'Sécurité et gouvernance', ko: '보안과 거버넌스' } }, + { slug: 'customer-stories', group: 'topic', label: { en: 'Customer Stories', 'zh-Hans': '客户故事', ja: '顧客事例', de: 'Kundengeschichten', es: 'Historias de clientes', fr: 'Cas clients', ko: '고객 사례' } }, + + // — Roles (受众) — single, required. + { slug: 'business', group: 'role', label: { en: 'Business Leaders', 'zh-Hans': '业务决策者', ja: 'ビジネスリーダー', de: 'Business-Verantwortliche', es: 'Líderes de negocio', fr: 'Dirigeants métier', ko: '비즈니스 리더' } }, + { slug: 'it', group: 'role', label: { en: 'IT Leaders', 'zh-Hans': 'IT / CIO', ja: 'IT / CIO', de: 'IT / CIO', es: 'IT / CIO', fr: 'IT / CIO', ko: 'IT / CIO' } }, + { slug: 'developer', group: 'role', label: { en: 'Developers', 'zh-Hans': '开发者', ja: '開発者', de: 'Entwickler', es: 'Desarrolladores', fr: 'Développeurs', ko: '개발자' } }, + { slug: 'general', group: 'role', label: { en: 'General', 'zh-Hans': '通用', ja: '一般', de: 'Allgemein', es: 'General', fr: 'Général', ko: '일반' } }, + + // — Solutions (解决方案) — vertical apps on the platform. Optional, 0..n. + { slug: 'crm', group: 'solution', label: { en: 'CRM', 'zh-Hans': 'CRM 客户管理', ja: 'CRM', de: 'CRM', es: 'CRM', fr: 'CRM', ko: 'CRM' } }, + { slug: 'portals', group: 'solution', label: { en: 'Customer Portals', 'zh-Hans': '客户门户与自助', ja: '顧客ポータル', de: 'Kundenportale', es: 'Portales de clientes', fr: 'Portails clients', ko: '고객 포털' } }, + { slug: 'onboarding', group: 'solution', label: { en: 'Customer Onboarding', 'zh-Hans': '客户开户', ja: '顧客オンボーディング', de: 'Kunden-Onboarding', es: 'Alta de clientes', fr: 'Onboarding client', ko: '고객 온보딩' } }, + { slug: 'case-management', group: 'solution', label: { en: 'Case Management', 'zh-Hans': '案件管理', ja: 'ケース管理', de: 'Fallmanagement', es: 'Gestión de casos', fr: 'Gestion des dossiers', ko: '케이스 관리' } }, + { slug: 'field-service', group: 'solution', label: { en: 'Field Service', 'zh-Hans': '现场服务', ja: 'フィールドサービス', de: 'Außendienst', es: 'Servicio de campo', fr: 'Service terrain', ko: '현장 서비스' } }, + { slug: 'supply-chain', group: 'solution', label: { en: 'Supply Chain & Procurement', 'zh-Hans': '供应链与采购', ja: 'サプライチェーンと調達', de: 'Lieferkette & Einkauf', es: 'Cadena de suministro y compras', fr: 'Supply chain et achats', ko: '공급망과 구매' } }, + { slug: 'hr', group: 'solution', label: { en: 'HR & Internal Apps', 'zh-Hans': '人事与内部应用', ja: '人事と社内アプリ', de: 'HR & interne Apps', es: 'RR. HH. y apps internas', fr: 'RH et apps internes', ko: 'HR 및 내부 앱' } }, + + // — Industries (行业) — optional, 0..n. Hidden until an article uses one. + { slug: 'financial-services', group: 'industry', label: { en: 'Financial Services', 'zh-Hans': '金融', ja: '金融サービス', de: 'Finanzdienstleistungen', es: 'Servicios financieros', fr: 'Services financiers', ko: '금융 서비스' } }, + { slug: 'insurance', group: 'industry', label: { en: 'Insurance', 'zh-Hans': '保险', ja: '保険', de: 'Versicherung', es: 'Seguros', fr: 'Assurance', ko: '보험' } }, + { slug: 'healthcare', group: 'industry', label: { en: 'Healthcare & Life Sciences', 'zh-Hans': '医疗健康', ja: '医療・ライフサイエンス', de: 'Gesundheit & Life Sciences', es: 'Salud y ciencias de la vida', fr: 'Santé et sciences de la vie', ko: '헬스케어와 생명과학' } }, + { slug: 'manufacturing', group: 'industry', label: { en: 'Manufacturing', 'zh-Hans': '制造', ja: '製造', de: 'Fertigung', es: 'Manufactura', fr: 'Industrie manufacturière', ko: '제조' } }, + { slug: 'retail', group: 'industry', label: { en: 'Retail & Consumer Goods', 'zh-Hans': '零售消费品', ja: '小売・消費財', de: 'Handel & Konsumgüter', es: 'Retail y bienes de consumo', fr: 'Retail et biens de consommation', ko: '리테일과 소비재' } }, + { slug: 'public-sector', group: 'industry', label: { en: 'Public Sector', 'zh-Hans': '政府公共', ja: '公共部門', de: 'Öffentlicher Sektor', es: 'Sector público', fr: 'Secteur public', ko: '공공 부문' } }, + { slug: 'telecom-media', group: 'industry', label: { en: 'Telecom & Media', 'zh-Hans': '电信媒体', ja: '通信・メディア', de: 'Telekommunikation & Medien', es: 'Telecomunicaciones y medios', fr: 'Télécoms et médias', ko: '통신과 미디어' } }, + { slug: 'energy-utilities', group: 'industry', label: { en: 'Energy & Utilities', 'zh-Hans': '能源公用', ja: 'エネルギー・公益', de: 'Energie & Versorgung', es: 'Energía y utilities', fr: 'Énergie et utilities', ko: '에너지와 유틸리티' } }, +]; + +/** + * URL path segment(s) for a term — nested for child topics. + * + * This is the only definition of the nesting rule. `./terms` re-exports it for + * the site (`termHref`, and the `getStaticPaths` of + * `src/pages/[lang]/blog/topics/[...slug].astro`), and `scripts/content-lint.mjs` + * imports it to resolve authored hub links, so the gate and the router cannot + * disagree about where a hub lives. + */ +export function termSlugPath(term: TermIdentity): string { + return term.group === 'topic' && term.parent + ? `${term.parent}/${term.slug}` + : term.slug; +} diff --git a/src/lib/terms.ts b/src/lib/terms.ts index c742bc1..1270bcb 100644 --- a/src/lib/terms.ts +++ b/src/lib/terms.ts @@ -1,21 +1,31 @@ import { localeSeg, type Locale } from './i18n'; import { s2t } from './zhconvert'; +import { + RAW_GROUPS, + RAW_TERMS, + termSlugPath, + type TermGroup, + type TermIdentity, + type TermLabels, +} from './term-data'; // One unified taxonomy. Every browse axis (topic / role / solution / industry) // is just a "term" with a `group`. Hubs all live under one flat URL namespace // (//blog/topics/); the axis never appears in the URL — it only // groups terms in the menus. `topic` may nest one level (parent -> child). // -// Labels are authored in English + Simplified Chinese; Traditional (zh-Hant) -// is auto-derived via OpenCC s2twp, so we never hand-maintain a third copy. +// The taxonomy *data* — slugs, groups, parents, authored labels — lives in +// `./term-data`, which imports nothing so that `scripts/content-lint.mjs` can +// load it under plain Node and resolve the `//blog/topics//` +// links authors write. This module adds what only the running site needs: the +// derived Traditional Chinese labels (OpenCC s2twp, so we never hand-maintain a +// third copy) and the lookups. Add a term in `./term-data`, not here. -export type TermGroup = 'topic' | 'role' | 'solution' | 'industry'; +export type { TermGroup, TermIdentity } from './term-data'; +export { termSlugPath }; -export interface Term { - slug: string; - group: TermGroup; +export interface Term extends TermIdentity { label: Record; - parent?: string; // topic-only: slug of the parent topic } export interface GroupMeta { @@ -25,68 +35,13 @@ export interface GroupMeta { inMainNav: boolean; // topic = primary header nav; others = filters } -type LabelInput = { - en: string; - 'zh-Hans': string; - ja: string; - de: string; - es: string; - fr: string; - ko: string; -}; -type RawTerm = Omit & { label: LabelInput }; -type RawGroup = Omit & { label: LabelInput }; - -const addHant = ( +const addHant = ( x: T ): T & { label: Record } => ({ ...x, label: { ...x.label, 'zh-Hant': s2t(x.label['zh-Hans']) }, }); -const RAW_GROUPS: RawGroup[] = [ - { group: 'topic', label: { en: 'Topics', 'zh-Hans': '主题', ja: 'トピック', de: 'Themen', es: 'Temas', fr: 'Sujets', ko: '주제' }, cardinality: 'one', inMainNav: true }, - { group: 'solution', label: { en: 'Solutions', 'zh-Hans': '解决方案', ja: 'ソリューション', de: 'Lösungen', es: 'Soluciones', fr: 'Solutions', ko: '솔루션' }, cardinality: 'many', inMainNav: false }, - { group: 'role', label: { en: 'Audience', 'zh-Hans': '受众', ja: '読者', de: 'Zielgruppe', es: 'Audiencia', fr: 'Audience', ko: '대상' }, cardinality: 'one', inMainNav: false }, - { group: 'industry', label: { en: 'Industry', 'zh-Hans': '行业', ja: '業界', de: 'Branche', es: 'Industria', fr: 'Secteur', ko: '산업' }, cardinality: 'many', inMainNav: false }, -]; - -const RAW_TERMS: RawTerm[] = [ - // — Topics (主题) — primary axis. Horizontal themes. Can nest via `parent`. - { slug: 'ai-agents', group: 'topic', label: { en: 'AI & Agents', 'zh-Hans': 'AI 与智能体', ja: 'AI とエージェント', de: 'AI & Agenten', es: 'AI y agentes', fr: 'AI et agents', ko: 'AI와 에이전트' } }, - { slug: 'app-building', group: 'topic', label: { en: 'App Development', 'zh-Hans': '应用搭建', ja: 'アプリ開発', de: 'App-Entwicklung', es: 'Desarrollo de apps', fr: 'Développement applicatif', ko: '앱 개발' } }, - { slug: 'integration-data', group: 'topic', label: { en: 'Integration & Data', 'zh-Hans': '集成与数据', ja: '連携とデータ', de: 'Integration & Daten', es: 'Integración y datos', fr: 'Intégration et données', ko: '통합과 데이터' } }, - { slug: 'automation', group: 'topic', label: { en: 'Process Automation', 'zh-Hans': '流程自动化', ja: 'プロセス自動化', de: 'Prozessautomatisierung', es: 'Automatización de procesos', fr: 'Automatisation des processus', ko: '프로세스 자동화' } }, - { slug: 'modernization', group: 'topic', label: { en: 'Modernization', 'zh-Hans': '系统现代化', ja: 'モダナイゼーション', de: 'Modernisierung', es: 'Modernización', fr: 'Modernisation', ko: '현대화' } }, - { slug: 'governance', group: 'topic', label: { en: 'Security & Governance', 'zh-Hans': '安全与治理', ja: 'セキュリティとガバナンス', de: 'Sicherheit & Governance', es: 'Seguridad y gobernanza', fr: 'Sécurité et gouvernance', ko: '보안과 거버넌스' } }, - { slug: 'customer-stories', group: 'topic', label: { en: 'Customer Stories', 'zh-Hans': '客户故事', ja: '顧客事例', de: 'Kundengeschichten', es: 'Historias de clientes', fr: 'Cas clients', ko: '고객 사례' } }, - - // — Roles (受众) — single, required. - { slug: 'business', group: 'role', label: { en: 'Business Leaders', 'zh-Hans': '业务决策者', ja: 'ビジネスリーダー', de: 'Business-Verantwortliche', es: 'Líderes de negocio', fr: 'Dirigeants métier', ko: '비즈니스 리더' } }, - { slug: 'it', group: 'role', label: { en: 'IT Leaders', 'zh-Hans': 'IT / CIO', ja: 'IT / CIO', de: 'IT / CIO', es: 'IT / CIO', fr: 'IT / CIO', ko: 'IT / CIO' } }, - { slug: 'developer', group: 'role', label: { en: 'Developers', 'zh-Hans': '开发者', ja: '開発者', de: 'Entwickler', es: 'Desarrolladores', fr: 'Développeurs', ko: '개발자' } }, - { slug: 'general', group: 'role', label: { en: 'General', 'zh-Hans': '通用', ja: '一般', de: 'Allgemein', es: 'General', fr: 'Général', ko: '일반' } }, - - // — Solutions (解决方案) — vertical apps on the platform. Optional, 0..n. - { slug: 'crm', group: 'solution', label: { en: 'CRM', 'zh-Hans': 'CRM 客户管理', ja: 'CRM', de: 'CRM', es: 'CRM', fr: 'CRM', ko: 'CRM' } }, - { slug: 'portals', group: 'solution', label: { en: 'Customer Portals', 'zh-Hans': '客户门户与自助', ja: '顧客ポータル', de: 'Kundenportale', es: 'Portales de clientes', fr: 'Portails clients', ko: '고객 포털' } }, - { slug: 'onboarding', group: 'solution', label: { en: 'Customer Onboarding', 'zh-Hans': '客户开户', ja: '顧客オンボーディング', de: 'Kunden-Onboarding', es: 'Alta de clientes', fr: 'Onboarding client', ko: '고객 온보딩' } }, - { slug: 'case-management', group: 'solution', label: { en: 'Case Management', 'zh-Hans': '案件管理', ja: 'ケース管理', de: 'Fallmanagement', es: 'Gestión de casos', fr: 'Gestion des dossiers', ko: '케이스 관리' } }, - { slug: 'field-service', group: 'solution', label: { en: 'Field Service', 'zh-Hans': '现场服务', ja: 'フィールドサービス', de: 'Außendienst', es: 'Servicio de campo', fr: 'Service terrain', ko: '현장 서비스' } }, - { slug: 'supply-chain', group: 'solution', label: { en: 'Supply Chain & Procurement', 'zh-Hans': '供应链与采购', ja: 'サプライチェーンと調達', de: 'Lieferkette & Einkauf', es: 'Cadena de suministro y compras', fr: 'Supply chain et achats', ko: '공급망과 구매' } }, - { slug: 'hr', group: 'solution', label: { en: 'HR & Internal Apps', 'zh-Hans': '人事与内部应用', ja: '人事と社内アプリ', de: 'HR & interne Apps', es: 'RR. HH. y apps internas', fr: 'RH et apps internes', ko: 'HR 및 내부 앱' } }, - - // — Industries (行业) — optional, 0..n. Hidden until an article uses one. - { slug: 'financial-services', group: 'industry', label: { en: 'Financial Services', 'zh-Hans': '金融', ja: '金融サービス', de: 'Finanzdienstleistungen', es: 'Servicios financieros', fr: 'Services financiers', ko: '금융 서비스' } }, - { slug: 'insurance', group: 'industry', label: { en: 'Insurance', 'zh-Hans': '保险', ja: '保険', de: 'Versicherung', es: 'Seguros', fr: 'Assurance', ko: '보험' } }, - { slug: 'healthcare', group: 'industry', label: { en: 'Healthcare & Life Sciences', 'zh-Hans': '医疗健康', ja: '医療・ライフサイエンス', de: 'Gesundheit & Life Sciences', es: 'Salud y ciencias de la vida', fr: 'Santé et sciences de la vie', ko: '헬스케어와 생명과학' } }, - { slug: 'manufacturing', group: 'industry', label: { en: 'Manufacturing', 'zh-Hans': '制造', ja: '製造', de: 'Fertigung', es: 'Manufactura', fr: 'Industrie manufacturière', ko: '제조' } }, - { slug: 'retail', group: 'industry', label: { en: 'Retail & Consumer Goods', 'zh-Hans': '零售消费品', ja: '小売・消費財', de: 'Handel & Konsumgüter', es: 'Retail y bienes de consumo', fr: 'Retail et biens de consommation', ko: '리테일과 소비재' } }, - { slug: 'public-sector', group: 'industry', label: { en: 'Public Sector', 'zh-Hans': '政府公共', ja: '公共部門', de: 'Öffentlicher Sektor', es: 'Sector público', fr: 'Secteur public', ko: '공공 부문' } }, - { slug: 'telecom-media', group: 'industry', label: { en: 'Telecom & Media', 'zh-Hans': '电信媒体', ja: '通信・メディア', de: 'Telekommunikation & Medien', es: 'Telecomunicaciones y medios', fr: 'Télécoms et médias', ko: '통신과 미디어' } }, - { slug: 'energy-utilities', group: 'industry', label: { en: 'Energy & Utilities', 'zh-Hans': '能源公用', ja: 'エネルギー・公益', de: 'Energie & Versorgung', es: 'Energía y utilities', fr: 'Énergie et utilities', ko: '에너지와 유틸리티' } }, -]; - export const GROUPS: GroupMeta[] = RAW_GROUPS.map(addHant); export const TERMS: Term[] = RAW_TERMS.map(addHant); @@ -161,13 +116,6 @@ export function termDescription(term: Term, locale: Locale): string { return descriptions[locale][term.group]; } -/** URL path segment(s) for a term — nested for child topics. */ -export function termSlugPath(term: Term): string { - return term.group === 'topic' && term.parent - ? `${term.parent}/${term.slug}` - : term.slug; -} - /** Full hub href for a term: //blog/topics/. */ export function termHref(locale: Locale, term: Term): string { return `/${localeSeg(locale)}/blog/topics/${termSlugPath(term)}/`;