Skip to content

Commit

Permalink
fix: use vue compiler for ast generation
Browse files Browse the repository at this point in the history
`ultrahtml` has some shortcomings e.g.

natemoo-re/ultrahtml#63

This PR switches to using the vue compiler for
ast generation.
  • Loading branch information
prashantpalikhe committed May 9, 2024
1 parent 3a78a20 commit 5cc9ed3
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 45 deletions.
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@
"@nuxt/kit": "^3.11.2",
"@webcomponents/template-shadowroot": "^0.2.1",
"magic-string": "^0.30.10",
"ufo": "^1.3.2",
"ultrahtml": "^1.5.2"
"ufo": "^1.3.2"
},
"devDependencies": {
"@commitlint/cli": "^17.2.0",
Expand Down
117 changes: 83 additions & 34 deletions src/runtime/plugins/autoLitWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { pathToFileURL } from "node:url";
import type { Plugin } from "vite";
import { parseURL } from "ufo";
import type { Plugin } from "vite";
import MagicString from "magic-string";
import { parse, walk, ELEMENT_NODE } from "ultrahtml";

import { parse } from "@vue/compiler-sfc";
import { transform, NodeTypes } from "@vue/compiler-core";
import type { NuxtSsrLitOptions } from "../../module";

const allAttributesToMove = ["v-for", ":key", "v-if", "v-else-if", "v-else"];
const attributesToMoveRegex = new RegExp(allAttributesToMove.map((attr) => `(\\s${attr}(="[^"]*")?)`).join("|"), "gi");
const allDirectivesToMove = ["v-for", ":key", "v-if", "v-else-if", "v-else"];
const directivesToMoveRegex = new RegExp(allDirectivesToMove.map((attr) => `(\\s${attr}(="[^"]*")?)`).join("|"), "gi");

export default function autoLitWrapper({
litElementPrefix = [],
Expand All @@ -16,9 +16,8 @@ export default function autoLitWrapper({
return {
name: "autoLitWrapper",
enforce: "pre",
async transform(code: string, id: string) {
transform(code: string, id: string) {
const litElementPrefixes = Array.isArray(litElementPrefix) ? litElementPrefix : [litElementPrefix];

const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href));
const isVueFile = pathname.endsWith(".vue");

Expand All @@ -27,7 +26,6 @@ export default function autoLitWrapper({
}

const template = code.match(/<template>([\s\S]*)<\/template>/);

if (!template) {
return;
}
Expand All @@ -37,35 +35,86 @@ export default function autoLitWrapper({
}

const prefixRegex = new RegExp(`^(${litElementPrefixes.join("|")})`, "i");
const sfcAst = parse(code);
const ast = sfcAst.descriptor.template?.ast;

const ast = parse(code);
const s = new MagicString(code);

await walk(ast, (node) => {
if (node.type !== ELEMENT_NODE || !prefixRegex.test(node.name)) {
return;
}

const foundAttributesToMove = Object.keys(node.attributes).filter((attr) => allAttributesToMove.includes(attr));

const attributesToAdd = foundAttributesToMove.length
? ` ${foundAttributesToMove
.map((attr) => (node.attributes[attr] ? `${attr}="${node.attributes[attr]}"` : attr))
.join(" ")}`
: "";

const wrapperStartTag = `<LitWrapper${attributesToAdd}>`;
const wrapperEndTag = `</LitWrapper>`;

const nodeWithAttributesRemoved = code
.slice(node.loc[0].start, node.loc[0].end)
.replace(attributesToMoveRegex, "")
.trim();

s.overwrite(node.loc[0].start, node.loc[0].end, nodeWithAttributesRemoved);

s.prependLeft(node.loc[0].start, wrapperStartTag);
s.appendRight(node.loc[1].end, wrapperEndTag);
transform(ast, {
nodeTransforms: [
(node) => {
if (node.type !== NodeTypes.ELEMENT || !prefixRegex.test(node.tag)) {
return;
}

const foundDirectivesToMove = node.props.filter((prop) => allDirectivesToMove.includes(prop.rawName));

const directivesToAdd = foundDirectivesToMove.length
? ` ${foundDirectivesToMove.map((attr) => attr.loc.source).join(" ")}`
: "";

const wrapperStartTag = `<LitWrapper${directivesToAdd}>`;
const wrapperEndTag = "</LitWrapper>";

const nodeStart = node.loc.start.offset;
const nodeEnd = node.loc.end.offset;

if (node.isSelfClosing) {
/**
* Converts
*
* ```html
* <my-element v-if="someCondition" />
* ```
*
* to
*
* `<LitWrapper v-if="someCondition"><my-element /></LitWrapper>`
*/
const nodeWithDirectivesRemoved = code
.slice(nodeStart, nodeEnd)
.replace(directivesToMoveRegex, "")
.trim();

s.overwrite(nodeStart, nodeEnd, `${wrapperStartTag}${nodeWithDirectivesRemoved}${wrapperEndTag}`);
} else {
/**
* Converts
*
* ```html
* <my-element v-if="someCondition">
* <div>Some content</div>
* </my-element>
* ```
*
* to
*
* ```html
* <LitWrapper v-if="someCondition">
* <my-element>
* <div>Some content</div>
* </my-element>
* </LitWrapper>
* ```
*/
let contentStart = nodeStart + node.tag.length + 2;
if (node.props.length > 0) {
const lastProp = node.props[node.props.length - 1];
contentStart = lastProp.loc.end.offset;
}

const nodeWithDirectivesRemoved = code
.slice(nodeStart, contentStart)
.replace(directivesToMoveRegex, "")
.trim();

s.overwrite(nodeStart, contentStart, nodeWithDirectivesRemoved);

s.prependLeft(nodeStart, wrapperStartTag);
s.appendRight(nodeEnd, wrapperEndTag);
}
}
]
});

if (s.hasChanged()) {
Expand Down
56 changes: 51 additions & 5 deletions tests/module/autoLitWrapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ describe("Lit wrapper plugin", () => {
<div>
<my-element
v-if="true"
foo
:key="someKey"
some-attr
foo="bar"
:baz="qux"
v-bind="{ ...someObj }"
Expand All @@ -100,7 +101,7 @@ describe("Lit wrapper plugin", () => {
</some-element>
</my-element>
<my-element v-else-if="false">Else if</my-element>
<my-element v-else-if="2 > 1">Else if</my-element>
<my-element v-else>
<do-not-wrap-me />
</my-element>
Expand All @@ -120,16 +121,31 @@ describe("Lit wrapper plugin", () => {
</my-element>
</template>
</SomeComponent>
<my-element :foo="'bar'" key="someKey" />
<SomeComponent @click="handleClick" />
<my-element>
<div>Some content {{ someExpression ? someValue1 : someValue 2 }}</div>
</my-element>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const someKey = ref<string>('some key');
</script>
`;

const expectedCode = `
<template>
<div>
<LitWrapper v-if="true"><my-element
<LitWrapper v-if="true" :key="someKey"><my-element
foo
some-attr
foo="bar"
:baz="qux"
v-bind="{ ...someObj }"
Expand All @@ -142,7 +158,7 @@ describe("Lit wrapper plugin", () => {
</some-element></LitWrapper>
</my-element></LitWrapper>
<LitWrapper v-else-if="false"><my-element>Else if</my-element></LitWrapper>
<LitWrapper v-else-if="2 > 1"><my-element>Else if</my-element></LitWrapper>
<LitWrapper v-else><my-element>
<do-not-wrap-me />
</my-element></LitWrapper>
Expand All @@ -162,12 +178,42 @@ describe("Lit wrapper plugin", () => {
</my-element></LitWrapper>
</template>
</SomeComponent>
<LitWrapper><my-element :foo="'bar'" key="someKey" /></LitWrapper>
<SomeComponent @click="handleClick" />
<LitWrapper><my-element>
<div>Some content {{ someExpression ? someValue1 : someValue 2 }}</div>
</my-element></LitWrapper>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const someKey = ref<string>('some key');
</script>
`;

const t = await plugin.transform(sourceCode, "src/pages/index.vue");

expect(t.code).toBe(expectedCode);
});

test.skip("Playground", () => {
const plugin = autoLitWrapper({
litElementPrefix: ["my-"]
});

const sourceCode = `
<template>
<my-element>test</my-element>
</template>
`;

const t = plugin.transform(sourceCode, "src/pages/index.vue");

console.log(t.code);
});
});

0 comments on commit 5cc9ed3

Please sign in to comment.