Skip to content

Commit 9b95505

Browse files
committed
chore: init reset tailwindcss plugin
1 parent f5fa2ca commit 9b95505

File tree

7 files changed

+476
-0
lines changed

7 files changed

+476
-0
lines changed

packages/weapp-tailwindcss/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@
8686
"import": "./dist/presets.mjs",
8787
"require": "./dist/presets.js"
8888
},
89+
"./reset": {
90+
"types": "./dist/reset.d.ts",
91+
"import": "./dist/reset.mjs",
92+
"require": "./dist/reset.js"
93+
},
8994
"./css-macro/postcss": {
9095
"types": "./dist/css-macro/postcss.d.ts",
9196
"import": "./dist/css-macro/postcss.mjs",
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import plugin from 'tailwindcss/plugin'
2+
3+
export interface ResetOptions {
4+
/**
5+
* 控制 `button` reset 的注入与选择器,传入 `false` 可跳过该规则。
6+
*/
7+
buttonReset?: false | ResetConfig
8+
/**
9+
* 控制 `image` reset(同时覆盖 `<image>` 与 `<img>`)。
10+
*/
11+
imageReset?: false | ResetConfig
12+
/**
13+
* 额外的 reset 规则,可根据业务自定义。
14+
*/
15+
extraResets?: ResetConfig[]
16+
}
17+
18+
export interface ResetConfig {
19+
selectors?: string[]
20+
declarations?: Record<string, string | number | false | null | undefined>
21+
pseudo?: Record<string, string | number | false | null | undefined>
22+
}
23+
24+
const DEFAULT_BUTTON_RESET_SELECTORS = ['button'] as const
25+
const DEFAULT_BUTTON_DECLARATIONS = {
26+
padding: '0',
27+
backgroundColor: 'transparent',
28+
fontSize: 'inherit',
29+
lineHeight: 'inherit',
30+
color: 'inherit',
31+
borderWidth: '0',
32+
} as const
33+
const BUTTON_RESET_PSEUDO_DECLARATIONS = {
34+
border: 'none',
35+
} as const
36+
37+
const DEFAULT_IMAGE_RESET_SELECTORS = ['image', 'img'] as const
38+
const DEFAULT_IMAGE_DECLARATIONS = {
39+
display: 'block',
40+
borderWidth: '0',
41+
backgroundColor: 'transparent',
42+
maxWidth: '100%',
43+
height: 'auto',
44+
} as const
45+
46+
type DeclValue = string | number | false | null | undefined
47+
48+
function normalizeResetSelectors(option: ResetConfig | undefined, defaults: readonly string[]) {
49+
const resolved = option?.selectors?.length ? option.selectors : defaults
50+
const normalized: string[] = []
51+
for (const selector of resolved) {
52+
const trimmed = selector.trim()
53+
if (!trimmed || normalized.includes(trimmed)) {
54+
continue
55+
}
56+
normalized.push(trimmed)
57+
}
58+
return normalized.length ? normalized : undefined
59+
}
60+
61+
function convertSelectorForBase(selector: string) {
62+
if (selector.startsWith('.')) {
63+
const className = selector.slice(1)
64+
if (className.length > 0) {
65+
return `[class~="${className}"]`
66+
}
67+
}
68+
if (selector.startsWith('#')) {
69+
const id = selector.slice(1)
70+
if (id.length > 0) {
71+
return `[id="${id}"]`
72+
}
73+
}
74+
return selector
75+
}
76+
77+
function normalizeDeclarations(
78+
option: ResetConfig | undefined,
79+
defaults: Record<string, string>,
80+
) {
81+
const normalized: Record<string, string> = { ...defaults }
82+
const overrides = option?.declarations
83+
if (!overrides) {
84+
return normalized
85+
}
86+
87+
const entries = Object.entries(overrides)
88+
for (const [prop, value] of entries) {
89+
const resolved = normalizeDeclarationValue(value)
90+
if (resolved === undefined) {
91+
delete normalized[prop]
92+
}
93+
else {
94+
normalized[prop] = resolved
95+
}
96+
}
97+
98+
return normalized
99+
}
100+
101+
function normalizePseudo(option: ResetConfig | undefined, defaults?: Record<string, string>) {
102+
const normalized: Record<string, string> = defaults ? { ...defaults } : {}
103+
const overrides = option?.pseudo
104+
if (!overrides) {
105+
return Object.keys(normalized).length ? normalized : undefined
106+
}
107+
108+
const entries = Object.entries(overrides)
109+
for (const [prop, value] of entries) {
110+
const resolved = normalizeDeclarationValue(value)
111+
if (resolved === undefined) {
112+
delete normalized[prop]
113+
}
114+
else {
115+
normalized[prop] = resolved
116+
}
117+
}
118+
return Object.keys(normalized).length ? normalized : undefined
119+
}
120+
121+
function normalizeDeclarationValue(value: DeclValue) {
122+
if (value === false || value === null || value === undefined) {
123+
return undefined
124+
}
125+
return typeof value === 'number' ? value.toString() : value
126+
}
127+
128+
interface NormalizedResetRule {
129+
selectors: string[]
130+
declarations: Record<string, string>
131+
pseudo?: Record<string, string>
132+
}
133+
134+
function createResetRule(
135+
option: ResetConfig | undefined | false,
136+
defaults: {
137+
selectors: readonly string[]
138+
declarations: Record<string, string>
139+
pseudo?: Record<string, string>
140+
},
141+
): NormalizedResetRule | undefined {
142+
if (option === false) {
143+
return undefined
144+
}
145+
const selectors = normalizeResetSelectors(option, defaults.selectors)
146+
if (!selectors) {
147+
return undefined
148+
}
149+
const declarations = normalizeDeclarations(option ?? {}, defaults.declarations)
150+
if (Object.keys(declarations).length === 0) {
151+
return undefined
152+
}
153+
const pseudo = normalizePseudo(option ?? {}, defaults.pseudo)
154+
return {
155+
selectors: selectors.map(convertSelectorForBase),
156+
declarations,
157+
pseudo,
158+
}
159+
}
160+
161+
export const reset = plugin.withOptions<ResetOptions>(
162+
(options?: ResetOptions) => {
163+
const rules: NormalizedResetRule[] = []
164+
const buttonRule = createResetRule(options?.buttonReset, {
165+
selectors: DEFAULT_BUTTON_RESET_SELECTORS,
166+
declarations: DEFAULT_BUTTON_DECLARATIONS,
167+
pseudo: BUTTON_RESET_PSEUDO_DECLARATIONS,
168+
})
169+
if (buttonRule) {
170+
rules.push(buttonRule)
171+
}
172+
const imageRule = createResetRule(options?.imageReset, {
173+
selectors: DEFAULT_IMAGE_RESET_SELECTORS,
174+
declarations: DEFAULT_IMAGE_DECLARATIONS,
175+
})
176+
if (imageRule) {
177+
rules.push(imageRule)
178+
}
179+
for (const extra of options?.extraResets ?? []) {
180+
const normalized = createResetRule(extra, {
181+
selectors: extra.selectors ?? [],
182+
declarations: {},
183+
})
184+
if (normalized) {
185+
rules.push(normalized)
186+
}
187+
}
188+
189+
return ({ addBase }) => {
190+
if (!rules.length) {
191+
return
192+
}
193+
const baseRules: Record<string, Record<string, string>> = {}
194+
for (const rule of rules) {
195+
baseRules[rule.selectors.join(',')] = rule.declarations
196+
if (rule.pseudo) {
197+
const pseudoSelectors = rule.selectors.map(selector => `${selector}::after`).join(',')
198+
baseRules[pseudoSelectors] = rule.pseudo
199+
}
200+
}
201+
addBase(baseRules)
202+
}
203+
},
204+
() => {
205+
return {}
206+
},
207+
)
208+
209+
export default reset
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<view class="p-4 text-blue-500">reset plugin</view>
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import { getCss } from '#test/helpers/getTwCss'
4+
import { generateCss4 } from '@weapp-tailwindcss/test-helper'
5+
import { execa } from 'execa'
6+
import { beforeAll, describe, expect, it } from 'vitest'
7+
import reset from '@/reset'
8+
9+
const repoRoot = path.resolve(__dirname, '../../..')
10+
const resetDistEntry = path.resolve(__dirname, '../../dist/reset.js')
11+
12+
beforeAll(async () => {
13+
if (!fs.existsSync(resetDistEntry)) {
14+
await execa('pnpm', ['--filter', 'weapp-tailwindcss', 'run', 'build'], {
15+
cwd: repoRoot,
16+
})
17+
}
18+
}, 120_000)
19+
20+
const BUTTON_REGEX = /button\{padding:0;background-color:transparent;font-size:inherit;line-height:inherit;color:inherit;border-width:0;?\}/
21+
const BUTTON_PSEUDO_REGEX = /button::after\{border:none;?\}/
22+
const IMAGE_REGEX = /image,img\{display:block;border-width:0;background-color:transparent;max-width:100%;height:auto;?\}/
23+
24+
describe('reset plugin', () => {
25+
it('injects default button and image resets for tailwindcss v3', async () => {
26+
const { css } = await getCss('', {
27+
css: '@tailwind base;',
28+
twConfig: {
29+
content: [{ raw: 'noop' }],
30+
plugins: [reset()],
31+
},
32+
})
33+
34+
const normalized = css.replace(/\s+/g, '')
35+
expect(normalized).toMatch(BUTTON_REGEX)
36+
expect(normalized).toMatch(BUTTON_PSEUDO_REGEX)
37+
expect(normalized).toMatch(IMAGE_REGEX)
38+
})
39+
40+
it('supports disabling button and image resets independently', async () => {
41+
const { css } = await getCss('', {
42+
css: '@tailwind base;',
43+
twConfig: {
44+
content: [{ raw: 'noop' }],
45+
plugins: [
46+
reset({
47+
buttonReset: false,
48+
}),
49+
],
50+
},
51+
})
52+
const withoutButton = css.replace(/\s+/g, '')
53+
expect(withoutButton).not.toMatch(BUTTON_REGEX)
54+
expect(withoutButton).toMatch(IMAGE_REGEX)
55+
56+
const { css: cssWithoutImage } = await getCss('', {
57+
css: '@tailwind base;',
58+
twConfig: {
59+
content: [{ raw: 'noop' }],
60+
plugins: [
61+
reset({
62+
imageReset: false,
63+
}),
64+
],
65+
},
66+
})
67+
const normalized = cssWithoutImage.replace(/\s+/g, '')
68+
expect(normalized).toMatch(BUTTON_REGEX)
69+
expect(normalized).not.toMatch(IMAGE_REGEX)
70+
})
71+
72+
it('allows customizing selectors and declarations', async () => {
73+
const { css } = await getCss('', {
74+
css: '@tailwind base;',
75+
twConfig: {
76+
content: [{ raw: 'noop' }],
77+
plugins: [
78+
reset({
79+
buttonReset: {
80+
selectors: ['.wx-reset-btn'],
81+
declarations: {
82+
padding: '0',
83+
backgroundColor: 'transparent',
84+
borderWidth: '0',
85+
},
86+
pseudo: {
87+
border: 'none',
88+
},
89+
},
90+
imageReset: {
91+
selectors: ['.wx-reset-image'],
92+
declarations: {
93+
display: 'inline-block',
94+
borderWidth: '0',
95+
},
96+
},
97+
}),
98+
],
99+
},
100+
})
101+
102+
const normalized = css.replace(/\s+/g, '')
103+
expect(normalized).toMatch(/\[class~="wx-reset-btn"\]\{[^}]*padding:0;[^}]*border-width:0[^}]*\}/)
104+
expect(normalized).toMatch(/\[class~="wx-reset-btn"\]::after\{[^}]*border:none;?\}/)
105+
expect(normalized).toMatch(/\[class~="wx-reset-image"\]\{[^}]*display:inline-block;[^}]*border-width:0[^}]*\}/)
106+
})
107+
108+
it('accepts extra resets', async () => {
109+
const { css } = await getCss('', {
110+
css: '@tailwind base;',
111+
twConfig: {
112+
content: [{ raw: 'noop' }],
113+
plugins: [
114+
reset({
115+
extraResets: [
116+
{
117+
selectors: ['.wx-reset-view'],
118+
declarations: {
119+
display: 'block',
120+
borderWidth: '0',
121+
},
122+
pseudo: {
123+
borderColor: 'transparent',
124+
},
125+
},
126+
],
127+
}),
128+
],
129+
},
130+
})
131+
132+
const normalized = css.replace(/\s+/g, '')
133+
expect(normalized).toMatch(/\[class~="wx-reset-view"\]\{display:block;border-width:0;?\}/)
134+
expect(normalized).toMatch(/\[class~="wx-reset-view"\]::after\{border-color:transparent;?\}/)
135+
})
136+
137+
it('works with tailwindcss v4 via @plugin', async () => {
138+
const baseDir = path.resolve(__dirname, './fixtures/v4')
139+
const { css } = await generateCss4(baseDir, {
140+
css: `
141+
@plugin "weapp-tailwindcss/reset";
142+
@import "tailwindcss/utilities";
143+
`,
144+
})
145+
146+
const normalized = css.replace(/\s+/g, '')
147+
expect(normalized).toMatch(BUTTON_REGEX)
148+
expect(normalized).toMatch(IMAGE_REGEX)
149+
})
150+
})

packages/weapp-tailwindcss/tsup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default defineConfig(
1616
'core': 'src/core.ts',
1717
'presets': 'src/presets.ts',
1818
'types': 'src/types/index.ts',
19+
'reset': 'src/reset/index.ts',
1920
'postcss-html-transform': 'src/postcss-html-transform.ts',
2021
},
2122
dts: true,

website/docs/options/exports.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ weapp-tailwindcss 同时提供 ESM 与 CommonJS 入口,并内置多个二级
2020
| --- | --- | --- |
2121
| `weapp-tailwindcss/defaults` | 默认插件配置、补丁行为等常量 | 查看/复用默认选项
2222
| `weapp-tailwindcss/presets` | 官方预设集合(差异化策略、Tailwind 配置等) | 扩展或组合默认行为
23+
| [`weapp-tailwindcss/reset`](./reset) | 内置默认 `button` reset,可通过 `buttonReset` 选项禁用或改写选择器 | `@plugin 'weapp-tailwindcss/reset';`
2324
| `weapp-tailwindcss/types` | TypeScript 类型定义便捷入口 | `import type { UserDefinedOptions } from 'weapp-tailwindcss/types'`
2425
| `weapp-tailwindcss/escape` | `replaceWxml``isAllowedClassName` 等字符串处理工具 | 单独处理模板/字符串时复用
2526
| `weapp-tailwindcss/postcss-html-transform` | 针对 HTML/WXML 的 PostCSS 转换器 | 自定义 PostCSS 流程

0 commit comments

Comments
 (0)