Skip to content

Commit

Permalink
Merge branch 'feature/alpinejs' of github.com:sbrow/mitosis into feat…
Browse files Browse the repository at this point in the history
…ure/multiple-outputs
  • Loading branch information
sbrow committed Nov 4, 2022
2 parents a7ab359 + a52f388 commit 853ab86
Show file tree
Hide file tree
Showing 11 changed files with 436 additions and 3 deletions.
3 changes: 3 additions & 0 deletions packages/cli/src/build/build.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
componentToAlpine,
componentToAngular,
componentToCustomElement,
componentToHtml,
Expand Down Expand Up @@ -205,6 +206,8 @@ export async function build(config?: MitosisConfig) {

const getGeneratorForTarget = ({ target }: { target: Target }): TargetContext['generator'] => {
switch (target) {
case 'alpine':
return componentToAlpine;
case 'customElement':
return componentToCustomElement;
case 'html':
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/__tests__/__snapshots__/alpine.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Alpine.js Remove Internal mitosis package 1`] = `
"<div x-data=\\"myBasicComponent()\\">
Hello
<span x-html=\\"name\\"></span>
! I can run in React, Qwik, Vue, Solid, or Liquid!
</div>
<script>
document.addEventListener(\\"alpine:init\\", () => {
Alpine.data(\\"myBasicComponent\\", () => ({ name: \\"PatrickJS\\" }));
});
</script>
"
`;
12 changes: 12 additions & 0 deletions packages/core/src/__tests__/alpine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { componentToAlpine, ToAlpineOptions } from '../generators/alpine';
import { runTestsForTarget } from './shared';

describe('Alpine.js', () => {
const possibleOptions: ToAlpineOptions[] = [
{},
// { inlineState: true },
// { useShorthandSyntax: true },
// { inlineState: true, useShorthandSyntax: true },
]
possibleOptions.map(options => runTestsForTarget({ options, target: 'alpine', generator: componentToAlpine }));
});
240 changes: 240 additions & 0 deletions packages/core/src/generators/alpine/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { format } from 'prettier/standalone';
import { collectCss } from '../../helpers/styles/collect-css';
import { fastClone } from '../../helpers/fast-clone';
import { stripStateAndPropsRefs } from '../../helpers/strip-state-and-props-refs';
import { selfClosingTags } from '../../parsers/jsx';
import { checkIsForNode, ForNode, MitosisNode } from '../../types/mitosis-node';
import {
runPostCodePlugins,
runPostJsonPlugins,
runPreCodePlugins,
runPreJsonPlugins,
} from '../../modules/plugins';
import { stripMetaProperties } from '../../helpers/strip-meta-properties';
import { getStateObjectStringFromComponent } from '../../helpers/get-state-object-string';
import { BaseTranspilerOptions, TranspilerGenerator } from '../../types/transpiler';
import { dashCase } from '../../helpers/dash-case';
import { removeSurroundingBlock } from '../../helpers/remove-surrounding-block';
import { camelCase, curry, flow, flowRight as compose } from 'lodash';
import { getRefs } from '../../helpers/get-refs';
import { MitosisComponent } from '../../types/mitosis-component';
import { hasRootUpdateHook, renderUpdateHooks } from './render-update-hooks';
import { renderMountHook } from './render-mount-hook';

export interface ToAlpineOptions extends BaseTranspilerOptions {
/**
* use @on and : instead of `x-on` and `x-bind`
*/
useShorthandSyntax?: boolean,
/**
* If true, the javascript won't be extracted into a separate script block.
*/
inlineState?: boolean,
}

export const checkIsComponentNode = (node: MitosisNode): boolean => node.name === '@builder.io/mitosis/component';

/**
* Test if the binding expression would be likely to generate
* valid or invalid liquid. If we generate invalid liquid tags
* Shopify will reject our PUT to update the template
*/
export const isValidAlpineBinding = (str = '') => {
return true;
/*
const strictMatches = Boolean(
// Test for our `context.shopify.liquid.*(expression), which
// we regex out later to transform back into valid liquid expressions
str.match(/(context|ctx)\s*(\.shopify\s*)?\.liquid\s*\./),
);
return (
strictMatches ||
// Test is the expression is simple and would map to Shopify bindings // Test for our `context.shopify.liquid.*(expression), which
// e.g. `state.product.price` -> `{{product.price}} // we regex out later to transform back into valid liquid expressions
Boolean(str.match(/^[a-z0-9_\.\s]+$/i))
);
*/
};

const removeOnFromEventName = (str: string) => str.replace(/^on/, '')
const prefixEvent = (str: string) => str.replace(/(?<=[\s]|^)event/gm, '$event')
const removeTrailingSemicolon = (str: string) => str.replace(/;$/, '')
const trim = (str: string) => str.trim();

const replaceInputRefs = curry((json: MitosisComponent, str: string) => {
getRefs(json).forEach(value => {
str = str.replaceAll(value, `this.$refs.${value}`);
});

return str;
});
const replaceStateWithThis = (str: string) => str.replaceAll('state.', 'this.');
const getStateObjectString = (json: MitosisComponent) => flow(
getStateObjectStringFromComponent,
trim,
replaceInputRefs(json),
renderMountHook(json),
renderUpdateHooks(json),
replaceStateWithThis,
)(json);

const bindEventHandlerKey = compose(
dashCase,
removeOnFromEventName
);
const bindEventHandlerValue = compose(
prefixEvent,
removeTrailingSemicolon,
trim,
removeSurroundingBlock,
stripStateAndPropsRefs
);

const bindEventHandler = ({ useShorthandSyntax }: ToAlpineOptions) => (eventName: string, code: string) => {
const bind = useShorthandSyntax ? '@' : 'x-on:'
return ` ${bind}${bindEventHandlerKey(eventName)}="${bindEventHandlerValue(code).trim()}"`;
};

const mappers: {
[key: string]: (json: MitosisNode, options: ToAlpineOptions) => string;
} = {
For: (json, options) => (
!(checkIsForNode(json) && isValidAlpineBinding(json.bindings.each?.code) && isValidAlpineBinding(json.scope.forName))
? ''
: `<template x-for="${json.scope.forName} in ${stripStateAndPropsRefs(json.bindings.each?.code)}">
${(json.children ?? []).map((item) => blockToAlpine(item, options)).join('\n')}
</template>`
),
Fragment: (json, options) => blockToAlpine({ ...json, name: "div" }, options),
Show: (json, options) => (
!isValidAlpineBinding(json.bindings.when?.code)
? ''
: `<template x-if="${stripStateAndPropsRefs(json.bindings.when?.code)}">
${(json.children ?? []).map((item) => blockToAlpine(item, options)).join('\n')}
</template>`
)
};

// TODO: spread support
const blockToAlpine = (json: MitosisNode|ForNode, options: ToAlpineOptions = {}): string => {
if (mappers[json.name]) {
return mappers[json.name](json, options);
}

// TODO: Add support for `{props.children}` bindings

if (json.properties._text) {
return json.properties._text;
}

if (json.bindings._text?.code) {
return isValidAlpineBinding(json.bindings._text.code)
? `<span x-html="${stripStateAndPropsRefs(json.bindings._text.code as string)}"></span>`
: '';
}

let str = `<${json.name} `;

/*
// Copied from the liquid generator. Not sure what it does.
if (
json.bindings._spread?.code === '_spread' &&
isValidAlpineBinding(json.bindings._spread.code)
) {
str += `
<template x-for="_attr in ${json.bindings._spread.code}">
{{ _attr[0] }}="{{ _attr[1] }}"
</template>
`;
}
*/

for (const key in json.properties) {
const value = json.properties[key];
str += ` ${key}="${value}" `;
}

for (const key in json.bindings) {
if (key === '_spread' || key === 'css') {
continue;
}
const { code: value, type: bindingType } = json.bindings[key]!;
// TODO: proper babel transform to replace. Util for this
const useValue = stripStateAndPropsRefs(value);

if (key.startsWith('on')) {
str += bindEventHandler(options)(key, value);
} else if (key === 'ref') {
str += ` x-ref="${useValue}"`;
} else if (isValidAlpineBinding(useValue)) {
const bind = options.useShorthandSyntax && bindingType !== 'spread' ? ':' : 'x-bind:'
str += ` ${bind}${bindingType === 'spread' ? '' : key}="${useValue}" `.replace(':=', '=');
}
}
return selfClosingTags.has(json.name)
? `${str} />`
: `${str}>${(json.children ?? []).map((item) => blockToAlpine(item, options)).join('\n')}</${json.name}>`;
};


export const componentToAlpine: TranspilerGenerator<ToAlpineOptions> =
(options = {}) =>
({ component }) => {
let json = fastClone(component);
if (options.plugins) {
json = runPreJsonPlugins(json, options.plugins);
}
const css = collectCss(json);
stripMetaProperties(json);
if (options.plugins) {
json = runPostJsonPlugins(json, options.plugins);
}

const stateObjectString = getStateObjectString(json);
// Set x-data on root element
json.children[0].properties['x-data'] = options.inlineState
? stateObjectString
: `${camelCase(json.name)}()`;

if (hasRootUpdateHook(json)) {
json.children[0].properties['x-effect'] = 'onUpdate'
}

let str = css.trim().length
? `<style>${css}</style>`
: '';
str += json.children.map((item) => blockToAlpine(item, options)).join('\n');

if (!options.inlineState) {
str += `<script>
document.addEventListener('alpine:init', () => {
Alpine.data('${camelCase(json.name)}', () => (${stateObjectString}))
})
</script>`
}

if (options.plugins) {
str = runPreCodePlugins(str, options.plugins);
}
if (options.prettier !== false) {
try {
str = format(str, {
parser: 'html',
htmlWhitespaceSensitivity: 'ignore',
plugins: [
// To support running in browsers
require('prettier/parser-html'),
require('prettier/parser-postcss'),
require('prettier/parser-babel'),
],
});
} catch (err) {
console.warn('Could not prettify', { string: str }, err);
}
}
if (options.plugins) {
str = runPostCodePlugins(str, options.plugins);
}
return str;
};
1 change: 1 addition & 0 deletions packages/core/src/generators/alpine/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './generate'
17 changes: 17 additions & 0 deletions packages/core/src/generators/alpine/render-mount-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { curry } from 'lodash';
import { MitosisComponent } from '../../types/mitosis-component';
import { hasWatchHooks, renderWatchHooks } from './render-update-hooks';

function shouldRenderMountHook(json: MitosisComponent): boolean {
return json.hooks.onMount !== undefined
|| hasWatchHooks(json)
}

export const renderMountHook = curry((json: MitosisComponent, objectString: string) => {
return shouldRenderMountHook(json)
? objectString.replace(/(?:,)?(\s*)(}\s*)$/, `, init() {
${renderWatchHooks(json)}
${json.hooks.onMount?.code ?? ''}
}$1$2`)
: objectString;
});
48 changes: 48 additions & 0 deletions packages/core/src/generators/alpine/render-update-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { curry } from "lodash";
import { extendedHook, MitosisComponent } from "../../types/mitosis-component";

const extractCode = (hook: extendedHook) => hook.code;
function renderRootUpdateHook(hooks: extendedHook[], output: string) {
if (hooks.length === 0) {
return output
}
const str = `onUpdate() {
${hooks.map(extractCode).join('\n')}
}`;

return output.replace(/,?(\s*})$/, `,\n${str}$1`);
}

function getRootUpdateHooks(json: MitosisComponent) {
return (json.hooks.onUpdate ?? []).filter(hook => hook.deps == '')
}

export function hasRootUpdateHook(json: MitosisComponent): boolean {
return getRootUpdateHooks(json).length > 0
}

export const renderUpdateHooks = curry((json: MitosisComponent, output: string) => {
return renderRootUpdateHook(getRootUpdateHooks(json), output);
});

function getWatchHooks(json: MitosisComponent) {
return (json.hooks.onUpdate ?? []).filter(hook => hook.deps?.match(/state|this/))
}

export const hasWatchHooks = (json: MitosisComponent): boolean => {
return getWatchHooks(json).length > 0
}

function renderWatchHook(hook: extendedHook): string {
const deps = (hook.deps ?? '')?.slice(1).slice(0, -1).split(', ')
.filter(dep => dep.match(/state|this/));


return deps.map(dep => `this.$watch('${dep.replace(/(state|this)\./, '')}', (value, oldValue) => { ${hook.code} });`).join('\n')
}

export const renderWatchHooks = (json: MitosisComponent): string => {
return hasWatchHooks(json)
? getWatchHooks(json).map(renderWatchHook).join('\n')
: ''
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export * from './parsers/builder';
export * from './parsers/angular';
export * from './parsers/context';
export * from './generators/vue';
export * from './generators/alpine';
export * from './generators/angular';
export * from './generators/context/react';
export * from './generators/context/qwik';
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
componentToCustomElement as webcomponent,
componentToHtml as html,
} from './generators/html';
import { componentToAlpine as alpine } from './generators/alpine';
import { componentToMitosis as mitosis } from './generators/mitosis';
import { componentToLiquid as liquid } from './generators/liquid';
import { componentToReact as react } from './generators/react';
Expand All @@ -24,6 +25,7 @@ import { componentToRsc as rsc } from './generators/rsc';
export const builder = componentToBuilder;

export const targets = {
alpine,
angular,
customElement,
html,
Expand Down
Loading

0 comments on commit 853ab86

Please sign in to comment.