mirror of
https://github.com/bendtherules/ask262.git
synced 2026-08-18 13:21:55 +00:00
refactor: rename files
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as cheerio from "cheerio";
|
||||
import { glob } from "glob";
|
||||
import Graph from "graphology";
|
||||
|
||||
// Directory containing built ECMAScript specification HTML files (ecmarkup output)
|
||||
const SPEC_DIR = "./spec-built/multipage";
|
||||
// Directory containing the JavaScript engine implementation source code
|
||||
const CODE_DIR = "./engine262/src";
|
||||
|
||||
import { GRAPH_FILE } from "../constants";
|
||||
|
||||
/**
|
||||
* Builds a knowledge graph mapping ECMAScript specification sections
|
||||
* to their implementation functions in the JavaScript engine.
|
||||
*
|
||||
* The graph contains:
|
||||
* - Nodes: Spec sections and JS functions
|
||||
* - Edges: LINKS_TO (spec section references) and IMPLEMENTS (code->spec)
|
||||
*/
|
||||
async function buildGraph() {
|
||||
// Initialize a multi-graph (allows multiple edges between same nodes)
|
||||
const graph = new Graph({
|
||||
multi: true,
|
||||
type: "directed",
|
||||
allowSelfLoops: false,
|
||||
});
|
||||
|
||||
// Phase 1: Discover and parse specification HTML files
|
||||
const htmlFiles = await glob(path.join(SPEC_DIR, "*.html"));
|
||||
console.log(
|
||||
`Found ${htmlFiles.length} specification HTML file(s) in ${SPEC_DIR}`,
|
||||
);
|
||||
if (htmlFiles.length === 0) {
|
||||
console.warn(`Warning: No specification HTML files found in ${SPEC_DIR}`);
|
||||
}
|
||||
|
||||
// Phase 2: Extract spec sections and create nodes
|
||||
console.log("Processing specification for graph...");
|
||||
for (const file of htmlFiles) {
|
||||
const fileName = path.basename(file);
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
const $ = cheerio.load(content);
|
||||
// ecmarkup generates content within #spec-container
|
||||
const $container = $("#spec-container");
|
||||
|
||||
// Each specification section is an <emu-clause> with an ID
|
||||
$container.find("emu-clause").each((_i, elem) => {
|
||||
const id = $(elem).attr("id");
|
||||
const title = $(elem).find("h1").first().text().trim();
|
||||
|
||||
if (id) {
|
||||
// Create node for this spec section if not already exists
|
||||
if (!graph.hasNode(id)) {
|
||||
graph.addNode(id, { title, type: "SpecSection", file: fileName });
|
||||
}
|
||||
|
||||
// Extract internal links (placeholder for potential future use)
|
||||
$(elem)
|
||||
.find("a[href]")
|
||||
.each((_j, link) => {
|
||||
const href = $(link).attr("href");
|
||||
if (href?.includes("#")) {
|
||||
const [_targetFile, targetId] = href.split("#");
|
||||
// We only care about links to other sections for now
|
||||
if (targetId?.startsWith("sec-")) {
|
||||
// Add relationship later if nodes exist
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 3: Create edges between spec sections based on internal links
|
||||
// This pass runs after all nodes are created to ensure target nodes exist
|
||||
for (const file of htmlFiles) {
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
const $ = cheerio.load(content);
|
||||
const $container = $("#spec-container");
|
||||
$container.find("emu-clause").each((_i, elem) => {
|
||||
const sourceId = $(elem).attr("id");
|
||||
if (sourceId && graph.hasNode(sourceId)) {
|
||||
$(elem)
|
||||
.find("a[href]")
|
||||
.each((_j, link) => {
|
||||
const href = $(link).attr("href");
|
||||
if (href?.includes("#")) {
|
||||
const [, targetId] = href.split("#");
|
||||
// Only create edge if target node exists and is different from source
|
||||
if (
|
||||
targetId &&
|
||||
graph.hasNode(targetId) &&
|
||||
sourceId !== targetId
|
||||
) {
|
||||
if (!graph.hasEdge(sourceId, targetId)) {
|
||||
graph.addEdge(sourceId, targetId, { type: "LINKS_TO" });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 4: Discover and parse JavaScript implementation files
|
||||
console.log("Processing code for graph...");
|
||||
const jsFiles = await glob(path.join(CODE_DIR, "**/*.mts"));
|
||||
console.log(`Found ${jsFiles.length} code file(s) in ${CODE_DIR}`);
|
||||
if (jsFiles.length === 0) {
|
||||
console.warn(`Warning: No code files found in ${CODE_DIR}`);
|
||||
}
|
||||
|
||||
// Phase 5: Extract evaluation functions and link to spec sections
|
||||
for (const file of jsFiles) {
|
||||
const fileName = path.basename(file);
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
|
||||
// Pattern matches: export function*? Evaluate_<Name>
|
||||
// The engine262 project uses this naming convention for spec implementations
|
||||
const functionMatches = content.matchAll(
|
||||
/export function\*? (Evaluate_([a-zA-Z0-9_]+))/g,
|
||||
);
|
||||
for (const match of functionMatches) {
|
||||
const fullFuncName = match[1];
|
||||
const shortName = match[2];
|
||||
const funcNodeId = `func-${fullFuncName}`;
|
||||
|
||||
// Create node for this function if not already exists
|
||||
if (!graph.hasNode(funcNodeId)) {
|
||||
graph.addNode(funcNodeId, {
|
||||
name: fullFuncName,
|
||||
type: "JSFunction",
|
||||
file: fileName,
|
||||
});
|
||||
}
|
||||
|
||||
// Link function to spec section using naming convention heuristic
|
||||
// Example: Evaluate_IfStatement -> sec-if-statement
|
||||
// Converts CamelCase to kebab-case: IfStatement -> if-statement
|
||||
const specId = `sec-${shortName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()}`;
|
||||
if (graph.hasNode(specId)) {
|
||||
graph.addEdge(funcNodeId, specId, { type: "IMPLEMENTS" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Export the completed graph to JSON
|
||||
console.log(`Graph built with ${graph.order} nodes and ${graph.size} edges.`);
|
||||
fs.writeFileSync(GRAPH_FILE, JSON.stringify(graph.export(), null, 2));
|
||||
console.log(`Graph saved to ${GRAPH_FILE}`);
|
||||
}
|
||||
|
||||
buildGraph().catch(console.error);
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bun
|
||||
import type { CheerioAPI } from "cheerio";
|
||||
import * as cheerio from "cheerio";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Resolve the path to the HTML file (relative to repo root)
|
||||
const htmlUrl = new URL(
|
||||
"../spec-built/multipage/ecmascript-data-types-and-values.html",
|
||||
import.meta.url,
|
||||
);
|
||||
const htmlPath = fileURLToPath(htmlUrl);
|
||||
|
||||
// Load and parse the HTML
|
||||
const htmlString = readFileSync(htmlPath, "utf-8");
|
||||
const htmlCheerioApi = cheerio.load(htmlString);
|
||||
|
||||
/**
|
||||
* Script to add unique `id` attributes to internal method links.
|
||||
*
|
||||
* It scans the ECMA spec HTML tables `#table-essential-internal-methods`
|
||||
* and `#table-additional-essential-internal-methods-of-function-objects`
|
||||
* and adds an `id` to the first `<var class="field">` element found in
|
||||
* the first `<td>` of each row. The generated id follows the pattern:
|
||||
* `ask262-internal-method-<methodName>` where `<methodName>` is the text
|
||||
* content of the `<var>` element with surrounding brackets stripped.
|
||||
*
|
||||
* @param $ - The Cheerio parsing instance.
|
||||
*/
|
||||
function addInternalMethodIds($: CheerioAPI): void {
|
||||
// Find the first <var class="field"> inside the first <td> of each row
|
||||
// in the two internal‑method tables and add a unique `id` attribute.
|
||||
$(
|
||||
"#table-essential-internal-methods tr > td:first-child, " +
|
||||
"#table-additional-essential-internal-methods-of-function-objects tr > td:first-child",
|
||||
).each((_, td: any) => {
|
||||
const $td = $(td);
|
||||
const $var = $td.find("var.field").first();
|
||||
if ($var.length) {
|
||||
const rawText = $var.text().trim();
|
||||
// Remove any square brackets from the extracted text
|
||||
const text = rawText.replace(/[[\]]/g, "");
|
||||
const id = `ask262-internal-method-${text}`;
|
||||
$var.attr("id", id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Execute core logic
|
||||
addInternalMethodIds(htmlCheerioApi);
|
||||
|
||||
// Write the updated HTML back
|
||||
writeFileSync(htmlPath, htmlCheerioApi.html(), "utf-8");
|
||||
console.log(
|
||||
`Updated ${htmlPath} – added id attributes to <var class="field"> elements.`,
|
||||
);
|
||||
@@ -0,0 +1,413 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import * as lancedbSdk from "@lancedb/lancedb";
|
||||
import { Index } from "@lancedb/lancedb";
|
||||
import { Document } from "@langchain/core/documents";
|
||||
import { OllamaEmbeddings } from "@langchain/ollama";
|
||||
import * as cheerio from "cheerio";
|
||||
import { glob } from "glob";
|
||||
import ora from "ora";
|
||||
import { EMBEDDING_MODEL, SPEC_DIR, STORAGE_DIR } from "../constants";
|
||||
import { HTMLTextSplitter } from "./text-splitters";
|
||||
import { formatForIngestion } from "./utils/formatHTMLForIngestion";
|
||||
|
||||
const embeddings = new OllamaEmbeddings({
|
||||
model: EMBEDDING_MODEL,
|
||||
});
|
||||
|
||||
const htmlSplitter = new HTMLTextSplitter({
|
||||
chunkSize: 8192,
|
||||
maxChunkSize: 12288,
|
||||
chunkOverlap: 0,
|
||||
separators: [
|
||||
"emu-note",
|
||||
"emu-example",
|
||||
"emu-table",
|
||||
"ul",
|
||||
"ol",
|
||||
"td",
|
||||
"h3",
|
||||
"h4",
|
||||
"p",
|
||||
"div",
|
||||
"br",
|
||||
],
|
||||
neverBreakWithin: ["emu-grammar", "emu-alg"],
|
||||
});
|
||||
|
||||
const LARGE_DOC_THRESHOLD = htmlSplitter.chunkSize + 100;
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
interface ChunkInfo {
|
||||
index: number;
|
||||
text: string;
|
||||
size: number;
|
||||
isSmall: boolean;
|
||||
}
|
||||
|
||||
async function generateEmbeddingsWithProgress(
|
||||
documents: Document[],
|
||||
): Promise<number[][]> {
|
||||
const total = documents.length;
|
||||
const vectors: number[][] = [];
|
||||
const spinner = ora({
|
||||
text: `Generating embeddings (0/${total})...`,
|
||||
discardStdin: false,
|
||||
}).start();
|
||||
|
||||
let currentIndex = 0;
|
||||
try {
|
||||
for (let i = 0; i < total; i += BATCH_SIZE) {
|
||||
currentIndex = i;
|
||||
const batch = documents.slice(i, i + BATCH_SIZE);
|
||||
const batchTexts = batch.map((doc) => doc.pageContent);
|
||||
|
||||
// Update spinner before processing batch
|
||||
const currentDoc = batch[0];
|
||||
const progress = `${i + batch.length}/${total}`;
|
||||
const sectionId = currentDoc.metadata.sectionid || "unknown";
|
||||
const contentLength = currentDoc.pageContent.length;
|
||||
spinner.text = `Generating embeddings (${progress}): ${sectionId} (${contentLength} chars)`;
|
||||
|
||||
const batchVectors = await embeddings.embedDocuments(batchTexts);
|
||||
vectors.push(...batchVectors);
|
||||
}
|
||||
spinner.succeed(`Generated ${vectors.length} embeddings`);
|
||||
} catch (error) {
|
||||
const failedDoc = documents[currentIndex];
|
||||
const failedSectionId = failedDoc?.metadata?.sectionid || "unknown";
|
||||
const failedSectionTitle = failedDoc?.metadata?.sectiontitle || "unknown";
|
||||
const failedContentLength = failedDoc?.pageContent?.length || 0;
|
||||
spinner.fail(`Failed to generate embeddings: ${error}`);
|
||||
console.error(
|
||||
`Debug: Failed on section ${failedSectionId} "${failedSectionTitle}" (${failedContentLength} chars)`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return vectors;
|
||||
}
|
||||
|
||||
function askUser(question: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${question} (yes/no): `, (answer) => {
|
||||
rl.close();
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
resolve(normalized === "yes" || normalized === "y");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function buildSpecDocuments(): Promise<Document[]> {
|
||||
const htmlFiles = await glob(path.join(SPEC_DIR, "*.html"));
|
||||
const documents: Document[] = [];
|
||||
|
||||
for (const file of htmlFiles) {
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
const $ = cheerio.load(`<body>${content}</body>`);
|
||||
|
||||
// First pass: collect all sections and build hierarchy
|
||||
const sectionMap = new Map<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
parentId: string | null;
|
||||
childrenIds: string[];
|
||||
html: string;
|
||||
}
|
||||
>();
|
||||
|
||||
$("#spec-container emu-clause").each((_i, elem) => {
|
||||
const id = $(elem).attr("id");
|
||||
const title = $(elem).find("h1").first().text().trim();
|
||||
|
||||
if (!id || !title) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get parent section id
|
||||
const parentElem = $(elem).parent("emu-clause");
|
||||
const parentId =
|
||||
parentElem.length > 0 ? parentElem.attr("id") || null : null;
|
||||
|
||||
sectionMap.set(id, {
|
||||
title,
|
||||
parentId,
|
||||
childrenIds: [],
|
||||
html: $(elem).html() || "",
|
||||
});
|
||||
});
|
||||
|
||||
// Build children relationships
|
||||
for (const [id, section] of sectionMap) {
|
||||
if (section.parentId && sectionMap.has(section.parentId)) {
|
||||
const parent = sectionMap.get(section.parentId);
|
||||
if (parent) {
|
||||
parent.childrenIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create documents with formatted text
|
||||
for (const [id, section] of sectionMap) {
|
||||
// Parse the stored HTML and replace direct children with placeholders
|
||||
const $ = cheerio.load(`<body>${section.html}</body>`);
|
||||
|
||||
const $section = $.root();
|
||||
|
||||
// Find direct children emu-clause elements only
|
||||
$section.children("emu-clause").each((_, childElem) => {
|
||||
const childId = $(childElem).attr("id");
|
||||
if (childId && sectionMap.has(childId)) {
|
||||
const child = sectionMap.get(childId);
|
||||
if (child) {
|
||||
const placeholder = `[Subsection available: sectiontitle "${child.title}" at sectionid: \`${childId}\`]`;
|
||||
$(childElem).replaceWith(placeholder);
|
||||
}
|
||||
} else {
|
||||
$(childElem).remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Remove any nested emu-clause elements that weren't direct children
|
||||
// (shouldn't happen with proper HTML structure, but just in case)
|
||||
$section.find("emu-clause").remove();
|
||||
|
||||
// All formatting transformations (single call)
|
||||
formatForIngestion($);
|
||||
|
||||
// Skip sections that only have h1 left (no meaningful content)
|
||||
const hasOnlyH1 =
|
||||
$section.children().length === 1 &&
|
||||
$section.children("h1").length === 1;
|
||||
const textContent = $section.text().trim();
|
||||
const hasMinimalContent = textContent.length <= section.title.length + 10; // title + small buffer
|
||||
|
||||
if (hasOnlyH1 || hasMinimalContent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get HTML content with inline placeholders for splitting
|
||||
const sectionHtml = $section.html() || "";
|
||||
|
||||
// Split HTML into normalized text chunks.
|
||||
const chunks = await htmlSplitter.splitText(sectionHtml);
|
||||
|
||||
// First pass: collect all chunk data
|
||||
// Only mark chunks as "small" if the original section content was long enough
|
||||
// to reasonably split (more than 100 chars). This prevents false positives
|
||||
// when the entire section was just naturally brief.
|
||||
const chunkData: ChunkInfo[] = chunks.map(
|
||||
(chunk: string, idx: number) => {
|
||||
return {
|
||||
index: idx,
|
||||
text: chunk,
|
||||
size: chunk.length,
|
||||
isSmall: chunk.length < 50 && textContent.length > 100,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Print warnings for small chunks
|
||||
const smallChunks = chunkData.filter((c: ChunkInfo) => c.isSmall);
|
||||
if (smallChunks.length > 0) {
|
||||
const cleanedHtml = sectionHtml.replace(/\s+/g, " ").trim();
|
||||
for (const chunk of smallChunks) {
|
||||
console.warn(
|
||||
` ⚠️ WARNING: Chunk ${chunk.index + 1}/${chunks.length} for section ${id} is very small (${chunk.size} chars)`,
|
||||
);
|
||||
console.warn(` Chunk content: "${chunk.text}"`);
|
||||
console.warn(
|
||||
` Original text that was split (${sectionHtml.length} chars):`,
|
||||
);
|
||||
console.warn(
|
||||
` "${cleanedHtml.slice(0, 500)}${cleanedHtml.length > 500 ? "... [truncated]" : ""}"`,
|
||||
);
|
||||
}
|
||||
// Print summary after all warnings
|
||||
const chunkSizes = chunkData
|
||||
.map((c: ChunkInfo) => `${c.index + 1}:${c.size}`)
|
||||
.join(", ");
|
||||
const totalChunkSize = chunkData.reduce(
|
||||
(sum: number, c: ChunkInfo) => sum + c.size,
|
||||
0,
|
||||
);
|
||||
console.warn(
|
||||
` All chunk sizes: [${chunkSizes}] (total: ${totalChunkSize} chars)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Print warnings for large sections wasn't split properly
|
||||
if (chunkData.length === 1 && chunkData[0].size > LARGE_DOC_THRESHOLD) {
|
||||
console.warn(
|
||||
` 🛑 Section ${id} (${section.title}) is large (${chunkData[0].size} chars) but was NOT split (1 chunk)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create documents
|
||||
for (const chunk of chunkData) {
|
||||
documents.push(
|
||||
new Document({
|
||||
pageContent: chunk.text,
|
||||
metadata: {
|
||||
source: path.basename(file),
|
||||
sectionid: id,
|
||||
sectiontitle: section.title,
|
||||
type: "specification",
|
||||
parentsectionid: section.parentId,
|
||||
childrensectionids: section.childrenIds,
|
||||
partindex: chunk.index,
|
||||
totalparts: chunkData.length,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Building specification documents...");
|
||||
const specDocs = await buildSpecDocuments();
|
||||
console.log(`Built ${specDocs.length} specification documents.`);
|
||||
|
||||
// Check for any large documents
|
||||
for (const doc of specDocs) {
|
||||
if (doc.pageContent.length > LARGE_DOC_THRESHOLD) {
|
||||
const id = doc.metadata.sectionid || "unknown";
|
||||
const title = doc.metadata.sectiontitle || "unknown";
|
||||
console.warn(
|
||||
`🚨 Warning: Document ${id} "${title}" is large (${doc.pageContent.length} chars)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary statistics
|
||||
printSummary(specDocs);
|
||||
|
||||
const db = await lancedbSdk.connect(STORAGE_DIR);
|
||||
|
||||
// Check if table exists and handle overwrite
|
||||
let tableExists = false;
|
||||
try {
|
||||
await db.openTable("spec_vectors");
|
||||
tableExists = true;
|
||||
console.log("Existing table found.");
|
||||
} catch {
|
||||
console.log("No existing table found, creating fresh...");
|
||||
}
|
||||
|
||||
if (tableExists) {
|
||||
const shouldOverwrite = await askUser(
|
||||
"Do you want to overwrite the existing vector store?",
|
||||
);
|
||||
if (!shouldOverwrite) {
|
||||
console.log("Ingest cancelled by user.");
|
||||
process.exit(0);
|
||||
}
|
||||
console.log("Overwriting existing table...");
|
||||
await db.dropTable("spec_vectors");
|
||||
}
|
||||
|
||||
console.log("Generating embeddings...");
|
||||
const vectors = await generateEmbeddingsWithProgress(specDocs);
|
||||
|
||||
console.log("Creating table with documents...");
|
||||
// Prepare data records with vector, text, and metadata
|
||||
const data = specDocs.map((doc, i) => ({
|
||||
vector: vectors[i],
|
||||
text: doc.pageContent,
|
||||
...doc.metadata,
|
||||
}));
|
||||
|
||||
// Create table with the data
|
||||
const table = await db.createTable("spec_vectors", data);
|
||||
|
||||
console.log("Creating scalar indexes...");
|
||||
await table.createIndex("sectionid", { config: Index.btree() });
|
||||
await table.createIndex("type", { config: Index.btree() });
|
||||
|
||||
console.log(`Index built and persisted to ${STORAGE_DIR}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints summary statistics about the ingested documents.
|
||||
* Shows distribution of document sizes, sections, and chunk counts.
|
||||
*/
|
||||
function printSummary(documents: Document[]): void {
|
||||
if (documents.length === 0) {
|
||||
console.log("\n📊 Summary: No documents ingested");
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate document size statistics
|
||||
const sizes = documents.map((doc) => doc.pageContent.length);
|
||||
const totalSize = sizes.reduce((sum, size) => sum + size, 0);
|
||||
const avgSize = totalSize / documents.length;
|
||||
const minSize = Math.min(...sizes);
|
||||
const maxSize = Math.max(...sizes);
|
||||
|
||||
// Count unique sections and track chunk sizes per section
|
||||
const sectionIds = new Set<string>();
|
||||
interface SectionInfo {
|
||||
count: number;
|
||||
chunkSizes: number[];
|
||||
}
|
||||
const sectionInfo = new Map<string, SectionInfo>();
|
||||
|
||||
for (const doc of documents) {
|
||||
const sectionId = doc.metadata.sectionid as string;
|
||||
if (sectionId) {
|
||||
sectionIds.add(sectionId);
|
||||
const existing = sectionInfo.get(sectionId);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.chunkSizes.push(doc.pageContent.length);
|
||||
} else {
|
||||
sectionInfo.set(sectionId, {
|
||||
count: 1,
|
||||
chunkSizes: [doc.pageContent.length],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get sections with multiple chunks, sorted by chunk count
|
||||
const multiChunkSections = Array.from(sectionInfo.entries())
|
||||
.filter(([, info]) => info.count > 1)
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
|
||||
console.log("\n📊 Ingest Summary:");
|
||||
console.log(` Total documents: ${documents.length}`);
|
||||
console.log(` Unique sections: ${sectionIds.size}`);
|
||||
console.log(` Sections with multiple chunks: ${multiChunkSections.length}`);
|
||||
console.log("\n Document size distribution:");
|
||||
console.log(` Average: ${avgSize.toFixed(0)} chars`);
|
||||
console.log(` Min: ${minSize} chars`);
|
||||
console.log(` Max: ${maxSize} chars`);
|
||||
console.log(` Total: ${totalSize} chars`);
|
||||
|
||||
if (multiChunkSections.length > 0) {
|
||||
console.log("\n Top 5 sections by chunk count:");
|
||||
for (const [sectionId, info] of multiChunkSections.slice(0, 5)) {
|
||||
const sectionTotal = info.chunkSizes.reduce((sum, size) => sum + size, 0);
|
||||
const chunkSizesStr = info.chunkSizes.join(", ");
|
||||
console.log(` ${sectionId}:`);
|
||||
console.log(` Chunks: ${info.count}, Total: ${sectionTotal} chars`);
|
||||
console.log(` Chunk sizes: [${chunkSizesStr}]`);
|
||||
}
|
||||
if (multiChunkSections.length > 5) {
|
||||
console.log(` ... and ${multiChunkSections.length - 5} more`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Add the engine262 repository as a git subtree (squashed)
|
||||
# Usage: ./setup/initEngine262.sh
|
||||
# This will add the repository at ./engine262 using a squashed subtree
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="https://github.com/bendtherules/engine262"
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
TARGET_DIR="${REPO_ROOT}/engine262"
|
||||
|
||||
# Add the repository as a git subtree (squashed)
|
||||
# If the target directory already exists, assume the subtree is present.
|
||||
if [ ! -d "$TARGET_DIR" ]; then
|
||||
git -C "${REPO_ROOT}" subtree add --prefix=engine262 "$REPO_URL" main --squash
|
||||
else
|
||||
echo "Subtree already present at $TARGET_DIR"
|
||||
fi
|
||||
|
||||
echo "Subtree added and initialized at ./$TARGET_DIR"
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bun
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as cheerio from "cheerio";
|
||||
|
||||
/**
|
||||
* Strips HTML files to only contain the #spec-container element.
|
||||
*
|
||||
* This script processes HTML files in the spec-built/multipage directory,
|
||||
* extracts the #spec-container element, and replaces the entire body
|
||||
* content with just that container. This reduces file size and focuses
|
||||
* only on the specification content.
|
||||
*/
|
||||
|
||||
const SPEC_DIR = path.join(__dirname, "../spec-built/multipage");
|
||||
|
||||
function stripSpecContainer(): void {
|
||||
const files = fs.readdirSync(SPEC_DIR);
|
||||
let processedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".html")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(SPEC_DIR, file);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const $ = cheerio.load(content);
|
||||
const container = $("#spec-container");
|
||||
|
||||
if (container.length) {
|
||||
$("body").empty().append(container);
|
||||
fs.writeFileSync(filePath, $.html());
|
||||
console.log(`Processed ${file}`);
|
||||
processedCount++;
|
||||
} else {
|
||||
console.warn(`Warning: #spec-container not found in ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nProcessed ${processedCount} HTML file(s)`);
|
||||
}
|
||||
|
||||
stripSpecContainer();
|
||||
@@ -0,0 +1,349 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Document } from "@langchain/core/documents";
|
||||
import { HTMLTextSplitter } from "./index";
|
||||
|
||||
describe("HTMLTextSplitter", () => {
|
||||
test("keeps small HTML in a single chunk", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 64,
|
||||
separators: ["h2"],
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(
|
||||
"<section><h2>Title</h2><p>Short body text</p></section>",
|
||||
);
|
||||
|
||||
expect(chunks).toEqual(["Title\nShort body text"]);
|
||||
});
|
||||
|
||||
test("does not add synthetic spaces between inline tags", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 64,
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(
|
||||
"<p><span>Hello</span><span>World</span></p>",
|
||||
);
|
||||
|
||||
expect(chunks).toEqual(["HelloWorld"]);
|
||||
});
|
||||
|
||||
test("preserves authored whitespace between inline tags", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 64,
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(
|
||||
"<p><span>Hello </span><span>World</span></p>",
|
||||
);
|
||||
|
||||
expect(chunks).toEqual(["Hello World"]);
|
||||
});
|
||||
|
||||
test("adds spacing between adjacent block-ish tags", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 64,
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText("<div>Hello</div><div>World</div>");
|
||||
|
||||
expect(chunks).toEqual(["Hello\nWorld"]);
|
||||
});
|
||||
|
||||
test("preserves whitespace as-is from HTML text content", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 64,
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(
|
||||
"<div> Hello\n\n World </div><div>Again</div>",
|
||||
);
|
||||
|
||||
expect(chunks).toEqual(["Hello\n\n World \nAgain"]);
|
||||
});
|
||||
|
||||
test("treats separators as soft hints until size pressure exists", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 18,
|
||||
separators: ["h2"],
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(
|
||||
[
|
||||
"<section>",
|
||||
"<p>Intro text</p>",
|
||||
"<h2>Section Title</h2>",
|
||||
"<p>Body text</p>",
|
||||
"</section>",
|
||||
].join(""),
|
||||
);
|
||||
|
||||
expect(chunks).toEqual(["Intro text", "Section Title\nBody text"]);
|
||||
});
|
||||
|
||||
test("groups consecutive separators into later section boundaries", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 4,
|
||||
separators: ["h2", "h3"],
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(
|
||||
"<section><h2>A</h2><h3>B</h3><p>Body</p></section>",
|
||||
);
|
||||
|
||||
expect(chunks).toEqual(["A", "B\nBody"]);
|
||||
});
|
||||
|
||||
test("keeps protected content intact up to maxChunkSize", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 10,
|
||||
maxChunkSize: 32,
|
||||
neverBreakWithin: ["pre"],
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(
|
||||
"<div><pre>12345678901234567890</pre></div>",
|
||||
);
|
||||
|
||||
expect(chunks).toEqual(["12345678901234567890"]);
|
||||
});
|
||||
|
||||
test("recurses into protected nodes once maxChunkSize is exceeded", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 12,
|
||||
maxChunkSize: 18,
|
||||
neverBreakWithin: [".keep"],
|
||||
separators: ["p"],
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(
|
||||
[
|
||||
'<div class="keep">',
|
||||
"<p>Alpha beta</p>",
|
||||
"<p>Gamma delta</p>",
|
||||
"</div>",
|
||||
].join(""),
|
||||
);
|
||||
|
||||
expect(chunks).toEqual(["Alpha beta", "Gamma delta"]);
|
||||
});
|
||||
|
||||
test("ignores separators inside protected subtrees until forced open", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 10,
|
||||
maxChunkSize: 40,
|
||||
neverBreakWithin: [".keep"],
|
||||
separators: ["h2"],
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(
|
||||
'<div class="keep"><h2>Title</h2><p>Body text</p></div>',
|
||||
);
|
||||
|
||||
expect(chunks).toEqual(["Title\nBody text"]);
|
||||
});
|
||||
|
||||
test("force-splits oversized leaf text when maxChunkSize is finite", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 12,
|
||||
maxChunkSize: 15,
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(`<pre>${"a".repeat(35)}</pre>`);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(15);
|
||||
}
|
||||
});
|
||||
|
||||
test("force-splits protected oversized leaf text when maxChunkSize is finite", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 12,
|
||||
maxChunkSize: 15,
|
||||
neverBreakWithin: ["pre"],
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(`<pre>${"x".repeat(35)}</pre>`);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(15);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not enforce a hard cap when maxChunkSize is omitted", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 12,
|
||||
neverBreakWithin: ["pre"],
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(`<pre>${"a".repeat(35)}</pre>`);
|
||||
|
||||
expect(chunks).toEqual(["a".repeat(35)]);
|
||||
});
|
||||
|
||||
test("splits plain text input without HTML structure", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 8,
|
||||
maxChunkSize: 8,
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText("alpha beta gamma");
|
||||
|
||||
expect(chunks).toEqual(["alpha", "beta", "gamma"]);
|
||||
});
|
||||
|
||||
test("preserves metadata and adds per-document part indexes", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 18,
|
||||
separators: ["h2"],
|
||||
});
|
||||
|
||||
const documents = await splitter.splitDocuments([
|
||||
new Document({
|
||||
pageContent:
|
||||
"<section><p>Intro text</p><h2>Section Title</h2><p>Body text</p></section>",
|
||||
metadata: { source: "sample.html" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(documents).toHaveLength(2);
|
||||
expect(documents[0].pageContent).toBe("Intro text");
|
||||
expect(documents[0].metadata).toMatchObject({
|
||||
source: "sample.html",
|
||||
partindex: 0,
|
||||
totalparts: 2,
|
||||
});
|
||||
expect(documents[1].pageContent).toBe("Section Title\nBody text");
|
||||
expect(documents[1].metadata).toMatchObject({
|
||||
source: "sample.html",
|
||||
partindex: 1,
|
||||
totalparts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("prepends chunkHeader in splitDocuments output", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 18,
|
||||
separators: ["h2"],
|
||||
});
|
||||
|
||||
const documents = await splitter.splitDocuments(
|
||||
[
|
||||
new Document({
|
||||
pageContent:
|
||||
"<section><p>Intro text</p><h2>Section Title</h2><p>Body text</p></section>",
|
||||
metadata: { source: "sample.html" },
|
||||
}),
|
||||
],
|
||||
{
|
||||
chunkHeader: "Header: ",
|
||||
},
|
||||
);
|
||||
|
||||
expect(documents).toHaveLength(2);
|
||||
expect(documents[0].pageContent).toBe("Header: Intro text");
|
||||
expect(documents[1].pageContent).toBe("Header: Section Title\nBody text");
|
||||
});
|
||||
|
||||
test("returns no documents when source content produces no chunks", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 18,
|
||||
});
|
||||
|
||||
const documents = await splitter.splitDocuments([
|
||||
new Document({
|
||||
pageContent: "<div> </div>",
|
||||
metadata: { source: "empty.html" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(documents).toEqual([]);
|
||||
});
|
||||
|
||||
test("resets part metadata per input document", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 18,
|
||||
separators: ["h2"],
|
||||
});
|
||||
|
||||
const documents = await splitter.splitDocuments([
|
||||
new Document({
|
||||
pageContent:
|
||||
"<section><p>Intro text</p><h2>Section Title</h2><p>Body text</p></section>",
|
||||
metadata: { source: "first.html" },
|
||||
}),
|
||||
new Document({
|
||||
pageContent:
|
||||
"<section><p>Lead text</p><h2>Second Title</h2><p>More text</p></section>",
|
||||
metadata: { source: "second.html" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(documents).toHaveLength(4);
|
||||
expect(documents[0].metadata).toMatchObject({
|
||||
source: "first.html",
|
||||
partindex: 0,
|
||||
totalparts: 2,
|
||||
});
|
||||
expect(documents[1].metadata).toMatchObject({
|
||||
source: "first.html",
|
||||
partindex: 1,
|
||||
totalparts: 2,
|
||||
});
|
||||
expect(documents[2].metadata).toMatchObject({
|
||||
source: "second.html",
|
||||
partindex: 0,
|
||||
totalparts: 2,
|
||||
});
|
||||
expect(documents[3].metadata).toMatchObject({
|
||||
source: "second.html",
|
||||
partindex: 1,
|
||||
totalparts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects chunkOverlap", () => {
|
||||
expect(
|
||||
() =>
|
||||
new HTMLTextSplitter({
|
||||
chunkSize: 32,
|
||||
chunkOverlap: 4,
|
||||
}),
|
||||
).toThrow("does not support chunkOverlap");
|
||||
});
|
||||
|
||||
test("keeps adjacent inline text contiguous when no whitespace exists", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 100,
|
||||
});
|
||||
|
||||
const chunks = await splitter.splitText(
|
||||
"<span>alpha</span><span>bet</span><span>gamma</span>",
|
||||
);
|
||||
|
||||
expect(chunks).toEqual(["alphabetgamma"]);
|
||||
});
|
||||
|
||||
test("preserves nested list indentation from formatForIngestion output", async () => {
|
||||
const splitter = new HTMLTextSplitter({
|
||||
chunkSize: 200,
|
||||
});
|
||||
|
||||
const html = [
|
||||
'<pre class="list-markdown">',
|
||||
"1. First",
|
||||
" A. Nested 1",
|
||||
" B. Nested 2",
|
||||
"2. Second",
|
||||
"</pre>",
|
||||
].join("\n");
|
||||
|
||||
const chunks = await splitter.splitText(html);
|
||||
|
||||
expect(chunks).toEqual([
|
||||
"1. First\n A. Nested 1\n B. Nested 2\n2. Second",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
import { Document } from "@langchain/core/documents";
|
||||
import {
|
||||
RecursiveCharacterTextSplitter,
|
||||
TextSplitter,
|
||||
type TextSplitterChunkHeaderOptions,
|
||||
type TextSplitterParams,
|
||||
} from "@langchain/textsplitters";
|
||||
import * as cheerio from "cheerio";
|
||||
import type { AnyNode } from "domhandler";
|
||||
|
||||
export interface HTMLTextSplitterParams extends TextSplitterParams {
|
||||
/**
|
||||
* CSS selectors that act as preferred structural split points.
|
||||
* They are only used when a subtree needs to be broken down.
|
||||
*/
|
||||
separators?: string[];
|
||||
|
||||
/**
|
||||
* CSS selectors for nodes that should stay atomic unless maxChunkSize forces recursion.
|
||||
*/
|
||||
neverBreakWithin?: string[];
|
||||
|
||||
/**
|
||||
* Absolute hard limit for chunk text length.
|
||||
* Defaults to Infinity if omitted.
|
||||
*/
|
||||
maxChunkSize?: number;
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
text: string;
|
||||
isProtected: boolean;
|
||||
sourceKind: "node" | "forced-split";
|
||||
}
|
||||
|
||||
const BLOCKISH_TAGS = new Set([
|
||||
"address",
|
||||
"article",
|
||||
"aside",
|
||||
"blockquote",
|
||||
"br",
|
||||
"dd",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"emu-clause",
|
||||
"emu-example",
|
||||
"emu-grammar",
|
||||
"emu-note",
|
||||
"emu-table",
|
||||
"fieldset",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"footer",
|
||||
"form",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"header",
|
||||
"hr",
|
||||
"li",
|
||||
"main",
|
||||
"nav",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"section",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"ul",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Splits HTML input into normalized text chunks.
|
||||
*
|
||||
* Important: this splitter does not preserve HTML markup in output.
|
||||
* It uses parsed HTML structure for traversal and sizing, but emits text only.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const splitter = new HTMLTextSplitter({
|
||||
* chunkSize: 32,
|
||||
* separators: ["h2"],
|
||||
* neverBreakWithin: ["pre"],
|
||||
* });
|
||||
*
|
||||
* const chunks = await splitter.splitText(
|
||||
* "<section><h2>Title</h2><p>Body</p></section>",
|
||||
* );
|
||||
* // => ["Title Body"]
|
||||
* ```
|
||||
*/
|
||||
export class HTMLTextSplitter extends TextSplitter {
|
||||
separators: string[];
|
||||
neverBreakWithin: string[];
|
||||
maxChunkSize: number;
|
||||
|
||||
constructor(fields?: Partial<HTMLTextSplitterParams>) {
|
||||
if ((fields?.chunkOverlap ?? 0) !== 0) {
|
||||
throw new Error("HTMLTextSplitter does not support chunkOverlap.");
|
||||
}
|
||||
|
||||
super({
|
||||
...fields,
|
||||
chunkOverlap: 0,
|
||||
keepSeparator: false,
|
||||
lengthFunction: (text: string) => text.trim().length,
|
||||
});
|
||||
|
||||
this.separators = fields?.separators ?? [];
|
||||
this.neverBreakWithin = fields?.neverBreakWithin ?? [];
|
||||
this.maxChunkSize = fields?.maxChunkSize ?? Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits HTML input into normalized text chunks.
|
||||
*
|
||||
* Important: this method emits text only. HTML markup is used for traversal
|
||||
* and sizing, but is not preserved in returned chunks.
|
||||
*/
|
||||
async splitText(text: string): Promise<string[]> {
|
||||
const $ = cheerio.load(text);
|
||||
const rootNodes =
|
||||
$("body").length > 0
|
||||
? $("body").contents().toArray()
|
||||
: $.root().contents().toArray();
|
||||
const roots = this.getMeaningfulNodes(rootNodes);
|
||||
|
||||
if (roots.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rootGroups = this.groupChildrenForDecomposition($, roots, false);
|
||||
const segments: Segment[] = [];
|
||||
|
||||
for (const group of rootGroups) {
|
||||
segments.push(...(await this.collectSegmentsFromNodes($, group, false)));
|
||||
}
|
||||
|
||||
return this.mergeSegments(segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits HTML documents into text-only child documents.
|
||||
*
|
||||
* Important: child document `pageContent` contains normalized text, not HTML.
|
||||
* Parent metadata is preserved and per-document part metadata is added.
|
||||
*/
|
||||
async splitDocuments(
|
||||
documents: Document[],
|
||||
chunkHeaderOptions?: TextSplitterChunkHeaderOptions,
|
||||
): Promise<Document[]> {
|
||||
const splitDocs: Document[] = [];
|
||||
const chunkHeader = chunkHeaderOptions?.chunkHeader ?? "";
|
||||
|
||||
for (const document of documents) {
|
||||
const chunks = await this.splitText(document.pageContent);
|
||||
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
splitDocs.push(
|
||||
new Document({
|
||||
pageContent: `${chunkHeader}${chunk}`,
|
||||
metadata: {
|
||||
...document.metadata,
|
||||
partindex: index,
|
||||
totalparts: chunks.length,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return splitDocs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a tag node matches any configured CSS selector.
|
||||
*
|
||||
* Needed so separator and protected-node checks share the same selector logic
|
||||
* and non-tag nodes are ignored safely.
|
||||
*/
|
||||
private matchesAny(
|
||||
$: cheerio.CheerioAPI,
|
||||
node: AnyNode,
|
||||
selectors: string[],
|
||||
): boolean {
|
||||
if (node.type !== "tag") {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const selector of selectors) {
|
||||
if ($(node).is(selector)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the canonical text value for a node.
|
||||
*
|
||||
* Needed so sizing and emitted output use the same trimmed text semantics for
|
||||
* both element nodes and raw text nodes.
|
||||
*/
|
||||
private getNodeText($: cheerio.CheerioAPI, node: AnyNode): string {
|
||||
if (node.type === "text") {
|
||||
return node.data ?? "";
|
||||
}
|
||||
|
||||
return $(node).text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a node should introduce a visible boundary when adjacent
|
||||
* text is concatenated.
|
||||
*
|
||||
* Needed so inline elements such as `span` do not get synthetic spaces while
|
||||
* block-ish elements still remain readable in emitted text.
|
||||
*/
|
||||
private isBlockishNode(node: AnyNode): boolean {
|
||||
return node.type === "tag" && BLOCKISH_TAGS.has(node.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins multiple nodes into the normalized text the splitter actually emits.
|
||||
*
|
||||
* Needed so grouped nodes are measured and emitted with the same spacing rules
|
||||
* used later during chunk merging.
|
||||
*/
|
||||
private getNodesText($: cheerio.CheerioAPI, nodes: AnyNode[]): string {
|
||||
let result = "";
|
||||
let previousNode: AnyNode | null = null;
|
||||
|
||||
for (const node of nodes) {
|
||||
const text = this.getNodeText($, node);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
result.length > 0 &&
|
||||
previousNode &&
|
||||
(this.isBlockishNode(previousNode) || this.isBlockishNode(node))
|
||||
) {
|
||||
result += "\n"; // Add newline between block elements for better structure
|
||||
}
|
||||
|
||||
result += text;
|
||||
previousNode = node;
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters child nodes down to the recursion units the splitter can handle.
|
||||
*
|
||||
* Needed to recurse through DOM structure without carrying empty whitespace-only
|
||||
* text nodes or unsupported node types through the algorithm.
|
||||
*/
|
||||
private getMeaningfulNodes(nodes: AnyNode[]): AnyNode[] {
|
||||
return nodes.filter((node) => {
|
||||
if (node.type === "text") {
|
||||
return (node.data ?? "").trim().length > 0;
|
||||
}
|
||||
|
||||
return node.type === "tag";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the child nodes that are meaningful recursion units.
|
||||
*
|
||||
* Needed to recurse through DOM structure without carrying empty whitespace-only
|
||||
* text nodes or unsupported node types through the algorithm.
|
||||
*/
|
||||
private getChildNodesForRecursion(node: AnyNode): AnyNode[] {
|
||||
if (!("children" in node) || !Array.isArray(node.children)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.getMeaningfulNodes(node.children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups child nodes so separator nodes start a new group.
|
||||
*
|
||||
* Needed because separators represent preferred section boundaries. Grouping
|
||||
* makes "separator plus following content" explicit during recursion.
|
||||
*/
|
||||
private groupChildrenForDecomposition(
|
||||
$: cheerio.CheerioAPI,
|
||||
childNodes: AnyNode[],
|
||||
inProtectedTree: boolean,
|
||||
): AnyNode[][] {
|
||||
if (childNodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (inProtectedTree || this.separators.length === 0) {
|
||||
return [childNodes];
|
||||
}
|
||||
|
||||
const groups: AnyNode[][] = [];
|
||||
let currentGroup: AnyNode[] = [];
|
||||
|
||||
const flush = () => {
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
currentGroup = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const child of childNodes) {
|
||||
if (this.matchesAny($, child, this.separators)) {
|
||||
flush();
|
||||
}
|
||||
|
||||
currentGroup.push(child);
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects text segments for a single node or grouped sibling nodes.
|
||||
*
|
||||
* Needed so separator-led groups can be treated as one structural unit before
|
||||
* the splitter decides whether it must recurse deeper.
|
||||
*/
|
||||
private async collectSegmentsFromNodes(
|
||||
$: cheerio.CheerioAPI,
|
||||
nodes: AnyNode[],
|
||||
inProtectedAncestor: boolean,
|
||||
): Promise<Segment[]> {
|
||||
const text = this.getNodesText($, nodes);
|
||||
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (nodes.length > 1) {
|
||||
const startsWithSeparator = this.matchesAny($, nodes[0], this.separators);
|
||||
|
||||
if (
|
||||
text.length <= this.chunkSize ||
|
||||
(startsWithSeparator && text.length <= this.maxChunkSize)
|
||||
) {
|
||||
return [{ text, isProtected: false, sourceKind: "node" }];
|
||||
}
|
||||
|
||||
const segments: Segment[] = [];
|
||||
for (const node of nodes) {
|
||||
segments.push(
|
||||
...(await this.collectSegmentsFromNodes(
|
||||
$,
|
||||
[node],
|
||||
inProtectedAncestor,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
return segments.length > 0
|
||||
? segments
|
||||
: await this.forceSplitText(text, false);
|
||||
}
|
||||
|
||||
const [node] = nodes;
|
||||
const isProtected =
|
||||
!inProtectedAncestor && this.matchesAny($, node, this.neverBreakWithin);
|
||||
const childNodes = this.getChildNodesForRecursion(node);
|
||||
|
||||
if (childNodes.length > 0) {
|
||||
const childGroups = this.groupChildrenForDecomposition(
|
||||
$,
|
||||
childNodes,
|
||||
isProtected || inProtectedAncestor,
|
||||
);
|
||||
const normalizedChildText = this.getNodesText($, childGroups.flat());
|
||||
|
||||
if (normalizedChildText.length <= this.chunkSize) {
|
||||
return [{ text: normalizedChildText, isProtected, sourceKind: "node" }];
|
||||
}
|
||||
|
||||
if (isProtected && normalizedChildText.length <= this.maxChunkSize) {
|
||||
return [{ text: normalizedChildText, isProtected, sourceKind: "node" }];
|
||||
}
|
||||
|
||||
const segments: Segment[] = [];
|
||||
for (const group of childGroups) {
|
||||
segments.push(
|
||||
...(await this.collectSegmentsFromNodes(
|
||||
$,
|
||||
group,
|
||||
isProtected || inProtectedAncestor,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
return segments.length > 0
|
||||
? segments
|
||||
: await this.forceSplitText(normalizedChildText, isProtected);
|
||||
}
|
||||
|
||||
if (text.length <= this.chunkSize) {
|
||||
return [{ text, isProtected, sourceKind: "node" }];
|
||||
}
|
||||
|
||||
if (isProtected && text.length <= this.maxChunkSize) {
|
||||
return [{ text, isProtected, sourceKind: "node" }];
|
||||
}
|
||||
|
||||
return this.forceSplitText(text, isProtected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the final plain-text fallback when a node cannot be decomposed further.
|
||||
*
|
||||
* Needed to enforce a finite hard cap with `RecursiveCharacterTextSplitter`
|
||||
* once DOM structure is exhausted.
|
||||
*/
|
||||
private async forceSplitText(
|
||||
text: string,
|
||||
isProtected: boolean,
|
||||
): Promise<Segment[]> {
|
||||
const normalizedText = text.trim();
|
||||
|
||||
if (!normalizedText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Number.isFinite(this.maxChunkSize)) {
|
||||
return [
|
||||
{
|
||||
text: normalizedText,
|
||||
isProtected,
|
||||
sourceKind: "forced-split",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const fallback = new RecursiveCharacterTextSplitter({
|
||||
chunkSize: this.maxChunkSize,
|
||||
chunkOverlap: 0,
|
||||
keepSeparator: false,
|
||||
lengthFunction: (value: string) => value.trim().length,
|
||||
});
|
||||
|
||||
const chunks = await fallback.splitText(normalizedText);
|
||||
|
||||
return chunks
|
||||
.map((chunk) => chunk.trim())
|
||||
.filter(Boolean)
|
||||
.map((chunk) => ({
|
||||
text: chunk,
|
||||
isProtected,
|
||||
sourceKind: "forced-split" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges flat segments into final chunks using `chunkSize` as a soft target.
|
||||
*
|
||||
* Needed to keep chunk assembly separate from DOM traversal and to normalize the
|
||||
* final text output by joining segment text with single spaces.
|
||||
*/
|
||||
private mergeSegments(segments: Segment[]): string[] {
|
||||
const chunks: string[] = [];
|
||||
let currentParts: string[] = [];
|
||||
let currentLength = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (currentParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
chunks.push(currentParts.join(" "));
|
||||
currentParts = [];
|
||||
currentLength = 0;
|
||||
};
|
||||
|
||||
for (const segment of segments) {
|
||||
const nextLength =
|
||||
currentLength === 0
|
||||
? segment.text.length
|
||||
: currentLength + 1 + segment.text.length;
|
||||
|
||||
if (currentParts.length > 0 && nextLength > this.chunkSize) {
|
||||
flush();
|
||||
}
|
||||
|
||||
currentParts.push(segment.text);
|
||||
currentLength =
|
||||
currentLength === 0
|
||||
? segment.text.length
|
||||
: currentLength + 1 + segment.text.length;
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./HtmlTextSplitter";
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as cheerio from "cheerio";
|
||||
import {
|
||||
convertBlockCodeToMarkdown,
|
||||
convertGrammarToMarkdown,
|
||||
convertInlineCodeToMarkdown,
|
||||
convertLinksToMarkdown,
|
||||
convertListsToMarkdown,
|
||||
convertTablesToMarkdown,
|
||||
DEFAULT_CONFIG,
|
||||
formatForIngestion,
|
||||
} from "./formatHTMLForIngestion";
|
||||
|
||||
describe("formatHTMLForIngestion", () => {
|
||||
describe("convertLinksToMarkdown", () => {
|
||||
test("converts emu-xref links to markdown and strips filenames", () => {
|
||||
const html = `<emu-xref href="abstract-operations.html#sec-tonumber"><a href="abstract-operations.html#sec-tonumber">ToNumber</a></emu-xref>`;
|
||||
const $ = cheerio.load(html);
|
||||
convertLinksToMarkdown($, DEFAULT_CONFIG.links.join(", "));
|
||||
expect($.html()).toContain(
|
||||
'<span class="link-markdown">[ToNumber](#sec-tonumber)</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test("skips external links", () => {
|
||||
const html = `<emu-xref href="https://example.com"><a href="https://example.com">External</a></emu-xref>`;
|
||||
const $ = cheerio.load(html);
|
||||
convertLinksToMarkdown($, DEFAULT_CONFIG.links.join(", "));
|
||||
expect($.html()).toContain('<a href="https://example.com">External</a>');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertInlineCodeToMarkdown", () => {
|
||||
test("converts var, emu-val, emu-const to inline code", () => {
|
||||
const html = `<div><var>x</var> <emu-val>y</emu-val> <emu-const>z</emu-const> <code>w</code></div>`;
|
||||
const $ = cheerio.load(html);
|
||||
convertInlineCodeToMarkdown($, DEFAULT_CONFIG.codeBlocks.inline);
|
||||
expect($.html()).toContain('<span class="inline-code">`x`</span>');
|
||||
expect($.html()).toContain('<span class="inline-code">`y`</span>');
|
||||
expect($.html()).toContain('<span class="inline-code">`z`</span>');
|
||||
expect($.html()).toContain('<span class="inline-code">`w`</span>');
|
||||
});
|
||||
|
||||
test("skips code inside pre", () => {
|
||||
const html = `<pre><code>x</code></pre>`;
|
||||
const $ = cheerio.load(html);
|
||||
convertInlineCodeToMarkdown($, DEFAULT_CONFIG.codeBlocks.inline);
|
||||
expect($.html()).toContain("<pre><code>x</code></pre>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertBlockCodeToMarkdown", () => {
|
||||
test("converts pre>code and emu-eqn", () => {
|
||||
const html = `<div>
|
||||
<pre><code class="javascript hljs">const x = 1;</code></pre>
|
||||
<emu-eqn>y = x + 1</emu-eqn>
|
||||
<emu-eqn class="inline">z = 2</emu-eqn>
|
||||
</div>`;
|
||||
const $ = cheerio.load(html);
|
||||
convertBlockCodeToMarkdown($, DEFAULT_CONFIG.codeBlocks.block);
|
||||
expect($.html()).toContain(
|
||||
'<pre class="code-markdown">```javascript\nconst x = 1;\n```</pre>',
|
||||
);
|
||||
expect($.html()).toContain(
|
||||
'<pre class="code-markdown">```\ny = x + 1\n```</pre>',
|
||||
);
|
||||
expect($.html()).toContain('<emu-eqn class="inline">z = 2</emu-eqn>');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertGrammarToMarkdown", () => {
|
||||
test("converts emu-grammar to fenced bnf", () => {
|
||||
const html = `<emu-grammar>Statement :: BlockStatement</emu-grammar>`;
|
||||
const $ = cheerio.load(html);
|
||||
convertGrammarToMarkdown($, DEFAULT_CONFIG.codeBlocks.grammar.join(", "));
|
||||
expect($.html()).toContain(
|
||||
'<pre class="code-markdown">```bnf\nStatement :: BlockStatement\n```</pre>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertListsToMarkdown", () => {
|
||||
test("converts ul with nested ol", () => {
|
||||
const html = `
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2
|
||||
<ol>
|
||||
<li>Subitem 1</li>
|
||||
<li>Subitem 2</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
const $ = cheerio.load(html);
|
||||
convertListsToMarkdown(
|
||||
$,
|
||||
DEFAULT_CONFIG.lists.ordered,
|
||||
DEFAULT_CONFIG.lists.unordered,
|
||||
);
|
||||
const lines = $("pre.list-markdown")
|
||||
.text()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toEqual([
|
||||
"- Item 1",
|
||||
"- Item 2",
|
||||
" A. Subitem 1",
|
||||
" B. Subitem 2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles 3-level deep nesting (ol > ul > ol)", () => {
|
||||
const html = `
|
||||
<ol>
|
||||
<li>First</li>
|
||||
<li>Second
|
||||
<ul>
|
||||
<li>Alpha
|
||||
<ol>
|
||||
<li>Deep 1</li>
|
||||
<li>Deep 2</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Beta</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
`;
|
||||
const $ = cheerio.load(html);
|
||||
convertListsToMarkdown(
|
||||
$,
|
||||
DEFAULT_CONFIG.lists.ordered,
|
||||
DEFAULT_CONFIG.lists.unordered,
|
||||
);
|
||||
const lines = $("pre.list-markdown")
|
||||
.text()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toEqual([
|
||||
"1. First",
|
||||
"2. Second",
|
||||
" * Alpha",
|
||||
" 1. Deep 1",
|
||||
" 2. Deep 2",
|
||||
" * Beta",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles multiple sibling nested lists in one item", () => {
|
||||
const html = `
|
||||
<ul>
|
||||
<li>Item
|
||||
<ol>
|
||||
<li>Ordered sub</li>
|
||||
</ol>
|
||||
<ul>
|
||||
<li>Unordered sub</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
const $ = cheerio.load(html);
|
||||
convertListsToMarkdown(
|
||||
$,
|
||||
DEFAULT_CONFIG.lists.ordered,
|
||||
DEFAULT_CONFIG.lists.unordered,
|
||||
);
|
||||
const lines = $("pre.list-markdown")
|
||||
.text()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toEqual([
|
||||
"- Item",
|
||||
" A. Ordered sub",
|
||||
" * Unordered sub",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles consecutive top-level lists", () => {
|
||||
const html = `
|
||||
<ul>
|
||||
<li>UL item 1</li>
|
||||
<li>UL item 2</li>
|
||||
</ul>
|
||||
<ol>
|
||||
<li>OL item 1</li>
|
||||
<li>OL item 2</li>
|
||||
</ol>
|
||||
`;
|
||||
const $ = cheerio.load(html);
|
||||
convertListsToMarkdown(
|
||||
$,
|
||||
DEFAULT_CONFIG.lists.ordered,
|
||||
DEFAULT_CONFIG.lists.unordered,
|
||||
);
|
||||
const results = $("pre.list-markdown");
|
||||
expect(results.length).toBe(2);
|
||||
|
||||
const ulLines = results
|
||||
.eq(0)
|
||||
.text()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(ulLines).toEqual(["- UL item 1", "- UL item 2"]);
|
||||
|
||||
const olLines = results
|
||||
.eq(1)
|
||||
.text()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(olLines).toEqual(["1. OL item 1", "2. OL item 2"]);
|
||||
});
|
||||
|
||||
test("preserves inline code inside list items", () => {
|
||||
const html = `
|
||||
<ul>
|
||||
<li>Call <code>foo()</code></li>
|
||||
<li>Use <var>x</var></li>
|
||||
</ul>
|
||||
`;
|
||||
const $ = cheerio.load(html);
|
||||
convertInlineCodeToMarkdown($, DEFAULT_CONFIG.codeBlocks.inline);
|
||||
convertListsToMarkdown(
|
||||
$,
|
||||
DEFAULT_CONFIG.lists.ordered,
|
||||
DEFAULT_CONFIG.lists.unordered,
|
||||
);
|
||||
const lines = $("pre.list-markdown")
|
||||
.text()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toEqual(["- Call `foo()`", "- Use `x`"]);
|
||||
});
|
||||
|
||||
test("handles ol nested inside ol", () => {
|
||||
const html = `
|
||||
<ol>
|
||||
<li>First
|
||||
<ol>
|
||||
<li>Nested 1</li>
|
||||
<li>Nested 2</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Second</li>
|
||||
</ol>
|
||||
`;
|
||||
const $ = cheerio.load(html);
|
||||
convertListsToMarkdown(
|
||||
$,
|
||||
DEFAULT_CONFIG.lists.ordered,
|
||||
DEFAULT_CONFIG.lists.unordered,
|
||||
);
|
||||
const lines = $("pre.list-markdown")
|
||||
.text()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toEqual([
|
||||
"1. First",
|
||||
" A. Nested 1",
|
||||
" B. Nested 2",
|
||||
"2. Second",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty list", () => {
|
||||
const html = `<ul></ul>`;
|
||||
const $ = cheerio.load(html);
|
||||
convertListsToMarkdown(
|
||||
$,
|
||||
DEFAULT_CONFIG.lists.ordered,
|
||||
DEFAULT_CONFIG.lists.unordered,
|
||||
);
|
||||
const result = $("pre.list-markdown").text().trim();
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertTablesToMarkdown", () => {
|
||||
test("converts tables to markdown tables", () => {
|
||||
const html = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Col 1</th><th>Col 2</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Data 1</td><td>Data 2</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
const $ = cheerio.load(html);
|
||||
convertTablesToMarkdown($);
|
||||
const result = $("pre.table-markdown").text();
|
||||
expect(result).toContain("| Col 1 | Col 2 |");
|
||||
expect(result).toContain("| --- | --- |");
|
||||
expect(result).toContain("| Data 1 | Data 2 |");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatForIngestion", () => {
|
||||
test("runs the full pipeline", () => {
|
||||
const html = `
|
||||
<div>
|
||||
<p>See <emu-xref href="#sec-example"><a href="#sec-example">Example</a></emu-xref> for <var>x</var></p>
|
||||
<pre><code class="javascript">let y = x;</code></pre>
|
||||
<ul>
|
||||
<li>One</li>
|
||||
<li>Two</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
const $ = cheerio.load(html);
|
||||
formatForIngestion($);
|
||||
|
||||
expect($.html()).toContain(
|
||||
'<span class="link-markdown">[Example](#sec-example)</span>',
|
||||
);
|
||||
expect($.html()).toContain('<span class="inline-code">`x`</span>');
|
||||
expect($.html()).toContain(
|
||||
'<pre class="code-markdown">```javascript\nlet y = x;\n```</pre>',
|
||||
);
|
||||
expect($.html()).toContain(
|
||||
'<pre class="list-markdown">- One\n- Two\n</pre>',
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves nested list indentation through the full pipeline", () => {
|
||||
const html = `
|
||||
<ol>
|
||||
<li>First</li>
|
||||
<li>Second
|
||||
<ul>
|
||||
<li>Alpha
|
||||
<ol>
|
||||
<li>Deep 1</li>
|
||||
<li>Deep 2</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Beta</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
`;
|
||||
const $ = cheerio.load(html);
|
||||
formatForIngestion($);
|
||||
|
||||
const lines = $("pre.list-markdown")
|
||||
.text()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toEqual([
|
||||
"1. First",
|
||||
"2. Second",
|
||||
" * Alpha",
|
||||
" 1. Deep 1",
|
||||
" 2. Deep 2",
|
||||
" * Beta",
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves ol nested inside ol through the full pipeline", () => {
|
||||
const html = `
|
||||
<ol>
|
||||
<li>First
|
||||
<ol>
|
||||
<li>Nested 1</li>
|
||||
<li>Nested 2</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Second</li>
|
||||
</ol>
|
||||
`;
|
||||
const $ = cheerio.load(html);
|
||||
formatForIngestion($);
|
||||
|
||||
const lines = $("pre.list-markdown")
|
||||
.text()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toEqual([
|
||||
"1. First",
|
||||
" A. Nested 1",
|
||||
" B. Nested 2",
|
||||
"2. Second",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import type * as cheerio from "cheerio";
|
||||
|
||||
/**
|
||||
* List of block-level HTML elements that should have newlines added after them
|
||||
* to preserve document structure during text extraction.
|
||||
*/
|
||||
export const BLOCK_ELEMENTS = [
|
||||
"p",
|
||||
"div",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"li",
|
||||
"td",
|
||||
"th",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"section",
|
||||
"emu-clause",
|
||||
"emu-note",
|
||||
"emu-example",
|
||||
"emu-table",
|
||||
"figure",
|
||||
"figcaption",
|
||||
"ul",
|
||||
"ol",
|
||||
"dl",
|
||||
"dt",
|
||||
"dd",
|
||||
"emu-production",
|
||||
"emu-rhs",
|
||||
];
|
||||
|
||||
export interface FormatConfig {
|
||||
codeBlocks: {
|
||||
block: string[];
|
||||
inline: string[];
|
||||
grammar: string[];
|
||||
};
|
||||
links: string[];
|
||||
lists: {
|
||||
ordered: string[];
|
||||
unordered: string[];
|
||||
};
|
||||
tables: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: FormatConfig = {
|
||||
codeBlocks: {
|
||||
block: ["pre>code", "emu-eqn:not([class*='inline'])"],
|
||||
inline: ["var", "emu-val", "emu-const", "emu-eqn.inline", "code"],
|
||||
grammar: ["emu-grammar"],
|
||||
},
|
||||
links: ["emu-xref a"],
|
||||
lists: {
|
||||
ordered: ["ol"],
|
||||
unordered: ["ul"],
|
||||
},
|
||||
tables: ["table", "emu-table"],
|
||||
};
|
||||
|
||||
export function convertLinksToMarkdown(
|
||||
$: cheerio.CheerioAPI,
|
||||
selector: string = "emu-xref a[href]",
|
||||
): void {
|
||||
$(selector).each((_, elem) => {
|
||||
const $a = $(elem);
|
||||
const href = $a.attr("href") ?? "";
|
||||
|
||||
// Skip external links
|
||||
if (href.startsWith("http")) return;
|
||||
|
||||
// Strip filename prefix: "abstract-operations.html#sec-tonumber" → "#sec-tonumber"
|
||||
const hash = href.includes("#") ? `#${href.split("#")[1]}` : href;
|
||||
const text = $a.text().trim();
|
||||
|
||||
if (!text || !hash) return;
|
||||
|
||||
$a.replaceWith($(`<span class="link-markdown">[${text}](${hash})</span>`));
|
||||
});
|
||||
}
|
||||
|
||||
export function convertInlineCodeToMarkdown(
|
||||
$: cheerio.CheerioAPI,
|
||||
tags: string[],
|
||||
): void {
|
||||
for (const tag of tags) {
|
||||
$(tag).each((_, elem) => {
|
||||
// Skip <code> inside <pre> (handled by block converter)
|
||||
if (
|
||||
"name" in elem &&
|
||||
elem.name === "code" &&
|
||||
$(elem).parent("pre").length > 0
|
||||
)
|
||||
return;
|
||||
|
||||
const text = $(elem).text().trim();
|
||||
if (!text) return;
|
||||
$(elem).replaceWith($(`<span class="inline-code">\`${text}\`</span>`));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function extractLanguage(codeClass: string | undefined): string {
|
||||
// "javascript hljs" → "javascript", "python hljs" → "python"
|
||||
if (!codeClass) return "";
|
||||
const match = codeClass.match(/^(\w+)/);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function convertBlockCodeToMarkdown(
|
||||
$: cheerio.CheerioAPI,
|
||||
tags: string[],
|
||||
): void {
|
||||
const selectors = tags.join(", ");
|
||||
if (!selectors) return;
|
||||
|
||||
$(selectors).each((_, elem) => {
|
||||
const $elem = $(elem);
|
||||
const lang = extractLanguage($elem.attr("class"));
|
||||
const text = $elem.text().trim();
|
||||
$elem.replaceWith(
|
||||
$(`<pre class="code-markdown">\`\`\`${lang}\n${text}\n\`\`\`</pre>`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the markdown list item prefix based on list type and nesting depth.
|
||||
* Alternates markers to visually distinguish nesting levels:
|
||||
* - Ordered lists: `1.` at even depth, `A.` at odd depth
|
||||
* - Unordered lists: `-` at even depth, `*` at odd depth
|
||||
*
|
||||
* @param isOrdered - Whether the list is ordered (ol) or unordered (ul)
|
||||
* @param depth - Nesting depth (0 = top-level)
|
||||
* @param index - Zero-based item index within its list
|
||||
* @returns Prefix string like "1. ", "A. ", "- ", or "* "
|
||||
*/
|
||||
function getListItemPrefix(
|
||||
isOrdered: boolean,
|
||||
depth: number,
|
||||
index: number,
|
||||
): string {
|
||||
if (isOrdered) {
|
||||
return depth % 2 === 0
|
||||
? `${index + 1}. `
|
||||
: `${String.fromCharCode(65 + index)}. `;
|
||||
}
|
||||
return depth % 2 === 0 ? "- " : "* ";
|
||||
}
|
||||
|
||||
function listToMarkdown(
|
||||
$: cheerio.CheerioAPI,
|
||||
elem: any,
|
||||
depth: number = 0,
|
||||
): string {
|
||||
const $elem = $(elem);
|
||||
const isOrdered = elem.name === "ol";
|
||||
const indent = " ".repeat(depth);
|
||||
const lines: string[] = [];
|
||||
|
||||
$elem.children("li").each((i, li) => {
|
||||
const prefix = getListItemPrefix(isOrdered, depth, i);
|
||||
const $li = $(li);
|
||||
|
||||
const nestedLists: string[] = [];
|
||||
|
||||
// Recurse into nested lists first (depth-first)
|
||||
$li.children("ol, ul").each((_, nested) => {
|
||||
const nestedMarkdown = listToMarkdown($, nested, depth + 1);
|
||||
nestedLists.push(nestedMarkdown);
|
||||
$(nested).remove();
|
||||
});
|
||||
|
||||
// Now get full text
|
||||
const itemText = $li.text().trim().replace(/\s+/g, " ");
|
||||
lines.push(`${indent}${prefix}${itemText}`);
|
||||
|
||||
for (const nested of nestedLists) {
|
||||
lines.push(nested);
|
||||
}
|
||||
});
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function convertListsToMarkdown(
|
||||
$: cheerio.CheerioAPI,
|
||||
ordered: string[] = ["ol"],
|
||||
unordered: string[] = ["ul"],
|
||||
): void {
|
||||
const selectors = [...ordered, ...unordered].join(", ");
|
||||
if (!selectors) return;
|
||||
|
||||
// Process from outermost — recursion handles depth-first nesting
|
||||
$(selectors).each((_, elem) => {
|
||||
// Skip if already processed (parent already handled this)
|
||||
if ($(elem).hasClass("list-markdown") || $(elem).hasClass("list-processed"))
|
||||
return;
|
||||
const markdown = listToMarkdown($, elem, 0);
|
||||
$(elem).replaceWith($(`<pre class="list-markdown">\n${markdown}\n</pre>`));
|
||||
});
|
||||
}
|
||||
|
||||
export function convertGrammarToMarkdown(
|
||||
$: cheerio.CheerioAPI,
|
||||
selector: string = "emu-grammar",
|
||||
): void {
|
||||
if (!selector) return;
|
||||
$(selector).each((_, grammar) => {
|
||||
const text = $(grammar).text().trim();
|
||||
$(grammar).replaceWith(
|
||||
$(`<pre class="code-markdown">\`\`\`bnf\n${text}\n\`\`\`</pre>`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts HTML tables to markdown table format.
|
||||
* This preserves table structure when extracting text from HTML.
|
||||
* Replaces the original table with a <pre> element containing the markdown.
|
||||
*
|
||||
* @param $ - Cheerio API instance
|
||||
*/
|
||||
export function convertTablesToMarkdown($: cheerio.CheerioAPI): void {
|
||||
$("table, emu-table").each((_, tableElem) => {
|
||||
const $table = $(tableElem);
|
||||
const rows: string[][] = [];
|
||||
|
||||
// Extract header rows
|
||||
$table.find("thead tr").each((_, rowElem) => {
|
||||
const row: string[] = [];
|
||||
$(rowElem)
|
||||
.find("th, td")
|
||||
.each((_, cellElem) => {
|
||||
row.push($(cellElem).text().trim().replace(/\|/g, "\\|"));
|
||||
});
|
||||
if (row.length > 0) rows.push(row);
|
||||
});
|
||||
|
||||
// Extract body rows
|
||||
$table.find("tbody tr, tr").each((_, rowElem) => {
|
||||
// Skip if already processed as header
|
||||
if ($(rowElem).parent("thead").length > 0) return;
|
||||
|
||||
const row: string[] = [];
|
||||
$(rowElem)
|
||||
.find("td, th")
|
||||
.each((_, cellElem) => {
|
||||
row.push($(cellElem).text().trim().replace(/\|/g, "\\|"));
|
||||
});
|
||||
if (row.length > 0) rows.push(row);
|
||||
});
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
// Determine max columns
|
||||
const maxCols = Math.max(...rows.map((r) => r.length));
|
||||
|
||||
// Build markdown table
|
||||
const mdLines: string[] = [];
|
||||
|
||||
// Header row
|
||||
if (rows.length > 0) {
|
||||
const header = rows[0].concat(Array(maxCols - rows[0].length).fill(""));
|
||||
mdLines.push("| " + header.join(" | ") + " |");
|
||||
}
|
||||
|
||||
// Separator
|
||||
mdLines.push("|" + Array(maxCols).fill(" --- ").join("|") + "|");
|
||||
|
||||
// Data rows (skip header if we have more rows)
|
||||
const dataRows = rows.length > 1 ? rows.slice(1) : [];
|
||||
for (const row of dataRows) {
|
||||
const padded = row.concat(Array(maxCols - row.length).fill(""));
|
||||
mdLines.push("| " + padded.join(" | ") + " |");
|
||||
}
|
||||
|
||||
// Replace table with markdown
|
||||
const markdown = mdLines.join("\n");
|
||||
$table.replaceWith($(`<pre class="table-markdown">\n${markdown}\n</pre>`));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds newlines after specified block elements to preserve document structure.
|
||||
* This helps text splitters maintain paragraph/section boundaries.
|
||||
*
|
||||
* @param $ - Cheerio API instance
|
||||
* @param elements - Array of element tag names to add newlines after
|
||||
*/
|
||||
export function addNewlinesAfterBlocks(
|
||||
$: cheerio.CheerioAPI,
|
||||
elements: string[] = BLOCK_ELEMENTS,
|
||||
): void {
|
||||
for (const tag of elements) {
|
||||
$.root()
|
||||
.find(tag)
|
||||
.each((_, el) => {
|
||||
$(el).append("\n");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function formatForIngestion(
|
||||
$: cheerio.CheerioAPI,
|
||||
config: Partial<FormatConfig> = {},
|
||||
): void {
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
// 1. Inject newlines globally (affects li, p, pre, emu-production, emu-rhs, etc.)
|
||||
addNewlinesAfterBlocks($);
|
||||
|
||||
// 2. Inline leaves (links first — must be before lists/code destroy DOM)
|
||||
convertLinksToMarkdown($, cfg.links.join(", "));
|
||||
|
||||
// 3. Inline code
|
||||
convertInlineCodeToMarkdown($, cfg.codeBlocks.inline);
|
||||
|
||||
// 4. Block leaves (fenced code)
|
||||
convertBlockCodeToMarkdown($, cfg.codeBlocks.block);
|
||||
|
||||
// 5. Structural parents (grammar, lists, tables)
|
||||
convertGrammarToMarkdown($, cfg.codeBlocks.grammar.join(", "));
|
||||
convertListsToMarkdown($, cfg.lists.ordered, cfg.lists.unordered);
|
||||
convertTablesToMarkdown($);
|
||||
}
|
||||
Reference in New Issue
Block a user