diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3472836..97b3577b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: - main - release - feat/lb + - feat/deploy_cn - cloudflare-pages workflow_dispatch: @@ -55,3 +56,38 @@ jobs: ${{github.workspace}}/ossutil cp ./docs/.vitepress/dist oss://lb-assets/github/release/open.longbridge.com/new-docs/raw/ -u -r -j 10 -e oss-cn-hangzhou.aliyuncs.com -i ${{ secrets.FE_LB_ASSET_ACCESS_KEY_ID }} -k ${{ secrets.FE_LB_ASSET_ACCESS_KEY_SECRET }} --include "*.md" --meta "Cache-Control:no-cache#Content-type:text/markdown;charset=utf-8" ${{github.workspace}}/ossutil cp ./docs/.vitepress/dist oss://lb-assets/github/release/open.longbridge.com/new-docs/raw/ -u -r -j 10 -e oss-cn-hangzhou.aliyuncs.com -i ${{ secrets.FE_LB_ASSET_ACCESS_KEY_ID }} -k ${{ secrets.FE_LB_ASSET_ACCESS_KEY_SECRET }} --include "*.txt" --meta "Cache-Control:no-cache#Content-type:text/plain;charset=utf-8" ${{github.workspace}}/ossutil cp ./docs/.vitepress/dist/ oss://lb-assets/github/release/open.longbridge.com/new-docs/raw/ -u -r -j 10 -e oss-cn-hangzhou.aliyuncs.com -i ${{ secrets.FE_LB_ASSET_ACCESS_KEY_ID }} -k ${{ secrets.FE_LB_ASSET_ACCESS_KEY_SECRET }} --include "*.html" --meta "Cache-Control:no-cache#Content-type:text/html;charset=utf-8" + + deploy_cn: + name: build-cn + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Download ossutils zip + run: | + wget https://github.com/aliyun/ossutil/releases/download/v1.7.17/ossutil-v1.7.17-linux-amd64.zip + unzip ossutil-v1.7.17-linux-amd64.zip && cp ./ossutil-v1.7.17-linux-amd64/ossutil ${{github.workspace}}/ && chmod +x ${{github.workspace}}/ossutil + + - name: Test ossutil + run: | + ${{github.workspace}}/ossutil --version + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependency + run: bun install + + - name: Build CN + run: bun run build:cn + + - name: Upload to Aliyun OSS (CN) + run: | + ${{github.workspace}}/ossutil cp ./docs/.vitepress/dist/ oss://lb-assets/github/release/open.longbridge.cn/new-docs/raw/ -u -r -j 10 -e oss-cn-hangzhou.aliyuncs.com -i ${{ secrets.FE_LB_ASSET_ACCESS_KEY_ID }} -k ${{ secrets.FE_LB_ASSET_ACCESS_KEY_SECRET }} --exclude "*.html" --exclude "*.md" --exclude "*.txt" --exclude "*.zip" --meta=Cache-Control:max-age=31536000 + ${{github.workspace}}/ossutil cp ./docs/.vitepress/dist/ oss://lb-assets/github/release/open.longbridge.com/new-docs/raw/ -u -r -j 10 -e oss-cn-hangzhou.aliyuncs.com -i ${{ secrets.FE_LB_ASSET_ACCESS_KEY_ID }} -k ${{ secrets.FE_LB_ASSET_ACCESS_KEY_SECRET }} --exclude "*.html" --exclude "*.md" --exclude "*.txt" --exclude "*.zip" --meta=Cache-Control:max-age=31536000 + ${{github.workspace}}/ossutil cp ./docs/.vitepress/dist/ oss://lb-assets/github/release/open.longbridge.cn/new-docs/raw/ -u -r -j 10 -e oss-cn-hangzhou.aliyuncs.com -i ${{ secrets.FE_LB_ASSET_ACCESS_KEY_ID }} -k ${{ secrets.FE_LB_ASSET_ACCESS_KEY_SECRET }} --include "*.zip" --meta "Cache-Control:no-cache#Content-type:application/zip;charset=utf-8" + ${{github.workspace}}/ossutil cp ./docs/.vitepress/dist oss://lb-assets/github/release/open.longbridge.cn/new-docs/raw/ -u -r -j 10 -e oss-cn-hangzhou.aliyuncs.com -i ${{ secrets.FE_LB_ASSET_ACCESS_KEY_ID }} -k ${{ secrets.FE_LB_ASSET_ACCESS_KEY_SECRET }} --include "*.md" --meta "Cache-Control:no-cache#Content-type:text/markdown;charset=utf-8" + ${{github.workspace}}/ossutil cp ./docs/.vitepress/dist oss://lb-assets/github/release/open.longbridge.cn/new-docs/raw/ -u -r -j 10 -e oss-cn-hangzhou.aliyuncs.com -i ${{ secrets.FE_LB_ASSET_ACCESS_KEY_ID }} -k ${{ secrets.FE_LB_ASSET_ACCESS_KEY_SECRET }} --include "*.txt" --meta "Cache-Control:no-cache#Content-type:text/plain;charset=utf-8" + ${{github.workspace}}/ossutil cp ./docs/.vitepress/dist/ oss://lb-assets/github/release/open.longbridge.cn/new-docs/raw/ -u -r -j 10 -e oss-cn-hangzhou.aliyuncs.com -i ${{ secrets.FE_LB_ASSET_ACCESS_KEY_ID }} -k ${{ secrets.FE_LB_ASSET_ACCESS_KEY_SECRET }} --include "*.html" --meta "Cache-Control:no-cache#Content-type:text/html;charset=utf-8" diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 288c138c..bcd64881 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -4,14 +4,18 @@ import Unocss from 'unocss/vite' import { markdownConfig } from './config/markdown' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { mkdirSync } from 'node:fs' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { execSync } from 'node:child_process' import { localesConfig } from './config/locales' import { withMermaid } from 'vitepress-plugin-mermaid' import { rewriteMarkdownPath } from './utils' +import { getRegionConfig, computeSrcExclude } from './region-utils' import * as cheerio from 'cheerio' const __dirname = dirname(fileURLToPath(import.meta.url)) +const docsRoot = resolve(__dirname, '..') +const regionCfg = getRegionConfig() +const regionSrcExclude = computeSrcExclude(docsRoot) const insertScript = (html: string) => { const $ = cheerio.load(html) @@ -32,11 +36,16 @@ export default defineConfig( ignoreDeadLinks: true, base: '/', - srcExclude: ['README.md'], + srcExclude: ['README.md', ...regionSrcExclude], rewrites: rewriteMarkdownPath, markdown: markdownConfig, transformHtml(code) { - return insertScript(code) + let html = insertScript(code) + // Region URL rewriting: replace global hostname with region hostname in final HTML + if (regionCfg?.siteHostname && regionCfg.siteHostname !== 'https://open.longbridge.com') { + html = html.split('https://open.longbridge.com').join(regionCfg.siteHostname) + } + return html }, transformHead(context) { const { page } = context @@ -54,13 +63,14 @@ export default defineConfig( markdownPath = markdownPath.replace('/index.md', '.md') } + const siteHost = regionCfg?.siteHostname || 'https://open.longbridge.com' return [ - ['link', { rel: 'canonical', href: `https://open.longbridge.com/${localePathname}` }], - ['link', { rel: 'alternate', hreflang: 'en', href: `https://open.longbridge.com/${pathname}` }], - ['link', { rel: 'alternate', hreflang: 'zh-Hans', href: `https://open.longbridge.com/zh-CN/${pathname}` }], - ['link', { rel: 'alternate', hreflang: 'zh-Hant', href: `https://open.longbridge.com/zh-HK/${pathname}` }], - ['link', { rel: 'alternate', type: 'text/markdown', href: `https://open.longbridge.com/${markdownPath}` }], - ['meta', { property: 'og:url', content: `https://open.longbridge.com/${localePathname}` }], + ['link', { rel: 'canonical', href: `${siteHost}/${localePathname}` }], + ['link', { rel: 'alternate', hreflang: 'en', href: `${siteHost}/${pathname}` }], + ['link', { rel: 'alternate', hreflang: 'zh-Hans', href: `${siteHost}/zh-CN/${pathname}` }], + ['link', { rel: 'alternate', hreflang: 'zh-Hant', href: `${siteHost}/zh-HK/${pathname}` }], + ['link', { rel: 'alternate', type: 'text/markdown', href: `${siteHost}/${markdownPath}` }], + ['meta', { property: 'og:url', content: `${siteHost}/${localePathname}` }], ['meta', { property: 'og:title', content: context.title }], ['meta', { property: 'og:description', content: context.description }], ['meta', { name: 'twitter:image', content: 'https://assets.lbctrl.com/uploads/b510b04f-9238-4fe0-b39d-11e076876ac1/longbridge-og.png' }], @@ -75,10 +85,21 @@ export default defineConfig( mkdirSync(outSkillDir, { recursive: true }) execSync(`zip -r "${outSkillDir}/longbridge.zip" longbridge`, { cwd: skillsDir }) console.log('✓ skill/longbridge.zip generated') + + // Region URL rewriting for static assets + if (regionCfg?.siteHostname && regionCfg.siteHostname !== 'https://open.longbridge.com') { + const installDir = resolve(siteConfig.outDir, 'longbridge-terminal') + for (const file of ['install', 'install.ps1']) { + const filePath = resolve(installDir, file) + const content = readFileSync(filePath, 'utf-8') + writeFileSync(filePath, content.split('https://open.longbridge.com').join(regionCfg.siteHostname)) + } + console.log('✓ install scripts rewritten for region') + } }, sitemap: { - hostname: 'https://open.longbridge.com', + hostname: regionCfg?.siteHostname || 'https://open.longbridge.com', transformItems(items) { return items.filter((item) => !item.url.includes('migration')) }, diff --git a/docs/.vitepress/config/markdown.ts b/docs/.vitepress/config/markdown.ts index bde7b442..c94a6f96 100644 --- a/docs/.vitepress/config/markdown.ts +++ b/docs/.vitepress/config/markdown.ts @@ -4,6 +4,7 @@ import { tipContainerPlugin } from '../md-plugins/tip-container' import { GenTryItPlugin } from '../md-plugins/gen-try-it.ts' import { NormalizeMdPlugin } from '../md-plugins/normalize-md' import { CliCommandPlugin } from '../md-plugins/cli-command' +import { RegionFilterPlugin } from '../md-plugins/region-filter' export const markdownConfig: MarkdownOptions = { image: { @@ -53,5 +54,6 @@ export const markdownConfig: MarkdownOptions = { md.use(tipContainerPlugin) md.use(GenTryItPlugin) md.use(CliCommandPlugin) + md.use(RegionFilterPlugin) }, } diff --git a/docs/.vitepress/locales/en/nav.ts b/docs/.vitepress/locales/en/nav.ts index c7bb3c1e..7193b3f9 100644 --- a/docs/.vitepress/locales/en/nav.ts +++ b/docs/.vitepress/locales/en/nav.ts @@ -1,12 +1,16 @@ import type { DefaultTheme } from 'vitepress' +import { filterNavItems, getRegion } from '../../region-utils' export const nav = (): DefaultTheme.NavItem[] => { - return [ + const isCN = getRegion() === 'cn' + return filterNavItems([ { text: 'Home', link: '/', activeMatch: '^(/en)?/$' }, { text: 'Skill', link: '/skill', activeMatch: '^(/en)?/skill' }, - { text: 'Docs', link: '/docs', activeMatch: '^(/en)?/docs(?!/api)' }, + isCN + ? { text: 'CLI', link: '/docs/cli', activeMatch: '^(/en)?/docs/cli' } + : { text: 'Docs', link: '/docs', activeMatch: '^(/en)?/docs(?!/api)' }, { text: 'API Reference', link: '/docs/api', activeMatch: '^(/en)?/docs/api' }, { text: 'SDK', link: '/sdk', activeMatch: '^(/en)?/sdk' }, { text: 'Issues', link: 'https://github.com/longbridge/openapi/issues', target: '_blank' }, - ] + ]) } diff --git a/docs/.vitepress/locales/zh-CN/nav.ts b/docs/.vitepress/locales/zh-CN/nav.ts index ea9fa84d..fe137eac 100644 --- a/docs/.vitepress/locales/zh-CN/nav.ts +++ b/docs/.vitepress/locales/zh-CN/nav.ts @@ -1,12 +1,16 @@ import type { DefaultTheme } from 'vitepress' +import { filterNavItems, getRegion } from '../../region-utils' export const nav = (lang: string): DefaultTheme.NavItem[] => { - return [ + const isCN = getRegion() === 'cn' + return filterNavItems([ { text: '首页', link: `/${lang}/`, activeMatch: `^/${lang}/$` }, { text: 'Skill', link: `/${lang}/skill`, activeMatch: `^/${lang}/skill` }, - { text: '文档', link: `/${lang}/docs`, activeMatch: `^/${lang}/docs(?!/api)` }, + isCN + ? { text: 'CLI', link: `/${lang}/docs/cli`, activeMatch: `^/${lang}/docs/cli` } + : { text: '文档', link: `/${lang}/docs`, activeMatch: `^/${lang}/docs(?!/api)` }, { text: 'API 参考', link: `/${lang}/docs/api`, activeMatch: `^/${lang}/docs/api` }, { text: 'SDK', link: `/${lang}/sdk`, activeMatch: `^/${lang}/sdk` }, { text: 'Issues', link: 'https://github.com/longbridge/openapi/issues', target: '_blank' }, - ] + ]) } diff --git a/docs/.vitepress/locales/zh-HK/nav.ts b/docs/.vitepress/locales/zh-HK/nav.ts index d32b5e1b..d822d107 100644 --- a/docs/.vitepress/locales/zh-HK/nav.ts +++ b/docs/.vitepress/locales/zh-HK/nav.ts @@ -1,12 +1,16 @@ import type { DefaultTheme } from 'vitepress' +import { filterNavItems, getRegion } from '../../region-utils' export const nav = (lang: string): DefaultTheme.NavItem[] => { - return [ + const isCN = getRegion() === 'cn' + return filterNavItems([ { text: '首頁', link: `/${lang}/`, activeMatch: `^/${lang}/$` }, { text: 'Skill', link: `/${lang}/skill`, activeMatch: `^/${lang}/skill` }, - { text: '文檔', link: `/${lang}/docs`, activeMatch: `^/${lang}/docs(?!/api)` }, + isCN + ? { text: 'CLI', link: `/${lang}/docs/cli`, activeMatch: `^/${lang}/docs/cli` } + : { text: '文檔', link: `/${lang}/docs`, activeMatch: `^/${lang}/docs(?!/api)` }, { text: 'API 參考', link: `/${lang}/docs/api`, activeMatch: `^/${lang}/docs/api` }, { text: 'SDK', link: `/${lang}/sdk`, activeMatch: `^/${lang}/sdk` }, { text: 'Issues', link: 'https://github.com/longbridge/openapi/issues', target: '_blank' }, - ] + ]) } diff --git a/docs/.vitepress/md-plugins/region-filter.ts b/docs/.vitepress/md-plugins/region-filter.ts new file mode 100644 index 00000000..d60a4f0a --- /dev/null +++ b/docs/.vitepress/md-plugins/region-filter.ts @@ -0,0 +1,102 @@ +import type MarkdownItAsync from 'markdown-it' +import type Token from 'markdown-it/lib/token.mjs' +import picomatch from 'picomatch' +import { getRegionConfig } from '../region-utils' + +/** + * Markdown-it plugin that removes sections (heading + content) based on region config. + * A "section" is defined as a heading token and all tokens until the next heading of the same or higher level. + */ +export function RegionFilterPlugin(md: MarkdownItAsync) { + const config = getRegionConfig() + if (!config) return + + // URL rewriting: replace global hostname with region hostname in all inline tokens + if (config.siteHostname && config.siteHostname !== 'https://open.longbridge.com') { + md.core.ruler.push('region_url_rewrite', (state) => { + for (const token of state.tokens) { + rewriteTokenUrls(token, 'https://open.longbridge.com', config.siteHostname) + if (token.children) { + for (const child of token.children) { + rewriteTokenUrls(child, 'https://open.longbridge.com', config.siteHostname) + } + } + } + }) + } + + if (config.excludeSections.length === 0) return + + md.core.ruler.push('region_filter', (state) => { + const env = state.env + // VitePress passes the relative file path in env.relativePath (e.g. 'en/docs/getting-started.md') + const relativePath: string | undefined = env?.relativePath + if (!relativePath) return + + // Find matching exclusion rules for this page + const matchingRules = config.excludeSections.filter((rule) => picomatch(rule.page)(relativePath)) + if (matchingRules.length === 0) return + + // Collect all headings to exclude + const headingsToExclude = new Set() + for (const rule of matchingRules) { + for (const h of rule.headings) { + headingsToExclude.add(h) + } + } + + // Filter tokens: remove heading + its content section + const tokens = state.tokens + const filtered: Token[] = [] + let skipUntilLevel = -1 + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + + if (token.type === 'heading_open') { + const level = parseInt(token.tag.slice(1)) // h2 -> 2 + // If we're skipping and hit a same-or-higher level heading, stop skipping + if (skipUntilLevel > 0 && level <= skipUntilLevel) { + skipUntilLevel = -1 + } + + // Check if the next inline token's content matches an excluded heading + const inlineToken = tokens[i + 1] + if (inlineToken?.type === 'inline' && headingsToExclude.has(inlineToken.content.trim())) { + skipUntilLevel = level + continue + } + } + + if (skipUntilLevel > 0) { + // Check if this is a heading that should end the skip + if (token.type === 'heading_open') { + const level = parseInt(token.tag.slice(1)) + if (level <= skipUntilLevel) { + skipUntilLevel = -1 + filtered.push(token) + } + } + continue + } + + filtered.push(token) + } + + state.tokens = filtered + }) +} + +function rewriteTokenUrls(token: Token, from: string, to: string) { + // Handle all token types that may contain URLs: inline text, code blocks, fences, html blocks + if (token.content && token.content.includes(from)) { + token.content = token.content.split(from).join(to) + } + if (token.attrs) { + for (const attr of token.attrs) { + if (typeof attr[1] === 'string' && attr[1].includes(from)) { + attr[1] = attr[1].split(from).join(to) + } + } + } +} diff --git a/docs/.vitepress/region-utils.ts b/docs/.vitepress/region-utils.ts new file mode 100644 index 00000000..590281e0 --- /dev/null +++ b/docs/.vitepress/region-utils.ts @@ -0,0 +1,89 @@ +import picomatch from 'picomatch' +import fs from 'node:fs' +import path from 'node:path' +import { regionConfig, type RegionConfig } from '../../region.config' + +/** Current region from VITE_REGION env, undefined means global (no filtering) */ +export function getRegion(): string | undefined { + return process.env['VITE_REGION'] || undefined +} + +/** Get region config, returns undefined for global build */ +export function getRegionConfig(): RegionConfig | undefined { + const region = getRegion() + if (!region) return undefined + return regionConfig[region] +} + +/** + * Check if a page path is included in the current region's whitelist. + * When no region is set (global build), all pages are included. + * + * @param pagePath - Relative path from docs/ root, e.g. 'en/docs/quote/pull.md' + */ +export function isPageIncluded(pagePath: string): boolean { + const config = getRegionConfig() + if (!config) return true // global build: include everything + + return config.includePages.some((pattern) => picomatch(pattern)(pagePath)) +} + +/** + * Filter nav items based on region config. + * Removes nav items whose link matches excludeNavLinks. + * For locale-prefixed links (e.g. /zh-CN/sdk), matches against the path without locale prefix. + */ +export function filterNavItems(items: T[]): T[] { + const config = getRegionConfig() + if (!config || config.excludeNavLinks.length === 0) return items + + const excluded = new Set(config.excludeNavLinks) + return items.filter((item) => { + if (!item.link) return true + // Strip locale prefix for matching: /zh-CN/sdk -> /sdk, /zh-HK/docs/api -> /docs/api + const normalized = item.link.replace(/^\/(zh-CN|zh-HK)/, '') + return !excluded.has(normalized) + }) +} + +/** + * Compute srcExclude patterns for VitePress config. + * Scans docs directory and returns paths not matching the whitelist. + */ +export function computeSrcExclude(docsRoot: string): string[] { + const config = getRegionConfig() + if (!config) return [] + + const excludes: string[] = [] + const locales = ['en', 'zh-CN', 'zh-HK'] + + for (const locale of locales) { + const localeDir = path.join(docsRoot, locale) + if (!fs.existsSync(localeDir)) continue + walkMarkdownFiles(localeDir, docsRoot, config.includePages, excludes) + } + + return excludes +} + +function walkMarkdownFiles( + dir: string, + docsRoot: string, + includePatterns: string[], + excludes: string[] +): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + const isMatch = picomatch(includePatterns) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + walkMarkdownFiles(fullPath, docsRoot, includePatterns, excludes) + } else if (entry.name.endsWith('.md')) { + const relativePath = path.relative(docsRoot, fullPath) + if (!isMatch(relativePath)) { + excludes.push(relativePath) + } + } + } +} diff --git a/docs/.vitepress/theme/components/Breadcrumb/index.vue b/docs/.vitepress/theme/components/Breadcrumb/index.vue index dfc88787..ecb40052 100644 --- a/docs/.vitepress/theme/components/Breadcrumb/index.vue +++ b/docs/.vitepress/theme/components/Breadcrumb/index.vue @@ -1,5 +1,5 @@