build-search-index.js 3.62 KB
#!/usr/bin/env node
/**
 * build-search-index.js
 *
 * 扫 docs/prd/newfunction/*.html,提取 title + headings + body 纯文本,
 * 输出 assets/search-index.json (~30-80KB)。
 *
 * 零依赖,用 regex 拆 HTML。每个 page 取前 4KB 纯文本入 index。
 */
const fs = require('fs');
const path = require('path');

const DOCS_DIR = path.resolve(__dirname, '../docs/prd/newfunction');
const OUT_PATH = path.join(DOCS_DIR, 'assets/search-index.json');

const SKIP_FILES = new Set(['index.html']);  // index 不需要搜(hero 没什么文本)
// 把 search-index.json / build-manifest.json 等也排除(即使它们存在)
const SKIP_PATTERN = /^(search-index|build-manifest|_)/;

function stripTagsKeepText(html){
  // 移除 script / style 块
  html = html.replace(/<script[\s\S]*?<\/script>/gi, ' ');
  html = html.replace(/<style[\s\S]*?<\/style>/gi, ' ');
  html = html.replace(/<svg[\s\S]*?<\/svg>/gi, ' ');   // SVG 大块图像跳过
  // 标签转空格
  html = html.replace(/<[^>]+>/g, ' ');
  // 实体
  html = html.replace(/&nbsp;/g, ' ')
             .replace(/&lt;/g, '<')
             .replace(/&gt;/g, '>')
             .replace(/&amp;/g, '&')
             .replace(/&quot;/g, '"')
             .replace(/&#x27;/g, "'")
             .replace(/&#39;/g, "'");
  // 合并空白
  html = html.replace(/\s+/g, ' ').trim();
  return html;
}

function extractTitle(html){
  const t = html.match(/<title>([\s\S]*?)<\/title>/i);
  return t ? t[1].replace(/\s*·\s*LemonBoss PRD\s*$/, '').trim() : null;
}

function extractH1(html){
  const m = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
  return m ? stripTagsKeepText(m[1]) : null;
}

function extractHeadings(html){
  const out = [];
  const re = /<h([2-4])[^>]*>([\s\S]*?)<\/h\1>/gi;
  let m;
  while ((m = re.exec(html)) !== null){
    out.push(stripTagsKeepText(m[2]));
  }
  return out;
}

function extractArticleBody(html){
  // <article class="body">...</article> if present, else everything inside <main>...</main>
  let m = html.match(/<article\s+class="body"[^>]*>([\s\S]*?)<\/article>/i);
  if (!m) m = html.match(/<main\s+class="page"[^>]*>([\s\S]*?)<\/main>/i);
  return m ? m[1] : html;
}

function extractDataDefs(html){
  // 抽出 SDD MVCP 的 data-def-name 作为额外搜索 token
  const out = [];
  const re = /data-def-name="([^"]+)"/g;
  let m;
  while ((m = re.exec(html)) !== null){
    out.push(m[1]);
  }
  return out;
}

function buildEntry(file, html){
  const title = extractTitle(html) || extractH1(html) || file;
  const headings = extractHeadings(html);
  const body = extractArticleBody(html);
  const text = stripTagsKeepText(body).slice(0, 4000);
  const defs = extractDataDefs(html);
  return {
    f: file,
    title,
    h1: extractH1(html),
    headings,
    defs,
    content: text,
  };
}

const files = fs.readdirSync(DOCS_DIR)
  .filter(f => f.endsWith('.html'))
  .filter(f => !SKIP_FILES.has(f) && !SKIP_PATTERN.test(f))
  .sort();

const entries = [];
for (const f of files){
  const html = fs.readFileSync(path.join(DOCS_DIR, f), 'utf8');
  entries.push(buildEntry(f, html));
}

const result = {
  version: '1',
  generated: new Date().toISOString(),
  pages: entries.length,
  entries,
};

fs.mkdirSync(path.dirname(OUT_PATH), { recursive: true });
fs.writeFileSync(OUT_PATH, JSON.stringify(result));

const size = fs.statSync(OUT_PATH).size;
console.log(`✓ search-index.json built`);
console.log(`  pages : ${entries.length}`);
console.log(`  size  : ${(size/1024).toFixed(1)} KB`);
console.log(`  path  : ${OUT_PATH}`);

// Brief preview
console.log(`\n--- first 3 entries (title only) ---`);
entries.slice(0, 3).forEach(e => console.log(`  · ${e.f.padEnd(34)} → ${e.title}`));