const path = require('path');
const fs = require('fs/promises');
const { provider } = require('@homer0/jimple');
/**
* @typedef {Object} SFCParserResultTag
* @property {string} statement The tag full statement (match) for the tag.
* @property {string} name The name of the tag.
* @property {boolean} closing Whether or not the tag is for closign (`</`).
* @property {Object} attributes A dictionary with the tag attributes.
* @ignore
*/
/**
* @typedef {Object} SFCParserResult
* @property {string} content The contents of a style/script tag.
* @property {SFCParserResultTag} tag The tag information.
* @ignore
*/
/**
* @typedef {Object} SFCParserResults
* @property {SFCParserResult[]} script A list of the script tags found on the SFC.
* @property {SFCParserResult[]} style A list of the style tags found on the SFC.
* @property {string} markup The HTML markup of the SFC.
* @ignore
*/
/**
* @typedef {Object} SFCParserExtendTag
* @property {string} statement The tag full statement (match) for the tag.
* @property {Object} attributes A dictionary with the tag attributes.
* @ignore
*/
/**
* This is the parser that reads a single file component (SFC) and transform it into a
* {@link SFCData} object.
*/
class SFCParser {
/**
* @param {Class<SFCData>} sfcData The class used to create the objects with the SFC
* parsed information.
*/
constructor(sfcData) {
/**
* The class used to create the objects with the SFC parsed information.
*
* @type {Class<SFCData>}
* @access protected
* @ignore
*/
this._sfcData = sfcData;
/**
* A dictionary of regular expression the parser uses.
*
* @type {Object}
* @property {RegExp} extendTag The expression that detects the `<extend />` tag.
* @property {RegExp} attributes A expression that matches HTML attributes (outside
* a tag).
* @property {RegExp} boolean A expression to detect whether or not a string is
* actually a boolean flag.
* @property {RegExp} relevantTags A expression that matches relevant tags for the
* parser from a line of code.
* @access protected
* @ignore
*/
this._expressions = {
extendTag: /<\s*extend\s+(.*?)\s*\/?>(?:\s*<\s*\/\s*extend\s*>)?/i,
attributes: /([\w-]+)(?:\s*=\s*['"](.*?)['"]|\s*|$)/g,
boolean: /(?:true|false)/i,
relevantTags: /<\s*(\/\s*)?(script|style)(.*?)>/gi,
};
}
/**
* Parses a SFC.
*
* @param {string} contents The contents of the file.
* @param {string} filepath The path of the file.
* @param {number} [maxDepth=0] How many components can be extended. For example, if a
* file extends from one that extends from another and the
* parameter is set to `1`, the parsing will fail.
* @returns {Promise<?SFCData, Error>} If the file doesn't implement the `<extend />`
* tag, the promise will resolve with `null`.
*/
parse(contents, filepath, maxDepth = 0) {
return this._parse(contents, filepath, maxDepth, 1);
}
/**
* Parses a SFC by loading the file first; after the file is loaded, the method will
* internally call {@link SFCParser#parse}.
*
* @param {string} filepath The path of the file.
* @param {number} [maxDepth=0] How many components can be extended. For example, if a
* file extends from one that extends from another and the
* parameter is set to `1`, the parsing will fail.
* @returns {Promise<?SFCData, Error>} If the file doesn't implement the `<extend />`
* tag, the promise will resolve with `null`.
*/
async parseFromPath(filepath, maxDepth = 0) {
const contents = await fs.readFile(filepath, 'utf-8');
return this.parse(contents, filepath, maxDepth);
}
/**
* Creates an instance of {@link SFCData} with the parsed results of an SFC.
*
* @param {string} filepath The path of the SFC.
* @param {SFCParserResults} parsedResults The information obtained from parsing the
* SFC.
* @returns {SFCData}
* @access protected
* @ignore
*/
_createDataObject(filepath, parsedResults) {
const data = this._sfcData.new(filepath);
data.addMarkup(parsedResults.markup);
parsedResults.script.forEach((script) => {
const { tag, content } = script;
data.addScript(content, tag.attributes);
});
parsedResults.style.forEach((style) => {
const { tag, content } = style;
data.addStyle(content, tag.attributes);
});
return data;
}
/**
* Finds and parses the information of an `<extend />` tag on a SFC.
*
* @param {string} contents The contents of the SFC.
* @returns {?SFCParserExtendTag}
* @access protected
* @ignore
*/
_getExtendTag(contents) {
let result;
const match = this._expressions.extendTag.exec(contents);
if (match) {
const [statement, rawAttributes] = match;
const attributes = this._getTagAttributes(rawAttributes);
result = {
statement,
attributes,
};
} else {
result = null;
}
return result;
}
/**
* Finds a relevant tag for the parser on a line of code.
*
* @param {string} line The line to parse.
* @param {number} lineNumber The number of the line, on the SFC.
* @param {string} filepath The path of the SFC.
* @returns {?SFCParserResultTag}
* @throws {Error} If it finds two relevant tags (style/script) on the same line.
* @access protected
* @ignore
*/
_getRelevantTag(line, lineNumber, filepath) {
let result;
const match = this._expressions.relevantTags.exec(line);
if (match) {
const [statement, slash, tagName, rawAttributes] = match;
const name = tagName.trim();
const closing = typeof slash !== 'undefined';
const attributes = this._getTagAttributes(rawAttributes);
result = {
statement,
name,
closing,
attributes,
};
if (this._expressions.relevantTags.exec(line)) {
const errorMessage = [
'The parser cant handle multiple script/style tags on the same line (sorry!)',
`- file: ${filepath}`,
`- line: ${lineNumber}`,
`- code: ${line}`,
].join('\n');
throw new Error(errorMessage);
}
} else {
result = null;
}
return result;
}
/**
* Parses a string of HTML tag attributes into an object.
* If an attribute doesn't have a value, its value will be `true` (boolean, no string);
* and if a value is a string for a boolean (`'true'` or `'false'`), it will become a
* real boolean.
*
* @param {string} rawAttributes The attributes to parse.
* @returns {Object}
* @access protected
* @example
*
* console.log(parser._getTagAttributes('from="file" html'));
* // { from: 'file', html: true }
* console.log(parser._getTagAttributes('from="file" html="false"'));
* // { from: 'file', html: false }
*
* @ignore
*/
_getTagAttributes(rawAttributes) {
const result = {};
let match = this._expressions.attributes.exec(rawAttributes);
while (match) {
let [, name, value] = match;
name = name.trim();
if (value) {
value = value.trim();
if (value.match(this._expressions.boolean)) {
value = value.toLowerCase() === 'true';
}
} else {
value = true;
}
result[name] = value;
match = this._expressions.attributes.exec(rawAttributes);
}
return result;
}
/**
* Loads a SFC and checks if it implements an `<extend />` tag; if it does, it calls
* {@link SFCParser#_parse} to parse its "base SFC" first; otherwise, it parses its
* contents directly.
*
* @param {string} filepath The path of the file.
* @param {number} maxDepth How many components can be extended. For example, if a
* file extends from one that extends from another and the
* parameter is set to `1`, the parsing will fail.
* @param {number} currentDepth The level of depth in which a file is currently being
* extended.
* @returns {Promise<SFCData | SFCParserResults, Error>}
* @access protected
* @ignore
*/
async _loadDataFromPath(filepath, maxDepth, currentDepth) {
const contents = await fs.readFile(filepath, 'utf-8');
const extendTag = this._getExtendTag(contents);
if (extendTag) {
return this._parse(contents, filepath, maxDepth, currentDepth, extendTag);
}
return this._parseFileData(contents, filepath);
}
/**
* The method that actually does the parsing. The reason this is not in
* {@link SFCParser#parse}
* is because this method can be called recursively for each "level of extension a file
* has".
*
* @param {string} contents The contents of the file.
* @param {string} filepath The path of the file.
* @param {number} maxDepth How many components can be extended.
* For example, if a file extends from
* one that extends from another and the
* parameter is set to `1`, the parsing
* will fail.
* @param {number} currentDepth The level of depth in which a file is
* currently being extended.
* @param {?SFCParserExtendTag} [extendTag=null] When this method is called internally,
* it's because another method found an
* `<extend />` tag by reading a file and
* needs the file parsed, so instead of
* looking for the tag again, the tag can
* be provided with this parameter.
* @returns {Promise<?SFCData, Error>} If the file doesn't implement the `<extend />`
* tag, the promise will resolve with `null`.
* @access protected
* @ignore
*/
async _parse(contents, filepath, maxDepth, currentDepth, extendTag = null) {
const useExtendTag = extendTag || this._getExtendTag(contents);
if (!useExtendTag || !useExtendTag.attributes.from) {
return null;
}
const newCurrentDepth = currentDepth + 1;
if (maxDepth && newCurrentDepth > maxDepth) {
throw new Error(
`The file '${filepath}' can't extend from another file, the max depth ` +
`limit is set to ${maxDepth}`,
);
}
const filedir = path.dirname(filepath);
const fromFilepath = path.join(filedir, useExtendTag.attributes.from);
const exists = await fs
.access(fromFilepath)
.then(() => true)
.catch(() => false);
if (!exists) {
throw new Error(
`Unable to load '${useExtendTag.attributes.from}' from '${filepath}'`,
);
}
const data = await this._loadDataFromPath(fromFilepath, maxDepth, newCurrentDepth);
const file = this._createDataObject(
filepath,
this._parseFileData(contents.replace(useExtendTag.statement, ''), filepath),
);
const useData =
data instanceof this._sfcData ? data : this._createDataObject(fromFilepath, data);
file.addBaseFileData(useData, useExtendTag.attributes);
return file;
}
/**
* Parses a SFC code and extract the information about its scripts, styling and markup.
*
* @param {string} contents The contents of the SFC.
* @param {string} filepath The path of the SFC.
* @returns {SFCParserResults}
* @access protected
* @ignore
*/
_parseFileData(contents, filepath) {
/**
* This will work as an accumulator that will take lines when a open tag is detected. When
* the closing tag is detected, all those lines will be associated to the tag, saved, and the
* accumulator resetted.
*/
let currentLines = [];
// This will be the information of the currently open tag the parser found.
let currentOpenTag = null;
// The dictionary with the information the method will eventually return.
const result = {
script: [],
style: [],
markup: '',
};
/**
* This is a safeguard in case the parser found an open tag inside another open tag (always
* talking about script and style). If a tag for opening is found inside one that is already
* opened, its counter will increment; if a closing tag is found and it's counter is not `0`,
* instead of closing the tag, the counter will decrement and the tag will be handled as a
* "content line".
*
* Not the best solution, and it's a pretty edge case, but you can't use conventional HTML
* parsers when with the Svelte DSL in the middle... I tried.
*/
const ignoreNextCounters = {
script: 0,
style: 0,
};
/**
* A list that will save all lines that are outside a script/style tag. They'll eventually be
* filtered to remove the empty ones, and joined into a string.
*/
const markupLines = [];
// Let the parsing beging!
contents
// Separate the file by its lines.
.split('\n')
// And for each line...
.forEach((line, index) => {
// Try to find a relevant tag for the parser, script or style.
const tag = this._getRelevantTag(line, index + 1, filepath);
if (tag) {
// If a tag was found, remove the tag form the line...
const rest = line.replace(tag.statement, '').trim();
/**
* And if the line still has content, and no tag is currently open, or another tag
* with the same name is open, or the tag that was removed is for closing the opened
* tag... consider it markup.
* Like the counters, this is for edge cases.
*/
if (
rest &&
(!currentOpenTag || currentOpenTag.name !== tag.name || tag.closing)
) {
markupLines.push(rest);
}
if (currentOpenTag) {
// If a tag is currently open...
if (currentOpenTag.name === tag.name && tag.closing) {
// And the tag found is for closing it...
if (ignoreNextCounters[tag.name]) {
// If the counter is not `0`, decrement it and ignore the tag, just save the line.
ignoreNextCounters[tag.name]--;
currentLines.push(line);
} else {
/**
* But if it's an actual closing tag, save all the accumulated lines, its reference
* and reset the accumulator.
*/
result[currentOpenTag.name].push({
tag: currentOpenTag,
content: currentLines.join('\n'),
});
currentOpenTag = null;
currentLines = [];
}
} else {
// But if the tag is not the one for closing, ignore it and save the line.
if (currentOpenTag.name === tag.name) {
ignoreNextCounters[tag.name]++;
}
currentLines.push(line);
}
} else {
// If no tag is open, send all the accumulated lines to the markup and open the tag.
markupLines.push(...currentLines);
currentLines = [];
currentOpenTag = tag;
}
} else {
// If no tag was found, just save the line.
currentLines.push(line);
}
});
// All lines that are not inside a tag, go to the markup.
if (currentLines.length) {
markupLines.push(...currentLines);
}
// Remove empty lines from the markup and transform it into text.
result.markup = markupLines.filter((line) => !!line.trim()).join('\n');
return result;
}
}
/**
* The service provider that once registered on {@link SvelteExtend} will save the an
* instance of {@link SFCParser} as the `sfcParser` service.
*
* @type {Provider}
*/
const sfcParser = provider((app) => {
app.set('sfcParser', () => new SFCParser(app.get('sfcData')));
});
module.exports = {
SFCParser,
sfcParser,
};