PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/services/wikiService.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:27:07 UTC 6,443 bytes, 175 lines download raw bytes

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.