From bc8517ade7b111cde62de6d763a9630e990e1ff9 Mon Sep 17 00:00:00 2001 From: jeromew Date: Wed, 18 Feb 2026 10:57:25 +0000 Subject: [PATCH] @strudel/transpiler plugin system --- packages/codemirror/codemirror.mjs | 2 +- packages/transpiler/README.md | 25 +- packages/transpiler/helpers.mjs | 159 +++++++ packages/transpiler/index.mjs | 9 +- packages/transpiler/plugin-kabelsalat.mjs | 98 ++++ packages/transpiler/plugin-mini.mjs | 200 ++++++++ packages/transpiler/plugin-sample.mjs | 21 + packages/transpiler/plugin-widgets.mjs | 123 +++++ packages/transpiler/transpiler.mjs | 549 ++-------------------- 9 files changed, 680 insertions(+), 506 deletions(-) create mode 100644 packages/transpiler/helpers.mjs create mode 100644 packages/transpiler/plugin-kabelsalat.mjs create mode 100644 packages/transpiler/plugin-mini.mjs create mode 100644 packages/transpiler/plugin-sample.mjs create mode 100644 packages/transpiler/plugin-widgets.mjs diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 072752420..6aac0c8a4 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -230,7 +230,7 @@ export class StrudelMirror { afterEval: (options) => { // remember for when highlighting is toggled on this.miniLocations = options.meta?.miniLocations || []; - this.widgets = options.meta?.widgets; + this.widgets = options.meta?.widgets || []; const sliders = this.widgets.filter((w) => w.type === 'slider'); const widgets = this.widgets.filter((w) => w.type !== 'slider'); diff --git a/packages/transpiler/README.md b/packages/transpiler/README.md index 9914174a7..a765d1b59 100644 --- a/packages/transpiler/README.md +++ b/packages/transpiler/README.md @@ -1,10 +1,31 @@ # @strudel/transpiler -This package contains a JS code transpiler with the following features: +This package contains a JS code transpiler with a plugin system that can be used to transform the code. -- add locations of mini notation strings (double quoted or backticked) for highlighting +A plugin can be registered via a `registerTranspilerPlugin` call : + +```js +registerTranspilerPlugin({ + walk: (context) => ({ + enter: function(node, parent, prop, index) {}, + leave: function(node, parent, prop, index) {} + }) +}) +``` + +where the enter/leave functions (both optional) follow the `estree-walker` walk API. + +4 plugins are currently hosted inside `@strudel/transpiler` + +- mini: add locations of mini notation strings (double quoted or backticked) for highlighting +- widgets: add handling of sliders & draw widgets +- sample: make it possible to call a sample without await +- kabelsalat: transform the code to handle the `K(..)` kabelsalat notation + +it also - converts pseudo note variables to note strings - adds return statement to the last expression +- handle label capturing for block-based eval ## Install diff --git a/packages/transpiler/helpers.mjs b/packages/transpiler/helpers.mjs new file mode 100644 index 000000000..7346a9afd --- /dev/null +++ b/packages/transpiler/helpers.mjs @@ -0,0 +1,159 @@ +/* +helpers.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +import escodegen from 'escodegen'; +import { walk } from 'estree-walker'; + +let languages = new Map(); +// config = { getLocations: (code: string, offset?: number) => number[][] } +// see mondough.mjs for example use +// the language will kick in when the code contains a template literal of type +// example: mondo`...` will use language of type "mondo" +// TODO: refactor tidal.mjs to use this +export function registerLanguage(type, config) { + languages.set(type, config); +} +export function getLanguages() { + return languages; +} + +export function withAwait(node) { + return { + type: 'AwaitExpression', + argument: node, + }; +} + +export function genExprSource(expr) { + return escodegen.generate(expr, { format: { semicolons: false } }); +} + +export function extractPatternPlaceholders(expr) { + const templateExpr = cloneNode(expr); + const parents = new Map(); + const targets = []; + + walk(templateExpr, { + enter(node, parent, prop, index) { + parents.set(node, { parent, prop, index }); + const patternExpr = getStrudelPatternExpr(node); + if (patternExpr) { + targets.push({ node, patternExpr }); + this.skip(); + } + }, + }); + + if (!targets.length) { + return { template: genExprSource(templateExpr), patternExprs: [] }; + } + + targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node)); + + const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr)); + + let currentExpr = templateExpr; + targets.forEach(({ node }, index) => { + currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr); + }); + + const template = genExprSource(currentExpr); + return { template, patternExprs }; +} + +function getStrudelPatternExpr(node) { + if (isStrudelPatternWrap(node)) { + const arg = node.arguments?.[0]; + if (!arg) { + throw new Error('S(...) requires an argument'); + } + return arg; + } + if (isMiniCall(node)) { + return node; + } + return null; +} + +function isStrudelPatternWrap(node) { + if (node.type !== 'CallExpression') { + return false; + } + const callee = node.callee; + if (callee.type === 'Identifier') { + return callee.name === 'S'; + } + if (callee.type === 'MemberExpression' && !callee.computed) { + return callee.property?.name === 'S'; + } + return false; +} + +// If `start` is available, we use it. If it's already been transpiled +// to `m(...)`, use the provided offset +function getPatternNodeOrder(node) { + if (typeof node.start === 'number') { + return node.start; + } + if (isMiniCall(node)) { + const offsetArg = node.arguments?.[1]; + if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') { + return offsetArg.value; + } + } + return 0; +} + +function placeholderAst(index) { + return { + type: 'MemberExpression', + object: { type: 'Identifier', name: 'pat' }, + property: { type: 'Literal', value: index }, + computed: true, + optional: false, + }; +} + +// Used to identify transpiled `m(...)` calls for proper conversion +// to, say, kabelsalat placeholders +function isMiniCall(node) { + if (node.type !== 'CallExpression') { + return false; + } + const callee = node.callee; + if (callee.type !== 'Identifier') { + return false; + } + if (callee.name !== getMinilangName()) { + return false; + } + const firstArg = node.arguments?.[0]; + return firstArg?.type === 'Literal' && typeof firstArg.value === 'string'; +} + +function getMinilangName() { + const minilang = getLanguages().get('minilang'); + return minilang?.name || 'm'; +} + +function replaceNode(node, replacement, parents, currentRoot) { + const info = parents.get(node); + if (!info || !info.parent) { + return replacement; + } + + const { parent, prop, index } = info; + if (Array.isArray(parent[prop])) { + parent[prop][index] = replacement; + } else { + parent[prop] = replacement; + } + parents.set(replacement, { parent, prop, index }); + return currentRoot; +} + +function cloneNode(node) { + return JSON.parse(JSON.stringify(node)); +} diff --git a/packages/transpiler/index.mjs b/packages/transpiler/index.mjs index e8f714dd9..eba72f7c3 100644 --- a/packages/transpiler/index.mjs +++ b/packages/transpiler/index.mjs @@ -1,5 +1,12 @@ +/* +index.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ import { evaluate as _evaluate } from '@strudel/core'; import { transpiler } from './transpiler.mjs'; -export * from './transpiler.mjs'; +export * from './transpiler.mjs'; +export * from './helpers.mjs'; +export { registerWidgetType, getWidgetID } from './plugin-widgets.mjs'; export const evaluate = (code, transpilerOptions) => _evaluate(code, transpiler, transpilerOptions); diff --git a/packages/transpiler/plugin-kabelsalat.mjs b/packages/transpiler/plugin-kabelsalat.mjs new file mode 100644 index 000000000..cee96f973 --- /dev/null +++ b/packages/transpiler/plugin-kabelsalat.mjs @@ -0,0 +1,98 @@ +/* +plugin-kabelsalat.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +import { genExprSource, extractPatternPlaceholders } from './helpers.mjs'; + +export const transpilerPlugin = { + walk: (context) => ({ + leave: function (node, parent, prop, index) { + if (!isKabelCall(node)) return; + let [expr, ...rest] = node.arguments; + if (!expr) throw new Error('K(...) requires an expression'); + if (shouldCallKabelExpression(expr)) { + expr = { + type: 'CallExpression', + callee: expr, + arguments: [], + optional: false, + }; + } + const language = 'kabelsalat'; + const { template, patternExprs } = extractPatternPlaceholders(expr); + if (patternExprs.length) { + const workletArgs = [ + /*{ type: 'Literal', value: language },*/ + { type: 'Literal', value: template }, + ...patternExprs, + ...rest, + ]; + let callee = node.callee; + if (callee.type === 'ChainExpression') callee = callee.expression; + if (callee.type === 'MemberExpression') { + return this.replace({ + type: 'CallExpression', + callee: workletMemberAst(callee.object), + arguments: workletArgs, + optional: false, + }); + } + return this.replace({ + type: 'CallExpression', + callee: { type: 'Identifier', name: 'worklet' }, + arguments: workletArgs, + optional: false, + }); + } + + const kabelSrc = genExprSource(expr); + const workletArgs = [/*{ type: 'Literal', value: language },*/ { type: 'Literal', value: kabelSrc }, ...rest]; + + let callee = node.callee; + if (callee.type === 'ChainExpression') callee = callee.expression; + if (callee.type === 'MemberExpression') { + return this.replace({ + type: 'CallExpression', + callee: workletMemberAst(callee.object), + arguments: workletArgs, + optional: false, + }); + } + return this.replace({ + type: 'CallExpression', + callee: { type: 'Identifier', name: 'worklet' }, + arguments: workletArgs, + optional: false, + }); + }, + }), +}; + +function isKabelCall(node) { + if (node.type !== 'CallExpression') return false; + let callee = node.callee; + if (callee.type === 'ChainExpression') callee = callee.expression; + if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K'; + return callee.type === 'Identifier' && callee.name === 'K'; +} + +function shouldCallKabelExpression(expr) { + if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') { + return false; + } + if (expr.params.length) { + return false; + } + return expr.body?.type === 'BlockStatement'; +} + +function workletMemberAst(objectExpr) { + return { + type: 'MemberExpression', + object: objectExpr, + property: { type: 'Identifier', name: 'worklet' }, + computed: false, + optional: false, + }; +} diff --git a/packages/transpiler/plugin-mini.mjs b/packages/transpiler/plugin-mini.mjs new file mode 100644 index 000000000..ae17d9fed --- /dev/null +++ b/packages/transpiler/plugin-mini.mjs @@ -0,0 +1,200 @@ +/* +plugin-mini.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +import { getLeafLocations } from '@strudel/mini'; +import { getLanguages } from './helpers.mjs'; + +const languageLiteral = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isLanguageLiteral(node)) return; + context.miniLocations ??= []; + const { options, miniLocations } = context; + const { emitMiniLocations } = options; + const { name } = node.tag; + const language = getLanguages().get(name); + const code = node.quasi.quasis[0].value.raw; + const offset = node.quasi.start + 1; + if (emitMiniLocations) { + const locs = language.getLocations(code, offset); + miniLocations.push(...locs); + } + this.skip(); + return this.replace(languageWithLocation(name, code, offset)); + }, + }), +}; + +const tidal = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isTemplateLiteral(node, 'tidal')) return; + context.miniLocations ??= []; + const { options, miniLocations } = context; + const { emitMiniLocations } = options; + const raw = node.quasi.quasis[0].value.raw; + const offset = node.quasi.start + 1; + if (emitMiniLocations) { + const stringLocs = collectHaskellMiniLocations(raw, offset); + miniLocations.push(...stringLocs); + } + this.skip(); + return this.replace(tidalWithLocation(raw, offset)); + }, + }), +}; + +const backtick = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isBackTickString(node, parent)) return; + context.miniLocations ??= []; + const { options, input, miniDisableRanges, miniLocations } = context; + const { emitMiniLocations } = options; + if (isMiniDisabled(node.start, miniDisableRanges)) { + return; + } + const { quasis } = node; + const { raw } = quasis[0].value; + this.skip(); + emitMiniLocations && collectMiniLocations(raw, node, miniLocations, input); + return this.replace(miniWithLocation(raw, node)); + }, + }), +}; + +const doublequotes = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isStringWithDoubleQuotes(node)) return; + context.miniLocations ??= []; + const { options, input, miniDisableRanges, miniLocations } = context; + const { emitMiniLocations } = options; + if (isMiniDisabled(node.start, miniDisableRanges)) { + return; + } + const { value } = node; + this.skip(); + emitMiniLocations && collectMiniLocations(value, node, miniLocations, input); + return this.replace(miniWithLocation(value, node)); + }, + }), +}; + +function isLanguageLiteral(node) { + return node.type === 'TaggedTemplateExpression' && getLanguages().has(node.tag.name); +} + +function languageWithLocation(name, value, offset) { + return { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: name, + }, + arguments: [ + { type: 'Literal', value }, + { type: 'Literal', value: offset }, + ], + optional: false, + }; +} + +function collectHaskellMiniLocations(haskellCode, offset) { + return haskellCode + .split('') + .reduce((acc, char, i) => { + if (char !== '"') { + return acc; + } + if (!acc.length || acc[acc.length - 1].length > 1) { + acc.push([i + 1]); + } else { + acc[acc.length - 1].push(i); + } + return acc; + }, []) + .map(([start, end]) => { + const miniString = haskellCode.slice(start, end); + return getLeafLocations(`"${miniString}"`, offset + start - 1); + }) + .flat(); +} + +function isTemplateLiteral(node, value) { + return node.type === 'TaggedTemplateExpression' && node.tag.name === value; +} + +function tidalWithLocation(value, offset) { + return { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'tidal', + }, + arguments: [ + { type: 'Literal', value }, + { type: 'Literal', value: offset }, + ], + optional: false, + }; +} + +function isBackTickString(node, parent) { + return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression'; +} + +function isMiniDisabled(offset, miniDisableRanges) { + for (const [start, end] of miniDisableRanges) { + if (offset >= start && offset < end) { + return true; + } + } + return false; +} + +function miniWithLocation(value, node) { + const { start: fromOffset } = node; + + const minilang = getLanguages().get('minilang'); + let name = 'm'; + if (minilang && minilang.name) { + name = minilang.name; // name is expected to be exported from the package of the minilang + } + + return { + type: 'CallExpression', + callee: { + type: 'Identifier', + name, + }, + arguments: [ + { type: 'Literal', value }, + { type: 'Literal', value: fromOffset }, + ], + optional: false, + }; +} + +const collectMiniLocations = (value, node, miniLocations, input) => { + const minilang = getLanguages().get('minilang'); + if (minilang) { + const code = `[${value}]`; + const locs = minilang.getLocations(code, node.start); + miniLocations.push(...locs); + } else { + const leafLocs = getLeafLocations(`"${value}"`, node.start, input); + miniLocations.push(...leafLocs); + } +}; + +function isStringWithDoubleQuotes(node, locations, code) { + if (node.type !== 'Literal') { + return false; + } + return node.raw[0] === '"'; +} + +export const miniTranspilerPlugins = [languageLiteral, tidal, backtick, doublequotes]; diff --git a/packages/transpiler/plugin-sample.mjs b/packages/transpiler/plugin-sample.mjs new file mode 100644 index 000000000..388b98eae --- /dev/null +++ b/packages/transpiler/plugin-sample.mjs @@ -0,0 +1,21 @@ +/* +plugin-sample.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +import { withAwait } from './helpers.mjs'; + +const bareSample = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isBareSamplesCall(node, parent)) return; + this.replace(withAwait(node)); + }, + }), +}; + +function isBareSamplesCall(node, parent) { + return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression'; +} + +export const sampleTranspilerPlugins = bareSample; diff --git a/packages/transpiler/plugin-widgets.mjs b/packages/transpiler/plugin-widgets.mjs new file mode 100644 index 000000000..db6379ddd --- /dev/null +++ b/packages/transpiler/plugin-widgets.mjs @@ -0,0 +1,123 @@ +/* +plugin-widgets.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +let widgetMethods = []; +export function registerWidgetType(type) { + widgetMethods.push(type); +} + +const widgetTranspilerPlugin = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isWidgetMethod(node)) return; + context.widgets ??= []; + const { widgets, options } = context; + const { emitWidgets } = options; + const type = node.callee.property.name; + const idx = widgets.filter((w) => w.type === type).length; + const widgetConfig = { + from: node.start, + to: node.end, + index: idx, + type, + id: options.id, + }; + emitWidgets && widgets.push(widgetConfig); + return this.replace(widgetWithLocation(node, widgetConfig)); + }, + }), +}; + +const sliderTranspilerPlugin = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isSliderFunction(node)) return; + context.widgets ??= []; + context.sliders ??= []; + const { options, widgets, sliders, nodeOffset } = context; + const { emitWidgets } = options; + const from = node.arguments[0].start + nodeOffset; + const to = node.arguments[0].end + nodeOffset; + const id = `${from}:${to}`; // Range-based ID for stability + const sliderConfig = { + from, + to, + id, + value: node.arguments[0].raw, // don't use value! + min: node.arguments[1]?.value ?? 0, + max: node.arguments[2]?.value ?? 1, + step: node.arguments[3]?.value, + type: 'slider', + }; + emitWidgets && widgets.push(sliderConfig); + sliders.push(sliderConfig); + return this.replace(sliderWithLocation(node, nodeOffset)); + }, + }), +}; + +export const widgetTranspilerPlugins = [sliderTranspilerPlugin, widgetTranspilerPlugin]; + +// these functions are connected to @strudel/codemirror -> slider.mjs +// maybe someday there will be pluggable transpiler functions, then move this there +function isSliderFunction(node) { + return node.type === 'CallExpression' && node.callee.name === 'slider'; +} + +function isWidgetMethod(node) { + return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name); +} + +function sliderWithLocation(node, nodeOffset = 0) { + // Apply nodeOffset for block-based evaluation to generate correct range + const from = node.arguments[0].start + nodeOffset; + const to = node.arguments[0].end + nodeOffset; + + // Use range-based ID for stability during block evaluation + const id = `${from}:${to}`; + + // add loc as identifier to first argument + // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) + node.arguments.unshift({ + type: 'Literal', + value: id, + raw: id, + }); + node.callee.name = 'sliderWithID'; + return node; +} + +export function getWidgetID(widgetConfig) { + // the widget id is used as id for the dom element + as key for eventual resources + // for example, for each scope widget, a new analyser + buffer (large) is created + // Update: use range-based ID generation for better stability during block evaluation + // When we have both from and to, use them together for stability + // Otherwise fall back to position-based ID for backward compatibility + let uniqueIdentifier; + if (widgetConfig.from !== undefined && widgetConfig.to !== undefined) { + // Use range for more stable identification + uniqueIdentifier = `${widgetConfig.from}-${widgetConfig.to}`; + } else { + // Fallback to single position (for backward compatibility) + uniqueIdentifier = widgetConfig.to || widgetConfig.from || 0; + } + const baseId = `${widgetConfig.id || ''}_widget_${widgetConfig.type}`; + return `${baseId}_${widgetConfig.index}_${uniqueIdentifier}`; +} + +function widgetWithLocation(node, widgetConfig) { + const id = getWidgetID(widgetConfig); + // Store the unique ID back into the config so it's available for widget management + // This is critical for block-based evaluation to match existing widgets with new ones + widgetConfig.id = id; + // add loc as identifier to first argument + // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) + node.arguments.unshift({ + type: 'Literal', + value: id, + raw: id, + }); + return node; +} diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index 1f783794f..b471051d1 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -1,32 +1,44 @@ +/* +transpiler.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ import { getLeafLocations } from '@strudel/mini'; import { parse } from 'acorn'; import escodegen from 'escodegen'; import { walk } from 'estree-walker'; +import { getLanguages } from './helpers.mjs'; +import { miniTranspilerPlugins } from './plugin-mini.mjs'; +import { widgetTranspilerPlugins } from './plugin-widgets.mjs'; +import { sampleTranspilerPlugins } from './plugin-sample.mjs'; +import { transpilerPlugin as kabelsalatTranspilerPlugins } from './plugin-kabelsalat.mjs'; -let widgetMethods = []; -export function registerWidgetType(type) { - widgetMethods.push(type); +const plugins = []; + +export function registerTranspilerPlugin(plugin) { + plugins.push(plugin); +} +export function getPlugins() { + return plugins.flat(Infinity); } -let languages = new Map(); -// config = { getLocations: (code: string, offset?: number) => number[][] } -// see mondough.mjs for example use -// the language will kick in when the code contains a template literal of type -// example: mondo`...` will use language of type "mondo" -// TODO: refactor tidal.mjs to use this -export function registerLanguage(type, config) { - languages.set(type, config); -} +registerTranspilerPlugin(miniTranspilerPlugins); +registerTranspilerPlugin(widgetTranspilerPlugins); +registerTranspilerPlugin(sampleTranspilerPlugins); +registerTranspilerPlugin(kabelsalatTranspilerPlugins); export function transpiler(input, options = {}) { - const { - wrapAsync = false, - addReturn = true, - emitMiniLocations = true, - emitWidgets = true, - blockBased = false, - range = [], - } = options; + options = { + wrapAsync: false, + addReturn: true, + emitMiniLocations: true, + emitWidgets: true, + blockBased: false, + range: [], + ...options, + }; + + const { wrapAsync, addReturn, emitMiniLocations, emitWidgets, blockBased, range } = options; const comments = []; let ast = parse(input, { @@ -36,8 +48,7 @@ export function transpiler(input, options = {}) { onComment: comments, }); - const miniDisableRanges = findMiniDisableRanges(comments, input.length); - let miniLocations = []; + let miniDisableRanges = findMiniDisableRanges(comments, input.length); // Position offset for block-based evaluation let nodeOffset = range && range.length > 0 ? range[0] : 0; @@ -45,23 +56,13 @@ export function transpiler(input, options = {}) { // Track declarations to add to strudelScope for block-based eval let scopeDeclarations = []; - const collectMiniLocations = (value, node) => { - const minilang = languages.get('minilang'); - if (minilang) { - const code = `[${value}]`; - const locs = minilang.getLocations(code, node.start); - miniLocations = miniLocations.concat(locs); - } else { - const leafLocs = getLeafLocations(`"${value}"`, node.start, input); - miniLocations = miniLocations.concat(leafLocs); - } - }; - let widgets = []; - let sliders = []; let labels = []; + const context = { options, input, nodeOffset, miniDisableRanges, labels }; + const plugins = getPlugins().map((plugin) => plugin.walk?.(context)); + walk(ast, { - enter(node, parent /* , prop, index */) { + enter(node, parent, prop, index) { // Apply position offset for block-based evaluation if (blockBased && node.start !== undefined) { node.start = node.start + nodeOffset; @@ -79,82 +80,12 @@ export function transpiler(input, options = {}) { scopeDeclarations.push(node.id.name); } } - if (isLanguageLiteral(node)) { - const { name } = node.tag; - const language = languages.get(name); - const code = node.quasi.quasis[0].value.raw; - const offset = node.quasi.start + 1; - if (emitMiniLocations) { - const locs = language.getLocations(code, offset); - miniLocations = miniLocations.concat(locs); - } - this.skip(); - return this.replace(languageWithLocation(name, code, offset)); - } - if (isTemplateLiteral(node, 'tidal')) { - const raw = node.quasi.quasis[0].value.raw; - const offset = node.quasi.start + 1; - if (emitMiniLocations) { - const stringLocs = collectHaskellMiniLocations(raw, offset); - miniLocations = miniLocations.concat(stringLocs); - } - this.skip(); - return this.replace(tidalWithLocation(raw, offset)); - } - if (isBackTickString(node, parent)) { - if (isMiniDisabled(node.start, miniDisableRanges)) { - return; - } - const { quasis } = node; - const { raw } = quasis[0].value; - this.skip(); - emitMiniLocations && collectMiniLocations(raw, node); - return this.replace(miniWithLocation(raw, node)); - } - if (isStringWithDoubleQuotes(node)) { - if (isMiniDisabled(node.start, miniDisableRanges)) { - return; - } - const { value } = node; - this.skip(); - emitMiniLocations && collectMiniLocations(value, node); - return this.replace(miniWithLocation(value, node)); - } - if (isSliderFunction(node)) { - const from = node.arguments[0].start + nodeOffset; - const to = node.arguments[0].end + nodeOffset; - const id = `${from}:${to}`; // Range-based ID for stability - const sliderConfig = { - from, - to, - id, - value: node.arguments[0].raw, // don't use value! - min: node.arguments[1]?.value ?? 0, - max: node.arguments[2]?.value ?? 1, - step: node.arguments[3]?.value, - type: 'slider', - }; - emitWidgets && widgets.push(sliderConfig); - sliders.push(sliderConfig); - return this.replace(sliderWithLocation(node, nodeOffset)); - } - if (isWidgetMethod(node)) { - const type = node.callee.property.name; - const index = widgets.filter((w) => w.type === type).length; - const widgetConfig = { - from: node.start, - to: node.end, - index, - type, - id: options.id, - }; - emitWidgets && widgets.push(widgetConfig); - return this.replace(widgetWithLocation(node, widgetConfig)); - } - if (isBareSamplesCall(node, parent)) { - return this.replace(withAwait(node)); + for (const plugin of plugins) { + if (!plugin?.enter?.call(this, node, parent, prop, index)) continue; + return; } + if (isLabelStatement(node)) { // Collect label info for block-based evaluation // Store positions WITHOUT offset so repl can slice the transpiler output correctly @@ -183,61 +114,10 @@ export function transpiler(input, options = {}) { }, leave(node, parent, prop, index) { - if (!isKabelCall(node)) return; - - let [expr, ...rest] = node.arguments; - if (!expr) throw new Error('K(...) requires an expression'); - - if (shouldCallKabelExpression(expr)) { - expr = { - type: 'CallExpression', - callee: expr, - arguments: [], - optional: false, - }; + for (const plugin of plugins) { + if (!plugin?.leave?.call(this, node, parent, prop, index)) continue; + return; } - - const { template, patternExprs } = extractPatternPlaceholders(expr); - if (patternExprs.length) { - const workletArgs = [{ type: 'Literal', value: template }, ...patternExprs, ...rest]; - - let callee = node.callee; - if (callee.type === 'ChainExpression') callee = callee.expression; - if (callee.type === 'MemberExpression') { - return this.replace({ - type: 'CallExpression', - callee: workletMemberAst(callee.object), - arguments: workletArgs, - optional: false, - }); - } - return this.replace({ - type: 'CallExpression', - callee: { type: 'Identifier', name: 'worklet' }, - arguments: workletArgs, - optional: false, - }); - } - - const kabelSrc = genExprSource(expr); - const workletArgs = [{ type: 'Literal', value: kabelSrc }, ...rest]; - - let callee = node.callee; - if (callee.type === 'ChainExpression') callee = callee.expression; - if (callee.type === 'MemberExpression') { - return this.replace({ - type: 'CallExpression', - callee: workletMemberAst(callee.object), - arguments: workletArgs, - optional: false, - }); - } - return this.replace({ - type: 'CallExpression', - callee: { type: 'Identifier', name: 'worklet' }, - arguments: workletArgs, - optional: false, - }); }, }); @@ -282,280 +162,17 @@ export function transpiler(input, options = {}) { if (!emitMiniLocations) { return { output }; } - return { output, miniLocations, widgets, sliders, labels }; -} -function isKabelCall(node) { - if (node.type !== 'CallExpression') return false; - let callee = node.callee; - if (callee.type === 'ChainExpression') callee = callee.expression; - if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K'; - return callee.type === 'Identifier' && callee.name === 'K'; -} + let pluginContext; + ({ options, input, miniDisableRanges, nodeOffset, ...pluginContext } = context); -function shouldCallKabelExpression(expr) { - if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') { - return false; - } - if (expr.params.length) { - return false; - } - return expr.body?.type === 'BlockStatement'; -} - -function genExprSource(expr) { - return escodegen.generate(expr, { format: { semicolons: false } }); -} - -function extractPatternPlaceholders(expr) { - const templateExpr = cloneNode(expr); - const parents = new Map(); - const targets = []; - - walk(templateExpr, { - enter(node, parent, prop, index) { - parents.set(node, { parent, prop, index }); - const patternExpr = getStrudelPatternExpr(node); - if (patternExpr) { - targets.push({ node, patternExpr }); - this.skip(); - } - }, - }); - - if (!targets.length) { - return { template: genExprSource(templateExpr), patternExprs: [] }; - } - - targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node)); - - const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr)); - - let currentExpr = templateExpr; - targets.forEach(({ node }, index) => { - currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr); - }); - - const template = genExprSource(currentExpr); - return { template, patternExprs }; -} - -function getStrudelPatternExpr(node) { - if (isStrudelPatternWrap(node)) { - const arg = node.arguments?.[0]; - if (!arg) { - throw new Error('S(...) requires an argument'); - } - return arg; - } - if (isMiniCall(node)) { - return node; - } - return null; -} - -function isStrudelPatternWrap(node) { - if (node.type !== 'CallExpression') { - return false; - } - const callee = node.callee; - if (callee.type === 'Identifier') { - return callee.name === 'S'; - } - if (callee.type === 'MemberExpression' && !callee.computed) { - return callee.property?.name === 'S'; - } - return false; -} - -function getMinilangName() { - const minilang = languages.get('minilang'); - return minilang?.name || 'm'; -} - -// Used to identify transpiled `m(...)` calls for proper conversion -// to, say, kabelsalat placeholders -function isMiniCall(node) { - if (node.type !== 'CallExpression') { - return false; - } - const callee = node.callee; - if (callee.type !== 'Identifier') { - return false; - } - if (callee.name !== getMinilangName()) { - return false; - } - const firstArg = node.arguments?.[0]; - return firstArg?.type === 'Literal' && typeof firstArg.value === 'string'; -} - -// If `start` is available, we use it. If it's already been transpiled -// to `m(...)`, use the provided offset -function getPatternNodeOrder(node) { - if (typeof node.start === 'number') { - return node.start; - } - if (isMiniCall(node)) { - const offsetArg = node.arguments?.[1]; - if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') { - return offsetArg.value; - } - } - return 0; -} - -function placeholderAst(index) { - return { - type: 'MemberExpression', - object: { type: 'Identifier', name: 'pat' }, - property: { type: 'Literal', value: index }, - computed: true, - optional: false, - }; -} - -function replaceNode(node, replacement, parents, currentRoot) { - const info = parents.get(node); - if (!info || !info.parent) { - return replacement; - } - - const { parent, prop, index } = info; - if (Array.isArray(parent[prop])) { - parent[prop][index] = replacement; - } else { - parent[prop] = replacement; - } - parents.set(replacement, { parent, prop, index }); - return currentRoot; -} - -function cloneNode(node) { - return JSON.parse(JSON.stringify(node)); -} - -function workletMemberAst(objectExpr) { - return { - type: 'MemberExpression', - object: objectExpr, - property: { type: 'Identifier', name: 'worklet' }, - computed: false, - optional: false, - }; -} - -function isStringWithDoubleQuotes(node, locations, code) { - if (node.type !== 'Literal') { - return false; - } - return node.raw[0] === '"'; -} - -function isBackTickString(node, parent) { - return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression'; -} - -function miniWithLocation(value, node) { - const { start: fromOffset } = node; - - const minilang = languages.get('minilang'); - let name = 'm'; - if (minilang && minilang.name) { - name = minilang.name; // name is expected to be exported from the package of the minilang - } - - return { - type: 'CallExpression', - callee: { - type: 'Identifier', - name, - }, - arguments: [ - { type: 'Literal', value }, - { type: 'Literal', value: fromOffset }, - ], - optional: false, - }; -} - -// these functions are connected to @strudel/codemirror -> slider.mjs -// maybe someday there will be pluggable transpiler functions, then move this there -function isSliderFunction(node) { - return node.type === 'CallExpression' && node.callee.name === 'slider'; -} - -function isWidgetMethod(node) { - return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name); -} - -function sliderWithLocation(node, nodeOffset = 0) { - // Apply nodeOffset for block-based evaluation to generate correct range - const from = node.arguments[0].start + nodeOffset; - const to = node.arguments[0].end + nodeOffset; - - // Use range-based ID for stability during block evaluation - const id = `${from}:${to}`; - - // add loc as identifier to first argument - // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) - node.arguments.unshift({ - type: 'Literal', - value: id, - raw: id, - }); - node.callee.name = 'sliderWithID'; - return node; -} - -export function getWidgetID(widgetConfig) { - // the widget id is used as id for the dom element + as key for eventual resources - // for example, for each scope widget, a new analyser + buffer (large) is created - // Update: use range-based ID generation for better stability during block evaluation - // When we have both from and to, use them together for stability - // Otherwise fall back to position-based ID for backward compatibility - let uniqueIdentifier; - if (widgetConfig.from !== undefined && widgetConfig.to !== undefined) { - // Use range for more stable identification - uniqueIdentifier = `${widgetConfig.from}-${widgetConfig.to}`; - } else { - // Fallback to single position (for backward compatibility) - uniqueIdentifier = widgetConfig.to || widgetConfig.from || 0; - } - const baseId = `${widgetConfig.id || ''}_widget_${widgetConfig.type}`; - return `${baseId}_${widgetConfig.index}_${uniqueIdentifier}`; -} - -function widgetWithLocation(node, widgetConfig) { - const id = getWidgetID(widgetConfig); - // Store the unique ID back into the config so it's available for widget management - // This is critical for block-based evaluation to match existing widgets with new ones - widgetConfig.id = id; - // add loc as identifier to first argument - // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) - node.arguments.unshift({ - type: 'Literal', - value: id, - raw: id, - }); - return node; -} - -function isBareSamplesCall(node, parent) { - return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression'; + return { output, ...pluginContext }; } function isAllCall(node) { return node.type === 'CallExpression' && node.callee.name === 'all'; } -function withAwait(node) { - return { - type: 'AwaitExpression', - argument: node, - }; -} - function isLabelStatement(node) { return node.type === 'LabeledStatement'; } @@ -586,69 +203,6 @@ function labelToP(node) { }; } -function isLanguageLiteral(node) { - return node.type === 'TaggedTemplateExpression' && languages.has(node.tag.name); -} - -// tidal highlighting -// this feels kind of stupid, when we also know the location inside the string op (tidal.mjs) -// but maybe it's the only way - -function isTemplateLiteral(node, value) { - return node.type === 'TaggedTemplateExpression' && node.tag.name === value; -} - -function collectHaskellMiniLocations(haskellCode, offset) { - return haskellCode - .split('') - .reduce((acc, char, i) => { - if (char !== '"') { - return acc; - } - if (!acc.length || acc[acc.length - 1].length > 1) { - acc.push([i + 1]); - } else { - acc[acc.length - 1].push(i); - } - return acc; - }, []) - .map(([start, end]) => { - const miniString = haskellCode.slice(start, end); - return getLeafLocations(`"${miniString}"`, offset + start - 1); - }) - .flat(); -} - -function tidalWithLocation(value, offset) { - return { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: 'tidal', - }, - arguments: [ - { type: 'Literal', value }, - { type: 'Literal', value: offset }, - ], - optional: false, - }; -} - -function languageWithLocation(name, value, offset) { - return { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: name, - }, - arguments: [ - { type: 'Literal', value }, - { type: 'Literal', value: offset }, - ], - optional: false, - }; -} - // List of non-inline widgets that need cleanup // These are Pattern.prototype methods that create persistent visualizations // (should be repalced by a function call producing an actual list of registered widgets) @@ -790,12 +344,3 @@ function findMiniDisableRanges(comments, codeEnd) { } return ranges; } - -function isMiniDisabled(offset, miniDisableRanges) { - for (const [start, end] of miniDisableRanges) { - if (offset >= start && offset < end) { - return true; - } - } - return false; -}