diff options
Diffstat (limited to 'src/html.js')
| -rw-r--r-- | src/html.js | 569 |
1 files changed, 431 insertions, 138 deletions
diff --git a/src/html.js b/src/html.js index 9e4c39ab..4cac9525 100644 --- a/src/html.js +++ b/src/html.js @@ -2,6 +2,8 @@ import {inspect} from 'node:util'; +import striptags from 'striptags'; + import {withAggregate} from '#aggregate'; import {colors} from '#cli'; import {empty, typeAppearance, unique} from '#sugar'; @@ -39,6 +41,40 @@ export const selfClosingTags = [ 'wbr', ]; +// Every element under: +// https://html.spec.whatwg.org/multipage/text-level-semantics.html +export const textLevelSemanticTags = [ + 'a', + 'abbr', + 'b', + 'bdi', + 'bdo', + 'br', + 'cite', + 'code', + 'data', + 'dfn', + 'em', + 'i', + 'kbd', + 'mark', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'time', + 'u', + 'var', + 'wbr', +]; + // Not so comprehensive!! export const attributeSpec = { 'class': { @@ -53,6 +89,17 @@ export const attributeSpec = { }, }; +let disabledSlotValidation = false; +let disabledTagTracing = false; + +export function disableSlotValidation() { + disabledSlotValidation = true; +} + +export function disableTagTracing() { + disabledTagTracing = true; +} + // Pass to tag() as an attributes key to make tag() return a 8lank tag if the // provided content is empty. Useful for when you'll only 8e showing an element // according to the presence of content that would 8elong there. @@ -223,7 +270,11 @@ export function isBlank(content) { // could include content. These need to be checked too. // Check each of the templates one at a time. for (const template of result) { - const content = template.content; + // Resolve the content all the way down to a tag - + // if it's a template that returns another template, + // that won't do, because we need to detect if its + // final content is a tag marked onlyIfSiblings. + const content = normalize(template); if (content instanceof Tag && content.onlyIfSiblings) { continue; @@ -271,7 +322,11 @@ export const validators = { }, }; -export function blank() { +export function blank(...args) { + if (args.length) { + throw new Error(`Passed arguments - did you mean isBlank() instead?`) + } + return []; } @@ -340,6 +395,22 @@ export function normalize(content) { return Tag.normalize(content); } +export function escape(string, {attribute = false} = {}) { + // https://html.spec.whatwg.org/multipage/parsing.html#escapingString + + string = string + .replaceAll('&', '&') + .replaceAll('\u00a0', ' ') + .replaceAll('<', '<') + .replaceAll('>', '>'); + + if (attribute) { + string = string.replaceAll('"', '"'); + } + + return string; +} + export class Tag { #tagName = ''; #content = null; @@ -352,8 +423,10 @@ export class Tag { this.attributes = attributes; this.content = content; - this.#traceError = new Error(); - } + if (!disabledTagTracing) { + this.#traceError = new Error(); + } +} clone() { return Reflect.construct(this.constructor, [ @@ -432,6 +505,7 @@ export class Tag { this.#content = contentArray; this.#content.toString = () => this.#stringifyContent(); + this.#content.toPlainText = () => this.#plainifyContent(); } get content() { @@ -583,7 +657,7 @@ export class Tag { try { this.content = this.content; - } catch (error) { + } catch { this.#setAttributeFlag(imaginarySibling, false); } } @@ -640,6 +714,10 @@ export class Tag { : '\n')); } + toPlainText() { + return this.content.toPlainText(); + } + #getContentJoiner() { if (this.joinChildren === undefined) { return '\n'; @@ -659,11 +737,8 @@ export class Tag { const joiner = this.#getContentJoiner(); - let content = ''; let blockwrapClosers = ''; - let seenSiblingIndependentContent = false; - const chunkwrapSplitter = (this.chunkwrap ? this.#getAttributeRaw('split') @@ -674,108 +749,64 @@ export class Tag { ? false : null); - let contentItems; - - determineContentItems: { - if (this.chunkwrap) { - contentItems = smush(this).content; - break determineContentItems; - } - - contentItems = this.content; - } - - for (const [index, item] of contentItems.entries()) { - const nonTemplateItem = - Template.resolve(item); - - if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) { - seenSiblingIndependentContent = true; - continue; - } - - let itemContent; - try { - itemContent = nonTemplateItem.toString(); - } catch (caughtError) { - const indexPart = colors.yellow(`child #${index + 1}`); - - const error = - new Error( - `Error in ${indexPart} ` + - `of ${inspect(this, {compact: true})}`, - {cause: caughtError}); - - error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; - error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError; - - error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ - /content-function\.js/, - /util\/html\.js/, - ]; - - error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ - /content\/dependencies\/(.*\.js:.*(?=\)))/, - ]; - - throw error; - } - - if (!itemContent) { - continue; - } - - if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) { - seenSiblingIndependentContent = true; - } + const contentItems = + (this.chunkwrap + ? smush(this).content + : this.content); + + let content = this.#renderContentItems({ + from: '', + items: contentItems, + + getItemContent: item => item.toString(), + + appendItemContent(content, itemContent, item) { + const chunkwrapChunks = + (typeof item === 'string' && chunkwrapSplitter + ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter)) + : null); + + const itemIncludesChunkwrapSplit = + (chunkwrapChunks + ? chunkwrapChunks.length > 1 + : null); + + if (content) { + if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) { + // The first time we see a chunkwrap splitter, backtrack and wrap + // the content *so far* in a chunk. This will be treated just like + // any other open chunkwrap, and closed after the first chunk of + // this item! (That means the existing content is part of the same + // chunk as the first chunk included in this content, which makes + // sense, because that first chink is really just more text that + // precedes the first split.) + content = `<span class="chunkwrap">` + content; + } - const chunkwrapChunks = - (typeof nonTemplateItem === 'string' && chunkwrapSplitter - ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter)) - : null); - - const itemIncludesChunkwrapSplit = - (chunkwrapChunks - ? chunkwrapChunks.length > 1 - : null); - - if (content) { - if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) { - // The first time we see a chunkwrap splitter, backtrack and wrap - // the content *so far* in a chunk. This will be treated just like - // any other open chunkwrap, and closed after the first chunk of - // this item! (That means the existing content is part of the same - // chunk as the first chunk included in this content, which makes - // sense, because that first chink is really just more text that - // precedes the first split.) - content = `<span class="chunkwrap">` + content; + content += joiner; + } else if (itemIncludesChunkwrapSplit) { + // We've encountered a chunkwrap split before any other content. + // This means there's no content to wrap, no existing chunkwrap + // to close, and no reason to add a joiner, but we *do* need to + // enter a chunkwrap wrapper *now*, so the first chunk of this + // item will be properly wrapped. + content = `<span class="chunkwrap">`; } - content += joiner; - } else if (itemIncludesChunkwrapSplit) { - // We've encountered a chunkwrap split before any other content. - // This means there's no content to wrap, no existing chunkwrap - // to close, and no reason to add a joiner, but we *do* need to - // enter a chunkwrap wrapper *now*, so the first chunk of this - // item will be properly wrapped. - content = `<span class="chunkwrap">`; - } - - if (itemIncludesChunkwrapSplit) { - seenChunkwrapSplitter = true; - } + if (itemIncludesChunkwrapSplit) { + seenChunkwrapSplitter = true; + } - // Blockwraps only apply if they actually contain some content whose - // words should be kept together, so it's okay to put them beneath the - // itemContent check. They also never apply at the very start of content, - // because at that point there aren't any preceding words from which the - // blockwrap would differentiate its content. - if (nonTemplateItem instanceof Tag && nonTemplateItem.blockwrap && content) { - content += `<span class="blockwrap">`; - blockwrapClosers += `</span>`; - } + // Blockwraps only apply if they actually contain some content whose + // words should be kept together, so it's okay to put them beneath the + // itemContent check. They also never apply at the very start of content, + // because at that point there aren't any preceding words from which the + // blockwrap would differentiate its content. + if (item instanceof Tag && item.blockwrap && content) { + content += `<span class="blockwrap">`; + blockwrapClosers += `</span>`; + } - appendItemContent: { if (itemIncludesChunkwrapSplit) { for (const [index, {chunk, following}] of chunkwrapChunks.entries()) { if (index === 0) { @@ -809,17 +840,15 @@ export class Tag { } } - break appendItemContent; + return content; } - content += itemContent; - } - } + return content += itemContent; + }, + }); - // If we've only seen sibling-dependent content (or just no content), - // then the content in total is blank. - if (!seenSiblingIndependentContent) { - return ''; + if (!content.length) { + return content; } if (chunkwrapSplitter) { @@ -839,6 +868,130 @@ export class Tag { return content; } + #plainifyContent() { + // Doesn't play too nice with transformContent, because that function, + // working with the Marked library to process markdown, returns a mix of + // raw HTML strings and actual tags - this function only makes nice line + // breaks out of actual tags. + + if (this.selfClosing) { + return ''; + } + + let joiner = this.#getContentJoiner(); + + if (joiner instanceof Tag && joiner.tagName === 'br') { + joiner = '\n'; + } + + if (joiner === '\n') { + joiner = ' '; + } + + let content = this.#renderContentItems({ + from: '', + items: this.content, + + getItemContent: item => + (item instanceof Tag + ? item.toPlainText() + : item.toString()), + + appendItemContent(content, itemContent, item) { + if (joiner === ' ') { + if (item instanceof Tag && !textLevelSemanticTags.includes(item.tagName)) { + content += '\n\n'; + } else if (!content.endsWith(' ')) { + content += ' '; + } + } else { + content += joiner; + } + + return content += itemContent; + }, + }); + + content = + striptags(content) + .replaceAll(''', `'`) + .replaceAll('"', `"`); + + return content; + } + + #renderContentItems(config) { + let content = structuredClone(config.from); + + let seenSiblingIndependentContent = false; + + for (const [index, item] of config.items.entries()) { + const nonTemplateItem = Template.resolve(item); + + if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) { + seenSiblingIndependentContent = true; + continue; + } + + let itemContent; + try { + itemContent = config.getItemContent(nonTemplateItem); + } catch (caughtError) { + throw this.#annotateContentItemError(caughtError, index); + } + + if (!itemContent) { + continue; + } + + const previousLength = content.length; + + content = config.appendItemContent(content, itemContent, nonTemplateItem); + + if (content.length === previousLength) { + continue; + } + + if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) { + seenSiblingIndependentContent = true; + } + } + + // If we've only seen sibling-dependent content (or just no content), + // then the content in total is blank. + if (!seenSiblingIndependentContent) { + return config.from; + } + + return content; + } + + #annotateContentItemError(caughtError, index) { + const indexPart = colors.yellow(`child #${index + 1}`); + + const error = + new Error( + `Error in ${indexPart} ` + + `of ${inspect(this, {compact: true})}`, + {cause: caughtError}); + + if (this.#traceError && !disabledTagTracing) { + error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; + error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError; + + error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ + /content-function\.js/, + /util\/html\.js/, + ]; + + error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ + /content\/dependencies\/(.*\.js:.*(?=\)))/, + ]; + } + + return error; + } + static normalize(content) { // Normalizes contents that are valid from an `isHTML` perspective so // that it's always a pure, single Tag object. @@ -1131,6 +1284,34 @@ export class Attributes { } add(...args) { + // Very common case: add({class: 'foo', id: 'bar'})¡ + // The argument is a plain object (no Template, no Attributes, + // no blessAttributes symbol). We can skip the expensive + // isAttributesAdditionSinglet() validation and flatten/array handling. + if ( + args.length === 1 && + args[0] && + typeof args[0] === 'object' && + !Array.isArray(args[0]) && + !(args[0] instanceof Attributes) && + !(args[0] instanceof Template) && + !Object.hasOwn(args[0], blessAttributes) + ) { + const obj = args[0]; + + // Preserve existing merge semantics by funnelling each key through + // the internal #addOneAttribute helper (handles class/style union, + // unique merging, etc.) but avoid *per-object* validation overhead. + for (const key of Reflect.ownKeys(obj)) { + this.#addOneAttribute(key, obj[key]); + } + + // Match the original return style (list of results) so callers that + // inspect the return continue to work. + return obj; + } + + // Fall back to the original slow-but-thorough implementation switch (args.length) { case 1: isAttributesAdditionSinglet(args[0]); @@ -1142,10 +1323,11 @@ export class Attributes { default: throw new Error( - `Expected array or object, or attribute and value`); + 'Expected array or object, or attribute and value'); } } + with(...args) { const clone = this.clone(); clone.add(...args); @@ -1291,7 +1473,7 @@ export class Attributes { attributeKeyValues .map(([key, value]) => { const keyPart = key; - const escapedValue = this.#escapeAttributeValue(value); + const escapedValue = escape(value.toString(), {attribute: true}); const valuePart = (color ? colors.green(`"${escapedValue}"`) @@ -1367,13 +1549,6 @@ export class Attributes { } } - #escapeAttributeValue(value) { - return value - .toString() - .replaceAll('"', '"') - .replaceAll("'", '''); - } - static parse(string) { const attributes = Object.create(null); @@ -1473,6 +1648,8 @@ export function resolve(tagOrTemplate, { return Tag.normalize(tagOrTemplate); } else if (normalize === 'string') { return Tag.normalize(tagOrTemplate).toString(); + } else if (normalize === 'plain') { + return Tag.normalize(tagOrTemplate).toPlainText(); } else if (normalize) { throw new TypeError(`Expected normalize to be 'tag', 'string', or null`); } else { @@ -1521,6 +1698,61 @@ export function smooth(smoothie) { return tags(helper(smoothie)); } +export function inside(insidee) { + if (insidee instanceof Template) { + return inside(Template.resolve(insidee)); + } + + if (insidee instanceof Tag) { + return Array.from(smooth(tags(insidee.content)).content); + } + + return []; +} + +export function findInside(insidee, query) { + if (typeof query === 'object' && query.slots) { + return findInside(insidee, item => + Template.resolveForSlots(item, query.slots, 'null')); + } + + if (typeof query === 'object' && query.annotation) { + return findInside(insidee, item => + Template.resolveForAnnotation(item, query.annotation, 'null')); + } + + if (typeof query === 'object' && query.tag) { + return findInside(insidee, item => { + const tag = normalize(item); + if (tag.tagName === query) { + return tag; + } else { + return null; + } + }); + } + + if (typeof query === 'string') { + return findInside(insidee, item => + Template.resolveForContentFunction(item, query, 'null')); + } + + if (typeof query !== 'function') { + throw new Error(`Expected {slots}, {annotation}, or query function`); + } + + for (const item of inside(insidee)) { + const result = query(item); + if (result && result === true) { + return item; + } else if (result) { + return result; + } + } + + return null; +} + export function template(description) { return new Template(description); } @@ -1739,6 +1971,10 @@ export class Template { } static validateSlotValueAgainstDescription(value, description) { + if (disabledSlotValidation) { + return true; + } + if (value === undefined) { throw new TypeError(`Specify value as null or don't specify at all`); } @@ -1916,17 +2152,11 @@ export class Template { return this.content.toString(); } - static resolve(tagOrTemplate) { + static resolve(content) { // Flattens contents of a template, recursively "resolving" until a // non-template is ready (or just returns a provided non-template // argument as-is). - if (!(tagOrTemplate instanceof Template)) { - return tagOrTemplate; - } - - let {content} = tagOrTemplate; - while (content instanceof Template) { content = content.content; } @@ -1934,7 +2164,7 @@ export class Template { return content; } - static resolveForSlots(tagOrTemplate, slots) { + static resolveForSlots(content, slots, without = 'throw') { if (!slots || typeof slots !== 'object') { throw new Error( `Expected slots to be an object or array, ` + @@ -1942,24 +2172,87 @@ export class Template { } if (!Array.isArray(slots)) { - return Template.resolveForSlots(tagOrTemplate, Object.keys(slots)).slots(slots); + return Template.resolveForSlots(content, Object.keys(slots)).slots(slots); } - while (tagOrTemplate && tagOrTemplate instanceof Template) { + while (content instanceof Template) { try { for (const slot of slots) { - tagOrTemplate.getSlotDescription(slot); + content.getSlotDescription(slot); } - return tagOrTemplate; + return content; } catch { - tagOrTemplate = tagOrTemplate.content; + content = content.content; } } - throw new Error( - `Didn't find slots ${inspect(slots, {compact: true})} ` + - `resolving ${inspect(tagOrTemplate, {compact: true})}`); + if (without === 'throw') { + throw new Error( + `Didn't find slots ${inspect(slots, {compact: true})} ` + + `resolving ${inspect(content, {compact: true})}`); + } else { + return null; + } + } + + static resolveForAnnotation(content, annotation, without = 'throw') { + if (!annotation || typeof annotation !== 'string') { + throw new Error( + `Expected annotation to be a string, ` + + `got ${typeAppearance(annotation)}`); + } + + while (content instanceof Template) { + if (content.description.annotation === annotation) { + return content; + } else { + content = content.content; + } + } + + if (without === 'throw') { + throw new Error( + `Didn't find annotation ${inspect(annotation, {compact: true})} ` + + `resolving ${inspect(content, {compact: true})}`); + } else { + return null; + } + } + + static resolveForContentFunction(content, dependency, without = 'throw') { + if (!dependency || typeof dependency !== 'string') { + throw new Error( + `Expected dependency to be a string, ` + + `got ${typeAppearance(dependency)}`); + } + + const considerContentFunction = () => + (content instanceof Tag || content instanceof Template) && + Object.hasOwn(content, Symbol.for('hsmusic.contentFunction.via')) && + content[Symbol.for('hsmusic.contentFunction.via')].includes(dependency); + + while (content instanceof Template) { + if (considerContentFunction()) { + return content; + } else if (content.description.annotation === dependency) { + return content; + } else { + content = content.content; + } + } + + if (considerContentFunction()) { + return content; + } + + if (without === 'throw') { + throw new Error( + `Didn't find dependency ${inspect(dependency, {compact: true})} ` + + `resolving ${inspect(content, {compact: true})}`); + } else { + return null; + } } [inspect.custom]() { |