|
| 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 | + }; |
0 commit comments