@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 +27 -0
- package/dist/compiler.d.ts +3 -0
- package/dist/compiler.d.ts.map +1 -0
- package/dist/compiler.js +17 -0
- package/dist/index.client.d.ts +3 -0
- package/dist/index.client.d.ts.map +1 -0
- package/dist/index.client.js +6 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +144 -0
- package/dist/package.json.js +6 -0
- package/dist/version +1 -0
- package/package.json +73 -0
- package/src/compiler.ts +17 -0
- package/src/index.client.ts +5 -0
- package/src/index.test.ts +297 -0
- package/src/index.ts +189 -0
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 @@
|
|
|
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"}
|
package/dist/compiler.js
ADDED
|
@@ -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 @@
|
|
|
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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|
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
|
+
}
|
package/src/compiler.ts
ADDED
|
@@ -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,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
|
+
})
|