summaryrefslogtreecommitdiff
path: root/packages/frontend/lib
diff options
context:
space:
mode:
authorAcid Chicken (硫酸鶏) <root@acid-chicken.com>2023-06-01 17:19:46 +0900
committerGitHub <noreply@github.com>2023-06-01 17:19:46 +0900
commit337dd97b490fb6bcfc351566a4fd80c35a9cda14 (patch)
tree5dc95fc8617a22f713dcf8511398f4100d96c5a9 /packages/frontend/lib
parentUpdate index.d.ts (diff)
downloadsharkey-337dd97b490fb6bcfc351566a4fd80c35a9cda14.tar.gz
sharkey-337dd97b490fb6bcfc351566a4fd80c35a9cda14.tar.bz2
sharkey-337dd97b490fb6bcfc351566a4fd80c35a9cda14.zip
perf(#10923): CSS Modules のクラス名をインライン化する (#10930)
* perf(#10923): unwind css module class name * perf(#10923): support multiple components * refactor: clean up * refactor(#10923): avoid `useCssModule()` * fix(#10923): allow direct literal class name * fix(#10923): avoid computed class name * fix(#10923): allow literal keys * fix(#10923): typo * fix(#10923): invalid class names * chore: test * revert: test This reverts commit 5c7ef366eceebe8ba260efa4d5d675f6c1775c45. * fix(#10923): hidden tale * perf(#10923): also unwind scoped css contained components * perf(#10923): `normalizeClass` AOT compilation --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Diffstat (limited to 'packages/frontend/lib')
-rw-r--r--packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts597
-rw-r--r--packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts275
2 files changed, 872 insertions, 0 deletions
diff --git a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts
new file mode 100644
index 0000000000..3929bf0608
--- /dev/null
+++ b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts
@@ -0,0 +1,597 @@
+import { parse } from 'acorn';
+import { generate } from 'astring';
+import { describe, expect, it } from 'vitest';
+import { normalizeClass, unwindCssModuleClassName } from './rollup-plugin-unwind-css-module-class-name';
+import type * as estree from 'estree';
+
+function parseExpression(code: string): estree.Expression {
+ const program = parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as estree.Program;
+ const statement = program.body[0] as estree.ExpressionStatement;
+ return statement.expression;
+}
+
+describe(normalizeClass.name, () => {
+ it('should normalize string', () => {
+ expect(normalizeClass(parseExpression('"a b c"'))).toBe('a b c');
+ });
+ it('should trim redundant spaces', () => {
+ expect(normalizeClass(parseExpression('" a b c "'))).toBe('a b c');
+ });
+ it('should ignore undefined', () => {
+ expect(normalizeClass(parseExpression('undefined'))).toBe('');
+ });
+ it('should ignore non string literals', () => {
+ expect(normalizeClass(parseExpression('0'))).toBe('');
+ expect(normalizeClass(parseExpression('true'))).toBe('');
+ expect(normalizeClass(parseExpression('null'))).toBe('');
+ expect(normalizeClass(parseExpression('/I.D/'))).toBe('');
+ });
+ it('should not normalize identifiers', () => {
+ expect(normalizeClass(parseExpression('EScape'))).toBeNull();
+ });
+ it('should normalize recursively array', () => {
+ expect(normalizeClass(parseExpression('["from", ...["Utopia"]]'))).toBe('from Utopia');
+ expect(normalizeClass(parseExpression('["from", ...[Utopia]]'))).toBeNull();
+ });
+ it('should normalize recursively template literal', () => {
+ expect(normalizeClass(parseExpression('`name ${"shiho"} code ${33}`'))).toBe('name shiho code');
+ expect(normalizeClass(parseExpression('`name ${shiho.name} code ${33}`'))).toBeNull();
+ });
+ it('should normalize recursively binary expression', () => {
+ expect(normalizeClass(parseExpression('"mirage" + "mirror"'))).toBe('miragemirror');
+ expect(normalizeClass(parseExpression('"mirage" + mirror'))).toBeNull();
+ });
+ it('should normalize recursively object expression', () => {
+ expect(normalizeClass(parseExpression('({ a: true, b: "c" })'))).toBe('a b');
+ expect(normalizeClass(parseExpression('({ a: false, b: "c" })'))).toBe('b');
+ expect(normalizeClass(parseExpression('({ a: true, b: c })'))).toBeNull();
+ expect(normalizeClass(parseExpression('({ a: true, b: "c", ...({ d: true }) })'))).toBe('a b d');
+ expect(normalizeClass(parseExpression('({ a: true, [b]: "c" })'))).toBeNull();
+ expect(normalizeClass(parseExpression('({ a: true, b: false, c: !false, d: !!0 })'))).toBe('a c');
+ });
+});
+
+it('Composition API (standard)', () => {
+ const ast = parse(`
+import { c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
+import { M as MkContainer } from './MkContainer-!~{03M}~.js';
+import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
+import './photoswipe-!~{003}~.js';
+
+const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
+const _sfc_main = /* @__PURE__ */ defineComponent({
+ __name: "index.photos",
+ props: {
+ user: {}
+ },
+ setup(__props) {
+ const props = __props;
+ let fetching = ref(true);
+ let images = ref([]);
+ function thumbnail(image) {
+ return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
+ }
+ onMounted(() => {
+ const image = [
+ "image/jpeg",
+ "image/webp",
+ "image/avif",
+ "image/png",
+ "image/gif",
+ "image/apng",
+ "image/vnd.mozilla.apng"
+ ];
+ api("users/notes", {
+ userId: props.user.id,
+ fileType: image,
+ excludeNsfw: defaultStore.state.nsfw !== "ignore",
+ limit: 10
+ }).then((notes) => {
+ for (const note of notes) {
+ for (const file of note.files) {
+ images.value.push({
+ note,
+ file
+ });
+ }
+ }
+ fetching.value = false;
+ });
+ });
+ return (_ctx, _cache) => {
+ const _component_MkLoading = resolveComponent("MkLoading");
+ const _component_MkA = resolveComponent("MkA");
+ return openBlock(), createBlock(MkContainer, {
+ "max-height": 300,
+ foldable: true
+ }, {
+ icon: withCtx(() => [
+ _hoisted_1
+ ]),
+ header: withCtx(() => [
+ createTextVNode(toDisplayString(unref(i18n).ts.images), 1)
+ ]),
+ default: withCtx(() => [
+ createBaseVNode("div", {
+ class: normalizeClass(_ctx.$style.root)
+ }, [
+ unref(fetching) ? (openBlock(), createBlock(_component_MkLoading, { key: 0 })) : createCommentVNode("", true),
+ !unref(fetching) && unref(images).length > 0 ? (openBlock(), createElementBlock("div", {
+ key: 1,
+ class: normalizeClass(_ctx.$style.stream)
+ }, [
+ (openBlock(true), createElementBlock(Fragment, null, renderList(unref(images), (image) => {
+ return openBlock(), createBlock(_component_MkA, {
+ key: image.note.id + image.file.id,
+ class: normalizeClass(_ctx.$style.img),
+ to: unref(notePage)(image.note)
+ }, {
+ default: withCtx(() => [
+ createVNode(ImgWithBlurhash, {
+ hash: image.file.blurhash,
+ src: thumbnail(image.file),
+ title: image.file.name
+ }, null, 8, ["hash", "src", "title"])
+ ]),
+ _: 2
+ }, 1032, ["class", "to"]);
+ }), 128))
+ ], 2)) : createCommentVNode("", true),
+ !unref(fetching) && unref(images).length == 0 ? (openBlock(), createElementBlock("p", {
+ key: 2,
+ class: normalizeClass(_ctx.$style.empty)
+ }, toDisplayString(unref(i18n).ts.nothing), 3)) : createCommentVNode("", true)
+ ], 2)
+ ]),
+ _: 1
+ });
+ };
+ }
+});
+
+const root = "xenMW";
+const stream = "xaZzf";
+const img = "xtA8t";
+const empty = "xhYKj";
+const style0 = {
+ root: root,
+ stream: stream,
+ img: img,
+ empty: empty
+};
+
+const cssModules = {
+ "$style": style0
+};
+const index_photos = /* @__PURE__ */ _export_sfc(_sfc_main, [["__cssModules", cssModules]]);
+
+export { index_photos as default };
+`.slice(1), { ecmaVersion: 'latest', sourceType: 'module' });
+ unwindCssModuleClassName(ast);
+ expect(generate(ast)).toBe(`
+import {c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc} from './app-!~{001}~.js';
+import {M as MkContainer} from './MkContainer-!~{03M}~.js';
+import {b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode} from './vue-!~{002}~.js';
+import './photoswipe-!~{003}~.js';
+const _hoisted_1 = createBaseVNode("i", {
+ class: "ti ti-photo"
+}, null, -1);
+const _sfc_main = defineComponent({
+ __name: "index.photos",
+ props: {
+ user: {}
+ },
+ setup(__props) {
+ const props = __props;
+ let fetching = ref(true);
+ let images = ref([]);
+ function thumbnail(image) {
+ return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
+ }
+ onMounted(() => {
+ const image = ["image/jpeg", "image/webp", "image/avif", "image/png", "image/gif", "image/apng", "image/vnd.mozilla.apng"];
+ api("users/notes", {
+ userId: props.user.id,
+ fileType: image,
+ excludeNsfw: defaultStore.state.nsfw !== "ignore",
+ limit: 10
+ }).then(notes => {
+ for (const note of notes) {
+ for (const file of note.files) {
+ images.value.push({
+ note,
+ file
+ });
+ }
+ }
+ fetching.value = false;
+ });
+ });
+ return (_ctx, _cache) => {
+ const _component_MkLoading = resolveComponent("MkLoading");
+ const _component_MkA = resolveComponent("MkA");
+ return (openBlock(), createBlock(MkContainer, {
+ "max-height": 300,
+ foldable: true
+ }, {
+ icon: withCtx(() => [_hoisted_1]),
+ header: withCtx(() => [createTextVNode(toDisplayString(unref(i18n).ts.images), 1)]),
+ default: withCtx(() => [createBaseVNode("div", {
+ class: "xenMW"
+ }, [unref(fetching) ? (openBlock(), createBlock(_component_MkLoading, {
+ key: 0
+ })) : createCommentVNode("", true), !unref(fetching) && unref(images).length > 0 ? (openBlock(), createElementBlock("div", {
+ key: 1,
+ class: "xaZzf"
+ }, [(openBlock(true), createElementBlock(Fragment, null, renderList(unref(images), image => {
+ return (openBlock(), createBlock(_component_MkA, {
+ key: image.note.id + image.file.id,
+ class: "xtA8t",
+ to: unref(notePage)(image.note)
+ }, {
+ default: withCtx(() => [createVNode(ImgWithBlurhash, {
+ hash: image.file.blurhash,
+ src: thumbnail(image.file),
+ title: image.file.name
+ }, null, 8, ["hash", "src", "title"])]),
+ _: 2
+ }, 1032, ["class", "to"]));
+ }), 128))], 2)) : createCommentVNode("", true), !unref(fetching) && unref(images).length == 0 ? (openBlock(), createElementBlock("p", {
+ key: 2,
+ class: "xhYKj"
+ }, toDisplayString(unref(i18n).ts.nothing), 3)) : createCommentVNode("", true)], 2)]),
+ _: 1
+ }));
+ };
+ }
+});
+const root = "xenMW";
+const stream = "xaZzf";
+const img = "xtA8t";
+const empty = "xhYKj";
+const style0 = {
+ root: root,
+ stream: stream,
+ img: img,
+ empty: empty
+};
+const cssModules = {
+ "$style": style0
+};
+const index_photos = _sfc_main;
+export {index_photos as default};
+`.slice(1));
+});
+
+it('Composition API (with `useCssModule()`)', () => {
+ const ast = parse(`
+import { a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup } from './!~{002}~.js';
+import { d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc } from './app-!~{001}~.js';
+
+function isDebuggerEnabled(id) {
+ try {
+ return localStorage.getItem(\`DEBUG_\${id}\`) !== null;
+ } catch {
+ return false;
+ }
+}
+function stackTraceInstances() {
+ let instance = getCurrentInstance();
+ const stack = [];
+ while (instance) {
+ stack.push(instance);
+ instance = instance.parent;
+ }
+ return stack;
+}
+
+const _sfc_main = defineComponent({
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ direction: {
+ type: String,
+ required: false,
+ default: "down"
+ },
+ reversed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ noGap: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ ad: {
+ type: Boolean,
+ required: false,
+ default: false
+ }
+ },
+ setup(props, { slots, expose }) {
+ const $style = useCssModule();
+ function getDateText(time) {
+ const date = new Date(time).getDate();
+ const month = new Date(time).getMonth() + 1;
+ return i18n.t("monthAndDay", {
+ month: month.toString(),
+ day: date.toString()
+ });
+ }
+ if (props.items.length === 0)
+ return;
+ const renderChildrenImpl = () => props.items.map((item, i) => {
+ if (!slots || !slots.default)
+ return;
+ const el = slots.default({
+ item
+ })[0];
+ if (el.key == null && item.id)
+ el.key = item.id;
+ if (i !== props.items.length - 1 && new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()) {
+ const separator = h("div", {
+ class: $style["separator"],
+ key: item.id + ":separator"
+ }, h("p", {
+ class: $style["date"]
+ }, [
+ h("span", {
+ class: $style["date-1"]
+ }, [
+ h("i", {
+ class: \`ti ti-chevron-up \${$style["date-1-icon"]}\`
+ }),
+ getDateText(item.createdAt)
+ ]),
+ h("span", {
+ class: $style["date-2"]
+ }, [
+ getDateText(props.items[i + 1].createdAt),
+ h("i", {
+ class: \`ti ti-chevron-down \${$style["date-2-icon"]}\`
+ })
+ ])
+ ]));
+ return [el, separator];
+ } else {
+ if (props.ad && item._shouldInsertAd_) {
+ return [h(MkAd, {
+ key: item.id + ":ad",
+ prefer: ["horizontal", "horizontal-big"]
+ }), el];
+ } else {
+ return el;
+ }
+ }
+ });
+ const renderChildren = () => {
+ const children = renderChildrenImpl();
+ if (isDebuggerEnabled(6864)) {
+ const nodes = children.flatMap((node) => node ?? []);
+ const keys = new Set(nodes.map((node) => node.key));
+ if (keys.size !== nodes.length) {
+ const id = crypto.randomUUID();
+ const instances = stackTraceInstances();
+ toast(instances.reduce((a, c) => \`\${a} at \${c.type.name}\`, \`[DEBUG_6864 (\${id})]: \${nodes.length - keys.size} duplicated keys found\`));
+ console.warn({ id, debugId: 6864, stack: instances });
+ }
+ }
+ return children;
+ };
+ function onBeforeLeave(el) {
+ el.style.top = \`\${el.offsetTop}px\`;
+ el.style.left = \`\${el.offsetLeft}px\`;
+ }
+ function onLeaveCanceled(el) {
+ el.style.top = "";
+ el.style.left = "";
+ }
+ return () => h(
+ defaultStore.state.animation ? TransitionGroup : "div",
+ {
+ class: {
+ [$style["date-separated-list"]]: true,
+ [$style["date-separated-list-nogap"]]: props.noGap,
+ [$style["reversed"]]: props.reversed,
+ [$style["direction-down"]]: props.direction === "down",
+ [$style["direction-up"]]: props.direction === "up"
+ },
+ ...defaultStore.state.animation ? {
+ name: "list",
+ tag: "div",
+ onBeforeLeave,
+ onLeaveCanceled
+ } : {}
+ },
+ { default: renderChildren }
+ );
+ }
+});
+
+const reversed = "xxiZh";
+const separator = "xxeDx";
+const date = "xxawD";
+const style0 = {
+ "date-separated-list": "xfKPa",
+ "date-separated-list-nogap": "xf9zr",
+ "direction-up": "x7AeO",
+ "direction-down": "xBIqc",
+ reversed: reversed,
+ separator: separator,
+ date: date,
+ "date-1": "xwtmh",
+ "date-1-icon": "xsNPa",
+ "date-2": "x1xvw",
+ "date-2-icon": "x9ZiG"
+};
+
+const cssModules = {
+ "$style": style0
+};
+const MkDateSeparatedList = /* @__PURE__ */ _export_sfc(_sfc_main, [["__cssModules", cssModules]]);
+
+export { MkDateSeparatedList as M };
+`.slice(1), { ecmaVersion: 'latest', sourceType: 'module' });
+ unwindCssModuleClassName(ast);
+ expect(generate(ast)).toBe(`
+import {a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup} from './!~{002}~.js';
+import {d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc} from './app-!~{001}~.js';
+function isDebuggerEnabled(id) {
+ try {
+ return localStorage.getItem(\`DEBUG_\${id}\`) !== null;
+ } catch {
+ return false;
+ }
+}
+function stackTraceInstances() {
+ let instance = getCurrentInstance();
+ const stack = [];
+ while (instance) {
+ stack.push(instance);
+ instance = instance.parent;
+ }
+ return stack;
+}
+const _sfc_main = defineComponent({
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ direction: {
+ type: String,
+ required: false,
+ default: "down"
+ },
+ reversed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ noGap: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ ad: {
+ type: Boolean,
+ required: false,
+ default: false
+ }
+ },
+ setup(props, {slots, expose}) {
+ const $style = useCssModule();
+ function getDateText(time) {
+ const date = new Date(time).getDate();
+ const month = new Date(time).getMonth() + 1;
+ return i18n.t("monthAndDay", {
+ month: month.toString(),
+ day: date.toString()
+ });
+ }
+ if (props.items.length === 0) return;
+ const renderChildrenImpl = () => props.items.map((item, i) => {
+ if (!slots || !slots.default) return;
+ const el = slots.default({
+ item
+ })[0];
+ if (el.key == null && item.id) el.key = item.id;
+ if (i !== props.items.length - 1 && new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()) {
+ const separator = h("div", {
+ class: $style["separator"],
+ key: item.id + ":separator"
+ }, h("p", {
+ class: $style["date"]
+ }, [h("span", {
+ class: $style["date-1"]
+ }, [h("i", {
+ class: \`ti ti-chevron-up \${$style["date-1-icon"]}\`
+ }), getDateText(item.createdAt)]), h("span", {
+ class: $style["date-2"]
+ }, [getDateText(props.items[i + 1].createdAt), h("i", {
+ class: \`ti ti-chevron-down \${$style["date-2-icon"]}\`
+ })])]));
+ return [el, separator];
+ } else {
+ if (props.ad && item._shouldInsertAd_) {
+ return [h(MkAd, {
+ key: item.id + ":ad",
+ prefer: ["horizontal", "horizontal-big"]
+ }), el];
+ } else {
+ return el;
+ }
+ }
+ });
+ const renderChildren = () => {
+ const children = renderChildrenImpl();
+ if (isDebuggerEnabled(6864)) {
+ const nodes = children.flatMap(node => node ?? []);
+ const keys = new Set(nodes.map(node => node.key));
+ if (keys.size !== nodes.length) {
+ const id = crypto.randomUUID();
+ const instances = stackTraceInstances();
+ toast(instances.reduce((a, c) => \`\${a} at \${c.type.name}\`, \`[DEBUG_6864 (\${id})]: \${nodes.length - keys.size} duplicated keys found\`));
+ console.warn({
+ id,
+ debugId: 6864,
+ stack: instances
+ });
+ }
+ }
+ return children;
+ };
+ function onBeforeLeave(el) {
+ el.style.top = \`\${el.offsetTop}px\`;
+ el.style.left = \`\${el.offsetLeft}px\`;
+ }
+ function onLeaveCanceled(el) {
+ el.style.top = "";
+ el.style.left = "";
+ }
+ return () => h(defaultStore.state.animation ? TransitionGroup : "div", {
+ class: {
+ [$style["date-separated-list"]]: true,
+ [$style["date-separated-list-nogap"]]: props.noGap,
+ [$style["reversed"]]: props.reversed,
+ [$style["direction-down"]]: props.direction === "down",
+ [$style["direction-up"]]: props.direction === "up"
+ },
+ ...defaultStore.state.animation ? {
+ name: "list",
+ tag: "div",
+ onBeforeLeave,
+ onLeaveCanceled
+ } : {}
+ }, {
+ default: renderChildren
+ });
+ }
+});
+const reversed = "xxiZh";
+const separator = "xxeDx";
+const date = "xxawD";
+const style0 = {
+ "date-separated-list": "xfKPa",
+ "date-separated-list-nogap": "xf9zr",
+ "direction-up": "x7AeO",
+ "direction-down": "xBIqc",
+ reversed: reversed,
+ separator: separator,
+ date: date,
+ "date-1": "xwtmh",
+ "date-1-icon": "xsNPa",
+ "date-2": "x1xvw",
+ "date-2-icon": "x9ZiG"
+};
+const cssModules = {
+ "$style": style0
+};
+const MkDateSeparatedList = _export_sfc(_sfc_main, [["__cssModules", cssModules]]);
+export {MkDateSeparatedList as M};
+`.slice(1));
+});
diff --git a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts
new file mode 100644
index 0000000000..a18f0d9049
--- /dev/null
+++ b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts
@@ -0,0 +1,275 @@
+import { generate } from 'astring';
+import * as estree from 'estree';
+import { walk } from '../node_modules/estree-walker/src/index.js';
+import type * as estreeWalker from 'estree-walker';
+import type { Plugin } from 'vite';
+
+function isFalsyIdentifier(identifier: estree.Identifier): boolean {
+ return identifier.name === 'undefined' || identifier.name === 'NaN';
+}
+
+function normalizeClassWalker(tree: estree.Node): string | null {
+ if (tree.type === 'Identifier') return isFalsyIdentifier(tree) ? '' : null;
+ if (tree.type === 'Literal') return typeof tree.value === 'string' ? tree.value : '';
+ if (tree.type === 'BinaryExpression') {
+ if (tree.operator !== '+') return null;
+ const left = normalizeClassWalker(tree.left);
+ const right = normalizeClassWalker(tree.right);
+ if (left === null || right === null) return null;
+ return `${left}${right}`;
+ }
+ if (tree.type === 'TemplateLiteral') {
+ if (tree.expressions.some((x) => x.type !== 'Literal' && (x.type !== 'Identifier' || !isFalsyIdentifier(x)))) return null;
+ return tree.quasis.reduce((a, c, i) => {
+ const v = i === tree.quasis.length - 1 ? '' : (tree.expressions[i] as Partial<estree.Literal>).value;
+ return a + c.value.raw + (typeof v === 'string' ? v : '');
+ }, '');
+ }
+ if (tree.type === 'ArrayExpression') {
+ const values = tree.elements.map((treeNode) => {
+ if (treeNode === null) return '';
+ if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument);
+ return normalizeClassWalker(treeNode);
+ });
+ if (values.some((x) => x === null)) return null;
+ return values.join(' ');
+ }
+ if (tree.type === 'ObjectExpression') {
+ const values = tree.properties.map((treeNode) => {
+ if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument);
+ let x = treeNode.value;
+ let inveted = false;
+ while (x.type === 'UnaryExpression' && x.operator === '!') {
+ x = x.argument;
+ inveted = !inveted;
+ }
+ if (x.type === 'Literal') {
+ if (inveted === !x.value) {
+ return treeNode.key.type === 'Identifier' ? treeNode.computed ? null : treeNode.key.name : treeNode.key.type === 'Literal' ? treeNode.key.value : '';
+ } else {
+ return '';
+ }
+ }
+ if (x.type === 'Identifier') {
+ if (inveted !== isFalsyIdentifier(x)) {
+ return '';
+ } else {
+ return null;
+ }
+ }
+ return null;
+ });
+ if (values.some((x) => x === null)) return null;
+ return values.join(' ');
+ }
+ console.error(`Unexpected node type: ${tree.type}`);
+ return null;
+}
+
+export function normalizeClass(tree: estree.Node): string | null {
+ const walked = normalizeClassWalker(tree);
+ return walked && walked.replace(/^\s+|\s+(?=\s)|\s+$/g, '');
+}
+
+export function unwindCssModuleClassName(ast: estree.Node): void {
+ (walk as typeof estreeWalker.walk)(ast, {
+ enter(node, parent): void {
+ if (parent?.type !== 'Program') return;
+ if (node.type !== 'VariableDeclaration') return;
+ if (node.declarations.length !== 1) return;
+ if (node.declarations[0].id.type !== 'Identifier') return;
+ const name = node.declarations[0].id.name;
+ if (node.declarations[0].init?.type !== 'CallExpression') return;
+ if (node.declarations[0].init.callee.type !== 'Identifier') return;
+ if (node.declarations[0].init.callee.name !== '_export_sfc') return;
+ if (node.declarations[0].init.arguments.length !== 2) return;
+ if (node.declarations[0].init.arguments[0].type !== 'Identifier') return;
+ const ident = node.declarations[0].init.arguments[0].name;
+ if (!ident.startsWith('_sfc_main')) return;
+ if (node.declarations[0].init.arguments[1].type !== 'ArrayExpression') return;
+ if (node.declarations[0].init.arguments[1].elements.length === 0) return;
+ const __cssModulesIndex = node.declarations[0].init.arguments[1].elements.findIndex((x) => {
+ if (x?.type !== 'ArrayExpression') return false;
+ if (x.elements.length !== 2) return false;
+ if (x.elements[0]?.type !== 'Literal') return false;
+ if (x.elements[0].value !== '__cssModules') return false;
+ if (x.elements[1]?.type !== 'Identifier') return false;
+ return true;
+ });
+ if (!~__cssModulesIndex) return;
+ const cssModuleForestName = ((node.declarations[0].init.arguments[1].elements[__cssModulesIndex] as estree.ArrayExpression).elements[1] as estree.Identifier).name;
+ const cssModuleForestNode = parent.body.find((x) => {
+ if (x.type !== 'VariableDeclaration') return false;
+ if (x.declarations.length !== 1) return false;
+ if (x.declarations[0].id.type !== 'Identifier') return false;
+ if (x.declarations[0].id.name !== cssModuleForestName) return false;
+ if (x.declarations[0].init?.type !== 'ObjectExpression') return false;
+ return true;
+ }) as unknown as estree.VariableDeclaration;
+ const moduleForest = new Map((cssModuleForestNode.declarations[0].init as estree.ObjectExpression).properties.flatMap((property) => {
+ if (property.type !== 'Property') return [];
+ if (property.key.type !== 'Literal') return [];
+ if (property.value.type !== 'Identifier') return [];
+ return [[property.key.value as string, property.value.name as string]];
+ }));
+ const sfcMain = parent.body.find((x) => {
+ if (x.type !== 'VariableDeclaration') return false;
+ if (x.declarations.length !== 1) return false;
+ if (x.declarations[0].id.type !== 'Identifier') return false;
+ if (x.declarations[0].id.name !== ident) return false;
+ return true;
+ }) as unknown as estree.VariableDeclaration;
+ if (sfcMain.declarations[0].init?.type !== 'CallExpression') return;
+ if (sfcMain.declarations[0].init.callee.type !== 'Identifier') return;
+ if (sfcMain.declarations[0].init.callee.name !== 'defineComponent') return;
+ if (sfcMain.declarations[0].init.arguments.length !== 1) return;
+ if (sfcMain.declarations[0].init.arguments[0].type !== 'ObjectExpression') return;
+ const setup = sfcMain.declarations[0].init.arguments[0].properties.find((x) => {
+ if (x.type !== 'Property') return false;
+ if (x.key.type !== 'Identifier') return false;
+ if (x.key.name !== 'setup') return false;
+ return true;
+ }) as unknown as estree.Property;
+ if (setup.value.type !== 'FunctionExpression') return;
+ const render = setup.value.body.body.find((x) => {
+ if (x.type !== 'ReturnStatement') return false;
+ return true;
+ }) as unknown as estree.ReturnStatement;
+ if (render.argument?.type !== 'ArrowFunctionExpression') return;
+ if (render.argument.params.length !== 2) return;
+ const ctx = render.argument.params[0];
+ if (ctx.type !== 'Identifier') return;
+ if (ctx.name !== '_ctx') return;
+ if (render.argument.body.type !== 'BlockStatement') return;
+ for (const [key, value] of moduleForest) {
+ const cssModuleTreeNode = parent.body.find((x) => {
+ if (x.type !== 'VariableDeclaration') return false;
+ if (x.declarations.length !== 1) return false;
+ if (x.declarations[0].id.type !== 'Identifier') return false;
+ if (x.declarations[0].id.name !== value) return false;
+ return true;
+ }) as unknown as estree.VariableDeclaration;
+ if (cssModuleTreeNode.declarations[0].init?.type !== 'ObjectExpression') return;
+ const moduleTree = new Map(cssModuleTreeNode.declarations[0].init.properties.flatMap((property) => {
+ if (property.type !== 'Property') return [];
+ const actualKey = property.key.type === 'Identifier' ? property.key.name : property.key.type === 'Literal' ? property.key.value : null;
+ if (typeof actualKey !== 'string') return [];
+ if (property.value.type === 'Literal') return [[actualKey, property.value.value as string]];
+ if (property.value.type !== 'Identifier') return [];
+ const labelledValue = property.value.name;
+ const actualValue = parent.body.find((x) => {
+ if (x.type !== 'VariableDeclaration') return false;
+ if (x.declarations.length !== 1) return false;
+ if (x.declarations[0].id.type !== 'Identifier') return false;
+ if (x.declarations[0].id.name !== labelledValue) return false;
+ return true;
+ }) as unknown as estree.VariableDeclaration;
+ if (actualValue.declarations[0].init?.type !== 'Literal') return [];
+ return [[actualKey, actualValue.declarations[0].init.value as string]];
+ }));
+ (walk as typeof estreeWalker.walk)(render.argument.body, {
+ enter(childNode) {
+ if (childNode.type !== 'MemberExpression') return;
+ if (childNode.object.type !== 'MemberExpression') return;
+ if (childNode.object.object.type !== 'Identifier') return;
+ if (childNode.object.object.name !== ctx.name) return;
+ if (childNode.object.property.type !== 'Identifier') return;
+ if (childNode.object.property.name !== key) return;
+ if (childNode.property.type !== 'Identifier') return;
+ const actualValue = moduleTree.get(childNode.property.name);
+ if (actualValue === undefined) return;
+ this.replace({
+ type: 'Literal',
+ value: actualValue,
+ });
+ },
+ });
+ (walk as typeof estreeWalker.walk)(render.argument.body, {
+ enter(childNode) {
+ if (childNode.type !== 'MemberExpression') return;
+ if (childNode.object.type !== 'MemberExpression') return;
+ if (childNode.object.object.type !== 'Identifier') return;
+ if (childNode.object.object.name !== ctx.name) return;
+ if (childNode.object.property.type !== 'Identifier') return;
+ if (childNode.object.property.name !== key) return;
+ if (childNode.property.type !== 'Identifier') return;
+ console.error(`Undefined style detected: ${key}.${childNode.property.name} (in ${name})`);
+ this.replace({
+ type: 'Identifier',
+ name: 'undefined',
+ });
+ },
+ });
+ (walk as typeof estreeWalker.walk)(render.argument.body, {
+ enter(childNode) {
+ if (childNode.type !== 'CallExpression') return;
+ if (childNode.callee.type !== 'Identifier') return;
+ if (childNode.callee.name !== 'normalizeClass') return;
+ if (childNode.arguments.length !== 1) return;
+ const normalized = normalizeClass(childNode.arguments[0]);
+ if (normalized === null) return;
+ this.replace({
+ type: 'Literal',
+ value: normalized,
+ });
+ },
+ });
+ }
+ if (node.declarations[0].init.arguments[1].elements.length === 1) {
+ this.replace({
+ type: 'VariableDeclaration',
+ declarations: [{
+ type: 'VariableDeclarator',
+ id: {
+ type: 'Identifier',
+ name: node.declarations[0].id.name,
+ },
+ init: {
+ type: 'Identifier',
+ name: ident,
+ },
+ }],
+ kind: 'const',
+ });
+ } else {
+ this.replace({
+ type: 'VariableDeclaration',
+ declarations: [{
+ type: 'VariableDeclarator',
+ id: {
+ type: 'Identifier',
+ name: node.declarations[0].id.name,
+ },
+ init: {
+ type: 'CallExpression',
+ callee: {
+ type: 'Identifier',
+ name: '_export_sfc',
+ },
+ arguments: [{
+ type: 'Identifier',
+ name: ident,
+ }, {
+ type: 'ArrayExpression',
+ elements: node.declarations[0].init.arguments[1].elements.slice(0, __cssModulesIndex).concat(node.declarations[0].init.arguments[1].elements.slice(__cssModulesIndex + 1)),
+ }],
+ },
+ }],
+ kind: 'const',
+ });
+ }
+ },
+ });
+}
+
+// eslint-disable-next-line import/no-default-export
+export default function pluginUnwindCssModuleClassName(): Plugin {
+ return {
+ name: 'UnwindCssModuleClassName',
+ renderChunk(code): { code: string } {
+ const ast = this.parse(code) as unknown as estree.Node;
+ unwindCssModuleClassName(ast);
+ return { code: generate(ast) };
+ },
+ };
+}