« 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.js174
1 files changed, 148 insertions, 26 deletions
diff --git a/src/html.js b/src/html.js
index 0fe424df..dd1d1960 100644
--- a/src/html.js
+++ b/src/html.js
@@ -53,6 +53,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 +234,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;
@@ -352,8 +367,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, [
@@ -512,6 +529,10 @@ export class Tag {
     }
   }
 
+  #getAttributeRaw(attribute) {
+    return this.attributes.get(attribute);
+  }
+
   set onlyIfContent(value) {
     this.#setAttributeFlag(onlyIfContent, value);
   }
@@ -579,7 +600,7 @@ export class Tag {
 
     try {
       this.content = this.content;
-    } catch (error) {
+    } catch {
       this.#setAttributeFlag(imaginarySibling, false);
     }
   }
@@ -662,7 +683,7 @@ export class Tag {
 
     const chunkwrapSplitter =
       (this.chunkwrap
-        ? this.#getAttributeString('split')
+        ? this.#getAttributeRaw('split')
         : null);
 
     let seenChunkwrapSplitter =
@@ -702,17 +723,19 @@ export class Tag {
             `of ${inspect(this, {compact: true})}`,
             {cause: caughtError});
 
-        error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
-        error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
+        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.unhelpfulTraceLines`)] = [
+            /content-function\.js/,
+            /util\/html\.js/,
+          ];
 
-        error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
-          /content\/dependencies\/(.*\.js:.*(?=\)))/,
-        ];
+          error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
+            /content\/dependencies\/(.*\.js:.*(?=\)))/,
+          ];
+        }
 
         throw error;
       }
@@ -727,7 +750,7 @@ export class Tag {
 
       const chunkwrapChunks =
         (typeof nonTemplateItem === 'string' && chunkwrapSplitter
-          ? itemContent.split(chunkwrapSplitter)
+          ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter))
           : null);
 
       const itemIncludesChunkwrapSplit =
@@ -773,7 +796,7 @@ export class Tag {
 
       appendItemContent: {
         if (itemIncludesChunkwrapSplit) {
-          for (const [index, chunk] of chunkwrapChunks.entries()) {
+          for (const [index, {chunk, following}] of chunkwrapChunks.entries()) {
             if (index === 0) {
               // The first chunk isn't actually a chunk all on its own, it's
               // text that should be appended to the previous chunk. We will
@@ -781,12 +804,27 @@ export class Tag {
               // the next chunk.
               content += chunk;
             } else {
-              const whitespace = chunk.match(/^\s+/) ?? '';
-              content += chunkwrapSplitter;
+              const followingWhitespace = following.match(/\s+$/) ?? '';
+              const chunkWhitespace = chunk.match(/^\s+/) ?? '';
+
+              if (followingWhitespace) {
+                content += following.slice(0, -followingWhitespace.length);
+              } else {
+                content += following;
+              }
+
               content += '</span>';
-              content += whitespace;
+
+              content += followingWhitespace;
+              content += chunkWhitespace;
+
               content += '<span class="chunkwrap">';
-              content += chunk.slice(whitespace.length);
+
+              if (chunkWhitespace) {
+                content += chunk.slice(chunkWhitespace.length);
+              } else {
+                content += chunk;
+              }
             }
           }
 
@@ -1009,6 +1047,49 @@ export class Tag {
   }
 }
 
+export function* getChunkwrapChunks(content, splitter) {
+  const splitString =
+    (typeof splitter === 'string'
+      ? splitter
+      : null);
+
+  if (splitString) {
+    let following = '';
+    for (const chunk of content.split(splitString)) {
+      yield {chunk, following};
+      following = splitString;
+    }
+
+    return;
+  }
+
+  const splitRegExp =
+    (splitter instanceof RegExp
+      ? new RegExp(
+          splitter.source,
+          (splitter.flags.includes('g')
+            ? splitter.flags
+            : splitter.flags + 'g'))
+      : null);
+
+  if (splitRegExp) {
+    let following = '';
+    let prevIndex = 0;
+    for (const match of content.matchAll(splitRegExp)) {
+      const chunk = content.slice(prevIndex, match.index);
+      yield {chunk, following};
+
+      following = match[0];
+      prevIndex = match.index + match[0].length;
+    }
+
+    const chunk = content.slice(prevIndex);
+    yield {chunk, following};
+
+    return;
+  }
+}
+
 export function attributes(attributes) {
   return new Attributes(attributes);
 }
@@ -1069,6 +1150,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]);
@@ -1080,10 +1189,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);
@@ -1254,6 +1364,9 @@ export class Attributes {
           return value.some(Boolean);
         } else if (value === null) {
           return false;
+        } else if (value instanceof RegExp) {
+          // Oooooooo.
+          return true;
         } else {
           // Other objects are an error.
           break;
@@ -1285,13 +1398,16 @@ export class Attributes {
       case 'number':
         return value.toString();
 
-      // If it's a kept object, it's an array.
       case 'object': {
-        const joiner =
-          (descriptor?.arraylike && descriptor?.join)
-            ?? ' ';
+        if (Array.isArray(value)) {
+          const joiner =
+            (descriptor?.arraylike && descriptor?.join)
+              ?? ' ';
 
-        return value.filter(Boolean).join(joiner);
+          return value.filter(Boolean).join(joiner);
+        } else {
+          return value;
+        }
       }
 
       default:
@@ -1671,6 +1787,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`);
     }
@@ -1963,6 +2083,8 @@ export const isAttributeValue =
   anyOf(
     isString, isNumber, isBoolean, isArray,
     isTag, isTemplate,
+    // Evil. Ooooo
+    validateInstanceOf(RegExp),
     validateArrayItems(item => isAttributeValue(item)));
 
 export const isAttributesAdditionPair = pair => {