Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@
"build:demo:jmapp": "npm run checked && VITE_APP_PROJECT_ID=jmapp vite build --config vite.config.demo.ts",
"prebuild:taro:demo": "SKIPDD=1 pnpm run update:taro:entry",
"build:taro:demo": "npm run checked:taro && npm run generate:file:taro:pages && pnpm --dir ./packages/nutui-taro-demo build:h5",
"prebuild:site": "npm run generate:meta && npm run generate:semantic && npm run generate:llms",
"prebuild:site": "npm run generate:props && npm run generate:meta && npm run generate:semantic && npm run generate:llms",
"build:site": "vite build --config vite.config.site.mts",
"prebuild:taro:site": "npm run generate:meta && npm run generate:semantic && npm run generate:llms",
"prebuild:taro:site": "npm run generate:props:taro && npm run generate:meta && npm run generate:semantic && npm run generate:llms",
"build:taro:site": "vite build --config vite.config.site.taro.mts && node ./scripts/rename.js",
"build:jdtaro:demo": "npm run checked:taro && npm run generate:file:taro:pages && JD=1 pnpm --dir ./packages/nutui-taro-demo build:h5",
"build:taro:demo:jmapp": "npm run checked:taro && VITE_APP_PROJECT_ID=jmapp npm run generate:file:taro:pages && pnpm --dir ./packages/nutui-taro-demo build:jmapp h5",
Expand All @@ -88,6 +88,8 @@
"generate:file:taro": "node scripts/taro/generate-nutui-taro.js",
"generate:file:taro:pages": "node scripts/taro/generate-taro-pages.js",
"generate:props_json": "PROPS_JSON=true node scripts/build-comments-to-dts.mjs",
"generate:props": "node scripts/create-properties.js",
"generate:props:taro": "node scripts/create-properties.js taro",
"generate:meta": "node scripts/build-meta.mjs",
"generate:llms": "node scripts/build-llms.mjs",
"generate:semantic": "node scripts/build-semantic.mjs",
Expand Down
3 changes: 3 additions & 0 deletions packages/nutui-react-cli-core/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
.DS_Store
*.log
22 changes: 22 additions & 0 deletions packages/nutui-react-cli-core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@nutui/nutui-react-cli-core",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "NutUI React CLI 的共享核心:命令 / MCP / 数据查询原语,由 H5 与 Taro 两个发布 CLI 包 bundle 复用(不单独发布)。",
"license": "MIT",
"author": "jdcfe",
"exports": {
".": "./src/index.ts",
"./scripts/prepare-data.mjs": "./scripts/prepare-data.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/node": "^22.5.5",
"@types/yargs": "^17.0.33",
"typescript": "^5.6.2"
}
}
Comment thread
alvinhui marked this conversation as resolved.
140 changes: 140 additions & 0 deletions packages/nutui-react-cli-core/scripts/prepare-data.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// 读仓库根 meta/components.json,为某个 CLI 叶子包产出自包含的 data/ 快照。
// 平台差异通过参数注入:H5 取 docs.h5/enUS + demos.h5 + api;Taro 取 docs.taro + demos.taro + apiTaro。
// 会先自动重生成 meta(generate:meta),无需手动同步。
// 各叶子包的 scripts/prepare-data.mjs 只需 import 本函数并传入自己的参数。
// DO NOT manual edit the output (data/). Run: pnpm run prepare-data
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFileSync } from 'node:child_process'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
// core 包目录:packages/nutui-react-cli-core/scripts -> ..
const CORE_DIR = path.resolve(__dirname, '..')
// 仓库根:packages/nutui-react-cli-core -> ../..
const REPO_ROOT = path.resolve(CORE_DIR, '..', '..')
const META_PATH = path.join(REPO_ROOT, 'meta/components.json')
const BUILD_META_SCRIPT = path.join(REPO_ROOT, 'scripts/build-meta.mjs')

// 先重生成 meta 再读,确保快照与仓库源码(config/properties/demos/...)同步,
// 免去手动 generate:meta 的漏同步风险。build-meta 纯读源码且幂等,可安全重复执行。
function regenerateMeta() {
if (!fs.existsSync(BUILD_META_SCRIPT)) {
// 脱离 monorepo(如已发布包被重新 build),无源码可生成,沿用现有快照。
console.log('ℹ️ 未找到 build-meta 脚本,跳过 meta 重生成(沿用现有快照)')
return
}
console.log('🔄 重新生成 meta/components.json(generate:meta)...')
execFileSync(process.execPath, [BUILD_META_SCRIPT], { stdio: 'inherit' })
}

function readMeta() {
try {
regenerateMeta()
} catch (err) {
// meta 生成失败:有旧快照则告警后沿用,否则无从继续。
if (fs.existsSync(META_PATH)) {
console.warn(
`⚠️ meta 重生成失败(${err.message}),沿用现有 ${path.relative(REPO_ROOT, META_PATH)}`
)
} else {
console.error(
`❌ meta 重生成失败且无现有快照:${err.message}\n` +
` 请在仓库根手动执行:npm run generate:meta`
)
process.exit(1)
}
}
if (!fs.existsSync(META_PATH)) {
console.error(
`❌ 未找到 ${path.relative(REPO_ROOT, META_PATH)}。\n` +
` 它是构建产物,请先在仓库根执行:npm run generate:meta`
)
process.exit(1)
}
return JSON.parse(fs.readFileSync(META_PATH, 'utf-8'))
}
Comment on lines +21 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

共享文件 meta/components.json 存在潜在并发写入风险,且失败时静默回退。

regenerateMeta() 会对仓库根共享文件 meta/components.json 执行"子进程重新生成",而该函数会被 nutui-react-clinutui-react-taro-cli 两个叶子包各自的 prepare-data.mjs 分别调用。如果两者的 prepare-data 脚本在 CI/构建流水线中被并行触发(例如通过 pnpm -r --parallel 之类的编排),会出现对同一文件的并发"生成+写入",存在数据损坏(TOCTOU/写入交叉)的风险。

此外,readMeta() 在 monorepo 内 regenerateMeta() 失败但存在旧快照时,只会 console.warn 并继续使用旧快照(Line 36-39),不会让构建失败,这可能掩盖 properties.json / build-meta.mjs 管线中的真实回归,导致 CLI 数据快照静默使用过期数据。

建议:

  1. 为共享的 meta/components.json 写入增加临时文件+原子 rename,或在编排层确保 prepare-data 脚本串行执行,避免并发写入同一文件。
  2. 考虑将"monorepo 内脚本存在但执行失败"与"脱离 monorepo 无脚本"两种场景区分处理:前者应让构建失败(而非仅告警),避免静默使用过期数据。

请确认这两个叶子包的 prepare-data 脚本在实际构建/发布流程中是否存在并行调用的可能。

#!/bin/bash
# 检查是否存在跨包并行调用 prepare-data 的编排脚本
rg -n 'prepare-data' --type=json --type=yaml -g '!node_modules' -C2
rg -nP '"prepare-data"' package.json packages/*/package.json -C2
fd -g 'turbo.json' -x cat {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nutui-react-cli-core/scripts/prepare-data.mjs` around lines 21 - 56,
Update regenerateMeta and the shared meta generation flow to serialize writes to
meta/components.json using a temporary output followed by an atomic rename,
preventing concurrent prepare-data executions from interleaving. In readMeta,
distinguish a missing BUILD_META_SCRIPT (published-package fallback) from an
existing script that fails: propagate the latter error and fail the build even
when an old META_PATH exists, while preserving fallback only for the
missing-script case. Verify the nutui-react-cli and nutui-react-taro-cli
prepare-data orchestration does not invoke these generators concurrently.


// meta 里的路径始终是 posix 相对仓库根路径,用 posix 取 basename,再 join 到本地。
function copyRepoFile(relPosixPath, destAbs) {
const srcAbs = path.join(REPO_ROOT, relPosixPath)
if (!fs.existsSync(srcAbs)) return false
fs.mkdirSync(path.dirname(destAbs), { recursive: true })
fs.copyFileSync(srcAbs, destAbs)
return true
}

/**
* 生成某叶子包的 data/ 快照。
* @param {object} opts
* @param {string} opts.pkgDir 叶子包根目录绝对路径(其下写入 data/)
* @param {Record<string,string>} opts.docKeys lang -> meta.docs 的 key。如 {zh:'h5', en:'enUS'} 或 {zh:'taro'}
* @param {'h5'|'taro'} opts.demoKey 取 meta.demos 的哪一端
* @param {'api'|'apiTaro'} opts.apiField 取哪一端的 API 表,归一写入快照的 `api` 字段
*/
export function prepareData({ pkgDir, docKeys, demoKey, apiField }) {
console.log(
`🚀 building data snapshot (${apiField === 'apiTaro' ? 'Taro' : 'React/H5'}) for ${path.relative(REPO_ROOT, pkgDir)} ...`
)
const meta = readMeta()
const DATA_DIR = path.join(pkgDir, 'data')

// 全量重建,避免残留上一版删掉的组件。
fs.rmSync(DATA_DIR, { recursive: true, force: true })
fs.mkdirSync(DATA_DIR, { recursive: true })

// 1. meta.json:把对应端的 API 归一到 `api`,删除 apiTaro,缩减 docs/demos 到本端,
// 使运行时代码只读 `api` / 只需 <lang>.md,无需感知平台。
const langKeys = Object.keys(docKeys)
const slimComponents = {}
for (const [id, c] of Object.entries(meta.components)) {
const api = c[apiField] || { tables: [] }
// docs 归一:只保留本端支持的 lang,key 换成 lang 名(zh/en),值为原相对路径(仅用于存在性)。
const docs = {}
for (const lang of langKeys) docs[lang] = c.docs?.[docKeys[lang]] ?? null
slimComponents[id] = {
...c,
api,
docs,
demos: c.demos?.[demoKey] ?? [],
}
delete slimComponents[id].apiTaro
}
const slimMeta = { ...meta, components: slimComponents }
fs.writeFileSync(
path.join(DATA_DIR, 'meta.json'),
`${JSON.stringify(slimMeta, null, 2)}\n`
)

let docCount = 0
let demoCount = 0
const missing = []

for (const c of Object.values(meta.components)) {
// 2. docs:按 lang 复制为 data/docs/<id>/<lang>.md(zh.md / en.md)。
for (const lang of langKeys) {
const rel = c.docs && c.docs[docKeys[lang]]
if (!rel) continue
const ok = copyRepoFile(rel, path.join(DATA_DIR, 'docs', c.id, `${lang}.md`))
if (ok) docCount++
else missing.push(rel)
}

// 3. demos:扁平放到 data/demos/<id>/<basename>.tsx(无 platform 子层)。
const demoList = (c.demos && c.demos[demoKey]) || []
for (const rel of demoList) {
const base = path.posix.basename(rel)
const ok = copyRepoFile(rel, path.join(DATA_DIR, 'demos', c.id, base))
if (ok) demoCount++
else missing.push(rel)
}
}

console.log(`✅ 写入 ${path.relative(pkgDir, DATA_DIR)}/`)
console.log(` meta.json + docs ${docCount} 份 + demos ${demoCount} 份`)
if (missing.length) {
console.log(`\n⚠️ ${missing.length} 个 meta 引用的文件在仓库中缺失(已跳过):`)
for (const m of missing.slice(0, 20)) console.log(` - ${m}`)
if (missing.length > 20) console.log(` … 其余 ${missing.length - 20} 条省略`)
}
}
142 changes: 142 additions & 0 deletions packages/nutui-react-cli-core/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// core CLI 入口:各叶子包构造 CliConfig 后调 runCli。命令 / 文案 / 语言选项均随 config 参数化。
import yargs from 'yargs'
import { runList } from './commands/list.js'
import { runInfo } from './commands/info.js'
import { runDoc } from './commands/doc.js'
import { runDemo } from './commands/demo.js'
import { runToken } from './commands/token.js'
import { runMcp } from './commands/mcp.js'
import type { CliConfig } from './config.js'
import type { Lang, OutputFormat } from './types.js'

export type { CliConfig } from './config.js'

export function runCli(config: CliConfig, argv: string[]): void {
// 仅当该端支持多语言时才暴露 --lang(Taro 只有 zh,隐藏该选项)。
const multiLang = config.langs.length > 1

const cli = yargs(argv)
.scriptName(config.binName)
.usage('$0 <command> [options]')
.option('format', {
alias: 'f',
describe: '输出格式',
choices: ['text', 'json'] as const,
default: 'text' as OutputFormat,
global: true,
})
.command(
['list', 'ls'],
'列出全部组件(按分类)',
(y) =>
y.option('category', {
alias: 'c',
type: 'string',
describe: '按分类 enName 筛选(如 base / feedback)',
}),
(a) =>
runList({
config,
category: a.category,
format: a.format as OutputFormat,
})
)
.command(
'info <component>',
'查看组件 Props 表',
(y) =>
y.positional('component', {
type: 'string',
describe: '组件名(大小写不敏感,如 Button)',
demandOption: true,
}),
(a) =>
runInfo({
config,
component: a.component as string,
format: a.format as OutputFormat,
})
)
.command(
'doc <component>',
'查看组件完整文档',
(y) => {
let b = y.positional('component', {
type: 'string',
describe: '组件名(大小写不敏感)',
demandOption: true,
})
if (multiLang) {
b = b.option('lang', {
alias: 'l',
choices: config.langs,
default: config.defaultLang,
describe: '文档语言',
}) as typeof b
}
return b
},
(a) =>
runDoc({
config,
component: a.component as string,
lang: (a.lang as Lang) ?? config.defaultLang,
format: a.format as OutputFormat,
})
)
.command(
'demo <component> [name]',
`列出或查看组件 ${config.demoLabel} 示例源码`,
(y) =>
y
.positional('component', {
type: 'string',
describe: '组件名(大小写不敏感)',
demandOption: true,
})
.positional('name', {
type: 'string',
describe: '示例名(如 demo1);省略则列出全部',
}),
(a) =>
runDemo({
config,
component: a.component as string,
name: a.name as string | undefined,
format: a.format as OutputFormat,
})
)
.command(
'token [component]',
'查看 Design Token(省略组件名则列全局 token)',
(y) =>
y.positional('component', {
type: 'string',
describe: '组件名(大小写不敏感);省略则列出全局 token',
}),
(a) =>
runToken({
config,
component: a.component as string | undefined,
format: a.format as OutputFormat,
})
)
.command(
'mcp',
'启动本地 MCP 服务(stdio),供 Claude Code / Cursor / VS Code / Codex 等调用',
(y) => y,
() => runMcp({ config })
)
.demandCommand(
1,
`请指定一个命令。运行 ${config.binName} --help 查看用法。`
)
.strict()
.alias('h', 'help')
.alias('v', 'version')
.version(config.version)
.wrap(null)

// eslint-disable-next-line @typescript-eslint/no-floating-promises
cli.parse()
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
// 命令间共享:解析组件名,未命中则打印 did-you-mean 并退出。
import { resolveComponent, suggestComponents } from '../data.js'
import type { CliConfig } from '../config.js'
import type { Component, Meta } from '../types.js'

export function resolveOrExit(meta: Meta, query: string): Component {
export function resolveOrExit(
config: CliConfig,
meta: Meta,
query: string
): Component {
const comp = resolveComponent(meta, query)
if (comp) return comp
const suggestions = suggestComponents(meta, query)
let msg = `未找到组件「${query}」。`
if (suggestions.length) msg += ` 你是否想找:${suggestions.join(' / ')}?`
msg += `\n运行 nutui-react list 查看全部组件。`
msg += `\n运行 ${config.binName} list 查看全部组件。`
process.stderr.write(`${msg}\n`)
process.exit(1)
}
Loading
Loading