Files
2026-06-21 10:07:43 -04:00

254 lines
11 KiB
JavaScript

// generate machine-readable docs for LLMs and coding-agents, from source-of-truth.
//
// node ./scripts/docs.js
//
// emits:
// docs/tags.md - the full tagset graph (from the running library)
// docs/api.md - every method, signature + one-line description (from types/*.d.ts)
// llms-full.txt - all in-repo docs concatenated into one fetchable file
//
// the curated docs (AGENTS.md, docs/match-syntax.md, docs/recipes.md, docs/concepts.md)
// are hand-written - this script does not touch them, it only stitches them into llms-full.txt
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import nlp from '../src/three.js'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const read = (f) => fs.readFileSync(path.join(root, f), 'utf-8')
const write = (f, str) => {
fs.writeFileSync(path.join(root, f), str)
console.log(` wrote ${f} (${(str.length / 1024).toFixed(1)}kb)`)
}
const stamp = `<!-- 🤖 generated by scripts/docs.js — do not edit by hand. -->\n`
// ------------------------------------------------------------------ tags.md
const colorName = { blue: 'Nouns', green: 'Verbs', red: 'Values & Dates', magenta: 'Adjectives', cyan: 'Closed-class' }
const generateTags = function () {
const tagSet = nlp.world().model.one.tagSet
const names = Object.keys(tagSet).sort()
// group by color, then 'other'
const groups = {}
for (const name of names) {
const t = tagSet[name]
const bucket = colorName[t.color] || 'Other'
groups[bucket] = groups[bucket] || []
groups[bucket].push(name)
}
let md = stamp + `# Compromise Tags (Part-of-Speech tagset)\n\n`
md += `Every term is assigned one or more of these **${names.length} tags**. `
md += `Use them in match-patterns with a \`#\` prefix, e.g. \`doc.match('#Person')\`.\n\n`
md += `Tags are a hierarchy: tagging a term \`#FirstName\` also makes it a \`#Person\` and a \`#Noun\`. `
md += `Tagging a term one thing may remove conflicting tags (a term can't be both \`#Singular\` and \`#Plural\`).\n\n`
md += `> ⚠️ Only the tags listed here are valid. \`#Name\`, \`#Subject\`, \`#Object\`, \`#Adj\` etc. are **not** real tags and silently match nothing.\n\n`
const order = ['Nouns', 'Verbs', 'Adjectives', 'Values & Dates', 'Closed-class', 'Other']
for (const bucket of order) {
if (!groups[bucket]) continue
md += `## ${bucket}\n\n`
md += `| Tag | Is also a | Example |\n|---|---|---|\n`
for (const name of groups[bucket]) {
const t = tagSet[name]
const parents = (t.parents || []).filter(p => p !== name)
const isAlso = parents.length ? parents.map(p => `\`#${p}\``).join(', ') : '—'
const eg = examples[name] || ''
md += `| \`#${name}\` | ${isAlso} | ${eg} |\n`
}
md += `\n`
}
return md
}
// representative example word(s) per tag — kept beside the generator so it's one file to update
const examples = {
Noun: 'cat', Singular: 'cat', Plural: 'cats', ProperNoun: 'Tesla', Uncountable: 'gravity',
Person: 'John Smith', FirstName: 'john', MaleName: 'john', FemaleName: 'mary', LastName: 'smith',
Honorific: 'dr.', Pronoun: 'he', Reflexive: 'yourself', Actor: 'swimmer', Activity: 'swimming',
Place: 'Paris', Country: 'Canada', City: 'Toronto', Region: 'California', Address: '4 main st.',
Organization: 'Google', Company: 'Google', School: 'UCLA', SportsTeam: 'the Leafs',
Unit: 'km', Demonym: 'Canadian', Possessive: "spencer's", Currency: '$', AtMention: '@nlp',
Verb: 'walk', PresentTense: 'walks', Infinitive: 'walk', Imperative: 'eat!', Gerund: 'walking',
PastTense: 'walked', FutureTense: 'will walk', Copula: 'is', Modal: 'could', Participle: 'awoken',
Auxiliary: 'will have', PhrasalVerb: 'walk out', Particle: 'out', Passive: 'was walked',
Adjective: 'quick', Comparable: 'quick', Comparative: 'quicker', Superlative: 'quickest', Adverb: 'quickly',
Value: '5', Cardinal: 'five', Ordinal: 'fifth', Fraction: '2/3', Multiple: 'million',
RomanNumeral: 'xviii', TextValue: 'five', NumericValue: '5', Money: '$5', Percent: '5%',
Date: 'monday', Month: 'march', WeekDay: 'monday', Year: '1992', FinancialQuarter: 'q2',
Holiday: 'easter', Season: 'summer', Timezone: 'EST', Time: '4:30pm', Duration: '2 weeks',
Determiner: 'the', Conjunction: 'and', Preposition: 'of', QuestionWord: 'who', Expression: 'hi',
Negative: 'not', Condition: 'if', There: 'there', Prefix: 'co-', Hyphenated: 'bone-headed',
Abbreviation: 'mrs.', Acronym: 'FBI', Url: 'compromise.cool', PhoneNumber: '(555) 123-4567',
HashTag: '#nlp', Email: 'hi@compromise.cool', Emoji: '💋', Emoticon: ':)', SlashedTerm: 'love/hate',
}
// ------------------------------------------------------------------ api.md
// parse the type-definitions into {name, signature, doc} grouped by section.
const parseDts = function (file) {
const lines = read(file).split('\n')
const out = []
let section = ''
let pendingDoc = ''
const memberRe = /^\s*([a-zA-Z][a-zA-Z0-9]*)(\??):\s*(.+?)\s*$/
for (let line of lines) {
// section headers, written as `// Match` or `// ### Pointers`
const sec = line.match(/^\s*\/\/\s*#*\s*([A-Z][A-Za-z ]+)\s*$/)
if (sec && !line.includes('alias') && !line.includes('support') && !line.includes('use ')) {
section = sec[1].trim()
continue
}
// one-line jsdoc
const doc = line.match(/\/\*\*\s*(.*?)\s*\*\//)
if (doc) {
pendingDoc = doc[1]
continue
}
const m = line.match(memberRe)
if (m && pendingDoc) {
const name = m[1]
let sig = m[3].replace(/,$/, '')
// turn `(a: T) => View` into `(a)` for readability
let args = ''
const arrow = sig.match(/^\((.*)\)\s*=>/)
if (arrow) {
args = arrow[1].split(',').map(s => s.split(':')[0].trim()).filter(Boolean).join(', ')
out.push({ section, name, call: `.${name}(${args})`, doc: pendingDoc })
} else {
// a getter/property
out.push({ section, name, call: `.${name}`, doc: pendingDoc, getter: true })
}
pendingDoc = ''
continue
}
if (line.trim() && !line.trim().startsWith('//')) pendingDoc = ''
}
return out
}
const generateApi = function () {
let md = stamp + `# Compromise API Reference\n\n`
md += `Every method returns a new **View** (a sub-selection of the document) unless noted, so calls chain: `
md += `\`nlp(text).match('#Verb').toPastTense().text()\`. Methods are grouped by what they do.\n\n`
md += `Method availability depends on the [build tier](concepts.md): \`compromise/one\` (tokenize), `
md += `\`compromise/two\` (+tags & contractions), and the default \`compromise\` / \`compromise/three\` (+ all selections below).\n\n`
// core View methods (one) + two additions
md += `## Core methods\n\n_(available on every View)_\n\n`
const core = [...parseDts('types/view/one.d.ts'), ...parseDts('types/view/two.d.ts')]
let lastSection = null
for (const m of core) {
if (m.section && m.section !== lastSection) {
md += `\n### ${m.section}\n\n`
lastSection = m.section
}
md += `- **\`${m.call}\`**${m.getter ? ' _[getter]_' : ''}${m.doc}\n`
}
// tier-three selection classes (Nouns, Verbs, Numbers, ...)
md += `\n## Selections (compromise/three)\n\n`
md += `These return specialised sub-views with extra methods. e.g. \`doc.verbs().toPastTense()\`.\n\n`
const three = read('types/view/three.d.ts')
// the main Three interface methods
const threeMain = three.match(/interface Three extends View \{([\s\S]*?)\n\}/)
if (threeMain) {
md += `### Selection methods on any View\n\n`
for (const m of parseBlock(threeMain[1])) {
md += `- **\`${m.call}\`** — ${m.doc}\n`
}
}
// the sub-classes
const classRe = /export interface (\w+) extends View \{([\s\S]*?)\n\}/g
let cm
while ((cm = classRe.exec(three))) {
const members = parseBlock(cm[2])
if (!members.length) continue
md += `\n### \`.${cm[1].toLowerCase()}()\` →\n\n`
for (const m of members) md += `- **\`${m.call}\`** — ${m.doc}\n`
}
// constructor / nlp.* methods
md += `\n## Constructor methods (\`nlp.*\`)\n\n_(called on the imported \`nlp\` object, not a View)_\n\n`
const ctor = read('types/three.d.ts')
for (const m of parseBlock(ctor, true)) {
md += `- **\`nlp${m.call.replace(/^\./, '.')}\`** — ${m.doc}\n`
}
return md
}
// parse a free block of jsdoc+member lines (no sections)
const parseBlock = function (str, isCtor) {
const lines = str.split('\n')
const out = []
let pendingDoc = ''
for (const line of lines) {
const doc = line.match(/\/\*\*\s*(.*?)\s*\*\//)
if (doc) { pendingDoc = doc[1]; continue }
let m = line.match(/^\s*([a-zA-Z][a-zA-Z0-9]*)(\??):\s*(.+?)\s*$/) // property/method
if (!m && isCtor) m = line.match(/^\s*export function ([a-zA-Z][a-zA-Z0-9]*)\s*(\(.*?\))/)
if (!m && isCtor) m = line.match(/^\s*export const ([a-zA-Z][a-zA-Z0-9]*)\s*(:)/) // e.g. version
if (m && pendingDoc) {
const name = m[1]
let rest = m[3] || m[2] || ''
const arrow = rest.match(/^\((.*?)\)\s*=>/) || rest.match(/^\((.*)\)/)
let call
if (arrow) {
const args = arrow[1].split(',').map(s => s.split(':')[0].trim()).filter(Boolean).join(', ')
call = `.${name}(${args})`
} else {
call = `.${name}`
}
out.push({ name, call, doc: pendingDoc })
pendingDoc = ''
} else if (line.trim() && !line.includes('/**')) {
pendingDoc = ''
}
}
return out
}
// ------------------------------------------------------------------ llms.txt (curated index)
const generateLlms = function () {
return `# compromise
> A rule-based natural-language-processing library for English. Tokenizes text, tags parts-of-speech,
> and finds & transforms parts of the text. Runs offline in node and the browser, no dependencies.
> Not an LLM/neural model. Current version ${nlp.version}.
Docs below are plain markdown (the published observablehq.com notebooks do not render as readable text).
## Docs
- [AGENTS.md](AGENTS.md): start here — mental model, rules, and the mistakes to avoid
- [Concepts](docs/concepts.md): document/View/Term model, mutability, build tiers
- [Match syntax](docs/match-syntax.md): the .match() mini-language
- [Tags](docs/tags.md): the complete, valid part-of-speech tagset
- [API](docs/api.md): every method, signature, and description
- [Recipes](docs/recipes.md): copy-paste solutions to common tasks
## Optional
- [Full text](docs/llms-full.txt): every doc above concatenated into one file
- [README](README.md): the human-facing overview
`
}
// ------------------------------------------------------------------ llms-full.txt
const generateLlmsFull = function () {
const files = ['AGENTS.md', 'docs/concepts.md', 'docs/match-syntax.md', 'docs/tags.md', 'docs/api.md', 'docs/recipes.md']
let out = `# compromise ${nlp.version} — full documentation for LLMs\n`
out += `# https://github.com/spencermountain/compromise\n`
out += `# This file concatenates every in-repo doc into one fetchable text.\n\n`
for (const f of files) {
if (!fs.existsSync(path.join(root, f))) continue
out += `\n\n${'='.repeat(78)}\n# FILE: ${f}\n${'='.repeat(78)}\n\n`
out += read(f).replace(stamp, '')
}
return out
}
// ------------------------------------------------------------------ run
console.log('generating docs...')
if (!fs.existsSync(path.join(root, 'docs'))) fs.mkdirSync(path.join(root, 'docs'))
write('docs/tags.md', generateTags())
write('docs/api.md', generateApi())
write('llms.txt', generateLlms())
write('docs/llms-full.txt', generateLlmsFull())
console.log('done.')