@oberoncms/plugin-tailwind 0.18.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/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # @oberoncms/plugin-tailwind
2
+
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8109ea8: Add a dynamic Tailwind plugin, expose public plugin settings through
8
+ the core adapter, and scaffold the Tailwind plugin into new apps.
9
+
10
+ ### Patch Changes
11
+
12
+ - 8109ea8: Fix Tailwind compiler loading, seed the welcome block on initial
13
+ pages, and make Playwright smoke report uploads rerun-safe.
14
+ - fc1747c: Refactor the tailwind plugin to simplify style syncing and serve
15
+ immutable hashed css assets from path-based endpoints.
16
+ - Updated dependencies [a73560b]
17
+ - Updated dependencies [8109ea8]
18
+ - Updated dependencies [b654991]
19
+ - Updated dependencies [8109ea8]
20
+ - Updated dependencies [a4578f6]
21
+ - Updated dependencies [a011a89]
22
+ - Updated dependencies [28aa7e5]
23
+ - Updated dependencies [48de893]
24
+ - Updated dependencies [aa5371a]
25
+ - Updated dependencies [36a3b7e]
26
+ - Updated dependencies [237d393]
27
+ - @oberoncms/core@0.18.0
@@ -0,0 +1,3 @@
1
+ import "server-cli-only";
2
+ export declare function buildCss(classes: string[]): Promise<string>;
3
+ //# sourceMappingURL=compiler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compiler.d.ts","sourceRoot":"","sources":["../src/compiler.ts"],"names":[],"mappings":"AAAA,OAAO,iBAAiB,CAAA;AASxB,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,mBAO/C"}
@@ -0,0 +1,17 @@
1
+ import "server-cli-only";
2
+ import { compile } from "@tailwindcss/node";
3
+ const tailwindEntry = [
4
+ '@import "tailwindcss/theme" theme(reference);',
5
+ '@import "tailwindcss/utilities";'
6
+ ].join("\n");
7
+ async function buildCss(classes) {
8
+ const compiler = await compile(tailwindEntry, {
9
+ base: process.cwd(),
10
+ onDependency() {
11
+ }
12
+ });
13
+ return compiler.build(classes);
14
+ }
15
+ export {
16
+ buildCss
17
+ };
@@ -0,0 +1,3 @@
1
+ import type { OberonPlugin } from "@oberoncms/core";
2
+ export declare const plugin: OberonPlugin;
3
+ //# sourceMappingURL=index.client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAEnD,eAAO,MAAM,MAAM,EAAE,YAEpB,CAAA"}
@@ -0,0 +1,6 @@
1
+ const plugin = () => {
2
+ throw new Error("Not available in browser");
3
+ };
4
+ export {
5
+ plugin
6
+ };
@@ -0,0 +1,5 @@
1
+ import "server-cli-only";
2
+ import { type OberonPlugin } from "@oberoncms/core";
3
+ export declare function extractTailwindClasses(data: unknown): Promise<string[]>;
4
+ export declare const plugin: OberonPlugin;
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,iBAAiB,CAAA;AAGxB,OAAO,EACL,KAAK,YAAY,EAGlB,MAAM,iBAAiB,CAAA;AAaxB,wBAAsB,sBAAsB,CAAC,IAAI,EAAE,OAAO,qBAczD;AA6ED,eAAO,MAAM,MAAM,EAAE,YA6EnB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,144 @@
1
+ import "server-cli-only";
2
+ import { createHash } from "crypto";
3
+ import { ResponseError } from "@oberoncms/core";
4
+ import { walkAsyncStep } from "walkjs";
5
+ import { z } from "zod";
6
+ import { version, name } from "./package.json.js";
7
+ import { buildCss } from "./compiler.js";
8
+ const tailwindStateSchema = z.object({
9
+ activeHash: z.string().nullable(),
10
+ classes: z.array(z.string())
11
+ });
12
+ const classDelimiter = /\s+/;
13
+ async function extractTailwindClasses(data) {
14
+ const classes = /* @__PURE__ */ new Set();
15
+ for await (const node of walkAsyncStep(data)) {
16
+ if (node.key !== "className" || typeof node.val !== "string") {
17
+ continue;
18
+ }
19
+ for (const cls of node.val.split(classDelimiter)) {
20
+ if (cls) classes.add(cls);
21
+ }
22
+ }
23
+ return [...classes].sort();
24
+ }
25
+ async function getState(adapter) {
26
+ const parsed = tailwindStateSchema.safeParse(
27
+ await adapter.getKV(name, "state")
28
+ );
29
+ return parsed.success ? parsed.data : { activeHash: null, classes: [] };
30
+ }
31
+ async function getAsset(adapter, hash) {
32
+ if (!hash) {
33
+ return null;
34
+ }
35
+ const asset = await adapter.getKV(name, `asset:${hash}`);
36
+ return typeof asset === "string" ? asset : null;
37
+ }
38
+ async function getAllPublishedClasses(adapter) {
39
+ const classes = /* @__PURE__ */ new Set();
40
+ const pages = await adapter.getAllPages();
41
+ for (const { key } of pages) {
42
+ const data = await adapter.getPageData(key);
43
+ if (!data) continue;
44
+ for await (const node of walkAsyncStep(data)) {
45
+ if (node.key !== "className" || typeof node.val !== "string") {
46
+ continue;
47
+ }
48
+ for (const cls of node.val.split(classDelimiter)) {
49
+ if (cls) classes.add(cls);
50
+ }
51
+ }
52
+ }
53
+ return [...classes].sort();
54
+ }
55
+ async function syncStyles(adapter) {
56
+ const classes = await getAllPublishedClasses(adapter);
57
+ const state = await getState(adapter);
58
+ if (!classes.length) {
59
+ if (state.activeHash === null && state.classes.length === 0) {
60
+ return;
61
+ }
62
+ await adapter.putKV(name, "state", { activeHash: null, classes: [] });
63
+ return;
64
+ }
65
+ const hash = createHash("sha256").update(classes.join("\n")).digest("hex");
66
+ if (hash === state.activeHash && await getAsset(adapter, hash)) {
67
+ return;
68
+ }
69
+ const css = await buildCss(classes);
70
+ await adapter.putKV(name, `asset:${hash}`, css);
71
+ await adapter.putKV(name, "state", { activeHash: hash, classes });
72
+ }
73
+ const plugin = (adapter) => ({
74
+ name,
75
+ version,
76
+ handlers: {
77
+ tailwind: () => ({
78
+ GET: async (request) => {
79
+ try {
80
+ const pathname = new URL(request.url).pathname;
81
+ const filename = pathname.split("/").pop();
82
+ const hash = filename?.endsWith(".css") ? filename.slice(0, -4) : void 0;
83
+ const css = await getAsset(adapter, hash);
84
+ if (!css) {
85
+ return new Response("", {
86
+ status: 404,
87
+ headers: {
88
+ "Cache-Control": "no-store",
89
+ "Content-Type": "text/css; charset=utf-8"
90
+ }
91
+ });
92
+ }
93
+ return new Response(css, {
94
+ status: 200,
95
+ headers: {
96
+ "Cache-Control": "public, max-age=31536000, immutable",
97
+ "Content-Type": "text/css; charset=utf-8"
98
+ }
99
+ });
100
+ } catch {
101
+ return new Response("", {
102
+ status: 404,
103
+ headers: {
104
+ "Cache-Control": "no-store",
105
+ "Content-Type": "text/css; charset=utf-8"
106
+ }
107
+ });
108
+ }
109
+ }
110
+ })
111
+ },
112
+ adapter: {
113
+ prebuild: async () => {
114
+ try {
115
+ await adapter.prebuild();
116
+ await syncStyles(adapter);
117
+ } catch (error) {
118
+ if (error instanceof ResponseError) {
119
+ throw error;
120
+ }
121
+ throw new ResponseError(
122
+ error instanceof Error && error.message ? `Failed to prepare Tailwind styles: ${error.message}` : "Failed to prepare Tailwind styles"
123
+ );
124
+ }
125
+ },
126
+ updatePageData: async (page) => {
127
+ try {
128
+ await adapter.updatePageData(page);
129
+ await syncStyles(adapter);
130
+ } catch (error) {
131
+ if (error instanceof ResponseError) {
132
+ throw error;
133
+ }
134
+ throw new ResponseError(
135
+ error instanceof Error && error.message ? `Failed to update Tailwind styles: ${error.message}` : "Failed to update Tailwind styles"
136
+ );
137
+ }
138
+ }
139
+ }
140
+ });
141
+ export {
142
+ extractTailwindClasses,
143
+ plugin
144
+ };
@@ -0,0 +1,6 @@
1
+ const name = "@oberoncms/plugin-tailwind";
2
+ const version = "0.18.0";
3
+ export {
4
+ name,
5
+ version
6
+ };
package/dist/version ADDED
@@ -0,0 +1 @@
1
+ 0.0.0
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@oberoncms/plugin-tailwind",
3
+ "version": "0.18.0",
4
+ "author": "Tohuhono ltd",
5
+ "license": "MIT",
6
+ "description": "A dynamic Tailwind utilities plugin for OberonCMS",
7
+ "keywords": [
8
+ "oberon",
9
+ "oberoncms",
10
+ "cms",
11
+ "nextjs",
12
+ "react",
13
+ "adapter",
14
+ "plugin",
15
+ "tailwind"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/Tohuhono/Oberon",
20
+ "directory": "packages/plugins/tailwind"
21
+ },
22
+ "type": "module",
23
+ "files": [
24
+ "dist",
25
+ "src",
26
+ "CHANGELOG*",
27
+ "README*",
28
+ "LICENSE*"
29
+ ],
30
+ "exports": {
31
+ ".": "./dist/index.js"
32
+ },
33
+ "scripts": {
34
+ "build": "vite build",
35
+ "clean": "odt clean",
36
+ "dev": "vite build --watch",
37
+ "lint": "eslint .",
38
+ "test:unit": "vitest run",
39
+ "test:watch": "vitest",
40
+ "tsc": "tsc --pretty",
41
+ "wait": "wait-on ./dist/version && echo done",
42
+ "wait:clean": "rimraf ./dist/version"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "dependencies": {
48
+ "@oberoncms/core": "workspace:*",
49
+ "@tailwindcss/node": "^4.2.1",
50
+ "server-cli-only": "^0.3.2",
51
+ "walkjs": "^6.0.1",
52
+ "zod": "^4.3.6"
53
+ },
54
+ "peerDependencies": {
55
+ "react": "^19.2.0",
56
+ "react-dom": "^19.2.0"
57
+ },
58
+ "devDependencies": {
59
+ "@dev/eslint": "workspace:*",
60
+ "@dev/scripts": "workspace:*",
61
+ "@dev/typescript": "workspace:*",
62
+ "@dev/vite": "workspace:*",
63
+ "@dev/vitest": "workspace:*",
64
+ "@oberoncms/sqlite": "workspace:*",
65
+ "@oberoncms/testing": "workspace:*",
66
+ "@types/node": "24.10.1",
67
+ "@types/react": "19.2.14",
68
+ "@types/react-dom": "19.2.3",
69
+ "react": "^19.2.4",
70
+ "react-dom": "^19.2.4",
71
+ "typescript": "5.9.3"
72
+ }
73
+ }
@@ -0,0 +1,17 @@
1
+ import "server-cli-only"
2
+
3
+ import { compile } from "@tailwindcss/node"
4
+
5
+ const tailwindEntry = [
6
+ '@import "tailwindcss/theme" theme(reference);',
7
+ '@import "tailwindcss/utilities";',
8
+ ].join("\n")
9
+
10
+ export async function buildCss(classes: string[]) {
11
+ const compiler = await compile(tailwindEntry, {
12
+ base: process.cwd(),
13
+ onDependency() {},
14
+ })
15
+
16
+ return compiler.build(classes)
17
+ }
@@ -0,0 +1,5 @@
1
+ import type { OberonPlugin } from "@oberoncms/core"
2
+
3
+ export const plugin: OberonPlugin = () => {
4
+ throw new Error("Not available in browser")
5
+ }
@@ -0,0 +1,297 @@
1
+ import { dirname, resolve } from "path"
2
+ import { fileURLToPath } from "url"
3
+ import { fromPartial, test } from "@dev/vitest"
4
+ import type { OberonPluginAdapter } from "@oberoncms/core"
5
+ import {
6
+ NotImplementedError,
7
+ ResponseError,
8
+ type OberonPage,
9
+ } from "@oberoncms/core"
10
+ import {
11
+ createPluginTest,
12
+ createStorageAdapterFactory,
13
+ } from "@oberoncms/testing"
14
+ import { z } from "zod"
15
+ import { name as pluginName } from "../package.json" with { type: "json" }
16
+ import { plugin as tailwindPlugin } from "./index"
17
+
18
+ const rootDirectory = resolve(
19
+ dirname(fileURLToPath(import.meta.url)),
20
+ "../../../..",
21
+ )
22
+
23
+ const sqliteFile = resolve(rootDirectory, ".tmp/tailwind-plugin-unit-tests.db")
24
+
25
+ function createPage(className: string, key = "/"): OberonPage {
26
+ return {
27
+ key,
28
+ updatedAt: new Date("2025-01-01T00:00:00.000Z"),
29
+ updatedBy: "test@oberon.invalid",
30
+ data: {
31
+ content: [
32
+ {
33
+ type: "Text",
34
+ props: {
35
+ className,
36
+ },
37
+ },
38
+ ],
39
+ root: {
40
+ props: {
41
+ title: "Test",
42
+ },
43
+ },
44
+ },
45
+ }
46
+ }
47
+
48
+ const tailwindStateSchema = z.object({
49
+ activeHash: z.string().nullable(),
50
+ classes: z.array(z.string()),
51
+ })
52
+
53
+ type TailwindState = z.infer<typeof tailwindStateSchema>
54
+
55
+ async function getState(adapter: Pick<OberonPluginAdapter, "getKV">) {
56
+ const parsed = tailwindStateSchema.safeParse(
57
+ await adapter.getKV(pluginName, "state"),
58
+ )
59
+
60
+ return parsed.success ? (parsed.data satisfies TailwindState) : null
61
+ }
62
+
63
+ async function getAsset(
64
+ adapter: Pick<OberonPluginAdapter, "getKV">,
65
+ hash: string,
66
+ ) {
67
+ return await adapter.getKV(pluginName, `asset:${hash}`)
68
+ }
69
+
70
+ async function getStylesheets(adapter: Pick<OberonPluginAdapter, "getKV">) {
71
+ const state = await getState(adapter)
72
+
73
+ if (!state?.activeHash) {
74
+ return []
75
+ }
76
+
77
+ const asset = await getAsset(adapter, state.activeHash)
78
+
79
+ if (typeof asset !== "string") {
80
+ return []
81
+ }
82
+
83
+ return [`/cms/api/tailwind/${state.activeHash}.css`]
84
+ }
85
+
86
+ const createAdapter = createStorageAdapterFactory({
87
+ sqliteFile,
88
+ })
89
+
90
+ const tailwindTest = createPluginTest(test)
91
+ .extend(
92
+ "adapter",
93
+ { scope: "worker" },
94
+ // eslint-disable-next-line no-empty-pattern
95
+ async ({}, { onCleanup }) => {
96
+ return await createAdapter(onCleanup)
97
+ },
98
+ )
99
+ .extend("plugin", { scope: "worker" }, async ({ adapter }) => {
100
+ return tailwindPlugin(adapter)
101
+ })
102
+
103
+ tailwindTest.describe("tailwind plugin", { tags: ["ai", "issue-314"] }, () => {
104
+ tailwindTest.beforeEach(async ({ adapter }) => {
105
+ const state = await getState(adapter)
106
+
107
+ if (state?.activeHash) {
108
+ await adapter.deleteKV(pluginName, `asset:${state.activeHash}`)
109
+ }
110
+
111
+ await adapter.deleteKV(pluginName, "state")
112
+
113
+ const pages = await adapter.getAllPages()
114
+
115
+ for (const { key } of pages) {
116
+ await adapter.deletePage(key)
117
+ }
118
+ })
119
+
120
+ tailwindTest(
121
+ "publishes a hashed dynamic asset and exposes its stylesheet href",
122
+ async ({ expect, adapter, plugin }) => {
123
+ const page = createPage("text-red-500 md:grid text-red-500")
124
+
125
+ await plugin.adapter?.updatePageData?.(page)
126
+
127
+ const state = await getState(adapter)
128
+
129
+ expect(state?.classes).toEqual(["md:grid", "text-red-500"])
130
+ expect(state?.activeHash).toHaveLength(64)
131
+ await expect(getAsset(adapter, state!.activeHash!)).resolves.toContain(
132
+ ".text-red-500",
133
+ )
134
+
135
+ await expect(getStylesheets(adapter)).resolves.toEqual([
136
+ `/cms/api/tailwind/${state!.activeHash}.css`,
137
+ ])
138
+
139
+ const response = await plugin.handlers
140
+ ?.tailwind?.(fromPartial({}))
141
+ .GET?.(
142
+ new Request(
143
+ `https://oberon.invalid/cms/api/tailwind/${state!.activeHash}.css`,
144
+ ) as never,
145
+ )
146
+
147
+ expect(response?.status).toBe(200)
148
+ await expect(response?.text()).resolves.toContain(".md\\:grid")
149
+ },
150
+ )
151
+
152
+ tailwindTest(
153
+ "publishes the playground text example classes without throwing",
154
+ async ({ expect, adapter, plugin }) => {
155
+ const page = createPage("prose dark:prose-invert lg:prose-lg p-1")
156
+
157
+ await expect(
158
+ plugin.adapter?.updatePageData?.(page),
159
+ ).resolves.toBeUndefined()
160
+
161
+ const state = await getState(adapter)
162
+
163
+ expect(state?.classes).toEqual([
164
+ "dark:prose-invert",
165
+ "lg:prose-lg",
166
+ "p-1",
167
+ "prose",
168
+ ])
169
+ expect(state?.activeHash).toHaveLength(64)
170
+ },
171
+ )
172
+
173
+ tailwindTest(
174
+ "reconciles missing assets during prebuild",
175
+ async ({ expect, adapter, plugin }) => {
176
+ await plugin.adapter?.updatePageData?.(createPage("underline"))
177
+
178
+ await plugin.adapter?.prebuild?.()
179
+
180
+ const firstState = await getState(adapter)
181
+
182
+ await adapter.deleteKV(pluginName, `asset:${firstState!.activeHash}`)
183
+
184
+ await expect(getStylesheets(adapter)).resolves.toEqual([])
185
+
186
+ await plugin.adapter?.prebuild?.()
187
+
188
+ await expect(
189
+ getAsset(adapter, firstState!.activeHash!),
190
+ ).resolves.toContain(".underline")
191
+ },
192
+ )
193
+
194
+ tailwindTest(
195
+ "degrades when no dynamic asset is available",
196
+ async ({ expect, adapter, plugin }) => {
197
+ await expect(getStylesheets(adapter)).resolves.toEqual([])
198
+
199
+ const response = await plugin.handlers
200
+ ?.tailwind?.(fromPartial({}))
201
+ .GET?.(new Request("https://oberon.invalid/cms/api/tailwind") as never)
202
+
203
+ expect(response?.status).toBe(404)
204
+ },
205
+ )
206
+
207
+ tailwindTest(
208
+ "fails loudly during prebuild when KV storage is unavailable",
209
+ async ({ expect }) => {
210
+ const plugin = tailwindPlugin(
211
+ fromPartial({
212
+ prebuild: async () => {},
213
+ getAllPages: async () => [{ key: "/" }],
214
+ getPageData: async () => createPage("underline").data,
215
+ getKV: async () => {
216
+ throw new NotImplementedError(
217
+ "This action is not available in the demo",
218
+ )
219
+ },
220
+ putKV: async () => {
221
+ throw new NotImplementedError(
222
+ "This action is not available in the demo",
223
+ )
224
+ },
225
+ }),
226
+ )
227
+
228
+ await expect(plugin.adapter?.prebuild?.()).rejects.toThrow(
229
+ new NotImplementedError("This action is not available in the demo"),
230
+ )
231
+
232
+ const response = await plugin.handlers
233
+ ?.tailwind?.(fromPartial({}))
234
+ .GET?.(new Request("https://oberon.invalid/cms/api/tailwind") as never)
235
+
236
+ expect(response?.status).toBe(404)
237
+ },
238
+ )
239
+
240
+ tailwindTest(
241
+ "fails loudly during page updates when KV storage is unavailable",
242
+ async ({ expect }) => {
243
+ const plugin = tailwindPlugin(
244
+ fromPartial({
245
+ updatePageData: async () => {},
246
+ getAllPages: async () => [{ key: "/" }],
247
+ getPageData: async () => createPage("underline").data,
248
+ getKV: async () => {
249
+ throw new NotImplementedError(
250
+ "This action is not available in the demo",
251
+ )
252
+ },
253
+ putKV: async () => {
254
+ throw new NotImplementedError(
255
+ "This action is not available in the demo",
256
+ )
257
+ },
258
+ }),
259
+ )
260
+
261
+ await expect(
262
+ plugin.adapter?.updatePageData?.(createPage("underline")),
263
+ ).rejects.toThrow(
264
+ new NotImplementedError("This action is not available in the demo"),
265
+ )
266
+
267
+ const response = await plugin.handlers
268
+ ?.tailwind?.(fromPartial({}))
269
+ .GET?.(new Request("https://oberon.invalid/cms/api/tailwind") as never)
270
+
271
+ expect(response?.status).toBe(404)
272
+ },
273
+ )
274
+
275
+ tailwindTest(
276
+ "surfaces generic Tailwind update failures as response errors",
277
+ async ({ expect }) => {
278
+ const plugin = tailwindPlugin(
279
+ fromPartial({
280
+ updatePageData: async () => {},
281
+ getAllPages: async () => [{ key: "/" }],
282
+ getPageData: async () => createPage("underline").data,
283
+ getKV: async () => null,
284
+ putKV: async () => {
285
+ throw new Error("boom")
286
+ },
287
+ }),
288
+ )
289
+
290
+ await expect(
291
+ plugin.adapter?.updatePageData?.(createPage("underline")),
292
+ ).rejects.toThrow(
293
+ new ResponseError("Failed to update Tailwind styles: boom"),
294
+ )
295
+ },
296
+ )
297
+ })
package/src/index.ts ADDED
@@ -0,0 +1,189 @@
1
+ import "server-cli-only"
2
+
3
+ import { createHash } from "crypto"
4
+ import {
5
+ type OberonPlugin,
6
+ type OberonPluginAdapter,
7
+ ResponseError,
8
+ } from "@oberoncms/core"
9
+ import { walkAsyncStep } from "walkjs"
10
+ import { z } from "zod"
11
+ import { name, version } from "../package.json" with { type: "json" }
12
+ import { buildCss } from "./compiler"
13
+
14
+ const tailwindStateSchema = z.object({
15
+ activeHash: z.string().nullable(),
16
+ classes: z.array(z.string()),
17
+ })
18
+
19
+ const classDelimiter = /\s+/
20
+
21
+ export async function extractTailwindClasses(data: unknown) {
22
+ const classes = new Set<string>()
23
+
24
+ for await (const node of walkAsyncStep(data)) {
25
+ if (node.key !== "className" || typeof node.val !== "string") {
26
+ continue
27
+ }
28
+
29
+ for (const cls of node.val.split(classDelimiter)) {
30
+ if (cls) classes.add(cls)
31
+ }
32
+ }
33
+
34
+ return [...classes].sort()
35
+ }
36
+
37
+ async function getState(adapter: Pick<OberonPluginAdapter, "getKV">) {
38
+ const parsed = tailwindStateSchema.safeParse(
39
+ await adapter.getKV(name, "state"),
40
+ )
41
+
42
+ return parsed.success ? parsed.data : { activeHash: null, classes: [] }
43
+ }
44
+
45
+ async function getAsset(
46
+ adapter: Pick<OberonPluginAdapter, "getKV">,
47
+ hash: string | null | undefined,
48
+ ) {
49
+ if (!hash) {
50
+ return null
51
+ }
52
+
53
+ const asset = await adapter.getKV(name, `asset:${hash}`)
54
+
55
+ return typeof asset === "string" ? asset : null
56
+ }
57
+
58
+ async function getAllPublishedClasses(
59
+ adapter: Pick<OberonPluginAdapter, "getAllPages" | "getPageData">,
60
+ ) {
61
+ const classes = new Set<string>()
62
+ const pages = await adapter.getAllPages()
63
+
64
+ for (const { key } of pages) {
65
+ const data = await adapter.getPageData(key)
66
+ if (!data) continue
67
+
68
+ for await (const node of walkAsyncStep(data)) {
69
+ if (node.key !== "className" || typeof node.val !== "string") {
70
+ continue
71
+ }
72
+
73
+ for (const cls of node.val.split(classDelimiter)) {
74
+ if (cls) classes.add(cls)
75
+ }
76
+ }
77
+ }
78
+
79
+ return [...classes].sort()
80
+ }
81
+
82
+ async function syncStyles(
83
+ adapter: Pick<
84
+ OberonPluginAdapter,
85
+ "getAllPages" | "getKV" | "getPageData" | "putKV"
86
+ >,
87
+ ) {
88
+ const classes = await getAllPublishedClasses(adapter)
89
+ const state = await getState(adapter)
90
+
91
+ if (!classes.length) {
92
+ if (state.activeHash === null && state.classes.length === 0) {
93
+ return
94
+ }
95
+
96
+ await adapter.putKV(name, "state", { activeHash: null, classes: [] })
97
+ return
98
+ }
99
+
100
+ const hash = createHash("sha256").update(classes.join("\n")).digest("hex")
101
+
102
+ if (hash === state.activeHash && (await getAsset(adapter, hash))) {
103
+ return
104
+ }
105
+
106
+ const css = await buildCss(classes)
107
+ await adapter.putKV(name, `asset:${hash}`, css)
108
+
109
+ await adapter.putKV(name, "state", { activeHash: hash, classes })
110
+ }
111
+
112
+ export const plugin: OberonPlugin = (adapter) => ({
113
+ name,
114
+ version,
115
+ handlers: {
116
+ tailwind: () => ({
117
+ GET: async (request) => {
118
+ try {
119
+ const pathname = new URL(request.url).pathname
120
+ const filename = pathname.split("/").pop()
121
+ const hash = filename?.endsWith(".css")
122
+ ? filename.slice(0, -4)
123
+ : undefined
124
+ const css = await getAsset(adapter, hash)
125
+
126
+ if (!css) {
127
+ return new Response("", {
128
+ status: 404,
129
+ headers: {
130
+ "Cache-Control": "no-store",
131
+ "Content-Type": "text/css; charset=utf-8",
132
+ },
133
+ })
134
+ }
135
+
136
+ return new Response(css, {
137
+ status: 200,
138
+ headers: {
139
+ "Cache-Control": "public, max-age=31536000, immutable",
140
+ "Content-Type": "text/css; charset=utf-8",
141
+ },
142
+ })
143
+ } catch {
144
+ return new Response("", {
145
+ status: 404,
146
+ headers: {
147
+ "Cache-Control": "no-store",
148
+ "Content-Type": "text/css; charset=utf-8",
149
+ },
150
+ })
151
+ }
152
+ },
153
+ }),
154
+ },
155
+ adapter: {
156
+ prebuild: async () => {
157
+ try {
158
+ await adapter.prebuild()
159
+ await syncStyles(adapter)
160
+ } catch (error) {
161
+ if (error instanceof ResponseError) {
162
+ throw error
163
+ }
164
+
165
+ throw new ResponseError(
166
+ error instanceof Error && error.message
167
+ ? `Failed to prepare Tailwind styles: ${error.message}`
168
+ : "Failed to prepare Tailwind styles",
169
+ )
170
+ }
171
+ },
172
+ updatePageData: async (page) => {
173
+ try {
174
+ await adapter.updatePageData(page)
175
+ await syncStyles(adapter)
176
+ } catch (error) {
177
+ if (error instanceof ResponseError) {
178
+ throw error
179
+ }
180
+
181
+ throw new ResponseError(
182
+ error instanceof Error && error.message
183
+ ? `Failed to update Tailwind styles: ${error.message}`
184
+ : "Failed to update Tailwind styles",
185
+ )
186
+ }
187
+ },
188
+ },
189
+ })