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)); });