Merge pull request #12203 from meteor/feature/static-html-tools

Move needed files for static-html into static-html-tools
This commit is contained in:
Denilson
2024-09-26 09:49:30 -04:00
committed by GitHub
11 changed files with 580 additions and 14 deletions

View File

@@ -7,7 +7,7 @@ Package.describe({
Npm.depends({
'lru-cache': '6.0.0'
})
});
Package.onUse(function(api) {
api.use(['ecmascript', 'random']);

View File

@@ -0,0 +1,148 @@
import { CompileError } from './throw-compile-error';
import isEmpty from 'lodash.isempty';
import { CachingCompiler } from 'meteor/caching-compiler';
const path = Plugin.path;
// The CompileResult type for this CachingCompiler is the return value of
// htmlScanner.scan: a {js, head, body, bodyAttrs} object.
export class CachingHtmlCompiler extends CachingCompiler {
/**
* Constructor for CachingHtmlCompiler
* @param {String} name The name of the compiler, printed in errors -
* should probably always be the same as the name of the build
* plugin/package
* @param {Function} tagScannerFunc Transforms a template file (commonly
* .html) into an array of Tags
* @param {Function} tagHandlerFunc Transforms an array of tags into a
* results object with js, body, head, and bodyAttrs properties
*/
constructor(name, tagScannerFunc, tagHandlerFunc) {
super({
compilerName: name,
defaultCacheSize: 1024 * 1024 * 10,
});
this._bodyAttrInfo = null;
this.tagScannerFunc = tagScannerFunc;
this.tagHandlerFunc = tagHandlerFunc;
}
// Implements method from CachingCompilerBase
// eslint-disable-next-line class-methods-use-this
compileResultSize(compileResult) {
const lengthOrZero = (field) => field ? field.length : 0;
const headSize = lengthOrZero(compileResult.head);
const bodySize = lengthOrZero(compileResult.body);
const jsSize = lengthOrZero(compileResult.js);
return headSize + bodySize + jsSize;
}
// Overrides method from CachingCompiler
processFilesForTarget(inputFiles) {
this._bodyAttrInfo = {};
return super.processFilesForTarget(inputFiles);
}
// Implements method from CachingCompilerBase
// eslint-disable-next-line class-methods-use-this
getCacheKey(inputFile) {
// Note: the path is only used for errors, so it doesn't have to be part
// of the cache key.
return [
inputFile.getArch(),
inputFile.getSourceHash(),
inputFile.hmrAvailable && inputFile.hmrAvailable(),
];
}
// Implements method from CachingCompiler
compileOneFile(inputFile) {
const contents = inputFile.getContentsAsString();
const inputPath = inputFile.getPathInPackage();
try {
const tags = this.tagScannerFunc({
sourceName: inputPath,
contents,
tagNames: ['body', 'head', 'template'],
});
return this.tagHandlerFunc(tags, inputFile.hmrAvailable && inputFile.hmrAvailable());
} catch (e) {
if (e instanceof CompileError) {
inputFile.error({
message: e.message,
line: e.line,
});
return null;
}
throw e;
}
}
// Implements method from CachingCompilerBase
addCompileResult(inputFile, compileResult) {
let allJavaScript = '';
if (compileResult.head) {
inputFile.addHtml({ section: 'head', data: compileResult.head });
}
if (compileResult.body) {
inputFile.addHtml({ section: 'body', data: compileResult.body });
}
if (compileResult.js) {
allJavaScript += compileResult.js;
}
if (!isEmpty(compileResult.bodyAttrs)) {
Object.keys(compileResult.bodyAttrs).forEach((attr) => {
const value = compileResult.bodyAttrs[attr];
if (Object.prototype.hasOwnProperty.call(this._bodyAttrInfo, attr) &&
this._bodyAttrInfo[attr].value !== value) {
// two conflicting attributes on <body> tags in two different template
// files
inputFile.error({
message:
`${`<body> declarations have conflicting values for the '${attr}' ` +
'attribute in the following files: '}${
this._bodyAttrInfo[attr].inputFile.getPathInPackage()
}, ${inputFile.getPathInPackage()}`,
});
} else {
this._bodyAttrInfo[attr] = { inputFile, value };
}
});
// Add JavaScript code to set attributes on body
allJavaScript +=
`Meteor.startup(function() {
var attrs = ${JSON.stringify(compileResult.bodyAttrs)};
for (var prop in attrs) {
document.body.setAttribute(prop, attrs[prop]);
}
});
`;
}
if (allJavaScript) {
const filePath = inputFile.getPathInPackage();
// XXX this path manipulation may be unnecessarily complex
let pathPart = path.dirname(filePath);
if (pathPart === '.') pathPart = '';
if (pathPart.length && pathPart !== path.sep) pathPart += path.sep;
const ext = path.extname(filePath);
const basename = path.basename(filePath, ext);
// XXX generate a source map
inputFile.addJavaScript({
path: path.join(pathPart, `template.${basename}.js`),
data: allJavaScript,
});
}
}
}

View File

@@ -0,0 +1,191 @@
import { TemplatingTools } from 'meteor/templating-tools';
Tinytest.add("static-html-tools - html scanner", function (test) {
var testInString = function(actualStr, wantedContents) {
if (actualStr.indexOf(wantedContents) >= 0)
test.ok();
else
test.fail("Expected "+JSON.stringify(wantedContents)+
" in "+JSON.stringify(actualStr));
};
var checkError = function(f, msgText, lineNum) {
try {
f();
} catch (e) {
if (! e instanceof TemplatingTools.CompileError) {
throw e;
}
if (e.line === lineNum)
test.ok();
else
test.fail("Error should have been on line " + lineNum + ", not " +
e.line);
testInString(e.message, msgText);
return;
}
test.fail("Parse error didn't throw exception");
};
// returns the appropriate code to put content in the body,
// where content is something simple like the string "Hello"
// (passed in as a source string including the quotes).
var simpleBody = function (content) {
return "\nTemplate.body.addContent((function() {\n var view = this;\n return " + content + ";\n}));\nMeteor.startup(Template.body.renderToDocument);\n";
};
// arguments are quoted strings like '"hello"'
var simpleTemplate = function (templateName, content) {
// '"hello"' into '"Template.hello"'
var viewName = templateName.slice(0, 1) + 'Template.' + templateName.slice(1);
return '\nTemplate.__checkName(' + templateName + ');\nTemplate[' + templateName +
'] = new Template(' + viewName +
', (function() {\n var view = this;\n return ' + content + ';\n}));\n';
};
var checkResults = function(results, expectJs, expectHead, expectBodyAttrs) {
test.equal(results.body, '');
test.equal(results.js, expectJs || '');
test.equal(results.head, expectHead || '');
test.equal(results.bodyAttrs, expectBodyAttrs || {});
};
function scanForTest(contents) {
const tags = TemplatingTools.scanHtmlForTags({
sourceName: "",
contents: contents,
tagNames: ["body", "head", "template"]
});
return TemplatingTools.compileTagsWithSpacebars(tags);
}
checkError(function() {
return scanForTest("asdf");
}, "Expected one of: <body>, <head>, <template>", 1);
// body all on one line
checkResults(
scanForTest("<body>Hello</body>"),
simpleBody('"Hello"'));
// multi-line body, contents trimmed
checkResults(
scanForTest("\n\n\n<body>\n\nHello\n\n</body>\n\n\n"),
simpleBody('"Hello"'));
// same as previous, but with various HTML comments
checkResults(
scanForTest("\n<!--\n\nfoo\n-->\n<!-- -->\n"+
"<body>\n\nHello\n\n</body>\n\n<!----\n>\n\n"),
simpleBody('"Hello"'));
// head and body
checkResults(
scanForTest("<head>\n<title>Hello</title>\n</head>\n\n<body>World</body>\n\n"),
simpleBody('"World"'),
"<title>Hello</title>");
// head and body with tag whitespace
checkResults(
scanForTest("<head\n>\n<title>Hello</title>\n</head >\n\n<body>World</body\n\n>\n\n"),
simpleBody('"World"'),
"<title>Hello</title>");
// head, body, and template
checkResults(
scanForTest("<head>\n<title>Hello</title>\n</head>\n\n<body>World</body>\n\n"+
'<template name="favoritefood">\n pizza\n</template>\n'),
simpleBody('"World"') + simpleTemplate('"favoritefood"', '"pizza"'),
"<title>Hello</title>");
// one-line template
checkResults(
scanForTest('<template name="favoritefood">pizza</template>'),
simpleTemplate('"favoritefood"', '"pizza"'));
// template with other attributes
checkResults(
scanForTest('<template foo="bar" name="favoritefood" baz="qux">'+
'pizza</template>'),
simpleTemplate('"favoritefood"', '"pizza"'));
// whitespace around '=' in attributes and at end of tag
checkResults(
scanForTest('<template foo = "bar" name ="favoritefood" baz= "qux" >'+
'pizza</template\n\n>'),
simpleTemplate('"favoritefood"', '"pizza"'));
// whitespace around template name
checkResults(
scanForTest('<template name=" favoritefood ">pizza</template>'),
simpleTemplate('"favoritefood"', '"pizza"'));
// single quotes around template name
checkResults(
scanForTest('<template name=\'the "cool" template\'>'+
'pizza</template>'),
simpleTemplate('"the \\"cool\\" template"', '"pizza"'));
checkResults(scanForTest('<body foo="bar">\n Hello\n</body>'), simpleBody('"Hello"'), "", {foo: "bar"});
// error cases; exact line numbers are not critical, these just reflect
// the current implementation
// unclosed body (error mentions body)
checkError(function() {
return scanForTest("\n\n<body>\n Hello\n</body");
}, "body", 3);
// bad open tag
checkError(function() {
return scanForTest("\n\n\n<bodyd>\n Hello\n</body>");
}, "Expected one of: <body>, <head>, <template>", 4);
checkError(function() {
return scanForTest("\n\n\n\n<body foo=>\n Hello\n</body>");
}, "error in tag", 5);
// unclosed tag
checkError(function() {
return scanForTest("\n<body>Hello");
}, "nclosed", 2);
// unnamed template
checkError(function() {
return scanForTest(
"\n\n<template>Hi</template>\n\n<template>Hi</template>");
}, "name", 3);
// helpful doctype message
checkError(function() {
return scanForTest(
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" '+
'"http://www.w3.org/TR/html4/strict.dtd">'+
'\n\n<head>\n</head>');
}, "DOCTYPE", 1);
// lowercase basic doctype
checkError(function() {
return scanForTest(
'<!doctype html>');
}, "DOCTYPE", 1);
// attributes on head not supported
checkError(function() {
return scanForTest('<head foo="bar">\n Hello\n</head>');
}, "<head>", 1);
// can't mismatch quotes
checkError(function() {
return scanForTest('<template name="foo\'>'+
'pizza</template>');
}, "error in tag", 1);
// unexpected <html> at top level
checkError(function() {
return scanForTest('\n<html>\n</html>');
}, "Expected one of: <body>, <head>, <template>", 2);
});

View File

@@ -0,0 +1,174 @@
import { CompileError } from './throw-compile-error';
export function scanHtmlForTags(options) {
const scan = new HtmlScan(options);
return scan.getTags();
}
/**
* Scan an HTML file for top-level tags and extract their contents. Pass them to
* a tag handler (an object with a handleTag method)
*
* This is a primitive, regex-based scanner. It scans
* top-level tags, which are allowed to have attributes,
* and ignores top-level HTML comments.
*/
class HtmlScan {
/**
* Initialize and run a scan of a single file
* @param {String} sourceName The filename, used in errors only
* @param {String} contents The contents of the file
* @param {String[]} tagNames An array of tag names that are accepted at the
* top level. If any other tag is encountered, an error is thrown.
*/
constructor({
sourceName,
contents,
tagNames
}) {
this.sourceName = sourceName;
this.contents = contents;
this.tagNames = tagNames;
this.rest = contents;
this.index = 0;
this.tags = [];
const tagNameRegex = this.tagNames.join("|");
const openTagRegex = new RegExp(`^((<(${tagNameRegex})\\b)|(<!--)|(<!DOCTYPE|{{!)|$)`, "i");
while (this.rest) {
// skip whitespace first (for better line numbers)
this.advance(this.rest.match(/^\s*/)[0].length);
const match = openTagRegex.exec(this.rest);
if (! match) {
this.throwCompileError(`Expected one of: <${this.tagNames.join('>, <')}>`);
}
const matchToken = match[1];
const matchTokenTagName = match[3];
const matchTokenComment = match[4];
const matchTokenUnsupported = match[5];
const tagStartIndex = this.index;
this.advance(match.index + match[0].length);
if (! matchToken) {
break; // matched $ (end of file)
}
if (matchTokenComment === '<!--') {
// top-level HTML comment
const commentEnd = /--\s*>/.exec(this.rest);
if (! commentEnd)
this.throwCompileError("unclosed HTML comment in template file");
this.advance(commentEnd.index + commentEnd[0].length);
continue;
}
if (matchTokenUnsupported) {
switch (matchTokenUnsupported.toLowerCase()) {
case '<!doctype':
this.throwCompileError(
"Can't set DOCTYPE here. (Meteor sets <!DOCTYPE html> for you)");
case '{{!':
this.throwCompileError(
"Can't use '{{! }}' outside a template. Use '<!-- -->'.");
}
this.throwCompileError();
}
// otherwise, a <tag>
const tagName = matchTokenTagName.toLowerCase();
const tagAttribs = {}; // bare name -> value dict
const tagPartRegex = /^\s*((([a-zA-Z0-9:_-]+)\s*=\s*(["'])(.*?)\4)|(>))/;
// read attributes
let attr;
while ((attr = tagPartRegex.exec(this.rest))) {
const attrToken = attr[1];
const attrKey = attr[3];
let attrValue = attr[5];
this.advance(attr.index + attr[0].length);
if (attrToken === '>') {
break;
}
// XXX we don't HTML unescape the attribute value
// (e.g. to allow "abcd&quot;efg") or protect against
// collisions with methods of tagAttribs (e.g. for
// a property named toString)
attrValue = attrValue.match(/^\s*([\s\S]*?)\s*$/)[1]; // trim
tagAttribs[attrKey] = attrValue;
}
if (! attr) { // didn't end on '>'
this.throwCompileError("Parse error in tag");
}
// find </tag>
const end = (new RegExp('</'+tagName+'\\s*>', 'i')).exec(this.rest);
if (! end) {
this.throwCompileError("unclosed <"+tagName+">");
}
const tagContents = this.rest.slice(0, end.index);
const contentsStartIndex = this.index;
// trim the tag contents.
// this is a courtesy and is also relied on by some unit tests.
var m = tagContents.match(/^([ \t\r\n]*)([\s\S]*?)[ \t\r\n]*$/);
const trimmedContentsStartIndex = contentsStartIndex + m[1].length;
const trimmedTagContents = m[2];
const tag = {
tagName: tagName,
attribs: tagAttribs,
contents: trimmedTagContents,
contentsStartIndex: trimmedContentsStartIndex,
tagStartIndex: tagStartIndex,
fileContents: this.contents,
sourceName: this.sourceName
};
// save the tag
this.tags.push(tag);
// advance afterwards, so that line numbers in errors are correct
this.advance(end.index + end[0].length);
}
}
/**
* Advance the parser
* @param {Number} amount The amount of characters to advance
*/
advance(amount) {
this.rest = this.rest.substring(amount);
this.index += amount;
}
throwCompileError(msg, overrideIndex) {
const finalIndex = (typeof overrideIndex === 'number' ? overrideIndex : this.index);
const err = new CompileError();
err.message = msg || "bad formatting in template file";
err.file = this.sourceName;
err.line = this.contents.substring(0, finalIndex).split('\n').length;
throw err;
}
throwBodyAttrsError(msg) {
this.parseError(msg);
}
getTags() {
return this.tags;
}
}

View File

@@ -0,0 +1,36 @@
Package.describe({
name: 'static-html-tools',
summary: "Tools for static-html",
version: '1.0.0',
git: 'https://github.com/meteor/meteor.git'
});
Npm.depends({
'lodash.isempty': '4.4.0'
});
Package.onUse(function(api) {
api.use([
'ecmascript',
'caching-compiler'
]);
api.export('TemplatingTools');
api.mainModule('templating-tools.js');
});
Package.onTest(function(api) {
api.use([
'tinytest',
'ecmascript'
]);
api.use([
'templating-tools'
]);
api.addFiles([
'html-scanner-tests.js'
], 'server');
});

View File

@@ -0,0 +1,3 @@
export { throwCompileError, CompileError } from './throw-compile-error';
export { scanHtmlForTags } from './html-scanner';
export { CachingHtmlCompiler } from './caching-html-compiler';

View File

@@ -0,0 +1,12 @@
export class CompileError {}
export function throwCompileError(tag, message, overrideIndex) {
const finalIndex = (typeof overrideIndex === 'number' ?
overrideIndex : tag.tagStartIndex);
const err = new CompileError();
err.message = message || "bad formatting in template file";
err.file = tag.sourceName;
err.line = tag.fileContents.substring(0, finalIndex).split('\n').length;
throw err;
}

View File

@@ -1,16 +1,16 @@
Package.describe({
name: 'static-html',
summary: "Define static page content in .html files",
version: '1.3.3',
version: '1.4.0',
git: 'https://github.com/meteor/meteor.git'
});
Package.registerBuildPlugin({
name: "compileStaticHtmlBatch",
use: [
'ecmascript@0.16.8-beta300.7',
'caching-html-compiler@1.2.2 || 2.0.0-alpha300.16',
'templating-tools@1.2.3 || 2.0.0-alpha300.16'
'ecmascript@0.16.9',
'static-html-tools@1.0.0',
'caching-compiler@2.0.0',
],
sources: [
'static-html.js'

View File

@@ -1,22 +1,24 @@
import { CachingHtmlCompiler, scanHtmlForTags, throwCompileError } from 'meteor/static-html-tools';
Plugin.registerCompiler({
extensions: ['html'],
archMatching: 'web',
isTemplate: true
}, () => new CachingHtmlCompiler("static-html", TemplatingTools.scanHtmlForTags, compileTagsToStaticHtml));
}, () => new CachingHtmlCompiler("static-html", scanHtmlForTags, compileTagsToStaticHtml));
// Same API as TutorialTools.compileTagsWithSpacebars, but instead of compiling
// with Spacebars, it just returns static HTML
function compileTagsToStaticHtml(tags) {
var handler = new StaticHtmlTagHandler();
const handler = new StaticHtmlTagHandler();
tags.forEach((tag) => {
handler.addTagToResults(tag);
});
return handler.getResults();
};
}
var isEmpty = obj => [Object, Array].includes((obj || {}).constructor) && !Object.entries((obj || {})).length;
const isEmpty = obj => [Object, Array].includes((obj || {}).constructor) && !Object.entries((obj || {})).length;
class StaticHtmlTagHandler {
constructor() {
@@ -86,7 +88,7 @@ class StaticHtmlTagHandler {
}
throwCompileError(message, overrideIndex) {
TemplatingTools.throwCompileError(this.tag, message, overrideIndex);
throwCompileError(this.tag, message, overrideIndex);
}
}

View File

@@ -1133,9 +1133,9 @@
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
},
"rimraf": {
"version": "2.7.1",