svelte
Version:
Cybernetically enhanced web apps
489 lines (431 loc) • 13.9 kB
JavaScript
/** @import { Context } from 'zimmerframe' */
/** @import { TransformState } from './types.js' */
/** @import { AST, Binding, Namespace, ValidatedCompileOptions } from '#compiler' */
/** @import { Node, Expression, CallExpression } from 'estree' */
import {
regex_ends_with_whitespaces,
regex_not_whitespace,
regex_starts_with_newline,
regex_starts_with_whitespaces
} from '../patterns.js';
import * as b from '../../utils/builders.js';
import * as e from '../../errors.js';
import { walk } from 'zimmerframe';
import { extract_identifiers } from '../../utils/ast.js';
import check_graph_for_cycles from '../2-analyze/utils/check_graph_for_cycles.js';
import is_reference from 'is-reference';
import { set_scope } from '../scope.js';
import { dev } from '../../state.js';
/**
* @param {Node} node
* @returns {boolean}
*/
export function is_hoisted_function(node) {
if (
node.type === 'ArrowFunctionExpression' ||
node.type === 'FunctionExpression' ||
node.type === 'FunctionDeclaration'
) {
return node.metadata?.hoisted === true;
}
return false;
}
/**
* Match Svelte 4 behaviour by sorting ConstTag nodes in topological order
* @param {AST.SvelteNode[]} nodes
* @param {TransformState} state
*/
function sort_const_tags(nodes, state) {
/**
* @typedef {{
* node: AST.ConstTag;
* deps: Set<Binding>;
* }} Tag
*/
const other = [];
/** @type {Map<Binding, Tag>} */
const tags = new Map();
for (const node of nodes) {
if (node.type === 'ConstTag') {
const declaration = node.declaration.declarations[0];
const bindings = extract_identifiers(declaration.id).map((id) => {
return /** @type {Binding} */ (state.scope.get(id.name));
});
/** @type {Set<Binding>} */
const deps = new Set();
walk(declaration.init, state, {
// @ts-expect-error don't know, don't care
_: set_scope,
Identifier(node, context) {
const parent = /** @type {Expression} */ (context.path.at(-1));
if (is_reference(node, parent)) {
const binding = context.state.scope.get(node.name);
if (binding) deps.add(binding);
}
}
});
for (const binding of bindings) {
tags.set(binding, { node, deps });
}
} else {
other.push(node);
}
}
if (tags.size === 0) {
return nodes;
}
/** @type {Array<[Binding, Binding]>} */
const edges = [];
for (const [id, tag] of tags) {
for (const dep of tag.deps) {
if (tags.has(dep)) {
edges.push([id, dep]);
}
}
}
const cycle = check_graph_for_cycles(edges);
if (cycle?.length) {
const tag = /** @type {Tag} */ (tags.get(cycle[0]));
e.const_tag_cycle(tag.node, cycle.map((binding) => binding.node.name).join(' → '));
}
/** @type {AST.ConstTag[]} */
const sorted = [];
/** @param {Tag} tag */
function add(tag) {
if (sorted.includes(tag.node)) {
return;
}
for (const dep of tag.deps) {
const dep_tag = tags.get(dep);
if (dep_tag) add(dep_tag);
}
sorted.push(tag.node);
}
for (const tag of tags.values()) {
add(tag);
}
return [...sorted, ...other];
}
/**
* Extract nodes that are hoisted and trim whitespace according to the following rules:
* - trim leading and trailing whitespace, regardless of surroundings
* - keep leading / trailing whitespace of inbetween text nodes,
* unless it's whitespace-only, in which case collapse to a single whitespace for all cases
* except when it's children of certain elements where we know ignore whitespace (like td/option/head),
* in which case we remove it entirely
* @param {AST.SvelteNode} parent
* @param {AST.SvelteNode[]} nodes
* @param {AST.SvelteNode[]} path
* @param {Namespace} namespace
* @param {TransformState & { options: ValidatedCompileOptions }} state
* @param {boolean} preserve_whitespace
* @param {boolean} preserve_comments
*/
export function clean_nodes(
parent,
nodes,
path,
namespace = 'html',
state,
// TODO give these defaults (state.options.preserveWhitespace and state.options.preserveComments).
// first, we need to make `Component(Client|Server)TransformState` inherit from a new `ComponentTransformState`
// rather than from `ClientTransformState` and `ServerTransformState`
preserve_whitespace,
preserve_comments
) {
if (!state.analysis.runes) {
nodes = sort_const_tags(nodes, state);
}
/** @type {AST.SvelteNode[]} */
const hoisted = [];
/** @type {AST.SvelteNode[]} */
const regular = [];
for (const node of nodes) {
if (node.type === 'Comment' && !preserve_comments) {
continue;
}
if (
node.type === 'ConstTag' ||
node.type === 'DebugTag' ||
node.type === 'SvelteBody' ||
node.type === 'SvelteWindow' ||
node.type === 'SvelteDocument' ||
node.type === 'SvelteHead' ||
node.type === 'TitleElement' ||
node.type === 'SnippetBlock'
) {
// TODO others?
hoisted.push(node);
} else {
regular.push(node);
}
}
let trimmed = regular;
if (!preserve_whitespace) {
trimmed = [];
let first, last;
while (
(first = regular[0]) &&
first.type === 'Text' &&
!regex_not_whitespace.test(first.data)
) {
regular.shift();
}
if (first?.type === 'Text') {
first.raw = first.raw.replace(regex_starts_with_whitespaces, '');
first.data = first.data.replace(regex_starts_with_whitespaces, '');
}
while (
(last = regular.at(-1)) &&
last.type === 'Text' &&
!regex_not_whitespace.test(last.data)
) {
regular.pop();
}
if (last?.type === 'Text') {
last.raw = last.raw.replace(regex_ends_with_whitespaces, '');
last.data = last.data.replace(regex_ends_with_whitespaces, '');
}
const can_remove_entirely =
(namespace === 'svg' &&
(parent.type !== 'RegularElement' || parent.name !== 'text') &&
!path.some((n) => n.type === 'RegularElement' && n.name === 'text')) ||
(parent.type === 'RegularElement' &&
// TODO others?
(parent.name === 'select' ||
parent.name === 'tr' ||
parent.name === 'table' ||
parent.name === 'tbody' ||
parent.name === 'thead' ||
parent.name === 'tfoot' ||
parent.name === 'colgroup' ||
parent.name === 'datalist'));
// Replace any whitespace between a text and non-text node with a single spaceand keep whitespace
// as-is within text nodes, or between text nodes and expression tags (because in the end they count
// as one text). This way whitespace is mostly preserved when using CSS with `white-space: pre-line`
// and default slot content going into a pre tag (which we can't see).
for (let i = 0; i < regular.length; i++) {
const prev = regular[i - 1];
const node = regular[i];
const next = regular[i + 1];
if (node.type === 'Text') {
if (prev?.type !== 'ExpressionTag') {
const prev_is_text_ending_with_whitespace =
prev?.type === 'Text' && regex_ends_with_whitespaces.test(prev.data);
node.data = node.data.replace(
regex_starts_with_whitespaces,
prev_is_text_ending_with_whitespace ? '' : ' '
);
node.raw = node.raw.replace(
regex_starts_with_whitespaces,
prev_is_text_ending_with_whitespace ? '' : ' '
);
}
if (next?.type !== 'ExpressionTag') {
node.data = node.data.replace(regex_ends_with_whitespaces, ' ');
node.raw = node.raw.replace(regex_ends_with_whitespaces, ' ');
}
if (node.data && (node.data !== ' ' || !can_remove_entirely)) {
trimmed.push(node);
}
} else {
trimmed.push(node);
}
}
}
var first = trimmed[0];
// initial newline inside a `<pre>` is disregarded, if not followed by another newline
if (parent.type === 'RegularElement' && parent.name === 'pre' && first?.type === 'Text') {
const text = first.data.replace(regex_starts_with_newline, '');
if (text !== first.data) {
const tmp = text.replace(regex_starts_with_newline, '');
if (text === tmp) {
first.data = text;
first.raw = first.raw.replace(regex_starts_with_newline, '');
if (first.data === '') {
trimmed.shift();
first = trimmed[0];
}
}
}
}
// Special case: Add a comment if this is a lone script tag. This ensures that our run_scripts logic in template.js
// will always be able to call node.replaceWith() on the script tag in order to make it run. If we don't add this
// and would still call node.replaceWith() on the script tag, it would be a no-op because the script tag has no parent.
if (trimmed.length === 1 && first.type === 'RegularElement' && first.name === 'script') {
trimmed.push({
type: 'Comment',
data: '',
start: -1,
end: -1
});
}
return {
hoisted,
trimmed,
/**
* In a case like `{#if x}<Foo />{/if}`, we don't need to wrap the child in
* comments — we can just use the parent block's anchor for the component.
* TODO extend this optimisation to other cases
*/
is_standalone:
trimmed.length === 1 &&
((first.type === 'RenderTag' && !first.metadata.dynamic) ||
(first.type === 'Component' &&
!state.options.hmr &&
!first.metadata.dynamic &&
!first.attributes.some(
(attribute) => attribute.type === 'Attribute' && attribute.name.startsWith('--')
))),
/** if a component/snippet/each block starts with text, we need to add an anchor comment so that its text node doesn't get fused with its surroundings */
is_text_first:
(parent.type === 'Fragment' ||
parent.type === 'SnippetBlock' ||
parent.type === 'EachBlock' ||
parent.type === 'SvelteComponent' ||
parent.type === 'SvelteBoundary' ||
parent.type === 'Component' ||
parent.type === 'SvelteSelf') &&
first &&
(first?.type === 'Text' || first?.type === 'ExpressionTag')
};
}
/**
* Infers the namespace for the children of a node that should be used when creating the `$.template(...)`.
* @param {Namespace} namespace
* @param {AST.SvelteNode} parent
* @param {AST.SvelteNode[]} nodes
*/
export function infer_namespace(namespace, parent, nodes) {
if (parent.type === 'RegularElement' && parent.name === 'foreignObject') {
return 'html';
}
if (parent.type === 'RegularElement' || parent.type === 'SvelteElement') {
if (parent.metadata.svg) {
return 'svg';
}
return parent.metadata.mathml ? 'mathml' : 'html';
}
// Re-evaluate the namespace inside slot nodes that reset the namespace
if (
parent.type === 'Fragment' ||
parent.type === 'Root' ||
parent.type === 'Component' ||
parent.type === 'SvelteComponent' ||
parent.type === 'SvelteFragment' ||
parent.type === 'SnippetBlock' ||
parent.type === 'SlotElement'
) {
const new_namespace = check_nodes_for_namespace(nodes, 'keep');
if (new_namespace !== 'keep' && new_namespace !== 'maybe_html') {
return new_namespace;
}
}
/** @type {Namespace | null} */
let new_namespace = null;
// Check the elements within the fragment and look for consistent namespaces.
// If we have no namespaces or they are mixed, then fallback to existing namespace
for (const node of nodes) {
if (node.type !== 'RegularElement') continue;
if (node.metadata.mathml) {
new_namespace = new_namespace === null || new_namespace === 'mathml' ? 'mathml' : 'html';
} else if (node.metadata.svg) {
new_namespace = new_namespace === null || new_namespace === 'svg' ? 'svg' : 'html';
} else {
return 'html';
}
}
return new_namespace ?? namespace;
}
/**
* Heuristic: Keep current namespace, unless we find a regular element,
* in which case we always want html, or we only find svg nodes,
* in which case we assume svg.
* @param {AST.SvelteNode[]} nodes
* @param {Namespace | 'keep' | 'maybe_html'} namespace
*/
function check_nodes_for_namespace(nodes, namespace) {
/**
* @param {AST.SvelteElement | AST.RegularElement} node}
* @param {{stop: () => void}} context
*/
const RegularElement = (node, { stop }) => {
if (!node.metadata.svg && !node.metadata.mathml) {
namespace = 'html';
stop();
} else if (namespace === 'keep') {
namespace = node.metadata.svg ? 'svg' : 'mathml';
}
};
for (const node of nodes) {
walk(
node,
{},
{
_(node, { next }) {
if (
node.type === 'EachBlock' ||
node.type === 'IfBlock' ||
node.type === 'AwaitBlock' ||
node.type === 'Fragment' ||
node.type === 'KeyBlock' ||
node.type === 'RegularElement' ||
node.type === 'SvelteElement' ||
node.type === 'Text'
) {
next();
}
},
SvelteElement: RegularElement,
RegularElement,
Text(node) {
if (node.data.trim() !== '') {
namespace = 'maybe_html';
}
}
}
);
if (namespace === 'html') return namespace;
}
return namespace;
}
/**
* Determines the namespace the children of this node are in.
* @param {AST.RegularElement | AST.SvelteElement} node
* @param {Namespace} namespace
* @returns {Namespace}
*/
export function determine_namespace_for_children(node, namespace) {
if (node.name === 'foreignObject') {
return 'html';
}
if (node.metadata.svg) {
return 'svg';
}
return node.metadata.mathml ? 'mathml' : 'html';
}
/**
* @template {TransformState} T
* @param {CallExpression} node
* @param {Context<any, T>} context
*/
export function transform_inspect_rune(node, context) {
const { state, visit } = context;
const as_fn = state.options.generate === 'client';
if (!dev) return b.empty;
if (node.callee.type === 'MemberExpression') {
const raw_inspect_args = /** @type {CallExpression} */ (node.callee.object).arguments;
const inspect_args =
/** @type {Array<Expression>} */
(raw_inspect_args.map((arg) => visit(arg)));
const with_arg = /** @type {Expression} */ (visit(node.arguments[0]));
return b.call(
'$.inspect',
as_fn ? b.thunk(b.array(inspect_args)) : b.array(inspect_args),
with_arg
);
} else {
const arg = node.arguments.map((arg) => /** @type {Expression} */ (visit(arg)));
return b.call('$.inspect', as_fn ? b.thunk(b.array(arg)) : b.array(arg));
}
}