1import { normalizeForSearch } from '../utils/searchNormalize.js?v=3.8.36'; 2 3const WIKI_DATA_URL = './data/wiki-seed.json'; 4 5export const WIKI_PAGE_KINDS = Object.freeze(['concept', 'entity', 'topic']); 6export const WIKI_MODERATION_STATES = Object.freeze(['seed', 'draft', 'pending', 'approved', 'locked']); 7export const WIKI_ROUTE_PREFIX = 'wiki'; 8export const WIKI_SLUG_PATTERN = /^(concept|entity|topic)\/[a-z0-9]+(?:-[a-z0-9]+)*$/; 9 10const SAFE_REFERENCE_PROTOCOLS = new Set(['http:', 'https:']); 11 12let wikiDataPromise = null; 13 14function asArray(value) { 15 return Array.isArray(value) ? value : []; 16} 17 18function asString(value) { 19 return typeof value === 'string' ? value.trim() : ''; 20} 21 22function pageSearchText(page) { 23 return normalizeForSearch([ 24 page.title, 25 page.summary, 26 page.kind, 27 ...page.aliases, 28 ...page.body.map(block => `${block.heading} ${block.text}`) 29 ].join(' ')); 30} 31 32export function isValidWikiSlug(slug) { 33 return WIKI_SLUG_PATTERN.test(asString(slug)); 34} 35 36export function wikiPageHref(slug) { 37 if (!isValidWikiSlug(slug)) return '#wiki'; 38 return `#${WIKI_ROUTE_PREFIX}/${slug}`; 39} 40 41export function parseWikiHash(hash) { 42 const cleanHash = asString(hash).replace(/^#/, '').split('?')[0]; 43 if (cleanHash === WIKI_ROUTE_PREFIX) return { route: WIKI_ROUTE_PREFIX, slug: null, notFound: false }; 44 if (!cleanHash.startsWith(`${WIKI_ROUTE_PREFIX}/`)) return { route: null, slug: null, notFound: false }; 45 46 // A slug under #wiki/ that fails validation is a malformed deep link, not the 47 // index. Flag it so the page renders a not-found state rather than silently 48 // falling back to the index (spec phase 1: a malformed slug renders not-found). 49 const slug = cleanHash.slice(WIKI_ROUTE_PREFIX.length + 1); 50 if (isValidWikiSlug(slug)) return { route: WIKI_ROUTE_PREFIX, slug, notFound: false }; 51 return { route: WIKI_ROUTE_PREFIX, slug: null, notFound: true }; 52} 53 54export function isSafeReferenceUrl(url) { 55 try { 56 return SAFE_REFERENCE_PROTOCOLS.has(new URL(url).protocol); 57 } catch { 58 return false; 59 } 60} 61 62export async function fetchWikiData() { 63 if (!wikiDataPromise) { 64 const pending = fetch(WIKI_DATA_URL).then(response => { 65 if (!response.ok) { 66 throw new Error(`Failed to load wiki seed data: ${response.status}`); 67 } 68 return response.json(); 69 }); 70 // Drop a failed load so a later visit retries instead of reusing the 71 // rejected promise for the rest of the session. The identity guard avoids 72 // clearing a newer promise a concurrent caller may have set. 73 pending.catch(() => { 74 if (wikiDataPromise === pending) wikiDataPromise = null; 75 }); 76 wikiDataPromise = pending; 77 } 78 return wikiDataPromise; 79} 80 81export function normalizeWikiPage(page) { 82 const aliases = asArray(page?.aliases).map(asString).filter(Boolean); 83 const body = asArray(page?.body) 84 .map(block => ({ 85 heading: asString(block?.heading), 86 text: asString(block?.text) 87 })) 88 .filter(block => block.heading && block.text); 89 const references = asArray(page?.references) 90 .map(ref => ({ 91 label: asString(ref?.label), 92 url: asString(ref?.url) 93 })) 94 .filter(ref => ref.label && isSafeReferenceUrl(ref.url)); 95 96 const normalized = { 97 id: asString(page?.id), 98 slug: asString(page?.slug), 99 kind: asString(page?.kind), 100 title: asString(page?.title), 101 summary: asString(page?.summary), 102 aliases, 103 body, 104 relatedConcepts: asArray(page?.relatedConcepts).map(asString).filter(Boolean), 105 relatedEntities: asArray(page?.relatedEntities).map(asString).filter(Boolean), 106 relatedRecords: asArray(page?.relatedRecords).map(asString).filter(Boolean), 107 references, 108 contributors: asArray(page?.contributors).map(asString).filter(Boolean), 109 lastUpdated: asString(page?.lastUpdated), 110 revisionCount: Number.isInteger(page?.revisionCount) ? page.revisionCount : 0, 111 moderationState: asString(page?.moderationState) || 'draft' 112 }; 113 114 return { 115 ...normalized, 116 searchText: pageSearchText(normalized) 117 }; 118} 119 120export function normalizeWikiPages(data) { 121 return asArray(data?.pages).map(normalizeWikiPage); 122} 123 124export function buildWikiPageIndex(pages) { 125 return new Map(pages.map(page => [page.slug, page])); 126} 127 128export function filterWikiPages(pages, { query = '', kind = 'all' } = {}) {
129 const term = normalizeForSearch(query); 130 return pages.filter(page => { 131 if (kind !== 'all' && page.kind !== kind) return false; 132 if (!term) return true; 133 return page.searchText.includes(term); 134 }); 135} 136 137export function findWikiPageBySlug(pages, slug) { 138 return buildWikiPageIndex(pages).get(slug) || null; 139} 140 141export function getRelatedWikiSlugs(page) { 142 return [...page.relatedConcepts, ...page.relatedEntities]; 143} 144 145export function validateWikiData(data) { 146 const pages = normalizeWikiPages(data); 147 const errors = []; 148 const slugs = new Set(); 149 150 if (data?.schemaVersion !== 1) errors.push('schemaVersion must be 1'); 151 if (!pages.length) errors.push('pages must contain at least one wiki page'); 152 153 for (const page of pages) { 154 if (!page.id.startsWith('wiki-')) errors.push(`${page.slug || page.id}: id must start with wiki-`); 155 if (!isValidWikiSlug(page.slug)) errors.push(`${page.id}: slug must match ${WIKI_SLUG_PATTERN}`); 156 if (slugs.has(page.slug)) errors.push(`${page.slug}: duplicate slug`); 157 slugs.add(page.slug); 158 if (!WIKI_PAGE_KINDS.includes(page.kind)) errors.push(`${page.slug}: unknown kind ${page.kind}`); 159 if (!page.title) errors.push(`${page.slug}: title is required`); 160 if (page.summary.length < 20) errors.push(`${page.slug}: summary must describe the page`); 161 if (!page.body.length) errors.push(`${page.slug}: at least one body block is required`); 162 if (!page.contributors.length) errors.push(`${page.slug}: at least one contributor is required`); 163 if (!/^\d{4}-\d{2}-\d{2}$/.test(page.lastUpdated)) errors.push(`${page.slug}: lastUpdated must be YYYY-MM-DD`); 164 if (page.revisionCount < 1) errors.push(`${page.slug}: revisionCount must be at least 1`); 165 if (!WIKI_MODERATION_STATES.includes(page.moderationState)) errors.push(`${page.slug}: unknown moderationState ${page.moderationState}`); 166 } 167 168 for (const page of pages) { 169 for (const slug of getRelatedWikiSlugs(page)) { 170 if (!slugs.has(slug)) errors.push(`${page.slug}: related page ${slug} does not exist`); 171 } 172 } 173 174 return { valid: errors.length === 0, errors, pages }; 175}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.