mirror of https://github.com/sveltejs/svelte
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
660 lines
16 KiB
660 lines
16 KiB
import MagicString, { Bundle } from 'magic-string';
|
|
import { walk, childKeys } from 'estree-walker';
|
|
import { getLocator } from 'locate-character';
|
|
import Stats from '../Stats';
|
|
import reservedNames from '../utils/reservedNames';
|
|
import namespaces from '../utils/namespaces';
|
|
import { removeNode } from '../utils/removeNode';
|
|
import wrapModule from './wrapModule';
|
|
import { createScopes, extractNames, Scope } from '../utils/annotateWithScopes';
|
|
import Stylesheet from './css/Stylesheet';
|
|
import { test } from '../config';
|
|
import Fragment from './nodes/Fragment';
|
|
import * as internal from '../internal/index';
|
|
import { Node, Ast, CompileOptions, CustomElementOptions } from '../interfaces';
|
|
import error from '../utils/error';
|
|
import getCodeFrame from '../utils/getCodeFrame';
|
|
import flattenReference from '../utils/flattenReference';
|
|
import addToSet from '../utils/addToSet';
|
|
import isReference from 'is-reference';
|
|
|
|
type Meta = {
|
|
namespace?: string;
|
|
tag?: string;
|
|
immutable?: boolean;
|
|
props?: string;
|
|
props_object?: string;
|
|
};
|
|
|
|
// We need to tell estree-walker that it should always
|
|
// look for an `else` block, otherwise it might get
|
|
// the wrong idea about the shape of each/if blocks
|
|
childKeys.EachBlock = childKeys.IfBlock = ['children', 'else'];
|
|
childKeys.Attribute = ['value'];
|
|
childKeys.ExportNamedDeclaration = ['declaration', 'specifiers'];
|
|
|
|
function get_context(script) {
|
|
const context = script.attributes.find(attribute => attribute.name === 'context');
|
|
if (!context) return 'default';
|
|
|
|
if (context.value.length !== 1 || context.value[0].type !== 'Text') {
|
|
throw new Error(`context attribute must be static`);
|
|
}
|
|
|
|
return context.value[0].data;
|
|
}
|
|
|
|
export default class Component {
|
|
stats: Stats;
|
|
|
|
ast: Ast;
|
|
source: string;
|
|
name: string;
|
|
options: CompileOptions;
|
|
fragment: Fragment;
|
|
module_scope: Scope;
|
|
module_scope_map: WeakMap<Node, Scope>;
|
|
|
|
meta: Meta;
|
|
|
|
customElement: CustomElementOptions;
|
|
tag: string;
|
|
|
|
properties: Map<string, Node>;
|
|
|
|
instance_script: Node;
|
|
module_script: Node;
|
|
|
|
imports: Node[] = [];
|
|
namespace: string;
|
|
hasComponents: boolean;
|
|
module_javascript: string;
|
|
javascript: string;
|
|
|
|
declarations: string[] = [];
|
|
writable_declarations: Set<string> = new Set();
|
|
initialised_declarations: Set<string> = new Set();
|
|
node_for_declaration: Map<string, Node> = new Map();
|
|
exports: Array<{ name: string, as: string }> = [];
|
|
module_exports: Array<{ name: string, as: string }> = [];
|
|
partly_hoisted: string[] = [];
|
|
fully_hoisted: string[] = [];
|
|
|
|
code: MagicString;
|
|
|
|
indirectDependencies: Map<string, Set<string>> = new Map();
|
|
expectedProperties: Set<string> = new Set();
|
|
refs: Set<string> = new Set();
|
|
|
|
file: string;
|
|
locate: (c: number) => { line: number, column: number };
|
|
|
|
stylesheet: Stylesheet;
|
|
|
|
userVars: Set<string> = new Set();
|
|
templateVars: Map<string, string> = new Map();
|
|
aliases: Map<string, string> = new Map();
|
|
usedNames: Set<string> = new Set();
|
|
init_uses_self = false;
|
|
|
|
locator: (search: number, startIndex?: number) => {
|
|
line: number,
|
|
column: number
|
|
};
|
|
|
|
constructor(
|
|
ast: Ast,
|
|
source: string,
|
|
name: string,
|
|
options: CompileOptions,
|
|
stats: Stats
|
|
) {
|
|
this.stats = stats;
|
|
|
|
this.ast = ast;
|
|
this.source = source;
|
|
this.options = options;
|
|
|
|
this.file = options.filename && (
|
|
typeof process !== 'undefined' ? options.filename.replace(process.cwd(), '').replace(/^[\/\\]/, '') : options.filename
|
|
);
|
|
this.locate = getLocator(this.source);
|
|
|
|
this.code = new MagicString(source);
|
|
|
|
// styles
|
|
this.stylesheet = new Stylesheet(source, ast, options.filename, options.dev);
|
|
this.stylesheet.validate(this);
|
|
|
|
this.properties = new Map();
|
|
|
|
this.module_script = ast.js.find(script => get_context(script) === 'module');
|
|
this.instance_script = ast.js.find(script => get_context(script) === 'default');
|
|
|
|
this.walk_module_js();
|
|
this.walk_instance_js();
|
|
this.name = this.getUniqueName(name);
|
|
|
|
this.meta = process_meta(this, this.ast.html.children);
|
|
this.namespace = namespaces[this.meta.namespace] || this.meta.namespace;
|
|
|
|
if (options.customElement === true) {
|
|
this.customElement = {
|
|
tag: this.meta.tag,
|
|
props: [] // TODO!!!
|
|
};
|
|
} else {
|
|
this.customElement = options.customElement;
|
|
}
|
|
|
|
if (this.customElement && !this.customElement.tag) {
|
|
throw new Error(`No tag name specified`); // TODO better error
|
|
}
|
|
|
|
this.fragment = new Fragment(this, ast.html);
|
|
if (!this.customElement) this.stylesheet.reify();
|
|
|
|
this.stylesheet.warnOnUnusedSelectors(options.onwarn);
|
|
|
|
if (!this.instance_script) {
|
|
const props = [...this.expectedProperties];
|
|
this.declarations.push(...props);
|
|
addToSet(this.writable_declarations, this.expectedProperties);
|
|
|
|
this.exports = props.map(name => ({
|
|
name,
|
|
as: name
|
|
}));
|
|
}
|
|
}
|
|
|
|
addSourcemapLocations(node: Node) {
|
|
walk(node, {
|
|
enter: (node: Node) => {
|
|
this.code.addSourcemapLocation(node.start);
|
|
this.code.addSourcemapLocation(node.end);
|
|
},
|
|
});
|
|
}
|
|
|
|
alias(name: string) {
|
|
if (!this.aliases.has(name)) {
|
|
this.aliases.set(name, this.getUniqueName(name));
|
|
}
|
|
|
|
return this.aliases.get(name);
|
|
}
|
|
|
|
generate(result: string, options: CompileOptions, {
|
|
banner = '',
|
|
name,
|
|
format
|
|
}) {
|
|
const pattern = /\[✂(\d+)-(\d+)$/;
|
|
|
|
const helpers = new Set();
|
|
|
|
// TODO use same regex for both
|
|
result = result.replace(options.generate === 'ssr' ? /(@+|#+)(\w*(?:-\w*)?)/g : /(@+)(\w*(?:-\w*)?)/g, (match: string, sigil: string, name: string) => {
|
|
if (sigil === '@') {
|
|
if (name in internal) {
|
|
if (options.dev && `${name}Dev` in internal) name = `${name}Dev`;
|
|
helpers.add(name);
|
|
}
|
|
|
|
return this.alias(name);
|
|
}
|
|
|
|
return sigil.slice(1) + name;
|
|
});
|
|
|
|
const importedHelpers = Array.from(helpers)
|
|
.concat(options.dev ? 'SvelteComponentDev' : 'SvelteComponent')
|
|
.sort()
|
|
.map(name => {
|
|
const alias = this.alias(name);
|
|
return { name, alias };
|
|
});
|
|
|
|
const sharedPath = typeof options.shared === 'string'
|
|
? options.shared
|
|
: 'svelte/internal.js';
|
|
|
|
const module = wrapModule(
|
|
result,
|
|
format,
|
|
name,
|
|
options,
|
|
banner,
|
|
sharedPath,
|
|
importedHelpers,
|
|
this.imports,
|
|
this.module_exports,
|
|
this.source
|
|
);
|
|
|
|
const parts = module.split('✂]');
|
|
const finalChunk = parts.pop();
|
|
|
|
const compiled = new Bundle({ separator: '' });
|
|
|
|
function addString(str: string) {
|
|
compiled.addSource({
|
|
content: new MagicString(str),
|
|
});
|
|
}
|
|
|
|
const { filename } = options;
|
|
|
|
// special case — the source file doesn't actually get used anywhere. we need
|
|
// to add an empty file to populate map.sources and map.sourcesContent
|
|
if (!parts.length) {
|
|
compiled.addSource({
|
|
filename,
|
|
content: new MagicString(this.source).remove(0, this.source.length),
|
|
});
|
|
}
|
|
|
|
parts.forEach((str: string) => {
|
|
const chunk = str.replace(pattern, '');
|
|
if (chunk) addString(chunk);
|
|
|
|
const match = pattern.exec(str);
|
|
|
|
const snippet = this.code.snip(+match[1], +match[2]);
|
|
|
|
compiled.addSource({
|
|
filename,
|
|
content: snippet,
|
|
});
|
|
});
|
|
|
|
addString(finalChunk);
|
|
|
|
const css = this.customElement ?
|
|
{ code: null, map: null } :
|
|
this.stylesheet.render(options.cssOutputFilename, true);
|
|
|
|
const js = {
|
|
code: compiled.toString(),
|
|
map: compiled.generateMap({
|
|
includeContent: true,
|
|
file: options.outputFilename,
|
|
})
|
|
};
|
|
|
|
return {
|
|
ast: this.ast,
|
|
js,
|
|
css,
|
|
stats: this.stats.render(this)
|
|
};
|
|
}
|
|
|
|
getUniqueName(name: string) {
|
|
if (test) name = `${name}$`;
|
|
let alias = name;
|
|
for (
|
|
let i = 1;
|
|
reservedNames.has(alias) ||
|
|
this.userVars.has(alias) ||
|
|
this.usedNames.has(alias);
|
|
alias = `${name}_${i++}`
|
|
);
|
|
this.usedNames.add(alias);
|
|
return alias;
|
|
}
|
|
|
|
getUniqueNameMaker() {
|
|
const localUsedNames = new Set();
|
|
|
|
function add(name: string) {
|
|
localUsedNames.add(name);
|
|
}
|
|
|
|
reservedNames.forEach(add);
|
|
this.userVars.forEach(add);
|
|
|
|
return (name: string) => {
|
|
if (test) name = `${name}$`;
|
|
let alias = name;
|
|
for (
|
|
let i = 1;
|
|
this.usedNames.has(alias) ||
|
|
localUsedNames.has(alias);
|
|
alias = `${name}_${i++}`
|
|
);
|
|
localUsedNames.add(alias);
|
|
return alias;
|
|
};
|
|
}
|
|
|
|
error(
|
|
pos: {
|
|
start: number,
|
|
end: number
|
|
},
|
|
e : {
|
|
code: string,
|
|
message: string
|
|
}
|
|
) {
|
|
error(e.message, {
|
|
name: 'ValidationError',
|
|
code: e.code,
|
|
source: this.source,
|
|
start: pos.start,
|
|
end: pos.end,
|
|
filename: this.options.filename
|
|
});
|
|
}
|
|
|
|
warn(
|
|
pos: {
|
|
start: number,
|
|
end: number
|
|
},
|
|
warning: {
|
|
code: string,
|
|
message: string
|
|
}
|
|
) {
|
|
if (!this.locator) {
|
|
this.locator = getLocator(this.source, { offsetLine: 1 });
|
|
}
|
|
|
|
const start = this.locator(pos.start);
|
|
const end = this.locator(pos.end);
|
|
|
|
const frame = getCodeFrame(this.source, start.line - 1, start.column);
|
|
|
|
this.stats.warn({
|
|
code: warning.code,
|
|
message: warning.message,
|
|
frame,
|
|
start,
|
|
end,
|
|
pos: pos.start,
|
|
filename: this.options.filename,
|
|
toString: () => `${warning.message} (${start.line + 1}:${start.column})\n${frame}`,
|
|
});
|
|
}
|
|
|
|
findDependenciesForFunctionCall(name) {
|
|
const declaration = this.node_for_declaration.get(name);
|
|
|
|
const dependencies = new Set();
|
|
|
|
if (!declaration) {
|
|
// Global or module-scoped function — can't have
|
|
// local state as dependency by definition
|
|
return dependencies;
|
|
}
|
|
|
|
let { module_scope, module_scope_map: map } = this;
|
|
let scope = module_scope;
|
|
|
|
const component = this;
|
|
let bail = false;
|
|
|
|
walk(declaration, {
|
|
enter(node, parent) {
|
|
if (map.has(node)) {
|
|
scope = map.get(node);
|
|
}
|
|
|
|
if (isReference(node, parent)) {
|
|
const { name } = flattenReference(node);
|
|
if (scope.findOwner(name) === module_scope) {
|
|
dependencies.add(name);
|
|
}
|
|
}
|
|
|
|
if (node.type === 'CallExpression') {
|
|
if (node.callee.type === 'Identifier') {
|
|
const call_dependencies = component.findDependenciesForFunctionCall(node.callee.name);
|
|
if (!call_dependencies) {
|
|
bail = true;
|
|
return this.skip();
|
|
}
|
|
|
|
addToSet(dependencies, call_dependencies);
|
|
} else {
|
|
bail = true;
|
|
return this.skip();
|
|
}
|
|
}
|
|
},
|
|
|
|
leave(node) {
|
|
if (map.has(node)) {
|
|
scope = map.get(node);
|
|
}
|
|
}
|
|
});
|
|
|
|
return bail ? null : dependencies;
|
|
}
|
|
|
|
extract_imports_and_exports(content, imports, exports) {
|
|
const { code } = this;
|
|
|
|
content.body.forEach(node => {
|
|
if (node.type === 'ExportDefaultDeclaration') {
|
|
this.error(node, {
|
|
code: `default-export`,
|
|
message: `A component cannot have a default export`
|
|
});
|
|
}
|
|
|
|
if (node.type === 'ExportNamedDeclaration') {
|
|
if (node.declaration) {
|
|
if (node.declaration.type === 'VariableDeclaration') {
|
|
node.declaration.declarations.forEach(declarator => {
|
|
extractNames(declarator.id).forEach(name => {
|
|
exports.push({ name, as: name });
|
|
});
|
|
});
|
|
} else {
|
|
const { name } = node.declaration.id;
|
|
exports.push({ name, as: name });
|
|
}
|
|
|
|
code.remove(node.start, node.declaration.start);
|
|
} else {
|
|
removeNode(code, content.start, content.end, content.body, node);
|
|
node.specifiers.forEach(specifier => {
|
|
exports.push({
|
|
name: specifier.local.name,
|
|
as: specifier.exported.name
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// imports need to be hoisted out of the IIFE
|
|
// TODO hoist other stuff where possible
|
|
else if (node.type === 'ImportDeclaration') {
|
|
removeNode(code, content.start, content.end, content.body, node);
|
|
imports.push(node);
|
|
|
|
node.specifiers.forEach((specifier: Node) => {
|
|
this.userVars.add(specifier.local.name);
|
|
this.declarations.push(specifier.local.name); // TODO we don't really want this, but it's convenient for now
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
extract_javascript(script) {
|
|
let a = script.content.start;
|
|
while (/\s/.test(this.source[a])) a += 1;
|
|
|
|
let b = script.content.end;
|
|
while (/\s/.test(this.source[b - 1])) b -= 1;
|
|
|
|
return a !== b ? `[✂${a}-${b}✂]` : null;
|
|
}
|
|
|
|
walk_module_js() {
|
|
const script = this.module_script;
|
|
if (!script) return;
|
|
|
|
this.addSourcemapLocations(script.content);
|
|
|
|
// TODO unindent
|
|
|
|
this.extract_imports_and_exports(script.content, this.imports, this.module_exports);
|
|
this.module_javascript = this.extract_javascript(script);
|
|
}
|
|
|
|
walk_instance_js() {
|
|
const script = this.instance_script;
|
|
if (!script) return;
|
|
|
|
this.addSourcemapLocations(script.content);
|
|
|
|
let { scope, map, globals } = createScopes(script.content);
|
|
this.module_scope = scope;
|
|
this.module_scope_map = map;
|
|
|
|
scope.declarations.forEach((node, name) => {
|
|
this.userVars.add(name);
|
|
this.declarations.push(name);
|
|
|
|
this.node_for_declaration.set(name, node);
|
|
});
|
|
|
|
this.writable_declarations = scope.writable_declarations;
|
|
this.initialised_declarations = scope.initialised_declarations;
|
|
|
|
globals.forEach(name => {
|
|
this.userVars.add(name);
|
|
});
|
|
|
|
this.extract_imports_and_exports(script.content, this.imports, this.exports);
|
|
|
|
const top_scope = scope;
|
|
|
|
walk(script.content, {
|
|
enter: (node, parent) => {
|
|
if (map.has(node)) {
|
|
scope = map.get(node);
|
|
}
|
|
|
|
if (node.type === 'AssignmentExpression') {
|
|
const { name } = flattenReference(node.left);
|
|
|
|
if (scope.findOwner(name) === top_scope) {
|
|
this.instrument(node, parent, name, false);
|
|
}
|
|
}
|
|
},
|
|
|
|
leave(node) {
|
|
if (map.has(node)) {
|
|
scope = scope.parent;
|
|
}
|
|
}
|
|
});
|
|
|
|
this.javascript = this.extract_javascript(script);
|
|
}
|
|
|
|
instrument(node, parent, name, is_event_handler) {
|
|
// TODO only make values reactive if they're used
|
|
// in the template
|
|
|
|
if (parent.type === 'ArrowFunctionExpression' && node === parent.body) {
|
|
if (is_event_handler) {
|
|
this.code.prependRight(node.start, `{ `);
|
|
this.code.appendLeft(node.end, `; $$make_dirty('${name}'); }`);
|
|
} else {
|
|
this.code.prependRight(node.start, `{ const $$result = `);
|
|
this.code.appendLeft(node.end, `; $$make_dirty('${name}'); return $$result; }`);
|
|
}
|
|
}
|
|
|
|
else {
|
|
this.code.appendLeft(node.end, `; $$make_dirty('${name}')`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function process_meta(component, nodes) {
|
|
const meta: Meta = {};
|
|
const node = nodes.find(node => node.name === 'svelte:meta');
|
|
|
|
if (node) {
|
|
node.attributes.forEach(attribute => {
|
|
if (attribute.type === 'Attribute') {
|
|
const { name, value } = attribute;
|
|
|
|
if (value.length > 1 || (value[0] && value[0].type !== 'Text')) {
|
|
component.error(attribute, {
|
|
code: `invalid-meta-attribute`,
|
|
message: `<svelte:meta> cannot have dynamic attributes`
|
|
});
|
|
}
|
|
|
|
const { data } = value[0];
|
|
|
|
switch (name) {
|
|
case 'tag':
|
|
case 'namespace':
|
|
if (!data) {
|
|
component.error(attribute, {
|
|
code: `invalid-${name}-attribute`,
|
|
message: `<svelte:meta> ${name} attribute must have a string value`
|
|
});
|
|
}
|
|
|
|
meta[name] = data;
|
|
break;
|
|
|
|
case 'immutable':
|
|
if (data && (data !== 'true' && data !== 'false')) {
|
|
component.error(attribute, {
|
|
code: `invalid-immutable-attribute`,
|
|
message: `<svelte:meta> immutable attribute must be true or false`
|
|
});
|
|
}
|
|
|
|
meta.immutable = data !== 'false';
|
|
|
|
default:
|
|
component.error(attribute, {
|
|
code: `invalid-meta-attribute`,
|
|
message: `<svelte:meta> unknown attribute`
|
|
});
|
|
}
|
|
}
|
|
|
|
else if (attribute.type === 'Binding') {
|
|
if (attribute.name !== 'props') {
|
|
component.error(attribute, {
|
|
code: `invalid-meta-binding`,
|
|
message: `<svelte:meta> only supports bind:props`
|
|
});
|
|
}
|
|
|
|
const { start, end } = attribute.expression;
|
|
const { name } = flattenReference(attribute.expression);
|
|
|
|
meta.props = `[✂${start}-${end}✂]`;
|
|
meta.props_object = name;
|
|
}
|
|
|
|
else {
|
|
component.error(attribute, {
|
|
code: `invalid-meta-attribute`,
|
|
message: `<svelte:meta> can only have static 'tag', 'namespace' and 'immutable' attributes, or a bind:props directive`
|
|
});
|
|
}
|
|
|
|
|
|
});
|
|
}
|
|
|
|
return meta;
|
|
} |