feat: MathML support (#11387)

* feat: MathML support

- Add support for MathML namespace
- Auto-infer MathML namespace

* tweak

* DRY out

* note to self

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/11396/head
Luke Warlow 1 year ago committed by GitHub
parent fe56c7fd2e
commit 8be6fdde54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
feat: MathML support

@ -1,4 +1,4 @@
import { namespace_svg } from '../../../../constants.js';
import { namespace_mathml, namespace_svg } from '../../../../constants.js';
import * as e from '../../../errors.js';
const regex_valid_tag_name = /^[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]+$/;
@ -155,10 +155,20 @@ export default function read_options(node) {
if (value === namespace_svg) {
component_options.namespace = 'svg';
} else if (value === 'html' || value === 'svg' || value === 'foreign') {
} else if (value === namespace_mathml) {
component_options.namespace = 'mathml';
} else if (
value === 'html' ||
value === 'mathml' ||
value === 'svg' ||
value === 'foreign'
) {
component_options.namespace = value;
} else {
e.svelte_options_invalid_attribute_value(attribute, `"html", "svg" or "foreign"`);
e.svelte_options_invalid_attribute_value(
attribute,
`"html", "mathml", "svg" or "foreign"`
);
}
break;

@ -11,14 +11,14 @@ import {
object
} from '../../utils/ast.js';
import * as b from '../../utils/builders.js';
import { ReservedKeywords, Runes, SVGElements } from '../constants.js';
import { MathMLElements, ReservedKeywords, Runes, SVGElements } from '../constants.js';
import { Scope, ScopeRoot, create_scopes, get_rune, set_scope } from '../scope.js';
import { merge } from '../visitors.js';
import { validation_legacy, validation_runes, validation_runes_js } from './validation.js';
import check_graph_for_cycles from './utils/check_graph_for_cycles.js';
import { regex_starts_with_newline } from '../patterns.js';
import { create_attribute, is_element_node } from '../nodes.js';
import { DelegatedEvents, namespace_svg } from '../../../constants.js';
import { DelegatedEvents, namespace_mathml, namespace_svg } from '../../../constants.js';
import { should_proxy_or_freeze } from '../3-transform/client/utils.js';
import { analyze_css } from './css/css-analyze.js';
import { prune } from './css/css-prune.js';
@ -1379,8 +1379,9 @@ const common_visitors = {
FunctionExpression: function_visitor,
FunctionDeclaration: function_visitor,
RegularElement(node, context) {
if (context.state.options.namespace !== 'foreign' && SVGElements.includes(node.name)) {
node.metadata.svg = true;
if (context.state.options.namespace !== 'foreign') {
if (SVGElements.includes(node.name)) node.metadata.svg = true;
else if (MathMLElements.includes(node.name)) node.metadata.mathml = true;
}
determine_element_spread(node);
@ -1438,20 +1439,29 @@ const common_visitors = {
SvelteElement(node, context) {
context.state.analysis.elements.push(node);
// TODO why are we handling the `<svelte:element this="x" />` case? there is no
// reason for someone to use a static value with `<svelte:element>`
if (
context.state.options.namespace !== 'foreign' &&
node.tag.type === 'Literal' &&
typeof node.tag.value === 'string' &&
SVGElements.includes(node.tag.value)
typeof node.tag.value === 'string'
) {
node.metadata.svg = true;
return;
if (SVGElements.includes(node.tag.value)) {
node.metadata.svg = true;
return;
}
if (MathMLElements.includes(node.tag.value)) {
node.metadata.mathml = true;
return;
}
}
for (const attribute of node.attributes) {
if (attribute.type === 'Attribute') {
if (attribute.name === 'xmlns' && is_text_attribute(attribute)) {
node.metadata.svg = attribute.value[0].data === namespace_svg;
node.metadata.mathml = attribute.value[0].data === namespace_mathml;
return;
}
}
@ -1467,6 +1477,7 @@ const common_visitors = {
) {
// Inside a slot or a snippet -> this resets the namespace, so assume the component namespace
node.metadata.svg = context.state.options.namespace === 'svg';
node.metadata.mathml = context.state.options.namespace === 'mathml';
return;
}
if (ancestor.type === 'SvelteElement' || ancestor.type === 'RegularElement') {
@ -1474,6 +1485,10 @@ const common_visitors = {
ancestor.type === 'RegularElement' && ancestor.name === 'foreignObject'
? false
: ancestor.metadata.svg;
node.metadata.mathml =
ancestor.type === 'RegularElement' && ancestor.name === 'foreignObject'
? false
: ancestor.metadata.mathml;
return;
}
}

@ -50,7 +50,11 @@ import { walk } from 'zimmerframe';
*/
function get_attribute_name(element, attribute, context) {
let name = attribute.name;
if (!element.metadata.svg && context.state.metadata.namespace !== 'foreign') {
if (
!element.metadata.svg &&
!element.metadata.mathml &&
context.state.metadata.namespace !== 'foreign'
) {
name = name.toLowerCase();
if (name in AttributeAliases) {
name = AttributeAliases[name];
@ -292,7 +296,9 @@ function serialize_element_spread_attributes(
}
const lowercase_attributes =
element.metadata.svg || is_custom_element_node(element) ? b.false : b.true;
element.metadata.svg || element.metadata.mathml || is_custom_element_node(element)
? b.false
: b.true;
const id = context.state.scope.generate('attributes');
const update = b.stmt(
@ -465,6 +471,7 @@ function serialize_element_attribute_update_assignment(element, node_id, attribu
const state = context.state;
const name = get_attribute_name(element, attribute, context);
const is_svg = context.state.metadata.namespace === 'svg';
const is_mathml = context.state.metadata.namespace === 'mathml';
let [contains_call_expression, value] = serialize_attribute_value(attribute.value, context);
// The foreign namespace doesn't have any special handling, everything goes through the attr function
@ -490,7 +497,13 @@ function serialize_element_attribute_update_assignment(element, node_id, attribu
let update;
if (name === 'class') {
update = b.stmt(b.call(is_svg ? '$.set_svg_class' : '$.set_class', node_id, value));
update = b.stmt(
b.call(
is_svg ? '$.set_svg_class' : is_mathml ? '$.set_mathml_class' : '$.set_class',
node_id,
value
)
);
} else if (DOMProperties.includes(name)) {
update = b.stmt(b.assignment('=', b.member(node_id, b.id(name)), value));
} else {
@ -872,7 +885,9 @@ function serialize_inline_component(node, component_name, context) {
node_id,
// TODO would be great to do this at runtime instead. Svelte 4 also can't handle cases today
// where it's not statically determinable whether the component is used in a svg or html context
context.state.metadata.namespace === 'svg' ? b.false : b.true,
context.state.metadata.namespace === 'svg' || context.state.metadata.namespace === 'mathml'
? b.false
: b.true,
b.thunk(b.object(custom_css_props)),
b.arrow([b.id('$$node')], prev(b.id('$$node')))
);
@ -1138,9 +1153,11 @@ function get_template_function(namespace, state) {
? contains_script_tag
? '$.svg_template_with_script'
: '$.svg_template'
: contains_script_tag
? '$.template_with_script'
: '$.template';
: namespace === 'mathml'
? '$.mathml_template'
: contains_script_tag
? '$.template_with_script'
: '$.template';
}
/**
@ -1635,7 +1652,8 @@ export const template_visitors = {
'$.html',
context.state.node,
b.thunk(/** @type {import('estree').Expression} */ (context.visit(node.expression))),
b.literal(context.state.metadata.namespace === 'svg')
b.literal(context.state.metadata.namespace === 'svg'),
b.literal(context.state.metadata.namespace === 'mathml')
)
)
);
@ -2125,7 +2143,11 @@ export const template_visitors = {
})
);
const args = [context.state.node, get_tag, node.metadata.svg ? b.true : b.false];
const args = [
context.state.node,
get_tag,
node.metadata.svg || node.metadata.mathml ? b.true : b.false
];
if (inner.length > 0) {
args.push(b.arrow([element_id, b.id('$$anchor')], b.block(inner)));
}

@ -484,7 +484,11 @@ function serialize_set_binding(node, context, fallback) {
*/
function get_attribute_name(element, attribute, context) {
let name = attribute.name;
if (!element.metadata.svg && context.state.metadata.namespace !== 'foreign') {
if (
!element.metadata.svg &&
!element.metadata.mathml &&
context.state.metadata.namespace !== 'foreign'
) {
name = name.toLowerCase();
// don't lookup boolean aliases here, the server runtime function does only
// check for the lowercase variants of boolean attributes
@ -899,15 +903,19 @@ function serialize_element_spread_attributes(
}
const lowercase_attributes =
element.metadata.svg || (element.type === 'RegularElement' && is_custom_element_node(element))
element.metadata.svg ||
element.metadata.mathml ||
(element.type === 'RegularElement' && is_custom_element_node(element))
? b.false
: b.true;
const is_svg = element.metadata.svg ? b.true : b.false;
const is_html = element.metadata.svg || element.metadata.mathml ? b.false : b.true;
/** @type {import('estree').Expression[]} */
const args = [
b.array(values),
lowercase_attributes,
is_svg,
is_html,
b.literal(context.state.analysis.css.hash)
];

@ -219,7 +219,10 @@ export function infer_namespace(namespace, parent, nodes, path) {
}
if (parent_node?.type === 'RegularElement' || parent_node?.type === 'SvelteElement') {
return parent_node.metadata.svg ? 'svg' : 'html';
if (parent_node.metadata.svg) {
return 'svg';
}
return parent_node.metadata.mathml ? 'mathml' : 'html';
}
// Re-evaluate the namespace inside slot nodes that reset the namespace
@ -254,11 +257,11 @@ function check_nodes_for_namespace(nodes, namespace) {
* @param {{stop: () => void}} context
*/
const RegularElement = (node, { stop }) => {
if (!node.metadata.svg) {
if (!node.metadata.svg && !node.metadata.mathml) {
namespace = 'html';
stop();
} else if (namespace === 'keep') {
namespace = 'svg';
namespace = node.metadata.svg ? 'svg' : 'mathml';
}
};
@ -312,7 +315,11 @@ export function determine_namespace_for_children(node, namespace) {
return 'html';
}
return node.metadata.svg ? 'svg' : 'html';
if (node.metadata.svg) {
return 'svg';
}
return node.metadata.mathml ? 'mathml' : 'html';
}
/**

@ -149,6 +149,39 @@ export const SVGElements = [
'vkern'
];
export const MathMLElements = [
'annotation',
'annotation-xml',
'maction',
'math',
'merror',
'mfrac',
'mi',
'mmultiscripts',
'mn',
'mo',
'mover',
'mpadded',
'mphantom',
'mprescripts',
'mroot',
'mrow',
'ms',
'mspace',
'msqrt',
'mstyle',
'msub',
'msubsup',
'msup',
'mtable',
'mtd',
'mtext',
'mtr',
'munder',
'munderover',
'semantics'
];
export const EventModifiers = [
'preventDefault',
'stopPropagation',

@ -39,11 +39,12 @@ export interface Fragment {
/**
* - `html` the default, for e.g. `<div>` or `<span>`
* - `svg` for e.g. `<svg>` or `<g>`
* - `mathml` for e.g. `<math>` or `<mrow>`
* - `foreign` for other compilation targets than the web, e.g. Svelte Native.
* Disallows bindings other than bind:this, disables a11y checks, disables any special attribute handling
* (also see https://github.com/sveltejs/svelte/pull/5652)
*/
export type Namespace = 'html' | 'svg' | 'foreign';
export type Namespace = 'html' | 'svg' | 'mathml' | 'foreign';
export interface Root extends BaseNode {
type: 'Root';
@ -287,6 +288,8 @@ export interface RegularElement extends BaseElement {
metadata: {
/** `true` if this is an svg element */
svg: boolean;
/** `true` if this is a mathml element */
mathml: boolean;
/** `true` if contains a SpreadAttribute */
has_spread: boolean;
scoped: boolean;
@ -319,6 +322,11 @@ export interface SvelteElement extends BaseElement {
* the tag is dynamic, but we do our best to infer it from the template.
*/
svg: boolean;
/**
* `true` if this is a mathml element. The boolean may not be accurate because
* the tag is dynamic, but we do our best to infer it from the template.
*/
mathml: boolean;
scoped: boolean;
};
}

@ -104,6 +104,7 @@ export const DOMBooleanAttributes = [
];
export const namespace_svg = 'http://www.w3.org/2000/svg';
export const namespace_mathml = 'http://www.w3.org/1998/Math/MathML';
// while `input` is also an interactive element, it is never moved by the browser, so we don't need to check for it
export const interactive_elements = new Set([

@ -30,14 +30,15 @@ function remove_from_parent_effect(effect, to_remove) {
* @param {Element | Text | Comment} anchor
* @param {() => string} get_value
* @param {boolean} svg
* @param {boolean} mathml
* @returns {void}
*/
export function html(anchor, get_value, svg) {
export function html(anchor, get_value, svg, mathml) {
const parent_effect = anchor.parentNode !== current_effect?.dom ? current_effect : null;
let value = derived(get_value);
render_effect(() => {
var dom = html_to_dom(anchor, parent_effect, get(value), svg);
var dom = html_to_dom(anchor, parent_effect, get(value), svg, mathml);
if (dom) {
return () => {
@ -58,20 +59,22 @@ export function html(anchor, get_value, svg) {
* @param {import('#client').Effect | null} effect
* @param {V} value
* @param {boolean} svg
* @param {boolean} mathml
* @returns {Element | Comment | (Element | Comment | Text)[]}
*/
function html_to_dom(target, effect, value, svg) {
function html_to_dom(target, effect, value, svg, mathml) {
if (hydrating) return hydrate_nodes;
var html = value + '';
if (svg) html = `<svg>${html}</svg>`;
else if (mathml) html = `<math>${html}</math>`;
// Don't use create_fragment_with_script_from_html here because that would mean script tags are executed.
// @html is basically `.innerHTML = ...` and that doesn't execute scripts either due to security reasons.
/** @type {DocumentFragment | Element} */
var node = create_fragment_from_html(html);
if (svg) {
if (svg || mathml) {
node = /** @type {Element} */ (node.firstChild);
}
@ -86,7 +89,7 @@ function html_to_dom(target, effect, value, svg) {
var nodes = /** @type {Array<Text | Element | Comment>} */ ([...node.childNodes]);
if (svg) {
if (svg || mathml) {
while (node.firstChild) {
target.before(node.firstChild);
}

@ -30,6 +30,35 @@ export function set_svg_class(dom, value) {
}
}
/**
* @param {MathMLElement} dom
* @param {string} value
* @returns {void}
*/
export function set_mathml_class(dom, value) {
// @ts-expect-error need to add __className to patched prototype
var prev_class_name = dom.__className;
var next_class_name = to_class(value);
if (hydrating && dom.getAttribute('class') === next_class_name) {
// In case of hydration don't reset the class as it's already correct.
// @ts-expect-error need to add __className to patched prototype
dom.__className = next_class_name;
} else if (
prev_class_name !== next_class_name ||
(hydrating && dom.getAttribute('class') !== next_class_name)
) {
if (next_class_name === '') {
dom.removeAttribute('class');
} else {
dom.setAttribute('class', next_class_name);
}
// @ts-expect-error need to add __className to patched prototype
dom.__className = next_class_name;
}
}
/**
* @param {HTMLElement} dom
* @param {string} value

@ -158,6 +158,49 @@ export function svg_template_with_script(content, flags) {
};
}
/**
* @param {string} content
* @param {number} flags
* @returns {() => Node | Node[]}
*/
/*#__NO_SIDE_EFFECTS__*/
export function mathml_template(content, flags) {
var is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;
var fn = template(`<math>${content}</math>`, 0); // we don't need to worry about using importNode for MathML
/** @type {Element | DocumentFragment} */
var node;
return () => {
if (hydrating) {
return push_template_node(is_fragment ? hydrate_nodes : hydrate_nodes[0]);
}
if (!node) {
var math = /** @type {Element} */ (fn());
if ((flags & TEMPLATE_FRAGMENT) === 0) {
node = /** @type {Element} */ (math.firstChild);
} else {
node = document.createDocumentFragment();
while (math.firstChild) {
node.appendChild(math.firstChild);
}
}
}
var clone = clone_node(node, true);
push_template_node(
is_fragment
? /** @type {import('#client').TemplateNode[]} */ ([...clone.childNodes])
: /** @type {import('#client').TemplateNode} */ (clone)
);
return clone;
};
}
/**
* Creating a document fragment from HTML that contains script tags will not execute
* the scripts. We need to replace the script tags with new ones so that they are executed.

@ -20,7 +20,7 @@ export {
set_dynamic_element_attributes,
set_xlink_attribute
} from './dom/elements/attributes.js';
export { set_class, set_svg_class, toggle_class } from './dom/elements/class.js';
export { set_class, set_svg_class, set_mathml_class, toggle_class } from './dom/elements/class.js';
export { event, delegate } from './dom/elements/events.js';
export { autofocus, remove_textarea_child } from './dom/elements/misc.js';
export { set_style } from './dom/elements/style.js';
@ -70,6 +70,7 @@ export {
comment,
svg_template,
svg_template_with_script,
mathml_template,
template,
template_with_script,
text

@ -289,12 +289,12 @@ export function css_props(payload, is_html, props, component) {
/**
* @param {Record<string, unknown>[]} attrs
* @param {boolean} lowercase_attributes
* @param {boolean} is_svg
* @param {boolean} is_html
* @param {string} class_hash
* @param {{ styles: Record<string, string> | null; classes: string }} [additional]
* @returns {string}
*/
export function spread_attributes(attrs, lowercase_attributes, is_svg, class_hash, additional) {
export function spread_attributes(attrs, lowercase_attributes, is_html, class_hash, additional) {
/** @type {Record<string, unknown>} */
const merged_attrs = {};
let key;
@ -344,7 +344,7 @@ export function spread_attributes(attrs, lowercase_attributes, is_svg, class_has
if (lowercase_attributes) {
name = name.toLowerCase();
}
const is_boolean = !is_svg && DOMBooleanAttributes.includes(name);
const is_boolean = is_html && DOMBooleanAttributes.includes(name);
attr_str += attr(name, merged_attrs[name], is_boolean);
}

@ -7,6 +7,10 @@ export default test({
<text textLength=100>hellooooo</text>
</svg>
<math>
<mrow></mrow>
</svg>
<div class="hi">hi</div>
`,
@ -15,6 +19,10 @@ export default test({
ok(svg);
assert.equal(svg.namespaceURI, 'http://www.w3.org/2000/svg');
const math = target.querySelector('math');
ok(math);
assert.equal(math.namespaceURI, 'http://www.w3.org/1998/Math/MathML');
const div = target.querySelector('div');
ok(div);
assert.equal(div.namespaceURI, 'http://www.w3.org/1999/xhtml');

@ -2,4 +2,8 @@
<text textlength=100>hellooooo</text>
</svg>
<math>
<mrow></mrow>
</math>
<div class="hi">hi</div>

Before

Width:  |  Height:  |  Size: 109 B

After

Width:  |  Height:  |  Size: 140 B

@ -0,0 +1,19 @@
<mrow></mrow>
{#if true}
<mrow></mrow>
{/if}
{#each Array(2).fill(0) as item, idx}
<mrow></mrow>
{/each}
{@html '<mrow></mrow>'}
{@render test()}
{#snippet test(text)}
<mrow></mrow>
{/snippet}
<!-- comment should not infer html namespace -->

@ -0,0 +1,27 @@
import { test, ok } from '../../test';
export default test({
html: `
<math>
<mrow></mrow>
<mrow></mrow>
<mrow></mrow>
<mrow></mrow>
<mrow></mrow>
<mrow></mrow>
</math>
`,
test({ assert, target }) {
const math = target.querySelector('math');
ok(math);
assert.equal(math.namespaceURI, 'http://www.w3.org/1998/Math/MathML');
const mrow_elements = target.querySelectorAll('mrow');
assert.equal(mrow_elements.length, 6);
for (const { namespaceURI } of mrow_elements)
assert.equal(namespaceURI, 'http://www.w3.org/1998/Math/MathML');
}
});

@ -0,0 +1,7 @@
<script>
import Wrapper from "./Wrapper.svelte";
</script>
<math>
<Wrapper />
</math>

@ -1,7 +1,7 @@
[
{
"code": "svelte_options_invalid_attribute_value",
"message": "Valid values are \"html\", \"svg\" or \"foreign\"",
"message": "Valid values are \"html\", \"mathml\", \"svg\" or \"foreign\"",
"start": {
"line": 1,
"column": 16

@ -1,7 +1,7 @@
[
{
"code": "svelte_options_invalid_attribute_value",
"message": "Valid values are \"html\", \"svg\" or \"foreign\"",
"message": "Valid values are \"html\", \"mathml\", \"svg\" or \"foreign\"",
"start": {
"line": 1,
"column": 16

@ -1321,11 +1321,12 @@ declare module 'svelte/compiler' {
/**
* - `html` the default, for e.g. `<div>` or `<span>`
* - `svg` for e.g. `<svg>` or `<g>`
* - `mathml` for e.g. `<math>` or `<mrow>`
* - `foreign` for other compilation targets than the web, e.g. Svelte Native.
* Disallows bindings other than bind:this, disables a11y checks, disables any special attribute handling
* (also see https://github.com/sveltejs/svelte/pull/5652)
*/
type Namespace = 'html' | 'svg' | 'foreign';
type Namespace = 'html' | 'svg' | 'mathml' | 'foreign';
interface Root extends BaseNode {
type: 'Root';
@ -1569,6 +1570,8 @@ declare module 'svelte/compiler' {
metadata: {
/** `true` if this is an svg element */
svg: boolean;
/** `true` if this is a mathml element */
mathml: boolean;
/** `true` if contains a SpreadAttribute */
has_spread: boolean;
scoped: boolean;
@ -1601,6 +1604,11 @@ declare module 'svelte/compiler' {
* the tag is dynamic, but we do our best to infer it from the template.
*/
svg: boolean;
/**
* `true` if this is a mathml element. The boolean may not be accurate because
* the tag is dynamic, but we do our best to infer it from the template.
*/
mathml: boolean;
scoped: boolean;
};
}
@ -2562,11 +2570,12 @@ declare module 'svelte/types/compiler/interfaces' {
/**
* - `html` the default, for e.g. `<div>` or `<span>`
* - `svg` for e.g. `<svg>` or `<g>`
* - `mathml` for e.g. `<math>` or `<mrow>`
* - `foreign` for other compilation targets than the web, e.g. Svelte Native.
* Disallows bindings other than bind:this, disables a11y checks, disables any special attribute handling
* (also see https://github.com/sveltejs/svelte/pull/5652)
*/
type Namespace = 'html' | 'svg' | 'foreign';
type Namespace = 'html' | 'svg' | 'mathml' | 'foreign';
}declare module '*.svelte' {
export { SvelteComponent as default } from 'svelte';
}

@ -164,7 +164,7 @@ Various error and warning codes have been renamed slightly.
### Reduced number of namespaces
The number of valid namespaces you can pass to the compiler option `namespace` has been reduced to `html` (the default), `svg` and `foreign`.
The number of valid namespaces you can pass to the compiler option `namespace` has been reduced to `html` (the default), `mathml`, `svg` and `foreign`.
### beforeUpdate/afterUpdate changes

Loading…
Cancel
Save