@wuchale/svelte
Version:
Protobuf-like i18n from plain code: Svelte adapter
400 lines (399 loc) • 16 kB
JavaScript
import MagicString from "magic-string";
import { parse, preprocess } from "svelte/compiler";
import { Message } from 'wuchale';
import { Transformer, parseScript } from 'wuchale/adapter-vanilla';
import { MixedVisitor, nonWhitespaceText, processCommentDirectives, varNames } from "wuchale/adapter-utils";
const nodesWithChildren = ['RegularElement', 'Component'];
const rtComponent = 'W_tx_';
const snipPrefix = '_w_snippet_';
const rtModuleVar = varNames.rt + 'mod_';
// for use before actually parsing the code,
// to remove the contents of e.g. <style lang="scss">
// without messing up indices for magic-string
const removeSCSS = ({ attributes, content }) => {
if (attributes.lang) {
return {
code: ' '.repeat(content.length),
};
}
};
export class SvelteTransformer extends Transformer {
// state
currentElement;
inCompoundText = false;
commentDirectivesStack = [];
lastVisitIsComment = false;
currentSnippet = 0;
moduleExportRanges = []; // to choose which runtime var to use for snippets
mixedVisitor;
constructor(content, filename, index, heuristic, patterns, catalogExpr, rtConf, matchUrl) {
super(content, filename, index, heuristic, patterns, catalogExpr, rtConf, matchUrl, [varNames.rt, rtModuleVar]);
this.heuristciDetails.insideProgram = false;
}
visitExpressionTag = (node) => this.visit(node.expression);
visitVariableDeclarator = (node) => {
const msgs = this.defaultVisitVariableDeclarator(node);
const init = node.init;
if (!msgs.length || this.heuristciDetails.declaring != null || init == null || ['ArrowFunctionExpression', 'FunctionExpression'].includes(init.type)) {
return msgs;
}
const needsWrapping = msgs.some(msg => {
if (msg.details.topLevelCall && ['$props', '$state', '$derived', '$derived.by'].includes(msg.details.topLevelCall)) {
return false;
}
if (msg.details.declaring !== 'variable') {
return false;
}
return true;
});
if (!needsWrapping) {
return msgs;
}
const isExported = this.moduleExportRanges.some(([start, end]) => init.start >= start && init.end <= end);
if (!isExported) {
this.mstr.appendLeft(init.start, '$derived(');
this.mstr.appendRight(init.end, ')');
}
return msgs;
};
initMixedVisitor = () => new MixedVisitor({
mstr: this.mstr,
vars: this.vars,
getRange: node => ({ start: node.start, end: node.end }),
isText: node => node.type === 'Text',
isComment: node => node.type === 'Comment',
leaveInPlace: node => ['ConstTag', 'SnippetBlock'].includes(node.type),
isExpression: node => node.type === 'ExpressionTag',
getTextContent: (node) => node.data,
getCommentData: (node) => node.data,
canHaveChildren: (node) => nodesWithChildren.includes(node.type),
visitFunc: (child, inCompoundText) => {
const inCompoundTextPrev = this.inCompoundText;
this.inCompoundText = inCompoundText;
const childTxts = this.visitSv(child);
this.inCompoundText = inCompoundTextPrev; // restore
return childTxts;
},
visitExpressionTag: this.visitExpressionTag,
fullHeuristicDetails: this.fullHeuristicDetails,
checkHeuristic: this.getHeuristicMessageType,
index: this.index,
wrapNested: (msgInfo, hasExprs, nestedRanges, lastChildEnd) => {
const snippets = [];
// create and reference snippets
for (const [childStart, childEnd, haveCtx] of nestedRanges) {
const snippetName = `${snipPrefix}${this.currentSnippet}`;
snippets.push(snippetName);
this.currentSnippet++;
const snippetBegin = `\n{#snippet ${snippetName}(${haveCtx ? this.vars().nestCtx : ''})}\n`;
this.mstr.appendRight(childStart, snippetBegin);
this.mstr.prependLeft(childEnd, '\n{/snippet}\n');
}
let begin = `\n<${rtComponent}`;
if (snippets.length) {
begin += ` t={[${snippets.join(', ')}]}`;
}
begin += ' x=';
if (this.inCompoundText) {
begin += `{${this.vars().nestCtx}} n`;
}
else {
const index = this.index.get(msgInfo.toKey());
begin += `{${this.vars().rtCtx}(${index})}`;
}
let end = ' />\n';
if (hasExprs) {
begin += ' a={[';
end = ']}' + end;
}
this.mstr.appendLeft(lastChildEnd, begin);
this.mstr.appendRight(lastChildEnd, end);
},
});
visitFragment = (node) => this.mixedVisitor.visit({
children: node.nodes,
commentDirectives: this.commentDirectives,
inCompoundText: this.inCompoundText,
scope: 'markup',
element: this.currentElement,
useComponent: this.currentElement !== 'title'
});
visitRegularElement = (node) => {
const currentElement = this.currentElement;
this.currentElement = node.name;
const msgs = [];
for (const attrib of node.attributes) {
msgs.push(...this.visitSv(attrib));
}
msgs.push(...this.visitFragment(node.fragment));
this.currentElement = currentElement;
return msgs;
};
visitComponent = this.visitRegularElement;
visitText = (node) => {
const [startWh, trimmed, endWh] = nonWhitespaceText(node.data);
const [pass, msgInfo] = this.checkHeuristic(trimmed, {
scope: 'markup',
element: this.currentElement,
});
if (!pass) {
return [];
}
this.mstr.update(node.start + startWh, node.end - endWh, `{${this.vars().rtTrans}(${this.index.get(msgInfo.toKey())})}`);
return [msgInfo];
};
visitSpreadAttribute = (node) => this.visit(node.expression);
visitAttribute = (node) => {
if (node.value === true) {
return [];
}
let values;
if (Array.isArray(node.value)) {
values = node.value;
}
else {
values = [node.value];
}
if (values.length > 1) {
return this.mixedVisitor.visit({
children: values,
commentDirectives: this.commentDirectives,
inCompoundText: false,
scope: 'attribute',
element: this.currentElement,
attribute: node.name,
});
}
const value = values[0];
const heuDetails = {
scope: 'script',
element: this.currentElement,
attribute: node.name,
};
if (value.type === 'ExpressionTag') {
if (value.expression.type === 'Literal') {
const expr = value.expression;
return this.visitWithCommentDirectives(expr, () => this.visitLiteral(expr, heuDetails));
}
if (value.expression.type === 'TemplateLiteral') {
const expr = value.expression;
return this.visitWithCommentDirectives(expr, () => this.visitTemplateLiteral(expr, heuDetails));
}
return this.visitSv(value);
}
heuDetails.scope = 'attribute';
const [pass, msgInfo] = this.checkHeuristic(value.data, heuDetails);
if (!pass) {
return [];
}
this.mstr.update(value.start, value.end, `{${this.vars().rtTrans}(${this.index.get(msgInfo.toKey())})}`);
if (`'"`.includes(this.content[value.start - 1])) {
this.mstr.remove(value.start - 1, value.start);
this.mstr.remove(value.end, value.end + 1);
}
return [msgInfo];
};
visitConstTag = (node) => {
// @ts-expect-error
return this.visitVariableDeclaration(node.declaration);
};
visitRenderTag = (node) => {
// @ts-expect-error
return this.visit(node.expression);
};
visitSnippetBlock = (node) => {
// use module runtime var because the snippet may be exported from the module
const prevRtVar = this.currentRtVar;
const pattern = new RegExp(`\\b${node.expression.name}\\b`);
if (this.moduleExportRanges.some(([start, end]) => pattern.test(this.content.slice(start, end)))) {
this.currentRtVar = rtModuleVar;
}
const msgs = this.visitFragment(node.body);
this.currentRtVar = prevRtVar;
return msgs;
};
visitIfBlock = (node) => {
const msgs = this.visit(node.test);
msgs.push(...this.visitSv(node.consequent));
if (node.alternate) {
msgs.push(...this.visitSv(node.alternate));
}
return msgs;
};
visitEachBlock = (node) => {
const msgs = [
...this.visit(node.expression),
...this.visitSv(node.body),
];
if (node.key) {
msgs.push(...this.visit(node.key));
}
if (node.fallback) {
msgs.push(...this.visitSv(node.fallback));
}
return msgs;
};
visitKeyBlock = (node) => {
return [
...this.visit(node.expression),
...this.visitSv(node.fragment),
];
};
visitAwaitBlock = (node) => {
const msgs = this.visit(node.expression);
if (node.then) {
msgs.push(...this.visitFragment(node.then));
}
if (node.pending) {
msgs.push(...this.visitFragment(node.pending));
}
if (node.catch) {
msgs.push(...this.visitFragment(node.catch));
}
return msgs;
};
visitSvelteBody = (node) => node.attributes.map(this.visitSv).flat();
visitSvelteDocument = (node) => node.attributes.map(this.visitSv).flat();
visitSvelteElement = (node) => node.attributes.map(this.visitSv).flat();
visitSvelteBoundary = (node) => [
...node.attributes.map(this.visitSv).flat(),
...this.visitSv(node.fragment),
];
visitSvelteHead = (node) => this.visitSv(node.fragment);
visitTitleElement = (node) => this.visitRegularElement(node);
visitSvelteWindow = (node) => node.attributes.map(this.visitSv).flat();
visitRoot = (node) => {
const msgs = [];
if (node.module) {
const prevRtVar = this.currentRtVar;
this.currentRtVar = rtModuleVar;
this.runtimeCtx = { module: true };
this.commentDirectives = {}; // reset
// @ts-expect-error
msgs.push(...this.visitProgram(node.module.content));
const runtimeInit = this.initRuntime();
if (runtimeInit) {
this.mstr.appendRight(
// @ts-expect-error
this.getRealBodyStart(node.module.content.body) ?? node.module.content.start, runtimeInit);
}
this.runtimeCtx = { module: false }; // reset
this.currentRtVar = prevRtVar; // reset
}
if (node.instance) {
this.commentDirectives = {}; // reset
msgs.push(...this.visitProgram(node.instance.content));
}
msgs.push(...this.visitFragment(node.fragment));
return msgs;
};
visitSv = (node) => {
if (node.type === 'Comment') {
this.commentDirectives = processCommentDirectives(node.data.trim(), this.commentDirectives);
if (this.lastVisitIsComment) {
this.commentDirectivesStack[this.commentDirectivesStack.length - 1] = this.commentDirectives;
}
else {
this.commentDirectivesStack.push(this.commentDirectives);
}
this.lastVisitIsComment = true;
return [];
}
if (node.type === 'Text' && !node.data.trim()) {
return [];
}
let msgs = [];
const commentDirectivesPrev = this.commentDirectives;
if (this.lastVisitIsComment) {
this.commentDirectives = this.commentDirectivesStack.pop();
this.lastVisitIsComment = false;
}
if (this.commentDirectives.ignoreFile) {
return [];
}
if (this.commentDirectives.forceType !== false) {
msgs = this.visit(node);
}
this.commentDirectives = commentDirectivesPrev;
return msgs;
};
/** collects the ranges that will be checked if a snippet identifier is exported using RegExp test to simplify */
collectModuleExportRanges = (script) => {
for (const stmt of script.content.body) {
if (stmt.type !== 'ExportNamedDeclaration') {
continue;
}
for (const spec of stmt.specifiers) {
if (spec.local.type === 'Identifier') {
const local = spec.local;
this.moduleExportRanges.push([local.start, local.end]);
}
}
const declaration = stmt.declaration;
if (!declaration) {
continue;
}
if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') {
this.moduleExportRanges.push([declaration.start, declaration.end]);
continue;
}
for (const decl of declaration?.declarations ?? []) {
if (!decl.init) {
continue;
}
this.moduleExportRanges.push([decl.init.start, decl.init.end]);
}
}
};
transformSv = async () => {
const isComponent = this.heuristciDetails.file.endsWith('.svelte');
let ast;
if (isComponent) {
const prepd = await preprocess(this.content, { style: removeSCSS });
ast = parse(prepd.code, { modern: true });
}
else {
const [pAst, comments] = parseScript(this.content);
ast = pAst;
this.comments = comments;
}
this.mstr = new MagicString(this.content);
this.mixedVisitor = this.initMixedVisitor();
if (ast.type === 'Root' && ast.module) {
this.collectModuleExportRanges(ast.module);
}
const msgs = this.visitSv(ast);
const initRuntime = this.initRuntime();
if (ast.type === 'Program') {
const bodyStart = this.getRealBodyStart(ast.body) ?? 0;
if (initRuntime) {
this.mstr.appendRight(bodyStart, initRuntime);
}
return this.finalize(msgs, bodyStart);
}
let headerIndex = 0;
if (ast.module) {
// @ts-expect-error
headerIndex = this.getRealBodyStart(ast.module.content.body) ?? ast.module.content.start;
}
if (ast.instance) {
// @ts-expect-error
const instanceBodyStart = this.getRealBodyStart(ast.instance.content.body) ?? ast.instance.content.start;
if (!ast.module) {
headerIndex = instanceBodyStart;
}
if (initRuntime) {
this.mstr.appendRight(instanceBodyStart, initRuntime);
}
}
else {
const instanceStart = ast.module?.end ?? 0;
this.mstr.prependLeft(instanceStart, '\n<script>');
// account index for hmr data here
this.mstr.prependRight(instanceStart, `${initRuntime}\n</script>\n`);
// now hmr data can be prependRight(0, ...)
}
const headerAdd = `\nimport ${rtComponent} from "@wuchale/svelte/runtime.svelte"`;
return this.finalize(msgs, headerIndex, headerAdd);
};
}