dsh-taiwind 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-PRESENT dsh-tauri-desk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,170 @@
1
- # Temporary Holding Version
1
+ # dsh-taiwind
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version][npm-version-src]][npm-version-href]
4
+ [![npm downloads][npm-downloads-src]][npm-downloads-href]
5
+ [![bundle][bundle-src]][bundle-href]
6
+ [![CI][ci-src]][ci-href]
7
+ [![Tailwind CSS][tailwind-src]][tailwind-href]
8
+ [![DSH][dsh-src]][dsh-href]
9
+ [![Node.js][node-src]][node-href]
10
+ [![License][license-src]][license-href]
11
+
12
+ 🎨 **dsh-taiwind** 仅适用于插件作者,为 DeepSeek Harness 插件提供一份共享的 Tailwind CSS:工具类在宿主侧统一收集、编译、去重,不必每个插件各带一份重复样式。
13
+
14
+ > 基于 Tailwind CSS **4.3.3**,要求 DSH ≥ `0.2.1-alpha.1`、Node.js ≥ `22.18.0`。
15
+
16
+ ## Why?
17
+
18
+ DSH 插件各自发布成独立的 npm 包样式的交付方式也跟着各自为政:按约定,插件挂一个 `<style data-plugin="...">` 标签,内容要么是手写 CSS。手写 CSS 意味着每个作者都要自己发明类名、主题和响应式写法,彼此不通用;而整份 Tailwind 输出塞进包里的话,由于任意两个插件都会用到同一批 `flex`、`gap-2`、`w-full`,同一个页面就会把相同的规则编译并加载 N 次。
19
+
20
+ `dsh-taiwind` 将这段编译放进宿主进程:每次客户端插件图变化,它从各插件的构建文件里收集完整类名,连同包内的 CSS-first 定义(`@theme`、`@utility` 等)一起交给同一个 Tailwind 编译器,输出一份样式注入页面。插件因此仍可独立发布、独立构建,不需要 monorepo、不需要统一构建工具,也不用各自处理去重:
21
+
22
+ ```mermaid
23
+ flowchart LR
24
+ A["插件 A:w-xl"] --> U["工具类并集"]
25
+ B["插件 B:w-full、w-xl"] --> U
26
+ U --> T["共享 Tailwind 编译"]
27
+ T --> S["一份样式:w-full、w-xl 各一条规则"]
28
+ ```
29
+
30
+ 而去重针对共享编译产生的工具类,不是把多个 CSS 文件拼起来删重复字符串,也不会删除其他插件已挂载的样式。
31
+
32
+ ## ✨ 特性
33
+
34
+ - **CSS-first**:保留未编译的 `main.css`,支持 `@theme`、`@utility`、`@custom-variant`、`@apply`、`@source inline(...)`。
35
+ - **按需扫描**:从 `clientModules.graph()` 取当前客户端包,`clientPath(id)` 定位构建产物,不依赖插件源码。
36
+ - **官方编译器**:Tailwind 扫描器提取候选类,统一编译;不加载 Preflight,不重置宿主界面。
37
+ - **单一 style**:首屏注入 `<head>`,客户端接管同一个 `<style data-plugin="dsh-taiwind" data-plugin-css="utilities">`。
38
+ - **自动收缩**:删除类名、分包或卸载插件后,下次成功编译即收缩;无磁盘缓存。
39
+ - **失败保留**:运行期编译失败返回 503,已打开页面保留上一份有效样式,修复后自动恢复。
40
+ - **条件请求**:带 ETag 的 CSS 接口,命中返回 304;插件图变化立即刷新,另每两秒检查产物与配置。
41
+ - **完整清理**:停用移除路由、监听、定时器与客户端样式,并取消进行中的请求。
42
+
43
+ ## 🚀 快速开始
44
+
45
+ ### 1. 安装
46
+
47
+ ```sh
48
+ pnpm add dsh-taiwind
49
+ ```
50
+
51
+ 在业务插件的 `cordis.patch.yml` 里插入它(连同自身行):
52
+
53
+ ```yaml
54
+ - insert:
55
+ - id: my-plugin
56
+ name: dsh-my-plugin
57
+ - id: dsh-taiwind
58
+ name: dsh-taiwind
59
+ ```
60
+
61
+ 用户启用业务插件时,`dsh-taiwind` 将随行一起启用,而客户端半也必须有一行启用中的 Loader 条目才会被 `dsh-client-modules` 加载。
62
+
63
+ - 它没有界面、没有工具,唯一产物是共享样式接口 `plugins/dsh-taiwind.css` 和页面上的一个 `<style data-plugin="dsh-taiwind">`。
64
+ - 宿主半要求 `clientModules`、`webServer`、`connection` 三件服务齐备。
65
+ - 扫描范围是当前客户端图里所有插件的构建产物,插件图变化会自动触发重新编译。
66
+
67
+ ### 2. 在插件包里写样式
68
+
69
+ 在插件包中保留未编译的 `main.css`,不需要 `tailwind.config.js`,也不用把路径传给本插件:
70
+
71
+ ```css
72
+ @import "tailwindcss";
73
+
74
+ @theme {
75
+ --color-notes-accent: #7c3aed;
76
+ --container-notes-panel: 28rem;
77
+ }
78
+
79
+ @utility notes-compact {
80
+ padding: 3px;
81
+ }
82
+ ```
83
+
84
+ 客户端直接使用 `bg-notes-accent w-notes-panel notes-compact` 即可。
85
+
86
+ 入口发现顺序:
87
+
88
+ 1. 包根目录的 `main.css`
89
+ 2. 客户端入口同目录的 `client/main.css`
90
+ 3. 找到多个会明确报错,不任意选择
91
+
92
+ ### 3. 发布时携带样式文件
93
+
94
+ 在 `package.json` 中添加 `files` 字段,发布时需要携带 `main.css`:
95
+
96
+ ```json
97
+ {
98
+ "files": [
99
+ "src/main.css",
100
+ "tailwind.config.js"
101
+ ]
102
+ }
103
+ ```
104
+
105
+ ## ⚙️ 配置文件(可选)
106
+
107
+ 可在包根目录放一份 `tailwind.config.js`(也认 `.cjs` / `.mjs`),或用 CSS 的 `@config` 显式引用:
108
+
109
+ ```js
110
+ /** @type {import("tailwindcss").Config} */
111
+ export default {
112
+ darkMode: 'class',
113
+ theme: {
114
+ extend: {
115
+ colors: { 'notes-accent': 'var(--notes-accent)' },
116
+ width: { 'notes-panel': '28rem' },
117
+ },
118
+ },
119
+ }
120
+ ```
121
+
122
+ CSS 入口存在时**不会自动加载根目录 JS 配置**,需要时在 CSS 中显式写 `@config "./tailwind.config.js";`(路径相对该 CSS 文件)。
123
+
124
+ 配置文件按包自身的 `type` 走原生 ESM / CJS 加载:改配置本身即刻生效;`.cjs` 配置连同 `require` 的依赖一起热重载,ESM 配置 import 的模块需要重启进程生效。
125
+
126
+ ## 📚 配置与限制
127
+
128
+ | 项目 | 说明 |
129
+ | --- | --- |
130
+ | 发布 | 原始 CSS 入口及其相对导入必须进 npm 包;只发 `dist` 时一并复制,不要用展开后的构建 CSS 代替 |
131
+ | 不支持 | TypeScript 配置(只认 `.js` / `.mjs` / `.cjs`)、npm CSS 包导入、路径别名、远程 CSS、`@plugin`、文件路径形式的 `@source`、自定义 `source(...)` 扫描目录、跨包 CSS 导入 |
132
+ | 冲突检测 | 自定义 token、同名 `@utility` / `@custom-variant` 定义不一致会报错并指包名;非完整 CSS 语义等价判断 |
133
+ | 主题合并 | 遵循 Tailwind 4:同 token 的 CSS 定义优先;所有插件共享全局主题,不按插件隔离 |
134
+ | 自定义 CSS | 一起输出,但不自动限定作用域、去重或改写资源 URL;请用插件前缀选择器和可直接访问的资源地址 |
135
+ | 动态值 | 适合 CSS 变量或内联样式,不要拼接不完整类名 |
136
+ | 共享编译器成本 | 需安装 Tailwind、Oxide 原生扫描器等宿主依赖;有磁盘占用与宿主 CPU 开销 |
137
+ | 共享不是隔离 | 共用主题与全局选项,冲突可能阻止整份样式更新;不适合要求完全独立 Tailwind 配置的插件 |
138
+
139
+ ## 🛠️ 开发与贡献
140
+
141
+ ```sh
142
+ pnpm install --frozen-lockfile
143
+ pnpm run lint
144
+ pnpm run typecheck
145
+ pnpm run test -- --run
146
+ pnpm run build
147
+ ```
148
+
149
+ ## 📜️ 许可证
150
+
151
+ - [MIT](<LICENSE.md>) © dsh-tauri-desk,保留上游模板授权。
152
+
153
+ <!-- Badges -->
154
+
155
+ [npm-version-src]: https://img.shields.io/npm/v/dsh-taiwind?style=flat&colorA=080f12&colorB=1fa669
156
+ [npm-version-href]: https://npmjs.com/package/dsh-taiwind
157
+ [npm-downloads-src]: https://img.shields.io/npm/dm/dsh-taiwind?style=flat&colorA=080f12&colorB=1fa669
158
+ [npm-downloads-href]: https://npmjs.com/package/dsh-taiwind
159
+ [bundle-src]: https://img.shields.io/bundlephobia/minzip/dsh-taiwind?style=flat&colorA=080f12&colorB=1fa669&label=minzip
160
+ [bundle-href]: https://bundlephobia.com/result?p=dsh-taiwind
161
+ [ci-src]: https://img.shields.io/github/actions/workflow/status/hairyf/dsh-taiwind/ci.yml?branch=main&style=flat&colorA=080f12&colorB=1fa669&label=CI
162
+ [ci-href]: https://github.com/hairyf/dsh-taiwind/actions/workflows/ci.yml
163
+ [tailwind-src]: https://img.shields.io/badge/Tailwind_CSS-4.3.3-1fa669?style=flat&colorA=080f12
164
+ [tailwind-href]: https://tailwindcss.com/docs
165
+ [dsh-src]: https://img.shields.io/badge/DSH-%3E%3D0.2.1--alpha.1-1fa669?style=flat&colorA=080f12
166
+ [dsh-href]: https://github.com/hairyf/dsh-taiwind/blob/main/package.json
167
+ [node-src]: https://img.shields.io/badge/Node.js-%3E%3D22.18.0-1fa669?style=flat&colorA=080f12
168
+ [node-href]: https://nodejs.org/
169
+ [license-src]: https://img.shields.io/github/license/hairyf/dsh-taiwind.svg?style=flat&colorA=080f12&colorB=1fa669
170
+ [license-href]: https://github.com/hairyf/dsh-taiwind/blob/main/LICENSE.md
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-taiwind
3
+ name: dsh-taiwind
@@ -0,0 +1,4 @@
1
+ window.__ModuleLoader__.load({id:"dsh-taiwind",factory:(require)=>{var module={exports:{}};var exports=module.exports;
2
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=`dsh-taiwind`;function t(){let t=document.head.querySelector(`[data-plugin="dsh-taiwind"][data-plugin-css="utilities"]`),n=t??document.createElement(`style`);n.dataset.plugin=e,n.dataset.pluginCss=`utilities`,t||document.head.append(n);let r=new AbortController,i=``,a=!1,o,s=``,c=async()=>{let t=new AbortController,l=setTimeout(()=>t.abort(),15e3),u=()=>t.abort();r.signal.addEventListener(`abort`,u,{once:!0});try{let e=await fetch(new URL(`plugins/dsh-taiwind.css`,document.baseURI),{credentials:`same-origin`,cache:`no-cache`,headers:i?{"If-None-Match":i}:{},signal:t.signal});if(e.status!==304){if(!e.ok)throw Error(`Stylesheet request failed (${e.status})`);let t=await e.text();a||(n.textContent!==t&&(n.textContent=t),i=e.headers.get(`ETag`)??``)}s=``}catch(t){if(!a){let n=t instanceof Error?t.message:String(t);s!==n&&(s=n,console.warn(`${e}: ${n}; keeping previous stylesheet`))}}finally{clearTimeout(l),r.signal.removeEventListener(`abort`,u),a||(o=setTimeout(()=>void c(),2e3))}};return c(),()=>{a=!0,r.abort(),clearTimeout(o),n.remove()}}function n(e){e.effect(t)}exports.apply=n;
3
+ return module.exports;}});
4
+ //# sourceMappingURL=client.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.cjs","names":[],"sources":["../src/constants.ts","../src/client/styles.ts","../src/client/index.ts"],"sourcesContent":["export const PLUGIN_ID = 'dsh-taiwind'\r\nexport const STYLESHEET_PATH = 'plugins/dsh-taiwind.css'\r\nexport const STYLE_SELECTOR = '[data-plugin=\"dsh-taiwind\"][data-plugin-css=\"utilities\"]'\r\nexport const POLL_INTERVAL_MS = 2000\r\n","import { PLUGIN_ID, POLL_INTERVAL_MS, STYLE_SELECTOR, STYLESHEET_PATH } from '../constants'\r\n\r\nexport function mountStyles(): () => void {\r\n const existing = document.head.querySelector<HTMLStyleElement>(STYLE_SELECTOR)\r\n const style = existing ?? document.createElement('style')\r\n style.dataset.plugin = PLUGIN_ID\r\n style.dataset.pluginCss = 'utilities'\r\n if (!existing)\r\n document.head.append(style)\r\n\r\n const controller = new AbortController()\r\n let etag = ''\r\n let disposed = false\r\n let timer: ReturnType<typeof setTimeout> | undefined\r\n let lastError = ''\r\n\r\n const update = async (): Promise<void> => {\r\n const request = new AbortController()\r\n const timeout = setTimeout(() => request.abort(), 15000)\r\n const abort = (): void => request.abort()\r\n controller.signal.addEventListener('abort', abort, { once: true })\r\n try {\r\n const response = await fetch(new URL(STYLESHEET_PATH, document.baseURI), {\r\n credentials: 'same-origin',\r\n cache: 'no-cache',\r\n headers: etag ? { 'If-None-Match': etag } : {},\r\n signal: request.signal,\r\n })\r\n if (response.status !== 304) {\r\n if (!response.ok)\r\n throw new Error(`Stylesheet request failed (${response.status})`)\r\n const css = await response.text()\r\n if (!disposed) {\r\n if (style.textContent !== css)\r\n style.textContent = css\r\n etag = response.headers.get('ETag') ?? ''\r\n }\r\n }\r\n lastError = ''\r\n }\r\n catch (error) {\r\n if (!disposed) {\r\n const message = error instanceof Error ? error.message : String(error)\r\n if (lastError !== message) {\r\n lastError = message\r\n console.warn(`${PLUGIN_ID}: ${message}; keeping previous stylesheet`)\r\n }\r\n }\r\n }\r\n finally {\r\n clearTimeout(timeout)\r\n controller.signal.removeEventListener('abort', abort)\r\n if (!disposed)\r\n timer = setTimeout(() => void update(), POLL_INTERVAL_MS)\r\n }\r\n }\r\n void update()\r\n\r\n return () => {\r\n disposed = true\r\n controller.abort()\r\n clearTimeout(timer)\r\n style.remove()\r\n }\r\n}\r\n","import type { Context } from '@deepseek-ai/cordis'\r\nimport { mountStyles } from './styles'\r\n\r\nexport function apply(ctx: Context): void {\r\n ctx.effect(mountStyles)\r\n}\r\n"],"mappings":";mEAAA,MAAa,EAAY,cCEzB,SAAgB,GAA0B,CACxC,IAAM,EAAW,SAAS,KAAK,cAAgC,0DAAc,EACvE,EAAQ,GAAY,SAAS,cAAc,OAAO,EACxD,EAAM,QAAQ,OAAS,EACvB,EAAM,QAAQ,UAAY,YACrB,GACH,SAAS,KAAK,OAAO,CAAK,EAE5B,IAAM,EAAa,IAAI,gBACnB,EAAO,GACP,EAAW,GACX,EACA,EAAY,GAEV,EAAS,SAA2B,CACxC,IAAM,EAAU,IAAI,gBACd,EAAU,eAAiB,EAAQ,MAAM,EAAG,IAAK,EACjD,MAAoB,EAAQ,MAAM,EACxC,EAAW,OAAO,iBAAiB,QAAS,EAAO,CAAE,KAAM,EAAK,CAAC,EACjE,GAAI,CACF,IAAM,EAAW,MAAM,MAAM,IAAI,IAAI,0BAAiB,SAAS,OAAO,EAAG,CACvE,YAAa,cACb,MAAO,WACP,QAAS,EAAO,CAAE,gBAAiB,CAAK,EAAI,CAAC,EAC7C,OAAQ,EAAQ,MAClB,CAAC,EACD,GAAI,EAAS,SAAW,IAAK,CAC3B,GAAI,CAAC,EAAS,GACZ,MAAU,MAAM,8BAA8B,EAAS,OAAO,EAAE,EAClE,IAAM,EAAM,MAAM,EAAS,KAAK,EAC3B,IACC,EAAM,cAAgB,IACxB,EAAM,YAAc,GACtB,EAAO,EAAS,QAAQ,IAAI,MAAM,GAAK,GAE3C,CACA,EAAY,EACd,OACO,EAAO,CACZ,GAAI,CAAC,EAAU,CACb,IAAM,EAAU,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,EACjE,IAAc,IAChB,EAAY,EACZ,QAAQ,KAAK,GAAG,EAAU,IAAI,EAAQ,8BAA8B,EAExE,CACF,QACQ,CACN,aAAa,CAAO,EACpB,EAAW,OAAO,oBAAoB,QAAS,CAAK,EAC/C,IACH,EAAQ,eAAiB,KAAK,EAAO,EAAG,GAAgB,EAC5D,CACF,EAGA,OAFA,EAAY,MAEC,CACX,EAAW,GACX,EAAW,MAAM,EACjB,aAAa,CAAK,EAClB,EAAM,OAAO,CACf,CACF,CC7DA,SAAgB,EAAM,EAAoB,CACxC,EAAI,OAAO,CAAW,CACxB"}
@@ -0,0 +1,17 @@
1
+ import { Context } from "@deepseek-ai/cordis";
2
+ import { HostConnectionHandle } from "@deepseek-ai/dsh-client-connection";
3
+ import { ClientModuleRegistry } from "@deepseek-ai/dsh-client-modules";
4
+ import { WebServer } from "@deepseek-ai/dsh-host-webserver";
5
+ //#region src/types.d.ts
6
+ type HostContext = Context & {
7
+ clientModules: ClientModuleRegistry;
8
+ webServer: WebServer;
9
+ connection: HostConnectionHandle;
10
+ };
11
+ //#endregion
12
+ //#region src/index.d.ts
13
+ declare const name = "dsh-taiwind";
14
+ declare const inject: string[];
15
+ declare function apply(ctx: HostContext): Promise<void>;
16
+ //#endregion
17
+ export { apply, inject, name };
package/dist/index.js ADDED
@@ -0,0 +1,452 @@
1
+ import { createRequire } from "node:module";
2
+ import { createHash } from "node:crypto";
3
+ import { readFile, readdir, realpath } from "node:fs/promises";
4
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { Scanner } from "@tailwindcss/oxide";
7
+ import postcss from "postcss";
8
+ import { compile } from "tailwindcss";
9
+ //#region src/compiler.ts
10
+ const configNamePattern = /^(?:tailwind|taiwind)\.config\.[^.]+$/;
11
+ const configPattern = /^(?:tailwind|taiwind)\.config\.[cm]?js$/;
12
+ const bundlePattern = /\.[cm]?js$/;
13
+ const nodeRequire = createRequire(import.meta.url);
14
+ let cached;
15
+ async function nativeModule(configPath, root) {
16
+ const extension = extname(configPath);
17
+ if (extension === ".mjs") return true;
18
+ if (extension === ".cjs") return false;
19
+ return JSON.parse(await readFile(join(root, "package.json"), "utf8")).type === "module";
20
+ }
21
+ async function loadModule(configPath, rawConfig, root) {
22
+ if (await nativeModule(configPath, root)) {
23
+ const url = pathToFileURL(configPath);
24
+ url.search = `?v=${createHash("sha256").update(rawConfig).digest("hex").slice(0, 16)}`;
25
+ const loaded = await import(url.href);
26
+ return loaded.default ?? loaded;
27
+ }
28
+ for (const key of Object.keys(nodeRequire.cache)) if (key.startsWith(`${root}${sep}`)) delete nodeRequire.cache[key];
29
+ const loaded = nodeRequire(configPath);
30
+ return loaded?.default ?? loaded;
31
+ }
32
+ function isObject(value) {
33
+ return value !== null && typeof value === "object" && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
34
+ }
35
+ function staticData(value, location) {
36
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return value;
37
+ if (Array.isArray(value)) return value.map((item, index) => staticData(item, `${location}[${index}]`));
38
+ if (isObject(value)) {
39
+ const output = Object.create(null);
40
+ for (const [key, item] of Object.entries(value)) {
41
+ if ([
42
+ "__proto__",
43
+ "constructor",
44
+ "prototype"
45
+ ].includes(key)) throw new Error(`${location}.${key}: unsupported configuration key`);
46
+ output[key] = staticData(item, `${location}.${key}`);
47
+ }
48
+ return output;
49
+ }
50
+ throw new Error(`${location}: only static JSON-compatible configuration values are supported`);
51
+ }
52
+ function canonical(value) {
53
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
54
+ if (isObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
55
+ return JSON.stringify(value);
56
+ }
57
+ function validateConfig(value, owner) {
58
+ if (!isObject(value)) throw new Error(`${owner}: Tailwind config must export an object`);
59
+ for (const key of Object.keys(value)) if (![
60
+ "theme",
61
+ "darkMode",
62
+ "prefix",
63
+ "important"
64
+ ].includes(key)) throw new Error(`${owner}: unsupported Tailwind configuration option "${key}"; supported: theme.extend, darkMode, prefix, important`);
65
+ const config = staticData(value, owner);
66
+ if ("theme" in config) {
67
+ if (!isObject(config.theme) || Object.keys(config.theme).some((key) => key !== "extend")) throw new Error(`${owner}: only theme.extend is supported; direct theme replacement is not supported`);
68
+ if ("extend" in config.theme) {
69
+ if (!isObject(config.theme.extend)) throw new Error(`${owner}: theme.extend must be an object`);
70
+ for (const [section, values] of Object.entries(config.theme.extend)) if (!isObject(values)) throw new Error(`${owner}: theme.extend.${section} must be a static object`);
71
+ }
72
+ }
73
+ if ("darkMode" in config) {
74
+ const mode = config.darkMode;
75
+ const validVariant = Array.isArray(mode) && mode[0] === "variant" && (typeof mode[1] === "string" || Array.isArray(mode[1]) && mode[1].every((item) => typeof item === "string"));
76
+ const validSelector = Array.isArray(mode) && ["class", "selector"].includes(mode[0]) && typeof mode[1] === "string";
77
+ if (!(mode === false || [
78
+ "media",
79
+ "class",
80
+ "selector"
81
+ ].includes(mode) || Array.isArray(mode) && mode.length === 2 && (validVariant || validSelector))) throw new Error(`${owner}: invalid darkMode strategy`);
82
+ }
83
+ if ("prefix" in config && (typeof config.prefix !== "string" || !/^[a-z]+$/.test(config.prefix))) throw new Error(`${owner}: prefix must contain lowercase letters only (Tailwind v4 prefix syntax)`);
84
+ if ("important" in config && typeof config.important !== "boolean" && (typeof config.important !== "string" || !config.important.trim())) throw new Error(`${owner}: important must be a boolean or a nonempty selector`);
85
+ return config;
86
+ }
87
+ function mergeValues(target, incoming, owner, owners, path = "theme.extend") {
88
+ for (const [key, value] of Object.entries(incoming)) {
89
+ const location = `${path}.${key}`;
90
+ if (Object.hasOwn(target, key)) {
91
+ if (isObject(target[key]) && isObject(value)) mergeValues(target[key], value, owner, owners, location);
92
+ else if (canonical(target[key]) !== canonical(value)) throw new Error(`Tailwind config conflict at ${location}: ${owners.get(location)} and ${owner}`);
93
+ } else {
94
+ target[key] = isObject(value) ? Object.create(null) : value;
95
+ owners.set(location, owner);
96
+ if (isObject(value)) mergeValues(target[key], value, owner, owners, location);
97
+ }
98
+ }
99
+ }
100
+ function checkAliases(extend, owner, aliases) {
101
+ for (const [section, values] of Object.entries(extend)) {
102
+ const visit = (value, path) => {
103
+ if (isObject(value)) {
104
+ for (const [key, item] of Object.entries(value)) visit(item, key === "DEFAULT" ? path : [...path, key]);
105
+ return;
106
+ }
107
+ const key = `${section}.${path.join("-")}`;
108
+ const serialized = canonical(value);
109
+ const previous = aliases.get(key);
110
+ if (previous && previous.value !== serialized) throw new Error(`Tailwind theme token conflict at ${key}: ${previous.owner} and ${owner}`);
111
+ aliases.set(key, {
112
+ value: serialized,
113
+ owner
114
+ });
115
+ };
116
+ visit(values, []);
117
+ }
118
+ }
119
+ async function packageRoot(path, id) {
120
+ for (let directory = dirname(path);;) {
121
+ try {
122
+ if (JSON.parse(await readFile(join(directory, "package.json"), "utf8")).name === id) return directory;
123
+ } catch (error) {
124
+ if (error.code !== "ENOENT") throw error;
125
+ }
126
+ const parent = dirname(directory);
127
+ if (parent === directory) throw new Error(`${id}: cannot find an owning package.json for ${path}`);
128
+ directory = parent;
129
+ }
130
+ }
131
+ async function bundles(directory) {
132
+ const files = [];
133
+ for (const item of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
134
+ if (item.name === "node_modules" || item.name === "archive" || item.isSymbolicLink()) continue;
135
+ const path = join(directory, item.name);
136
+ if (item.isDirectory()) files.push(...await bundles(path));
137
+ else if (item.isFile() && bundlePattern.test(item.name)) files.push(path);
138
+ }
139
+ return files;
140
+ }
141
+ async function cssEntry(root, clientDirectory) {
142
+ for (const directory of /* @__PURE__ */ new Set([root, clientDirectory])) {
143
+ const entry = (await readdir(directory, { withFileTypes: true })).find((item) => item.name === "main.css");
144
+ if (entry) {
145
+ if (!entry.isFile()) throw new Error(`${directory}: main.css must be a regular file`);
146
+ return join(directory, entry.name);
147
+ }
148
+ }
149
+ const found = [];
150
+ const visit = async (directory) => {
151
+ for (const item of await readdir(directory, { withFileTypes: true })) {
152
+ if ([
153
+ "node_modules",
154
+ "archive",
155
+ ".git"
156
+ ].includes(item.name) || item.isSymbolicLink()) continue;
157
+ const path = join(directory, item.name);
158
+ if (item.isDirectory()) await visit(path);
159
+ else if (item.isFile() && item.name === "main.css") found.push(path);
160
+ }
161
+ };
162
+ await visit(root);
163
+ if (found.length > 1) throw new Error(`${root}: multiple main.css entries found: ${found.sort().join(", ")}`);
164
+ return found[0];
165
+ }
166
+ async function compilePlugins(entries) {
167
+ const hash = createHash("sha256");
168
+ const sources = [];
169
+ const extend = Object.create(null);
170
+ const merged = { theme: { extend } };
171
+ const owners = /* @__PURE__ */ new Map();
172
+ const aliases = /* @__PURE__ */ new Map();
173
+ const seen = /* @__PURE__ */ new Set();
174
+ const stylesheets = /* @__PURE__ */ new Map();
175
+ const cssImports = [];
176
+ const cssDefinitions = /* @__PURE__ */ new Map();
177
+ const configsSeen = /* @__PURE__ */ new Set();
178
+ const setGlobal = (key, value, owner) => {
179
+ if (Object.hasOwn(merged, key) && canonical(merged[key]) !== canonical(value)) throw new Error(`Tailwind global configuration conflict at ${key}: ${owners.get(key)} and ${owner}`);
180
+ merged[key] = value;
181
+ owners.set(key, owner);
182
+ };
183
+ const loadConfig = async (configPath, owner, root) => {
184
+ configPath = await realpath(configPath);
185
+ if (configsSeen.has(configPath)) return;
186
+ configsSeen.add(configPath);
187
+ const rawConfig = await readFile(configPath, "utf8");
188
+ let loaded;
189
+ try {
190
+ loaded = await loadModule(configPath, rawConfig, root);
191
+ } catch (error) {
192
+ throw new Error(`${owner}: failed to load ${configPath}`, { cause: error });
193
+ }
194
+ const config = validateConfig(loaded, owner);
195
+ hash.update(JSON.stringify([
196
+ configPath,
197
+ rawConfig,
198
+ canonical(config)
199
+ ]));
200
+ const configExtend = config.theme?.extend;
201
+ if (configExtend) {
202
+ checkAliases(configExtend, owner, aliases);
203
+ mergeValues(extend, configExtend, owner, owners);
204
+ }
205
+ for (const key of [
206
+ "darkMode",
207
+ "prefix",
208
+ "important"
209
+ ]) if (Object.hasOwn(config, key)) setGlobal(key, config[key], owner);
210
+ };
211
+ const checkDefinition = (key, value, owner) => {
212
+ const previous = cssDefinitions.get(key);
213
+ if (previous && previous.owner !== owner && previous.value !== value) throw new Error(`Tailwind CSS conflict at ${key}: ${previous.owner} and ${owner}`);
214
+ cssDefinitions.set(key, {
215
+ value,
216
+ owner
217
+ });
218
+ };
219
+ const loadCss = async (filename, root, owner, ancestors = []) => {
220
+ const path = await realpath(filename);
221
+ const local = relative(root, path);
222
+ if (local === ".." || local.startsWith("../") || local.startsWith("..\\") || isAbsolute(local)) throw new Error(`${owner}: CSS dependency is outside its package: ${filename}`);
223
+ if (ancestors.includes(path)) throw new Error(`${owner}: circular CSS import: ${[...ancestors, path].join(" -> ")}`);
224
+ if (stylesheets.has(path)) return path;
225
+ const content = await readFile(path, "utf8");
226
+ hash.update(JSON.stringify([path, content]));
227
+ const ast = postcss.parse(content, { from: path });
228
+ const directives = [];
229
+ ast.walkAtRules((rule) => {
230
+ directives.push(rule);
231
+ });
232
+ for (const rule of directives) {
233
+ if ([
234
+ "import",
235
+ "reference",
236
+ "tailwind"
237
+ ].includes(rule.name) && /\bsource\((?!none\))/.test(rule.params.replace(/^(?:"[^"]*"|'[^']*')/, ""))) throw new Error(`${owner}: source(...) directory scanning is not supported; file candidates come from client bundles`);
238
+ if ([
239
+ "import",
240
+ "reference",
241
+ "config"
242
+ ].includes(rule.name)) {
243
+ const match = /^(?:"([^"\\]+)"|'([^'\\]+)')(.*)$/s.exec(rule.params);
244
+ if (!match) throw new Error(`${owner}: ${path}: @${rule.name} requires a quoted local path`);
245
+ const id = match[1] ?? match[2];
246
+ const options = match[3].trim();
247
+ if (rule.name === "config") {
248
+ if (options || !id.startsWith(".")) throw new Error(`${owner}: @config requires a relative path without options`);
249
+ await loadConfig(resolve(dirname(path), id), owner, root);
250
+ rule.remove();
251
+ } else if (/^tailwindcss(?:\/(?:index|theme|utilities|preflight)(?:\.css)?)?$/.test(id)) {
252
+ const rest = options.replace(/\blayer\((?:theme|base|utilities)\)|\bsource\(none\)/g, "").trim();
253
+ const prefix = /\bprefix\(([a-z]+)\)/.exec(rest);
254
+ if (prefix) setGlobal("prefix", prefix[1], owner);
255
+ if (/\bimportant\b/.test(rest)) setGlobal("important", true, owner);
256
+ if (rest.replace(/\bprefix\([a-z]+\)|\bimportant\b/g, "").trim()) throw new Error(`${owner}: unsupported Tailwind import options: ${options}`);
257
+ rule.remove();
258
+ } else {
259
+ if (!id.startsWith(".") || extname(id) !== ".css") throw new Error(`${owner}: only relative .css imports and tailwindcss imports are supported: ${id}`);
260
+ const imported = await loadCss(resolve(dirname(path), id), root, owner, [...ancestors, path]);
261
+ rule.params = `${JSON.stringify(imported.replaceAll("\\", "/"))}${options ? ` ${options}` : ""}`;
262
+ }
263
+ } else if (rule.name === "plugin") throw new Error(`${owner}: @plugin is not supported; use CSS @utility instead`);
264
+ else if (rule.name === "source" && !/^(?:not\s+)?inline\(/.test(rule.params)) throw new Error(`${owner}: only @source inline(...) is supported; file candidates come from client bundles`);
265
+ else if (rule.name === "theme") rule.each((node) => {
266
+ if (node.type === "decl") checkDefinition(node.prop, JSON.stringify([
267
+ rule.params,
268
+ node.value,
269
+ node.important
270
+ ]), owner);
271
+ });
272
+ else if (["utility", "custom-variant"].includes(rule.name)) checkDefinition(`@${rule.name} ${rule.params.split(/[\s(]/)[0]}`, rule.toString(), owner);
273
+ }
274
+ stylesheets.set(path, ast.toString());
275
+ return path;
276
+ };
277
+ for (const entry of [...entries].sort((a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path))) {
278
+ const path = await realpath(resolve(entry.path));
279
+ const root = await packageRoot(path, entry.id);
280
+ hash.update(JSON.stringify([entry.id, path]));
281
+ for (const file of await bundles(dirname(path))) {
282
+ if (seen.has(file)) continue;
283
+ seen.add(file);
284
+ const content = await readFile(file, "utf8");
285
+ hash.update(JSON.stringify([file, content]));
286
+ sources.push({
287
+ content,
288
+ extension: extname(file).slice(1)
289
+ });
290
+ }
291
+ const stylesheet = await cssEntry(root, dirname(path));
292
+ if (stylesheet) {
293
+ cssImports.push(await loadCss(stylesheet, root, entry.id));
294
+ continue;
295
+ }
296
+ const configs = (await readdir(root, { withFileTypes: true })).filter((item) => configNamePattern.test(item.name));
297
+ if (configs.length > 1) throw new Error(`${entry.id}: multiple Tailwind configs found: ${configs.map((item) => item.name).join(", ")}`);
298
+ if (!configs.length) continue;
299
+ if (!configs[0].isFile()) throw new Error(`${entry.id}: Tailwind config must be a regular file`);
300
+ if (!configPattern.test(configs[0].name)) throw new Error(`${entry.id}: unsupported Tailwind config ${configs[0].name}; use .js, .cjs or .mjs`);
301
+ await loadConfig(join(root, configs[0].name), entry.id, root);
302
+ }
303
+ if (!Object.hasOwn(merged, "darkMode")) merged.darkMode = "class";
304
+ const theme = await readFile(new URL(import.meta.resolve("tailwindcss/theme.css")), "utf8");
305
+ const utilities = await readFile(new URL(import.meta.resolve("tailwindcss/utilities.css")), "utf8");
306
+ hash.update(JSON.stringify([theme, utilities]));
307
+ const digest = hash.digest("hex");
308
+ if (cached?.hash === digest) return cached.css;
309
+ const candidates = new Scanner({}).scanFiles(sources);
310
+ const compiler = await compile(`@layer theme, base, components, utilities;\n@layer theme {\n${theme}\n}\n${cssImports.map((path) => `@import ${JSON.stringify(path.replaceAll("\\", "/"))};`).join("\n")}\n@layer utilities {\n${utilities}\n}\n@config "dsh-taiwind:merged";`, {
311
+ loadModule: async (id) => {
312
+ if (id !== "dsh-taiwind:merged") throw new Error(`Unexpected Tailwind module: ${id}`);
313
+ return {
314
+ path: id,
315
+ base: "",
316
+ module: merged
317
+ };
318
+ },
319
+ loadStylesheet: async (id) => {
320
+ const path = resolve(id);
321
+ const content = stylesheets.get(path);
322
+ if (content === void 0) throw new Error(`Unexpected Tailwind stylesheet: ${id}`);
323
+ return {
324
+ path,
325
+ base: dirname(path),
326
+ content
327
+ };
328
+ }
329
+ });
330
+ if (compiler.sources.length || compiler.root !== null && compiler.root !== "none") throw new Error("Tailwind source(...) directory scanning is not supported; file candidates come from client bundles");
331
+ const css = compiler.build(candidates);
332
+ cached = {
333
+ hash: digest,
334
+ css
335
+ };
336
+ return css;
337
+ }
338
+ //#endregion
339
+ //#region src/constants.ts
340
+ const PLUGIN_ID = "dsh-taiwind";
341
+ const STYLESHEET_PATH = "plugins/dsh-taiwind.css";
342
+ const POLL_INTERVAL_MS = 2e3;
343
+ //#endregion
344
+ //#region src/index.ts
345
+ const name = PLUGIN_ID;
346
+ const inject = [
347
+ "clientModules",
348
+ "webServer",
349
+ "connection"
350
+ ];
351
+ async function apply(ctx) {
352
+ let css = "";
353
+ let etag = "";
354
+ let failed = false;
355
+ let disposed = false;
356
+ let generation = 0;
357
+ let pending;
358
+ let lastError = "";
359
+ const refresh = () => {
360
+ if (pending) return pending;
361
+ pending = (async () => {
362
+ while (true) {
363
+ const current = generation;
364
+ const compiled = await compilePlugins(ctx.clientModules.graph().entries.map(({ id }) => {
365
+ const path = ctx.clientModules.clientPath(id);
366
+ if (!path) throw new Error(`Missing client bundle: ${id}`);
367
+ return {
368
+ id,
369
+ path
370
+ };
371
+ }));
372
+ if (disposed) return;
373
+ if (generation !== current) continue;
374
+ css = compiled;
375
+ etag = `"${createHash("sha256").update(css).digest("hex")}"`;
376
+ failed = false;
377
+ lastError = "";
378
+ break;
379
+ }
380
+ })().finally(() => {
381
+ pending = void 0;
382
+ });
383
+ return pending;
384
+ };
385
+ ctx.effect(() => () => {
386
+ disposed = true;
387
+ });
388
+ const update = () => {
389
+ refresh().catch((error) => {
390
+ if (disposed) return;
391
+ failed = true;
392
+ const message = error instanceof Error ? error.message : String(error);
393
+ if (message !== lastError) {
394
+ lastError = message;
395
+ ctx.logger.warn(`${PLUGIN_ID}: ${message}; keeping previous stylesheet`);
396
+ }
397
+ });
398
+ };
399
+ ctx.effect(() => ctx.clientModules.onGraphChanged(() => {
400
+ generation++;
401
+ update();
402
+ }));
403
+ await refresh();
404
+ if (disposed) return;
405
+ ctx.effect(() => ctx.webServer.register({
406
+ kind: "exact",
407
+ path: `/${STYLESHEET_PATH}`,
408
+ handler: (req, res) => {
409
+ const rejection = ctx.connection.requestRejection(req);
410
+ if (rejection) {
411
+ res.writeHead(rejection);
412
+ res.end();
413
+ return;
414
+ }
415
+ if (req.method !== "GET" && req.method !== "HEAD") {
416
+ res.writeHead(405, { Allow: "GET, HEAD" });
417
+ res.end();
418
+ return;
419
+ }
420
+ res.setHeader("Cache-Control", "private, no-cache");
421
+ res.setHeader("X-Content-Type-Options", "nosniff");
422
+ if (failed) {
423
+ res.writeHead(503, { "Retry-After": "2" });
424
+ res.end();
425
+ return;
426
+ }
427
+ res.setHeader("ETag", etag);
428
+ if (req.headers["if-none-match"] === etag) {
429
+ res.writeHead(304);
430
+ res.end();
431
+ return;
432
+ }
433
+ res.setHeader("Content-Type", "text/css; charset=utf-8");
434
+ res.writeHead(200);
435
+ res.end(req.method === "HEAD" ? void 0 : css);
436
+ }
437
+ }));
438
+ ctx.on("webserver/index-inject", (rows) => {
439
+ rows.push({
440
+ kind: "html",
441
+ placement: "head",
442
+ html: `<style data-plugin="${PLUGIN_ID}" data-plugin-css="utilities">${css.replace(/<\/style/gi, "<\\/style")}</style>`
443
+ });
444
+ });
445
+ ctx.effect(() => {
446
+ const timer = setInterval(update, POLL_INTERVAL_MS);
447
+ timer.unref();
448
+ return () => clearInterval(timer);
449
+ });
450
+ }
451
+ //#endregion
452
+ export { apply, inject, name };
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#102a43"/><g fill="none" stroke="#67e8f9" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 18h12l9 14h15M14 46h12l9-14M14 32h36"/><path d="m44 26 6 6-6 6"/></g></svg>
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Shared Tailwind CSS",
4
+ "description": "Combines client bundle utility classes into one stylesheet, with per-plugin static Tailwind configuration."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "共享 Tailwind 样式",
4
+ "description": "聚合客户端插件的工具类,生成一份去重样式,支持各插件独立的静态 Tailwind 配置。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,93 @@
1
1
  {
2
2
  "name": "dsh-taiwind",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "One shared Tailwind stylesheet for DSH plugins with per-plugin configuration",
6
+ "author": "Hairy <wwu710632@gmail.com>",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/hairyf/dsh-taiwind#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/hairyf/dsh-taiwind.git"
12
+ },
13
+ "bugs": "https://github.com/hairyf/dsh-taiwind/issues",
14
+ "keywords": [
15
+ "dsh",
16
+ "plugin",
17
+ "tailwindcss"
18
+ ],
19
+ "sideEffects": false,
20
+ "exports": {
21
+ ".": "./dist/index.js",
22
+ "./client": "./dist/client.cjs",
23
+ "./package.json": "./package.json",
24
+ "./locale/*.json": "./locale/*.json",
25
+ "./icon": "./icon.svg"
26
+ },
27
+ "main": "./dist/index.js",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "files": [
31
+ "README.md",
32
+ "cordis.patch.yml",
33
+ "dist",
34
+ "icon.svg",
35
+ "locale"
36
+ ],
37
+ "engines": {
38
+ "node": ">=22.18.0",
39
+ "dsh": ">=0.2.1-alpha.1"
40
+ },
41
+ "dsh": {
42
+ "client": {
43
+ "inject": [
44
+ "@deepseek-ai/dsh-client-modules"
45
+ ],
46
+ "platform": "web"
47
+ },
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ }
51
+ },
52
+ "dependencies": {
53
+ "@tailwindcss/oxide": "4.3.3",
54
+ "postcss": "8.5.20",
55
+ "tailwindcss": "4.3.3"
56
+ },
57
+ "devDependencies": {
58
+ "@antfu/eslint-config": "^9.1.0",
59
+ "@antfu/ni": "^30.2.0",
60
+ "@deepseek-ai/cordis": "4.0.5-alpha.1",
61
+ "@deepseek-ai/dsh-client-connection": "0.2.1-alpha.1",
62
+ "@deepseek-ai/dsh-client-modules": "0.2.1-alpha.1",
63
+ "@deepseek-ai/dsh-host-webserver": "0.2.1-alpha.1",
64
+ "@types/node": "^26.1.1",
65
+ "bumpp": "^12.0.0",
66
+ "eslint": "^10.7.0",
67
+ "nano-staged": "^1.0.2",
68
+ "publint": "^0.3.21",
69
+ "simple-git-hooks": "^2.13.1",
70
+ "skills-npm": "^1.2.0",
71
+ "tsdown": "^0.22.12",
72
+ "tsdown-stale-guard": "^0.1.3",
73
+ "tsnapi": "^1.0.0",
74
+ "tsx": "^4.23.1",
75
+ "typescript": "^6.0.3",
76
+ "vite": "^8.1.5",
77
+ "vitest": "^4.1.10"
78
+ },
79
+ "simple-git-hooks": {
80
+ "pre-commit": "pnpm i --frozen-lockfile --ignore-scripts --offline && pnpm run build && npx nano-staged"
81
+ },
82
+ "nano-staged": {
83
+ "*": "eslint --fix"
84
+ },
85
+ "scripts": {
86
+ "build": "tsdown",
87
+ "dev": "tsdown --watch",
88
+ "lint": "eslint",
89
+ "release": "bumpp",
90
+ "test": "pnpm run build && vitest",
91
+ "typecheck": "tsc"
92
+ }
6
93
  }