从 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)。
204 lines
7.9 KiB
TypeScript
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[]
|