Files
apes-sidebar/tsdown.config.ts
T
figmar 0e91e99f49 feat(apes-sidebar): Phase 0 工程骨架 — host/client 分裂 + i18n + 不变量面
从 0 重写的 v2.0.0 DSH 侧边栏插件第一阶段:
- 工程骨架:tsdown 双构建(host ESM + client CJS closure)、vitest、strict TS
- host 半:namespace 插件形状(name/inject/apply,无 default),minimal apply
- client 半:closure factory 注册 5 个 apes:* tab 占位 + zh/en i18n
- 不变量面:assert/semver/peer guard(dsh-better-sidebar >= 0.18.0)
- 依赖策略:dsh-better-sidebar=peerDependency(不打包),cordis=devDependency(仅类型)
- 门禁:pnpm typecheck + build + test 全绿(14 单测通过)
- AGENTS.md 工程约定 + .gitignore(排除 lib/node_modules/maxkb-integration)

模板:dsh-better-sidebar。作用域锁定信息层(tasks/agents/apps/messages/profile),
不含 file/terminal/browser(归 better-sidebar)、不含 team/room(归 background-agents)。
2026-09-04 17:14:22 +08:00

204 lines
7.9 KiB
TypeScript

/**
* tsdown build for apes-sidebar:
* - host-half lib (lib/index.js + lib/invariant.js, ESM node)
* - the browser client bundle (lib/client.js, CJS closure factory) registered
* under the package-name id `apes-sidebar` (official profile channel).
*
* The client bundle replicates the official DSH client-bundle preset
* (packages/client/tsdown.client.ts): externals resolve through the loader
* module table at runtime; everything else inlines; the purity gate rejects
* any other @deepseek-ai value import (cross-plugin collaboration goes
* through cordis services, never value imports); CSS compiles to hashed
* class maps and injects <style data-plugin> tags at factory execution;
* the artifact registers itself via window.__ModuleLoader__.load.
*
* Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
*/
import { readFile } from 'node:fs/promises'
import { basename, dirname, join, relative, resolve as resolvePath, sep } from 'node:path'
import { builtinModules } from 'node:module'
import { fileURLToPath } from 'node:url'
import type { UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
/** Node builtins must never survive into the browser module-loader factory. */
const NODE_BUILTINS = new Set([
...builtinModules,
...builtinModules.map(id => `node:${id}`),
])
/** Module specifiers the web shell shares into the frozen module table. */
const CLIENT_EXTERNALS = [
'react',
'react/jsx-runtime',
'react-dom',
'react-dom/client',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
]
/**
* Wire/type layers a client bundle may inline (mirror of the official
* INLINE_SAFE list): browser-safe contract surfaces with no runtime identity
* to share.
*/
const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
/** Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline. */
const CSS_VIRTUAL_PREFIX = '\0apes-css:'
const CSS_VIRTUAL_SUFFIX = '.mjs'
const REPOSITORY_ROOT = fileURLToPath(new URL('.', import.meta.url))
/** The style-injection prologue shared by module css and plain css loads. */
function injectTag(pluginId: string, fileId: string, cssText: string): string {
const tagId = `${pluginId}/${basename(fileId)}`
return [
`const css = ${JSON.stringify(cssText)};`,
`const tagId = ${JSON.stringify(tagId)};`,
`if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
` const tag = document.createElement('style');`,
` tag.dataset.plugin = ${JSON.stringify(pluginId)};`,
` tag.dataset.pluginCss = tagId;`,
` tag.textContent = css;`,
` document.head.appendChild(tag);`,
`}`,
].join('\n')
}
/** Rebase a physical lib-relative source onto the repository-shaped URL tree. */
function browserSourcePath(source: string, sourcemapPath: string): string {
if (!source.startsWith('.')) return source
const physicalSource = resolvePath(dirname(sourcemapPath), source)
const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
return `../../../${repositoryPath}`
}
/**
* One client bundle build for a plugin id. The same src/client/index.tsx is
* compiled once per install channel; only the registered id and the output
* file name differ, so they cannot drift.
* @param pluginId - the `__ModuleLoader__.load({ id })` value and the
* data-plugin style-tag prefix of this bundle.
* @param entryFile - the output file name under lib/.
*/
function clientBundle(pluginId: string, entryFile: string): UserConfig {
return {
entry: { client: 'src/client/index.tsx' },
outDir: 'lib',
format: 'cjs',
platform: 'browser',
dts: false,
sourcemap: true,
clean: false,
deps: {
neverBundle: [...CLIENT_EXTERNALS],
alwaysBundle: (id: string) => !CLIENT_EXTERNALS.includes(id),
},
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
'import.meta.resolve': 'undefined',
},
inputOptions: {
resolve: {
conditionNames: ['browser', 'import', 'require', 'default'],
},
},
plugins: [purityGatePlugin(), makeCssPlugin(pluginId)],
outputOptions: {
entryFileNames: entryFile,
sourcemapPathTransform: browserSourcePath,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(pluginId)}, factory: (require) => {`,
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
codeSplitting: false,
},
}
}
/** A rolldown plugin as tsdown's config accepts it (contextual `this` for load/resolveId). */
type BuildPlugin = NonNullable<UserConfig['plugins']>
/** The shared client-bundle purity gate. */
function purityGatePlugin(): BuildPlugin {
return {
name: 'apes-client-bundle-purity',
resolveId(source: string) {
if (NODE_BUILTINS.has(source)) {
throw new Error(
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
+ 'select the dependency browser export or add an explicit browser implementation',
)
}
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},
}
}
/** The shared CSS-inline virtual-module plugin (one <style data-plugin> per file). */
function makeCssPlugin(pluginId: string): BuildPlugin {
return {
name: 'apes-css-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.css')) return null
let abs: string
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
} else {
// Bare specifiers resolve from the package.
const { createRequire } = require('node:module')
abs = createRequire(import.meta.url).resolve(source)
}
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
if (fileId.endsWith('.module.css')) {
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: `[hash]_[local]` },
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
return [
injectTag(pluginId, fileId, code.toString()),
`export default ${JSON.stringify(classMap)};`,
].join('\n')
}
return [
injectTag(pluginId, fileId, source.toString('utf8')),
'export default "";',
].join('\n')
},
}
}
export default [
{
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
// Official profile channel: bundle id = package name (package.json `name`).
clientBundle('apes-sidebar', 'client.js'),
] satisfies UserConfig[]