Skip to content

Commit 853ab86

Browse files
committed
Merge branch 'feature/alpinejs' of github.com:sbrow/mitosis into feature/multiple-outputs
2 parents a7ab359 + a52f388 commit 853ab86

File tree

11 files changed

+436
-3
lines changed

11 files changed

+436
-3
lines changed

packages/cli/src/build/build.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
componentToAlpine,
23
componentToAngular,
34
componentToCustomElement,
45
componentToHtml,
@@ -205,6 +206,8 @@ export async function build(config?: MitosisConfig) {
205206

206207
const getGeneratorForTarget = ({ target }: { target: Target }): TargetContext['generator'] => {
207208
switch (target) {
209+
case 'alpine':
210+
return componentToAlpine;
208211
case 'customElement':
209212
return componentToCustomElement;
210213
case 'html':
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`Alpine.js Remove Internal mitosis package 1`] = `
4+
"<div x-data=\\"myBasicComponent()\\">
5+
Hello
6+
<span x-html=\\"name\\"></span>
7+
! I can run in React, Qwik, Vue, Solid, or Liquid!
8+
</div>
9+
<script>
10+
document.addEventListener(\\"alpine:init\\", () => {
11+
Alpine.data(\\"myBasicComponent\\", () => ({ name: \\"PatrickJS\\" }));
12+
});
13+
</script>
14+
"
15+
`;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { componentToAlpine, ToAlpineOptions } from '../generators/alpine';
2+
import { runTestsForTarget } from './shared';
3+
4+
describe('Alpine.js', () => {
5+
const possibleOptions: ToAlpineOptions[] = [
6+
{},
7+
// { inlineState: true },
8+
// { useShorthandSyntax: true },
9+
// { inlineState: true, useShorthandSyntax: true },
10+
]
11+
possibleOptions.map(options => runTestsForTarget({ options, target: 'alpine', generator: componentToAlpine }));
12+
});
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { format } from 'prettier/standalone';
2+
import { collectCss } from '../../helpers/styles/collect-css';
3+
import { fastClone } from '../../helpers/fast-clone';
4+
import { stripStateAndPropsRefs } from '../../helpers/strip-state-and-props-refs';
5+
import { selfClosingTags } from '../../parsers/jsx';
6+
import { checkIsForNode, ForNode, MitosisNode } from '../../types/mitosis-node';
7+
import {
8+
runPostCodePlugins,
9+
runPostJsonPlugins,
10+
runPreCodePlugins,
11+
runPreJsonPlugins,
12+
} from '../../modules/plugins';
13+
import { stripMetaProperties } from '../../helpers/strip-meta-properties';
14+
import { getStateObjectStringFromComponent } from '../../helpers/get-state-object-string';
15+
import { BaseTranspilerOptions, TranspilerGenerator } from '../../types/transpiler';
16+
import { dashCase } from '../../helpers/dash-case';
17+
import { removeSurroundingBlock } from '../../helpers/remove-surrounding-block';
18+
import { camelCase, curry, flow, flowRight as compose } from 'lodash';
19+
import { getRefs } from '../../helpers/get-refs';
20+
import { MitosisComponent } from '../../types/mitosis-component';
21+
import { hasRootUpdateHook, renderUpdateHooks } from './render-update-hooks';
22+
import { renderMountHook } from './render-mount-hook';
23+
24+
export interface ToAlpineOptions extends BaseTranspilerOptions {
25+
/**
26+
* use @on and : instead of `x-on` and `x-bind`
27+
*/
28+
useShorthandSyntax?: boolean,
29+
/**
30+
* If true, the javascript won't be extracted into a separate script block.
31+
*/
32+
inlineState?: boolean,
33+
}
34+
35+
export const checkIsComponentNode = (node: MitosisNode): boolean => node.name === '@builder.io/mitosis/component';
36+
37+
/**
38+
* Test if the binding expression would be likely to generate
39+
* valid or invalid liquid. If we generate invalid liquid tags
40+
* Shopify will reject our PUT to update the template
41+
*/
42+
export const isValidAlpineBinding = (str = '') => {
43+
return true;
44+
/*
45+
const strictMatches = Boolean(
46+
// Test for our `context.shopify.liquid.*(expression), which
47+
// we regex out later to transform back into valid liquid expressions
48+
str.match(/(context|ctx)\s*(\.shopify\s*)?\.liquid\s*\./),
49+
);
50+
51+
return (
52+
strictMatches ||
53+
// Test is the expression is simple and would map to Shopify bindings // Test for our `context.shopify.liquid.*(expression), which
54+
// e.g. `state.product.price` -> `{{product.price}} // we regex out later to transform back into valid liquid expressions
55+
Boolean(str.match(/^[a-z0-9_\.\s]+$/i))
56+
);
57+
*/
58+
};
59+
60+
const removeOnFromEventName = (str: string) => str.replace(/^on/, '')
61+
const prefixEvent = (str: string) => str.replace(/(?<=[\s]|^)event/gm, '$event')
62+
const removeTrailingSemicolon = (str: string) => str.replace(/;$/, '')
63+
const trim = (str: string) => str.trim();
64+
65+
const replaceInputRefs = curry((json: MitosisComponent, str: string) => {
66+
getRefs(json).forEach(value => {
67+
str = str.replaceAll(value, `this.$refs.${value}`);
68+
});
69+
70+
return str;
71+
});
72+
const replaceStateWithThis = (str: string) => str.replaceAll('state.', 'this.');
73+
const getStateObjectString = (json: MitosisComponent) => flow(
74+
getStateObjectStringFromComponent,
75+
trim,
76+
replaceInputRefs(json),
77+
renderMountHook(json),
78+
renderUpdateHooks(json),
79+
replaceStateWithThis,
80+
)(json);
81+
82+
const bindEventHandlerKey = compose(
83+
dashCase,
84+
removeOnFromEventName
85+
);
86+
const bindEventHandlerValue = compose(
87+
prefixEvent,
88+
removeTrailingSemicolon,
89+
trim,
90+
removeSurroundingBlock,
91+
stripStateAndPropsRefs
92+
);
93+
94+
const bindEventHandler = ({ useShorthandSyntax }: ToAlpineOptions) => (eventName: string, code: string) => {
95+
const bind = useShorthandSyntax ? '@' : 'x-on:'
96+
return ` ${bind}${bindEventHandlerKey(eventName)}="${bindEventHandlerValue(code).trim()}"`;
97+
};
98+
99+
const mappers: {
100+
[key: string]: (json: MitosisNode, options: ToAlpineOptions) => string;
101+
} = {
102+
For: (json, options) => (
103+
!(checkIsForNode(json) && isValidAlpineBinding(json.bindings.each?.code) && isValidAlpineBinding(json.scope.forName))
104+
? ''
105+
: `<template x-for="${json.scope.forName} in ${stripStateAndPropsRefs(json.bindings.each?.code)}">
106+
${(json.children ?? []).map((item) => blockToAlpine(item, options)).join('\n')}
107+
</template>`
108+
),
109+
Fragment: (json, options) => blockToAlpine({ ...json, name: "div" }, options),
110+
Show: (json, options) => (
111+
!isValidAlpineBinding(json.bindings.when?.code)
112+
? ''
113+
: `<template x-if="${stripStateAndPropsRefs(json.bindings.when?.code)}">
114+
${(json.children ?? []).map((item) => blockToAlpine(item, options)).join('\n')}
115+
</template>`
116+
)
117+
};
118+
119+
// TODO: spread support
120+
const blockToAlpine = (json: MitosisNode|ForNode, options: ToAlpineOptions = {}): string => {
121+
if (mappers[json.name]) {
122+
return mappers[json.name](json, options);
123+
}
124+
125+
// TODO: Add support for `{props.children}` bindings
126+
127+
if (json.properties._text) {
128+
return json.properties._text;
129+
}
130+
131+
if (json.bindings._text?.code) {
132+
return isValidAlpineBinding(json.bindings._text.code)
133+
? `<span x-html="${stripStateAndPropsRefs(json.bindings._text.code as string)}"></span>`
134+
: '';
135+
}
136+
137+
let str = `<${json.name} `;
138+
139+
/*
140+
// Copied from the liquid generator. Not sure what it does.
141+
if (
142+
json.bindings._spread?.code === '_spread' &&
143+
isValidAlpineBinding(json.bindings._spread.code)
144+
) {
145+
str += `
146+
<template x-for="_attr in ${json.bindings._spread.code}">
147+
{{ _attr[0] }}="{{ _attr[1] }}"
148+
</template>
149+
`;
150+
}
151+
*/
152+
153+
for (const key in json.properties) {
154+
const value = json.properties[key];
155+
str += ` ${key}="${value}" `;
156+
}
157+
158+
for (const key in json.bindings) {
159+
if (key === '_spread' || key === 'css') {
160+
continue;
161+
}
162+
const { code: value, type: bindingType } = json.bindings[key]!;
163+
// TODO: proper babel transform to replace. Util for this
164+
const useValue = stripStateAndPropsRefs(value);
165+
166+
if (key.startsWith('on')) {
167+
str += bindEventHandler(options)(key, value);
168+
} else if (key === 'ref') {
169+
str += ` x-ref="${useValue}"`;
170+
} else if (isValidAlpineBinding(useValue)) {
171+
const bind = options.useShorthandSyntax && bindingType !== 'spread' ? ':' : 'x-bind:'
172+
str += ` ${bind}${bindingType === 'spread' ? '' : key}="${useValue}" `.replace(':=', '=');
173+
}
174+
}
175+
return selfClosingTags.has(json.name)
176+
? `${str} />`
177+
: `${str}>${(json.children ?? []).map((item) => blockToAlpine(item, options)).join('\n')}</${json.name}>`;
178+
};
179+
180+
181+
export const componentToAlpine: TranspilerGenerator<ToAlpineOptions> =
182+
(options = {}) =>
183+
({ component }) => {
184+
let json = fastClone(component);
185+
if (options.plugins) {
186+
json = runPreJsonPlugins(json, options.plugins);
187+
}
188+
const css = collectCss(json);
189+
stripMetaProperties(json);
190+
if (options.plugins) {
191+
json = runPostJsonPlugins(json, options.plugins);
192+
}
193+
194+
const stateObjectString = getStateObjectString(json);
195+
// Set x-data on root element
196+
json.children[0].properties['x-data'] = options.inlineState
197+
? stateObjectString
198+
: `${camelCase(json.name)}()`;
199+
200+
if (hasRootUpdateHook(json)) {
201+
json.children[0].properties['x-effect'] = 'onUpdate'
202+
}
203+
204+
let str = css.trim().length
205+
? `<style>${css}</style>`
206+
: '';
207+
str += json.children.map((item) => blockToAlpine(item, options)).join('\n');
208+
209+
if (!options.inlineState) {
210+
str += `<script>
211+
document.addEventListener('alpine:init', () => {
212+
Alpine.data('${camelCase(json.name)}', () => (${stateObjectString}))
213+
})
214+
</script>`
215+
}
216+
217+
if (options.plugins) {
218+
str = runPreCodePlugins(str, options.plugins);
219+
}
220+
if (options.prettier !== false) {
221+
try {
222+
str = format(str, {
223+
parser: 'html',
224+
htmlWhitespaceSensitivity: 'ignore',
225+
plugins: [
226+
// To support running in browsers
227+
require('prettier/parser-html'),
228+
require('prettier/parser-postcss'),
229+
require('prettier/parser-babel'),
230+
],
231+
});
232+
} catch (err) {
233+
console.warn('Could not prettify', { string: str }, err);
234+
}
235+
}
236+
if (options.plugins) {
237+
str = runPostCodePlugins(str, options.plugins);
238+
}
239+
return str;
240+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './generate'
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { curry } from 'lodash';
2+
import { MitosisComponent } from '../../types/mitosis-component';
3+
import { hasWatchHooks, renderWatchHooks } from './render-update-hooks';
4+
5+
function shouldRenderMountHook(json: MitosisComponent): boolean {
6+
return json.hooks.onMount !== undefined
7+
|| hasWatchHooks(json)
8+
}
9+
10+
export const renderMountHook = curry((json: MitosisComponent, objectString: string) => {
11+
return shouldRenderMountHook(json)
12+
? objectString.replace(/(?:,)?(\s*)(}\s*)$/, `, init() {
13+
${renderWatchHooks(json)}
14+
${json.hooks.onMount?.code ?? ''}
15+
}$1$2`)
16+
: objectString;
17+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { curry } from "lodash";
2+
import { extendedHook, MitosisComponent } from "../../types/mitosis-component";
3+
4+
const extractCode = (hook: extendedHook) => hook.code;
5+
function renderRootUpdateHook(hooks: extendedHook[], output: string) {
6+
if (hooks.length === 0) {
7+
return output
8+
}
9+
const str = `onUpdate() {
10+
${hooks.map(extractCode).join('\n')}
11+
}`;
12+
13+
return output.replace(/,?(\s*})$/, `,\n${str}$1`);
14+
}
15+
16+
function getRootUpdateHooks(json: MitosisComponent) {
17+
return (json.hooks.onUpdate ?? []).filter(hook => hook.deps == '')
18+
}
19+
20+
export function hasRootUpdateHook(json: MitosisComponent): boolean {
21+
return getRootUpdateHooks(json).length > 0
22+
}
23+
24+
export const renderUpdateHooks = curry((json: MitosisComponent, output: string) => {
25+
return renderRootUpdateHook(getRootUpdateHooks(json), output);
26+
});
27+
28+
function getWatchHooks(json: MitosisComponent) {
29+
return (json.hooks.onUpdate ?? []).filter(hook => hook.deps?.match(/state|this/))
30+
}
31+
32+
export const hasWatchHooks = (json: MitosisComponent): boolean => {
33+
return getWatchHooks(json).length > 0
34+
}
35+
36+
function renderWatchHook(hook: extendedHook): string {
37+
const deps = (hook.deps ?? '')?.slice(1).slice(0, -1).split(', ')
38+
.filter(dep => dep.match(/state|this/));
39+
40+
41+
return deps.map(dep => `this.$watch('${dep.replace(/(state|this)\./, '')}', (value, oldValue) => { ${hook.code} });`).join('\n')
42+
}
43+
44+
export const renderWatchHooks = (json: MitosisComponent): string => {
45+
return hasWatchHooks(json)
46+
? getWatchHooks(json).map(renderWatchHook).join('\n')
47+
: ''
48+
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export * from './parsers/builder';
5151
export * from './parsers/angular';
5252
export * from './parsers/context';
5353
export * from './generators/vue';
54+
export * from './generators/alpine';
5455
export * from './generators/angular';
5556
export * from './generators/context/react';
5657
export * from './generators/context/qwik';

packages/core/src/targets.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
componentToCustomElement as webcomponent,
66
componentToHtml as html,
77
} from './generators/html';
8+
import { componentToAlpine as alpine } from './generators/alpine';
89
import { componentToMitosis as mitosis } from './generators/mitosis';
910
import { componentToLiquid as liquid } from './generators/liquid';
1011
import { componentToReact as react } from './generators/react';
@@ -24,6 +25,7 @@ import { componentToRsc as rsc } from './generators/rsc';
2425
export const builder = componentToBuilder;
2526

2627
export const targets = {
28+
alpine,
2729
angular,
2830
customElement,
2931
html,

0 commit comments

Comments
 (0)