Files
longbridge__developers/scripts/generate-llms.ts
hold-baby b952761fa6 Complete .com→.cn hostname rewrite for CN region build (#1100)
## Merge Verdict
**[APPROVE]** — Region URL rewriting is now complete across every
artifact channel; global build verified untouched.
> 6 files · +73 / -26 · `docs/.vitepress` `scripts/` `package.json`

---
## Summary
- Extract a shared `buildRegionUrlReplacements()` in `region-utils.ts`
that emits **four** rules per non-default hostname (protocol-prefixed +
bare-text for both `siteHostname` and `apiBaseUrl`), and route the four
pre-existing rewrite sites (`region-filter.ts`, `transformHtml`,
`buildEnd install`, plus the new ones) through it.
- Add a new Vite `region-source-url-rewrite` plugin (`enforce: 'pre'`)
that rewrites hardcoded hostnames inside `.vue/.ts/.json/.yaml` source
modules — this is the only channel that reaches Vite-compiled JS bundles
(install command strings in Vue components, `mcp-tools.json` connect
links, `openapi.yaml` error-message text).
- Bring `.md` copies + `llms.txt` into region rewriting:
`normalize_md.ts` (the real writer of `dist/**/*.md`) and
`generate-llms.ts` (the static `llms-intro.md` injection point) now
share the same helper.
- Fix root-cause env-leak: `build:cn` now also passes `VITE_REGION=cn`
to the `bun run build:llms` segment — previously `cross-env` only scoped
to the `vitepress build` process, so `normalize_md`/`generate-llms`
never knew it was a CN build and silently produced `.com` artifacts.

---
## Risk Analysis
| Risk | Level | Mitigation |
|------|-------|-----------|
| Global build (`build:release`) accidentally rewritten |  | Shared
helper returns `[]` when `VITE_REGION` is unset → every call site is a
no-op. Verified: `dist/longbridge-terminal/install.ps1` is
byte-identical to source; `mcp.html` keeps all 9 `.com`; `llms-full.txt`
keeps all 112 `open.longbridge.com`. |
| Bare-rule replacement double-matches |  | `open.longbridge.com` is
**not** a substring of `openapi.longbridge.com` (5th char `.` vs `a`),
so the four rules are mutually independent regardless of order. |
| Vite `transform` runs on every module — performance hit | 🟢 |
`buildRegionUrlReplacements()` is lightweight (one env read + small
array build). On global builds it short-circuits via
`replacements.length === 0`. |
| `enforce: 'pre'` ordering vs `yaml-transform` |  | `'pre'` plugins
run before normal plugins, so this transform sees raw YAML text and
rewrites it before `yaml-transform` JSON-stringifies it. |
| Hardcoded global hostnames in helper | 🟡 | Helper compares against the
literal `'https://open.longbridge.com'` /
`'https://openapi.longbridge.com'`. If the global domain ever changes,
this file plus `region.config.ts` must be updated together. Same
constraint already existed before this PR. |

---
## Design Decisions
- **Centralize rules in `region-utils.ts`** instead of inlining at four
call sites — four sites already drifted (HTML had two rules but markdown
had only the URL form before the previous PR). One source of truth
prevents future drift.
- **Pre-stage Vite transform** rather than a post-build dist scan —
keeps source maps intact and lets the rewrite participate in dependency
invalidation. It also naturally covers `openapi.yaml` (huge but fine —
string `split/join` is O(n) and only runs once per module per build).
- **Bare-hostname rules alongside URL rules** — covers
`[open.longbridge.com/connect](https://...)` markdown patterns where
only the link target gets matched by URL rules; the display text needs
the bare-host rule.
- **Source `install` / `install.ps1` keep `.com`** — global build's
`buildEnd` already had a rewrite pass; making source `.com`-default lets
the existing rewrite mechanism do the work and avoids two
source-of-truth files.

---
## Code Notes
1. **[Info]** `region-utils.ts:25` comment "first so bare rules don't
double-match"
In practice both orderings are correct because after either rule runs
the other one's "from" string no longer exists in the result. The note
is defensive rather than load-bearing.
   — Author note: deferred to next iteration.
2. **[Info]** `config.mts` Vite transform hook calls
`buildRegionUrlReplacements()` per module
The helper is cheap but is invoked once per source module on every
build. Could be hoisted to the closure top if profiling ever flags it;
not worth the structural change today.
   — Author note: deferred to next iteration.
3. **[Info]** `package.json` build:cn duplicates `cross-env
VITE_REGION=cn` across two segments
Maintainable but easy to forget if a third stage is added later. Could
be solved with `cross-env-shell` wrapping the whole chain, but that's a
separate cleanup.
   — Author note: deferred to next iteration.
4. **[Needs review]** Vite `transform` regex includes `.yaml`/`.yml`
This is intentional — `openapi.yaml` ships hardcoded
`https://open.longbridge.com/sdk` and error-message URLs that must be
rewritten for CN. Reviewer should confirm there's no other YAML in the
dependency graph whose `.com` strings must be preserved as global
references. None observed in the current tree.

---
## Verification
-  `bun run build:cn` succeeds; `rg -l
'(open|openapi)\.longbridge\.com' docs/.vitepress/dist` → **zero
residual `.com`** across HTML/MD/JS/scripts.
-  `bun run build:release` succeeds; `install.ps1` and `install` are
byte-identical to source; `mcp.html` keeps 9× `.com`; `llms-full.txt`
keeps 112× `open.longbridge.com`; CN endpoint mentions inside docs
(`getting-started.md` etc.) are preserved as intended.
-  `openapi-quote.longbridge.cn` / `openapi-trade.longbridge.cn` counts
in `getting-started.html` match source 1:1 (no over-rewrite).
- 📋 Reviewer to confirm: CN site (`open.longbridge.cn`) renders `mcp.md`
/ `skill/install` pages with the new URLs after deploy.

Co-authored-by: 袁昌瑞 <changrui.yuan@longbridge-inc.com>
2026-06-18 11:37:13 +08:00

262 lines
7.7 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { getRegionConfig, buildRegionUrlReplacements } from '../docs/.vitepress/region-utils'
const regionUrlReplacements = buildRegionUrlReplacements()
function applyRegionUrlRewrite(content: string): string {
for (const [from, to] of regionUrlReplacements) {
content = content.split(from).join(to)
}
return content
}
// Simple capitalize function to replace lodash
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
// List of file paths to ignore
const ignoredFiles: string[] = ['changelog.md']
interface MarkdownInfo {
title: string
slug: string
description: string
}
interface DirectoryStructure {
sections: Record<string, DirectoryStructure>
links: MarkdownInfo[]
}
interface FrontMatter {
title?: string
slug?: string
[key: string]: any
}
/**
* Extract title, slug and description from Markdown file
* @param filePath - Markdown file path
* @param rootDir - Root directory path
* @returns Object containing title, slug and description
*/
function extractMarkdownInfo(filePath: string, rootDir: string): MarkdownInfo {
try {
const fileContent = fs.readFileSync(filePath, 'utf8')
const { data, content } = matter(fileContent) as { data: FrontMatter; content: string }
// Extract title
const title = data.title || path.basename(filePath, path.extname(filePath))
// Extract slug and ensure correct format (starts with / and ends with .md)
// Extract description
let description = ''
// Try to find content after the first heading as description
const headingMatch = content.match(/^#\s+(.+)$/m)
if (headingMatch && headingMatch[0]) {
const headingIndex = content.indexOf(headingMatch[0])
const afterHeading = content.substring(headingIndex + headingMatch[0].length).trim()
const nextParagraph = afterHeading.split('\n\n')[0]?.trim() || ''
description = nextParagraph
} else {
// If no heading found, use the first few sentences of content
description = content.split('\n\n')[0]?.trim() || ''
}
// If description is too long, truncate it
if (description.length > 200) {
description = description.substring(0, 147) + '...'
}
const slug = `/${path.relative(rootDir, filePath)}`
return { title, slug, description }
} catch (error) {
console.error(`Error processing file ${filePath}:`, error)
return { title: path.basename(filePath), slug: path.relative(rootDir, filePath), description: '' }
}
}
function shouldIgnoreFile(filePath: string): boolean {
return (
ignoredFiles.some((ignoredFile) => filePath.endsWith(ignoredFile)) ||
filePath === 'index.md' ||
filePath === 'sdk.md' ||
filePath.startsWith('zh-CN') ||
filePath.startsWith('zh-HK')
)
}
/**
* Recursively traverse directory and generate Markdown link list
* @param dir - Directory to traverse
* @param rootDir - Root directory path
* @returns Object containing directory structure and links
*/
function traverseDirectory(dir: string, rootDir: string): DirectoryStructure {
const result: DirectoryStructure = {
sections: {},
links: [],
}
const files = fs.readdirSync(dir)
for (const file of files) {
const fullPath = path.join(dir, file)
const relativePath = path.relative(rootDir, fullPath)
const stat = fs.statSync(fullPath)
if (shouldIgnoreFile(relativePath)) {
// Skip ignored files
continue
}
if (stat.isDirectory()) {
// Process subdirectory
const dirName = capitalize(path.basename(fullPath))
result.sections[dirName] = traverseDirectory(fullPath, rootDir)
} else if (stat.isFile() && path.extname(file) === '.md') {
// Process Markdown file
const { title, slug, description } = extractMarkdownInfo(fullPath, rootDir)
result.links.push({ title, slug, description })
}
}
return result
}
/**
* Generate Markdown link list
* @param structure - Directory structure and links
* @returns Formatted Markdown link list
*/
const llmsSiteHost = getRegionConfig()?.siteHostname || 'https://open.longbridge.com'
function generateMarkdownList(structure: DirectoryStructure): string {
let output = ''
// Add links for current directory
for (const link of structure.links) {
output += `- [${link.title}](${llmsSiteHost}${link.slug})\n`
}
// Add links for subdirectories
for (const [section, content] of Object.entries(structure.sections)) {
if (content.links.length === 0) {
continue
}
output += `\n## ${section}\n\n`
output += generateMarkdownList(content)
}
return output
}
/**
* Generate full content file with all markdown files
*/
function generateLLMSFullTxt(): void {
const rootDir = path.join(process.cwd(), 'docs/.vitepress/dist')
try {
if (!fs.existsSync(rootDir)) {
console.error(`Directory does not exist: ${rootDir}`)
return
}
let fullContent = '# Longbridge Developers Documentation'
// Function to recursively process all markdown files
function processDirectory(dir: string, indent = ''): void {
const files = fs.readdirSync(dir)
const relativePath = path.relative(rootDir, dir)
// Process files first, then directories for better organization
// First pass: process markdown files
for (const file of files) {
const fullPath = path.join(dir, file)
const stat = fs.statSync(fullPath)
if (shouldIgnoreFile(relativePath)) {
// Skip ignored files
continue
}
if (stat.isFile() && path.extname(file) === '.md') {
const fileContent = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContent) as { data: FrontMatter; content: string }
// Add file title as heading
const fileName = path.basename(file, '.md')
const title = data.title || capitalize(fileName)
fullContent += `\n\n${indent}# ${title}\n\n`
fullContent += content.trim()
}
}
// Second pass: process directories
for (const file of files) {
const fullPath = path.join(dir, file)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
const dirName = capitalize(path.basename(fullPath))
fullContent += `\n\n${indent}## ${dirName}\n`
processDirectory(fullPath, indent + '#')
}
}
}
// Start processing from the en directory
processDirectory(rootDir)
// Write to llms-full.txt
fs.writeFileSync(path.join(process.cwd(), 'docs/.vitepress/dist', 'llms-full.txt'), fullContent)
console.log('--> Generated llms-full.txt with all markdown content')
} catch (error) {
console.error('Error generating full content file:', error)
}
}
function generateLLMSTxt(): void {
const rootDir = path.join(process.cwd(), 'docs/.vitepress/dist')
try {
if (!fs.existsSync(rootDir)) {
console.error(`Directory does not exist: ${rootDir}`)
return
}
let content = ''
const structure = traverseDirectory(rootDir, rootDir)
const markdownList = generateMarkdownList(structure)
// Extract content from index.md
let introContent = fs.readFileSync(path.join(__dirname, './llms-intro.md'), 'utf8')
introContent = applyRegionUrlRewrite(introContent)
content = `${introContent}\n\n## SDK \n\n${markdownList}`
// write to llms.txt
fs.writeFileSync(path.join(process.cwd(), 'docs/.vitepress/dist', 'llms.txt'), content)
console.log('--> Generated llms.txt with fast markdown content')
} catch (error) {
console.error('Error processing directory:', error)
}
}
/**
* Main function
*/
function main(): void {
generateLLMSTxt()
generateLLMSFullTxt()
}
main()