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 +21 -0
- package/README.md +169 -2
- package/cordis.patch.yml +3 -0
- package/dist/client.cjs +4 -0
- package/dist/client.cjs.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +452 -0
- package/icon.svg +1 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +90 -3
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
|
-
#
|
|
1
|
+
# dsh-taiwind
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/cordis.patch.yml
ADDED
package/dist/client.cjs
ADDED
|
@@ -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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,93 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-taiwind",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
}
|