« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/html.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/html.js')
-rw-r--r--src/html.js569
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('<', '&lt;')
+    .replaceAll('>', '&gt;');
+
+  if (attribute) {
+    string = string.replaceAll('"', '&quot;');
+  }
+
+  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('&#39;', `'`)
+        .replaceAll('&quot;', `"`);
+
+    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('"', '&quot;')
-      .replaceAll("'", '&apos;');
-  }
-
   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]() {