]> Lady’s Gitweb - Etiquette/blobdiff - model.js
Initial Tag model
[Etiquette] / model.js
diff --git a/model.js b/model.js
new file mode 100644 (file)
index 0000000..9f7962d
--- /dev/null
+++ b/model.js
@@ -0,0 +1,1689 @@
+// 📧🏷️ Étiquette ∷ model.js
+// ====================================================================
+//
+// Copyright © 2023 Lady [@ Lady’s Computer].
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at <https://mozilla.org/MPL/2.0/>.
+
+import { Storage } from "./memory.js";
+import { taggingDiscoveryContext } from "./names.js";
+
+// TODO: Move these somewhere else and allow for modification before
+// they are used, freezing them only once tags actually start being
+// constructed (probably on first call to the `TagSystem` initializer
+// for convenience).
+//
+// Or else make them properties of the tag system itself and ∼fully
+// modifiable.
+
+/**
+ * Tag kinds which denote entity tags.
+ *
+ * ※ This object is not exposed.
+ */
+const ENTITY_TAG_KINDS = new Set([
+  "EntityTag",
+  "CharacterTag",
+  "InanimateEntityTag",
+]);
+
+/**
+ * Tag kinds which denote relationship tags.
+ *
+ * ※ This object is not exposed.
+ */
+const RELATIONSHIP_TAG_KINDS = new Set([
+  "RelationshipTag",
+  "FamilialRelationship Tag",
+  "FriendshipTag",
+  "RivalryTag",
+  "RomanticRelationshipTag",
+  "SexualRelationshipTag",
+]);
+
+/**
+ * Tag kinds which denote setting tags.
+ *
+ * ※ This object is not exposed.
+ */
+const SETTING_TAG_KINDS = new Set([
+  "SettingTag",
+  "LocationTag",
+  "TimePeriodTag",
+  "UniverseTag",
+]);
+
+/**
+ * Tag kinds which denote conceptual tags.
+ *
+ * ※ This object is not exposed.
+ */
+const CONCEPTUAL_TAG_KINDS = new Set(function* () {
+  yield "ConceptualTag";
+  yield* RELATIONSHIP_TAG_KINDS;
+}());
+
+/**
+ * All recognized tag kinds.
+ *
+ * ※ This object is not exposed.
+ */
+const TAG_KINDS = new Set(function* () {
+  yield "Tag";
+  yield "CanonTag";
+  yield* CONCEPTUAL_TAG_KINDS;
+  yield* ENTITY_TAG_KINDS;
+  yield "GenreTag";
+  yield* SETTING_TAG_KINDS;
+}());
+
+/**
+ * Tag kinds which can be in canon.
+ *
+ * ※ This object is not exposed.
+ */
+const HAS_IN_CANON = new Set(function* () {
+  yield* ENTITY_TAG_KINDS;
+  yield* SETTING_TAG_KINDS;
+}());
+
+/**
+ * Tag kinds which can be involved in relationship tags.
+ *
+ * ※ This object is not exposed.
+ */
+const INVOLVABLE_IN_RELATIONSHIP = new Set(function* () {
+  yield "CharacterTag";
+  yield* RELATIONSHIP_TAG_KINDS;
+}());
+
+/**
+ * Properties which take literal values instead of identifiers.
+ *
+ * These are the label terms.
+ */
+const LITERAL_TERMS = new Set([
+  "prefLabel",
+  "altLabel",
+  "hiddenLabel",
+]);
+
+/**
+ * Properties to skip when diffing.
+ *
+ * These are all inverses of properties included in diffs and cannot be
+ * changed manually.
+ */
+const SKIP_IN_DIFF = new Set([
+  "hasInCanon",
+  "isIncludedIn",
+  "narrower",
+]);
+
+/**
+ * A tag.
+ *
+ * `Tag`s are not assigned identifiers and do not have side·effects on
+ * other tags in the `TagSystem` until they are persisted with
+ * `::persist`, at which point changes to their relationships are
+ * applied.
+ *
+ * `Tag`s are also not kept up‐to‐date, but persisting an outdated
+ * `Tag` will *not* undo subsequent changes.
+ *
+ * ※ This class is not itself directly exposed, although bound
+ * versions of it are via `TagSystem::Tag`.
+ */
+class Tag {
+  /** The `TagSystem` this `Tag` belongs to. */
+  #system;
+
+  /** The `Storage` managed by this `Tag`’s `TagSystem`. */
+  #storage;
+
+  /**
+   * The 30‐bit W·R·M·G base32 identifier with leading checksum which
+   * has been assigned to this `Tag`.
+   *
+   * Will be `null` if this `Tag` has not been persisted. Otherwise,
+   * the format is `cxx-xxxx` (`c` = checksum; `x` = digit).
+   */
+  #identifier = null;
+
+  /** The kind of this `Tag`. */
+  #kind = "Tag";
+
+  /**
+   * The data which was attached to this `Tag` the last time it was
+   * persisted or retrieved from storage.
+   *
+   * Diffing with this will reveal changes.
+   */
+  #persistedData = null;
+
+  /** The current (modified) data associated with this `Tag`. */
+  #data = tagData();
+
+  /**
+   * Returns whether or not the provided value is a tag which shares a
+   * storage with this tag.
+   *
+   * Sharing a storage also implies sharing a `TagSystem`.
+   */
+  #isTagInStorage($) {
+    try {
+      // Try to compare the provided value’s internal store with
+      // the provided storage.
+      return $.#storage == this.#storage;
+    } catch {
+      // The provided value was not a `Tag`.
+      return false;
+    }
+  }
+
+  /**
+   * Constructs a new `Tag` of the provided kind and with the provided
+   * preferred label.
+   *
+   * ※ The first two arguments of this constructor are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this constructor in its unbound form from outside this
+   * module.
+   *
+   * ☡ This constructor throws if the provided kind is not supported.
+   */
+  constructor(system, storage, kind = "Tag", prefLabel = "") {
+    const kindString = `${kind}`;
+    if (TAG_KINDS.has(kindString)) {
+      // The provided kind is one of the recognized tag kinds.
+      this.#system = system;
+      this.#storage = storage;
+      this.#kind = kindString;
+      this.#data.prefLabel = prefLabel;
+    } else {
+      // The provided kind is not supported.
+      throw new RangeError(
+        `Cannot construct Tag: Unrecognized kind: ${kind}.`,
+      );
+    }
+  }
+
+  /**
+   * Yields the tags in the `TagSystem` associated with this
+   * constructor.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
+   */
+  static *all(system, storage) {
+    for (const instance of storage.values()) {
+      // Iterate over the entries and yield the ones which are `Tag`s
+      // in this `TagSystem`.
+      if (Tag.getSystem(instance) == system) {
+        // The current instance is a `Tag` in this `TagSystem`.
+        yield instance;
+      } else {
+        // The current instance is not a `Tag` in this `TagSystem`.
+        /* do nothing */
+      }
+    }
+  }
+
+  /**
+   * Returns a new `Tag` resolved from the provided I·R·I.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
+   *
+   * ※ If the I·R·I is not recognized, this function returns `null`.
+   */
+  static fromIRI(system, storage, iri) {
+    const name = `${iri}`;
+    const prefix =
+      `https://${system.authorityName}/tag:${system.taggingEntity}:`;
+    if (!name.startsWith(prefix)) {
+      // The I·R·I does not begin with the expected prefix.
+      return null;
+    } else {
+      // The I·R·I begins with the expected prefix.
+      const identifier = name.substring(prefix.length);
+      try {
+        // Attempt to resolve the identifier.
+        const instance = storage.get(identifier);
+        return Tag.getSystem(instance) == system ? instance : null;
+      } catch {
+        // Do not throw for bad identifiers.
+        return null;
+      }
+    }
+  }
+
+  /**
+   * Returns a new `Tag` resolved from the provided identifier.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
+   *
+   * ☡ This function throws if the identifier is invalid.
+   *
+   * ※ If the identifier is valid but not recognized, this function
+   * returns `null`.
+   */
+  static fromIdentifier(system, storage, identifier) {
+    const instance = storage.get(identifier);
+    return Tag.getSystem(instance) == system ? instance : null;
+  }
+
+  /**
+   * Returns a new `Tag` resolved from the provided Tag U·R·I.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
+   *
+   * ☡ This function throws if the provided Tag U·R·I does not match
+   * the tagging entity of this constructor’s `TagSystem`.
+   *
+   * ※ If the specific component of the Tag U·R·I is not recognized,
+   * this function returns `null`.
+   */
+  static fromTagURI(system, storage, tagURI) {
+    const tagName = `${tagURI}`;
+    const tagPrefix = `tag:${system.taggingEntity}:`;
+    if (!tagName.startsWith(tagPrefix)) {
+      // The Tag U·R·I does not begin with the expected prefix.
+      throw new RangeError(
+        `Tag U·R·I did not begin with the expected prefix: ${tagName}`,
+      );
+    } else {
+      // The I·R·I begins with the expected prefix.
+      const identifier = tagName.substring(tagPrefix.length);
+      try {
+        // Attempt to resolve the identifier.
+        const instance = storage.get(identifier);
+        return Tag.getSystem(instance) == system ? instance : null;
+      } catch {
+        // Do not throw for bad identifiers.
+        return null;
+      }
+    }
+  }
+
+  /**
+   * Returns the `TagSystem` that the provided value belongs to.
+   *
+   * ※ This function can be used to check if the provided value has
+   * private tag features.
+   *
+   * ※ This function is not exposed.
+   */
+  static getSystem($) {
+    return !(#system in Object($)) ? null : $.#system;
+  }
+
+  /**
+   * Yields the tag identifiers in the `TagSystem` associated with this
+   * constructor.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
+   */
+  static *identifiers(system, storage) {
+    for (const [identifier, instance] of storage.entries()) {
+      // Iterate over the entries and yield the ones which are `Tag`s
+      // in this `TagSystem`.
+      if (Tag.getSystem(instance) == system) {
+        // The current instance is a `Tag` in this `TagSystem`.
+        yield identifier;
+      } else {
+        // The current instance is not a `Tag` in this `TagSystem`.
+        /* do nothing */
+      }
+    }
+  }
+
+  /**
+   * Returns a new `Tag` constructed from the provided data and with
+   * the provided identifier.
+   *
+   * ※ This function will not work if called directly from `Tag` (and
+   * nor is it available *to* be called as such from outside this
+   * module). It must be called from a `TagSystem::Tag` bound
+   * constructor.
+   *
+   * ※ This function is not really intended for public usage.
+   */
+  static [Storage.toInstance](_system, _storage, data, identifier) {
+    const tag = new this(data.kind);
+    tag.#identifier = `${identifier}`;
+    tag.#persistedData = tagData(data);
+    tag.#data = tagData(data);
+    return tag;
+  }
+
+  static {
+    // Overwrite the default `::constructor` method to instead give the
+    // actual (bound) constructor which was used to generate a given
+    // `Tag`.
+    Object.defineProperties(this.prototype, {
+      constructor: {
+        configurable: true,
+        enumerable: false,
+        get() {
+          // All `Tag`s are constructed via the `.Tag` constructor
+          // available in their `TagSystem`; return it.
+          return this.#system.Tag;
+        },
+        set: undefined,
+      },
+    });
+  }
+
+  /** Adds the provided label(s) to this `Tag` as alternate labels. */
+  addAltLabel(...labels) {
+    const altLabels = this.#data.altLabel;
+    let objectLabels = null; // initialized on first use
+    for (const $ of labels) {
+      // Iterate over each provided label and attempt to add it.
+      const literal = langString($);
+      if (Object(literal) === literal) {
+        // The current label is a language‐tagged string.
+        objectLabels ??= [...function* () {
+          for (const altLabel of altLabels) {
+            // Iterate over the existing labels and yield the
+            // language‐tagged strings.
+            if (Object(altLabel) === altLabel) {
+              // The current existing label is a language‐tagged
+              // string.
+              yield altLabel;
+            } else {
+              // The current existing label is not a language‐tagged
+              // string.
+              /* do nothing */
+            }
+          }
+        }()];
+        if (
+          objectLabels.some((objectLabel) =>
+            objectLabel["@value"] == literal["@value"] &&
+            objectLabel["@language"] == literal["@language"]
+          )
+        ) {
+          // There is a match with the current label in the existing
+          // labels.
+          /* do nothing */
+        } else {
+          // There is no match and this label must be added.
+          altLabels.add(literal);
+          objectLabels.push(literal);
+        }
+      } else {
+        // The current label is a simple string.
+        altLabels.add(literal);
+      }
+    }
+  }
+
+  /**
+   * Adds the provided tags to the list of tags that this `Tag` is
+   * narrower than.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   */
+  addBroaderTag(...tags) {
+    const storage = this.#storage;
+    const broader = this.#data.broader;
+    for (const $ of tags) {
+      // Iterate over each tag and attempt to set it as broader than
+      // this `Tag`.
+      const identifier = toIdentifier($);
+      if (identifier == null) {
+        // ☡ The current tag has no identifier.
+        throw new TypeError(
+          "Cannot assign broader to Tag: Identifier must not be nullish.",
+        );
+      } else if (broader.has(identifier)) {
+        // Short‐circuit: The identifier is already something this
+        // `Tag` is narrower than.
+        /* do nothing */
+      } else {
+        // The current tag has an identifier.
+        const tag = storage.get(identifier);
+        if (tag == null) {
+          // ☡ The current tag has not been persisted to this `Tag`’s
+          // storage.
+          throw new RangeError(
+            `Cannot assign broader to Tag: Identifier is not persisted: ${identifier}.`,
+          );
+        } else if (!this.#isTagInStorage(tag)) {
+          // ☡ The current tag is not a tag in the correct tag system.
+          throw new TypeError(
+            `Cannot assign broader to Tag: Tags must be from the same Tag System, but got: ${identifier}.`,
+          );
+        } else {
+          // The current tag is a tag in the correct tag system; add
+          // its identifier.
+          broader.add(identifier);
+        }
+      }
+    }
+  }
+
+  /** Adds the provided label(s) to this `Tag` as hidden labels. */
+  addHiddenLabel(...labels) {
+    const hiddenLabels = this.#data.hiddenLabel;
+    let objectLabels = null; // initialized on first use
+    for (const $ of labels) {
+      // Iterate over each provided label and attempt to add it.
+      const literal = langString($);
+      if (Object(literal) === literal) {
+        // The current label is a language‐tagged string.
+        objectLabels ??= [...function* () {
+          for (const hiddenLabel of hiddenLabels) {
+            // Iterate over the existing labels and yield the
+            // language‐tagged strings.
+            if (Object(hiddenLabel) === hiddenLabel) {
+              // The current existing label is a language‐tagged
+              // string.
+              yield hiddenLabel;
+            } else {
+              // The current existing label is not a language‐tagged
+              // string.
+              /* do nothing */
+            }
+          }
+        }()];
+        if (
+          objectLabels.some((objectLabel) =>
+            objectLabel["@value"] == literal["@value"] &&
+            objectLabel["@language"] == literal["@language"]
+          )
+        ) {
+          // There is a match with the current label in the existing
+          // labels.
+          /* do nothing */
+        } else {
+          // There is no match and this label must be added.
+          hiddenLabels.add(literal);
+          objectLabels.push(literal);
+        }
+      } else {
+        // The current label is a simple string.
+        hiddenLabels.add(literal);
+      }
+    }
+  }
+
+  /**
+   * Adds the provided tags to the list of tags that this `Tag` is in
+   * canon with.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   *
+   * ☡ This method will throw if a provided argument does not indicate
+   * a canon tag, or if this `Tag` is not of a kind which can be placed
+   * in canon.
+   */
+  addInCanonTag(...tags) {
+    const storage = this.#storage;
+    const kind = this.#kind;
+    const inCanon = this.#data.inCanon;
+    if (!HAS_IN_CANON.has(kind)) {
+      // ☡ This is not an entity tag, setting tag, or recognized
+      // subclass.
+      throw new TypeError(
+        `Cannot put Tag in canon: Incorrect Tag type: ${kind}.`,
+      );
+    } else {
+      // This has a kind which can be placed in canon.
+      for (const $ of tags) {
+        // Iterate over each tag and attempt to set this `Tag` in canon
+        // of it.
+        const identifier = toIdentifier($);
+        if (identifier == null) {
+          // ☡ The current tag has no identifier.
+          throw new TypeError(
+            "Cannot put Tag in canon: Identifier must not be nullish.",
+          );
+        } else if (inCanon.has(identifier)) {
+          // Short‐circuit: The identifier is already something this
+          // `Tag` is in canon of.
+          /* do nothing */
+        } else {
+          // The current tag has an identifier.
+          const tag = storage.get(identifier);
+          if (tag == null) {
+            // ☡ The current tag has not been persisted to this `Tag`’s
+            // storage.
+            throw new RangeError(
+              `Cannot put Tag in canon: Identifier is not persisted: ${identifier}.`,
+            );
+          } else if (
+            // ※ If the first check succeeds, then the current tag
+            // must have `Tag` private class features.
+            !this.#isTagInStorage(tag) || tag.#kind != "CanonTag"
+          ) {
+            // ☡ The current tag is not a canon tag in the correct
+            // tag system.
+            throw new TypeError(
+              `Cannot put Tag in canon: Tags can only be in Canon Tags from the same Tag System, but got: ${identifier}.`,
+            );
+          } else {
+            // The current tag is a canon tag in the correct tag
+            // system; add its identifier.
+            inCanon.add(identifier);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Adds the provided tags to the list of tags that this `Tag`
+   * involves.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   *
+   * ☡ This method will throw if this `Tag` is not a conceptual tag, or
+   * if this `Tag` is a relationship tag and a provided argument does
+   * not indicate a character or relationship tag.
+   */
+  addInvolvesTag(...tags) {
+    const storage = this.#storage;
+    const kind = this.#kind;
+    const involves = this.#data.involves;
+    if (!CONCEPTUAL_TAG_KINDS.has(kind)) {
+      // ☡ This is not a conceptual tag or recognized subclass.
+      throw new TypeError(
+        `Cannot involve Tag: Incorrect Tag type: ${kind}.`,
+      );
+    } else {
+      // This is a conceptual tag.
+      for (const $ of tags) {
+        // Iterate over each tag and attempt to set this `Tag` as
+        // involving it.
+        const identifier = toIdentifier($);
+        if (identifier == null) {
+          // ☡ The current tag has no identifier.
+          throw new TypeError(
+            "Cannot involve Tag: Identifier must not be nullish.",
+          );
+        } else if (involves.has(identifier)) {
+          // Short‐circuit: The identifier is already something this
+          // `Tag` involves.
+          /* do nothing */
+        } else {
+          // The current tag has an identifier.
+          const tag = storage.get(identifier);
+          if (tag == null) {
+            // ☡ The current tag has not been persisted to this `Tag`’s
+            // storage.
+            throw new RangeError(
+              `Cannot involve Tag: Identifier is not persisted: ${identifier}.`,
+            );
+          } else if (
+            // ※ If the first check succeeds, then the current tag
+            // must have `Tag` private class features.
+            !this.#isTagInStorage(tag) ||
+            RELATIONSHIP_TAG_KINDS.has(kind) &&
+              !INVOLVABLE_IN_RELATIONSHIP.has(tag.#kind)
+          ) {
+            // ☡ The current tag is in the correct tag system and
+            // includable.
+            throw new TypeError(
+              `Cannot involve Tag: Tags must be the same Tag System and involvable, but got: ${identifier}.`,
+            );
+          } else {
+            // The current tag is an involvable tag in the correct tag
+            // system; add its identifier.
+            involves.add(identifier);
+          }
+        }
+      }
+    }
+  }
+
+  /** Yields the alternative labels of this `Tag`. */
+  *altLabels() {
+    yield* this.#data.altLabel;
+  }
+
+  /** Returns the authority (domain) name for this `Tag`. */
+  get authorityName() {
+    return this.#system.authorityName;
+  }
+
+  /** Yields `Tag`s which are broader than this `Tag`. */
+  *broaderTags() {
+    const storage = this.#storage;
+    for (const identifier of this.#data.broader) {
+      // Iterate over the broader tags and yield them if possible.
+      const tag = storage.get(identifier);
+      if (!this.#isTagInStorage(tag)) {
+        // The broader tag no longer appears in storage; perhaps it was
+        // deleted.
+        /* do nothing */
+      } else {
+        // The broader tag exists and is constructable from storage.
+        yield tag;
+      }
+    }
+  }
+
+  /** Yields `Tag`s which are broader than this `Tag`, transitively. */
+  *broaderTransitiveTags() {
+    const storage = this.#storage;
+    const encountered = new Set();
+    let pending = new Set(this.#data.broader);
+    while (pending.size > 0) {
+      // Loop until all broader tags have been encountered.
+      const processing = pending;
+      pending = new Set();
+      for (const identifier of processing) {
+        // Iterate over the broader tags and yield them if possible.
+        if (!encountered.has(identifier)) {
+          // The broader tag has not been encountered before.
+          encountered.add(identifier);
+          const tag = storage.get(identifier);
+          if (!this.#isTagInStorage(tag)) {
+            // The broader tag no longer appears in storage; perhaps it
+            // was deleted.
+            /* do nothing */
+          } else {
+            // The broader tag exists and is constructable from
+            // storage.
+            yield tag;
+            for (const transitive of tag.#data.broader) {
+              // Iterate over the broader tags of the current broader
+              // tag and add them to pending as needed.
+              if (!encountered.has(transitive)) {
+                // The broader broader tag has not been encountered
+                // yet.
+                pending.add(transitive);
+              } else {
+                // The broader broader tag has already been
+                // encountered.
+                /* do nothing */
+              }
+            }
+          }
+        } else {
+          // The broader tag has already been encountered.
+          /* do nothing */
+        }
+      }
+    }
+  }
+
+  /**
+   * Removes the provided string label(s) from this `Tag` as alternate
+   * labels.
+   */
+  deleteAltLabel(...labels) {
+    const altLabels = this.#data.altLabel;
+    let objectLabels = null; // initialized on first use
+    for (const $ of labels) {
+      // Iterate over each provided label and attempt to remove it.
+      const literal = langString($);
+      if (Object(literal) === literal) {
+        // The current label is a language‐tagged string.
+        objectLabels ??= [...function* () {
+          for (const altLabel of altLabels) {
+            // Iterate over the existing labels and yield the
+            // language‐tagged strings.
+            if (Object(altLabel) === altLabel) {
+              // The current existing label is a language‐tagged
+              // string.
+              yield altLabel;
+            } else {
+              // The current existing label is not a language‐tagged
+              // string.
+              /* do nothing */
+            }
+          }
+        }()];
+        const existing = objectLabels.find((objectLabel) =>
+          objectLabel["@value"] == literal["@value"] &&
+          objectLabel["@language"] == literal["@language"]
+        );
+        altLabels.delete(existing);
+      } else {
+        // The current label is a simple string.
+        altLabels.delete(literal);
+      }
+    }
+  }
+
+  /**
+   * Removes the provided tags from the list of tags that this `Tag` is
+   * narrower than.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   */
+  deleteBroaderTag(...tags) {
+    const broader = this.#data.broader;
+    for (const $ of tags) {
+      // Iterate over the provided tags and delete them.
+      broader.delete(toIdentifier($));
+    }
+  }
+
+  /**
+   * Removes the provided string label(s) from this `Tag` as hidden
+   * labels.
+   */
+  deleteHiddenLabel(...labels) {
+    const hiddenLabels = this.#data.hiddenLabel;
+    let objectLabels = null; // initialized on first use
+    for (const $ of labels) {
+      // Iterate over each provided label and attempt to remove it.
+      const literal = langString($);
+      if (Object(literal) === literal) {
+        // The current label is a language‐tagged string.
+        objectLabels ??= [...function* () {
+          for (const hiddenLabel of hiddenLabels) {
+            // Iterate over the existing labels and yield the
+            // language‐tagged strings.
+            if (Object(hiddenLabel) === hiddenLabel) {
+              // The current existing label is a language‐tagged
+              // string.
+              yield hiddenLabel;
+            } else {
+              // The current existing label is not a language‐tagged
+              // string.
+              /* do nothing */
+            }
+          }
+        }()];
+        const existing = objectLabels.find((objectLabel) =>
+          objectLabel["@value"] == literal["@value"] &&
+          objectLabel["@language"] == literal["@language"]
+        );
+        hiddenLabels.delete(existing);
+      } else {
+        // The current label is a simple string.
+        hiddenLabels.delete(literal);
+      }
+    }
+  }
+
+  /**
+   * Removes the provided tags from the list of tags that this `Tag` is
+   * in canon with.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   */
+  deleteInCanonTag(...tags) {
+    const inCanon = this.#data.inCanon;
+    for (const $ of tags) {
+      // Iterate over the provided tags and delete them.
+      inCanon.delete(toIdentifier($));
+    }
+  }
+
+  /**
+   * Removes the provided tags from the list of tags that this `Tag`
+   * involves.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   */
+  deleteInvolvesTag(...tags) {
+    const involves = this.#data.involves;
+    for (const $ of tags) {
+      // Iterate over the provided tags and delete them.
+      involves.delete(toIdentifier($));
+    }
+  }
+
+  /** Yields `Tag`s that are in canon of this `Tag`. */
+  *hasInCanonTags() {
+    const storage = this.#storage;
+    if (this.#kind == "CanonTag") {
+      // This is a canon tag.
+      for (const identifier of this.#data.hasInCanon) {
+        // Iterate over the tags in canon and yield them if possible.
+        const tag = storage.get(identifier);
+        if (
+          !this.#isTagInStorage(tag) || !HAS_IN_CANON.has(tag.#kind)
+        ) {
+          // The tag in canon no longer appears in storage; perhaps it
+          // was deleted.
+          /* do nothing */
+        } else {
+          // The tag in canon exists and is constructable from storage.
+          yield tag;
+        }
+      }
+    } else {
+      /* do nothing */
+    }
+  }
+
+  /** Yields the hidden labels of this `Tag`. */
+  *hiddenLabels() {
+    yield* this.#data.hiddenLabel;
+  }
+
+  /** Returns the identifier of this `Tag`. */
+  get identifier() {
+    return this.#identifier;
+  }
+
+  /** Yields `Tag`s that this `Tag` is in canon of. */
+  *inCanonTags() {
+    const storage = this.#storage;
+    if (HAS_IN_CANON.has(this.#kind)) {
+      // This tag can be placed in canon.
+      for (const identifier of this.#data.inCanon) {
+        // Iterate over the canon tags and yield them if possible.
+        const tag = storage.get(identifier);
+        if (!this.#isTagInStorage(tag) || tag.#kind != "CanonTag") {
+          // The canon tag no longer appears in storage; perhaps it was
+          // deleted.
+          /* do nothing */
+        } else {
+          // The canon tag exists and is constructable from storage.
+          yield tag;
+        }
+      }
+    } else {
+      // This tag cannot be placed in canon.
+      /* do nothing */
+    }
+  }
+
+  /** Yields `Tag`s which involve this `Tag`. */
+  *involvedInTags() {
+    const storage = this.#storage;
+    for (const identifier of this.#data.involvedIn) {
+      // Iterate over the involving tags and yield them if possible.
+      const tag = storage.get(identifier);
+      const tagKind = tag.#kind;
+      if (
+        !this.#isTagInStorage(tag) ||
+        !CONCEPTUAL_TAG_KINDS.has(tagKind) ||
+        RELATIONSHIP_TAG_KINDS.has(tagKind) &&
+          !INVOLVABLE_IN_RELATIONSHIP.has(this.#kind)
+      ) {
+        // The including tag no longer appears in storage; perhaps it
+        // was deleted.
+        /* do nothing */
+      } else {
+        // The including tag exists and is constructable from storage.
+        yield tag;
+      }
+    }
+  }
+
+  /** Yields `Tag`s that this `Tag` involves. */
+  *involvesTags() {
+    const storage = this.#storage;
+    const kind = this.#kind;
+    if (CONCEPTUAL_TAG_KINDS.has(kind)) {
+      // This tag can involve other tags.
+      for (const identifier of this.#data.involves) {
+        // Iterate over the involved and yield them if possible.
+        const tag = storage.get(identifier);
+        if (
+          !this.#isTagInStorage(tag) ||
+          RELATIONSHIP_TAG_KINDS.has(kind) &&
+            !INVOLVABLE_IN_RELATIONSHIP.has(tag.#kind)
+        ) {
+          // The involved tag no longer appears in storage; perhaps it
+          // was deleted.
+          /* do nothing */
+        } else {
+          // The involved tag exists and is constructable from storage.
+          yield tag;
+        }
+      }
+    } else {
+      // This tag cannot involve other tags.
+      /* do nothing */
+    }
+  }
+
+  /** Returns the I·R·I for this `Tag`. */
+  get iri() {
+    const tagURI = this.tagURI;
+    return tagURI == null
+      ? null
+      : `https://${this.authorityName}/${tagURI}`;
+  }
+
+  /** Returns the kind of this `Tag`. */
+  get kind() {
+    return this.#kind;
+  }
+
+  /** Yields `Tag`s which are narrower than this `Tag`. */
+  *narrowerTags() {
+    const storage = this.#storage;
+    for (const identifier of this.#data.narrower) {
+      const tag = storage.get(identifier);
+      if (!this.#isTagInStorage(tag)) {
+        // The narrower tag no longer appears in storage; perhaps it
+        // was deleted.
+        /* do nothing */
+      } else {
+        // The narrower tag exists and is constructable from storage.
+        yield tag;
+      }
+    }
+  }
+
+  /**
+   * Yields `Tag`s which are narrower than this `Tag`, transitively.
+   */
+  *narrowerTransitiveTags() {
+    const storage = this.#storage;
+    const encountered = new Set();
+    let pending = new Set(this.#data.narrower);
+    while (pending.size > 0) {
+      // Loop until all narrower tags have been encountered.
+      const processing = pending;
+      pending = new Set();
+      for (const identifier of processing) {
+        // Iterate over the narrower tags and yield them if possible.
+        if (!encountered.has(identifier)) {
+          // The narrower tag has not been encountered before.
+          encountered.add(identifier);
+          const tag = storage.get(identifier);
+          if (!this.#isTagInStorage(tag)) {
+            // The narrower tag no longer appears in storage; perhaps
+            // it was deleted.
+            /* do nothing */
+          } else {
+            // The narrower tag exists and is constructable from
+            // storage.
+            yield tag;
+            for (const transitive of tag.#data.narrower) {
+              // Iterate over the narrower tags of the current narrower
+              // tag and add them to pending as needed.
+              if (!encountered.has(transitive)) {
+                // The narrower narrower tag has not been encountered
+                // yet.
+                pending.add(transitive);
+              } else {
+                // The narrower narrower tag has already been
+                // encountered.
+                /* do nothing */
+              }
+            }
+          }
+        } else {
+          // The narrower tag has already been encountered.
+          /* do nothing */
+        }
+      }
+    }
+  }
+
+  /**
+   * Persist this `Tag` to storage and return an ActivityStreams
+   * serialization of a Tag Activity representing any changes, or
+   * `null` if no changes were made.
+   *
+   * ※ Persistence can imply side‐effects on other objects, which are
+   * not noted explicitly in the activity. For example, marking a tag
+   * as broader than another causes the other tag to reciprocally be
+   * marked as narrower.
+   *
+   * ※ The inverse terms `hasInCanon`, `isIncludedIn`, and `narrower`
+   * will never appear in the predicates of generated activities.
+   */
+  persist() {
+    const system = this.#system;
+    const storage = this.#storage;
+    const persistedData = this.#persistedData;
+    const data = this.#data;
+    const diffs = {};
+    for (const [key, value] of Object.entries(data)) {
+      // Iterate over each entry of the tag data and create a diff with
+      // the last persisted information.
+      if (SKIP_IN_DIFF.has(key)) {
+        // The current property is one which is skipped in diffs.
+        /* do nothing */
+      } else {
+        // The current property should be diffed.
+        const persisted = persistedData?.[key] ?? null;
+        if (persisted == null) {
+          // There is no persisted data for the current property yet.
+          diffs[key] = {
+            old: new Set(),
+            new: value instanceof Set
+              ? new Set(value)
+              : new Set([value]),
+          };
+        } else if (value instanceof Set) {
+          // The current property is set‐valued.
+          let values = null; // initialized on first use
+          const oldValues = new Set(persisted);
+          const newValues = new Set(value);
+          for (const existing of persisted) {
+            // Iterate over each persisted property and either remove
+            // it from the list of new values or add it to the list of
+            // removed ones.
+            //
+            // ※ Some special handling is required here for
+            // language‐tagged strings.
+            if (
+              value.has(existing) ||
+              Object(existing) === existing &&
+                (values ??= [...value]).some(($) =>
+                  `${$}` == `${existing}` &&
+                  $.language == existing.language
+                )
+            ) {
+              // The value is in both the old and new version of the
+              // data.
+              oldValues.delete(existing);
+              newValues.delete(existing);
+            } else {
+              // The value is not shared.
+              /* do nothing */
+            }
+          }
+          diffs[key] = {
+            old: oldValues,
+            new: newValues,
+          };
+        } else if (
+          `${value}` != `${persisted}` ||
+          value.language != persisted.language
+        ) {
+          // The current property is (optionally language‐tagged)
+          // string‐valued and the value changed.
+          diffs[key] = {
+            old: new Set([persisted]),
+            new: new Set([value]),
+          };
+        } else {
+          // The current property did not change.
+          diffs[key] = {
+            old: new Set(),
+            new: new Set(),
+          };
+        }
+      }
+    }
+    const identifier = this.#identifier;
+    if (identifier != null) {
+      // This `Tag` has already been persisted; use its existing
+      // identifier and persist.
+      storage.set(identifier, this);
+    } else {
+      // This `Tag` has not been persisted yet; save the new
+      // identifier after persisting.
+      this.#identifier = storage.add(this);
+    }
+    const persistedIdentifier = this.#identifier;
+    this.#persistedData = tagData(data); // need to clone here
+    for (
+      const [term, inverse] of [
+        ["broader", "narrower"],
+        ["inCanon", "hasInCanon"],
+        ["involves", "involvedIn"],
+      ]
+    ) {
+      // Iterate over each term referencing other tags and update the
+      // inverse property on those tags if possible.
+      for (const referencedIdentifier of diffs[term].old) {
+        // Iterate over the removed tags and remove this `Tag` from
+        // their inverse property.
+        const referenced = storage.get(referencedIdentifier);
+        try {
+          // Try removing this `Tag`.
+          referenced.#data[inverse].delete(persistedIdentifier);
+          storage.set(referencedIdentifier, referenced);
+        } catch {
+          // Removal failed, possibly because the other tag was
+          // deleted.
+          /* do nothing */
+        }
+      }
+      for (const referencedIdentifier of diffs[term].new) {
+        const referenced = storage.get(referencedIdentifier);
+        try {
+          // Try adding this `Tag`.
+          referenced.#data[inverse].add(persistedIdentifier);
+          storage.set(referencedIdentifier, referenced);
+        } catch {
+          // Adding failed, possibly because the other tag was deleted.
+          /* do nothing */
+        }
+      }
+    }
+    const activity = {
+      "@context": taggingDiscoveryContext,
+      "@type": [
+        "TagActivity",
+        identifier == null ? "Create" : "Update",
+      ],
+      context: `${system.iri}`,
+      object: `${this.iri}`,
+      endTime: new Date().toISOString(),
+      ...(() => {
+        const statements = {
+          unstates: [],
+          states: [],
+        };
+        const { unstates, states } = statements;
+        if (identifier == null) {
+          // This is a Create activity.
+          states.push({ predicate: "a", object: `${this.kind}` });
+        } else {
+          // This is an Update activity.
+          /* do nothing */
+        }
+        for (
+          const [term, {
+            old: oldValues,
+            new: newValues,
+          }] of Object.entries(diffs)
+        ) {
+          // Iterate over the diffs of each term and state/unstate
+          // things as needed.
+          for (const oldValue of oldValues) {
+            // Iterate over removals and unstate them.
+            if (LITERAL_TERMS.has(term)) {
+              // This is a literal term; push the change wrapped in an
+              // object.
+              unstates.push({
+                predicate: term,
+                object: Object(oldValue) === oldValue
+                  ? { ...langString(oldValue) }
+                  : { "@value": `${oldValue}` },
+              });
+            } else {
+              // This is a named term; attempt to get its I·R·I and
+              // push it.
+              try {
+                // Attempt to resolve the value and push the change.
+                const tag = storage.get(oldValue);
+                if (!this.#isTagInStorage(tag)) {
+                  // The value did not resolve to a tag in storage.
+                  /* do nothing */
+                } else {
+                  // The value resolved; push its I·R·I.
+                  unstates.push({
+                    predicate: term,
+                    object: tag.iri,
+                  });
+                }
+              } catch {
+                // Value resolution failed for some reason; perhaps the
+                // tag was deleted.
+                /* do nothing */
+              }
+            }
+          }
+          for (const newValue of newValues) {
+            // Iterate over additions and state them.
+            if (LITERAL_TERMS.has(term)) {
+              // This is a literal term; push the change wrapped in an
+              // object.
+              states.push({
+                predicate: term,
+                object: Object(newValue) === newValue
+                  ? { ...langString(newValue) }
+                  : { "@value": `${newValue}` },
+              });
+            } else {
+              // This is a named term; attempt to get its I·R·I and
+              // push it.
+              try {
+                // Attempt to resolve the value and push the change.
+                const tag = storage.get(newValue);
+                if (!this.#isTagInStorage(tag)) {
+                  // The value did not resolve to a tag in storage.
+                  /* do nothing */
+                } else {
+                  // The value resolved; push its I·R·I.
+                  states.push({
+                    predicate: term,
+                    object: tag.iri,
+                  });
+                }
+              } catch {
+                // Value resolution failed for some reason; perhaps the
+                // tag was deleted.
+                /* do nothing */
+              }
+            }
+          }
+        }
+        if (unstates.length == 0) {
+          // Nothing was unstated.
+          delete statements.unstates;
+        } else {
+          // Things were stated.
+          /* do nothing */
+        }
+        if (states.length == 0) {
+          // Nothing was stated.
+          delete statements.states;
+        } else {
+          // Things were stated.
+          /* do nothing */
+        }
+        return statements;
+      })(),
+    };
+    if (
+      !Object.hasOwn(activity, "states") &&
+      !Object.hasOwn(activity, "unstates")
+    ) {
+      // No meaningful changes were actually persisted.
+      return null;
+    } else {
+      // There were meaningful changes persisted regarding this `Tag`.
+      return activity;
+    }
+  }
+
+  /** Returns the preferred label for this `Tag`. */
+  get prefLabel() {
+    return this.#data.prefLabel;
+  }
+
+  /** Sets the preferred label of this `Tag` to the provided label. */
+  set prefLabel($) {
+    this.#data.prefLabel = langString($);
+  }
+
+  /** Returns the Tag U·R·I for this `Tag`. */
+  get tagURI() {
+    const { identifier } = this;
+    return identifier == null
+      ? null
+      : `tag:${this.taggingEntity}:${identifier}`;
+  }
+
+  /** Returns the tagging entity (domain and date) for this `Tag`. */
+  get taggingEntity() {
+    return this.#system.taggingEntity;
+  }
+
+  /** Returns the string form of the preferred label of this `Tag`. */
+  toString() {
+    return `${this.#data.prefLabel}`;
+  }
+
+  /**
+   * Returns a new object whose enumerable own properties contain the
+   * data from this object needed for storage.
+   *
+   * ※ This method is not really intended for public usage.
+   */
+  [Storage.toObject]() {
+    const data = this.#data;
+    return Object.assign(Object.create(null), {
+      ...data,
+      kind: this.#kind,
+    });
+  }
+}
+
+const {
+  /**
+   * Returns the provided value converted into either a plain string
+   * primitive or an object with `.["@value"]` and `.["@language"]`
+   * properties.
+   *
+   * TODO: Ideally this would be extracted more fully into an R·D·F
+   * library.
+   *
+   * ※ This function is not exposed.
+   */
+  langString,
+} = (() => {
+  /** Returns the `.["@language"]` of this object. */
+  const getLanguage = Object.defineProperty(
+    function () {
+      return this["@language"];
+    },
+    "name",
+    { value: "get language" },
+  );
+
+  /** Returns the `.["@value"]` of this object. */
+  const toString = function () {
+    return this["@value"];
+  };
+
+  /** Returns the `.["@value"]` of this object. */
+  const valueOf = function () {
+    return this["@value"];
+  };
+
+  return {
+    langString: ($) =>
+      Object($) === $
+        ? "@value" in $
+          ? "@language" in $
+            ? Object.preventExtensions(
+              Object.create(String.prototype, {
+                "@value": {
+                  enumerable: true,
+                  value: `${$["@value"]}`,
+                },
+                "@language": {
+                  enumerable: true,
+                  value: `${$["@language"]}`,
+                },
+                language: { enumerable: false, get: getLanguage },
+                toString: { enumerable: false, value: toString },
+                valueOf: { enumerable: false, value: valueOf },
+              }),
+            )
+            : `${$["@value"]}`
+          : "language" in $
+          ? Object.preventExtensions(
+            Object.create(String.prototype, {
+              "@value": { enumerable: true, value: `${$}` },
+              "@language": {
+                enumerable: true,
+                value: `${$.language}`,
+              },
+              language: { enumerable: false, get: getLanguage },
+              toString: { enumerable: false, value: toString },
+              valueOf: { enumerable: false, value: valueOf },
+            }),
+          )
+          : `${$}`
+        : `${$ ?? ""}`,
+  };
+})();
+
+/**
+ * Returns a normalized tag data object derived from the provided
+ * object.
+ *
+ * ※ The properties of this function need to match the term names used
+ * in the ActivityStreams serialization.
+ *
+ * ※ This function is not exposed.
+ */
+const tagData = ($) => {
+  const data = Object($);
+  const {
+    // prefLabel intentionally not set here
+    altLabel,
+    hiddenLabel,
+    broader,
+    narrower,
+    inCanon,
+    hasInCanon,
+    involves,
+    involvedIn,
+  } = data;
+  let prefLabel = langString(data.prefLabel);
+  return Object.preventExtensions(Object.create(null, {
+    prefLabel: {
+      enumerable: true,
+      get: () => prefLabel,
+      set: ($) => {
+        prefLabel = langString($);
+      },
+    },
+    altLabel: {
+      enumerable: true,
+      value: new Set(
+        altLabel != null
+          ? Array.from(altLabel, langString)
+          : undefined,
+      ),
+    },
+    hiddenLabel: {
+      enumerable: true,
+      value: new Set(
+        hiddenLabel != null
+          ? Array.from(hiddenLabel, langString)
+          : undefined,
+      ),
+    },
+    broader: {
+      enumerable: true,
+      value: new Set(
+        broader != null
+          ? Array.from(broader, toIdentifier)
+          : undefined,
+      ),
+    },
+    narrower: {
+      enumerable: true,
+      value: new Set(
+        narrower != null
+          ? Array.from(narrower, toIdentifier)
+          : undefined,
+      ),
+    },
+    inCanon: {
+      enumerable: true,
+      value: new Set(
+        inCanon != null
+          ? Array.from(inCanon, toIdentifier)
+          : undefined,
+      ),
+    },
+    hasInCanon: {
+      enumerable: true,
+      value: new Set(
+        hasInCanon != null
+          ? Array.from(hasInCanon, toIdentifier)
+          : undefined,
+      ),
+    },
+    involves: {
+      enumerable: true,
+      value: new Set(
+        involves != null
+          ? Array.from(involves, toIdentifier)
+          : undefined,
+      ),
+    },
+    involvedIn: {
+      enumerable: true,
+      value: new Set(
+        involvedIn != null
+          ? Array.from(involvedIn, toIdentifier)
+          : undefined,
+      ),
+    },
+  }));
+};
+
+/**
+ * Returns an identifier corresponding to the provided object.
+ *
+ * This is either the value of its `.identifier` or its string value.
+ *
+ * ※ This function is not exposed.
+ */
+const toIdentifier = ($) =>
+  $ == null
+    ? null
+    : Object($) === $ && "identifier" in $
+    ? $.identifier
+    : `${$}`;
+
+/**
+ * A tag system, with storage.
+ *
+ * The `::Tag` constructor available on any `TagSystem` instance can be
+ * used to create new `Tag`s within the system.
+ */
+export class TagSystem {
+  /** The cached bound `Tag` constructor for this `TagSystem`. */
+  #Tag = null;
+
+  /** The domain of this `TagSystem`. */
+  #domain;
+
+  /** The date of this `TagSystem`. */
+  #date;
+
+  /** The identifier of this `TagSystem`. */
+  #identifier;
+
+  /** The internal `Storage` of this `TagSystem`. */
+  #storage = new Storage();
+
+  /**
+   * Constructs a new `TagSystem` with the provided domain and date.
+   *
+   * Only actual, lowercased domain names are allowed for the domain,
+   * and the date must be “full” (include month and day components).
+   * This is for alignment with general best practices for Tag URI’s.
+   *
+   * ☡ This constructor throws if provided with an invalid date.
+   */
+  constructor(domain, date, identifier = "") {
+    const domainString = `${domain}`;
+    const dateString = `${date}`;
+    this.#identifier = `${identifier}`;
+    try {
+      // If the identifier is a valid storage I·D, reserve it.
+      this.#storage.delete(this.#identifier);
+    } catch {
+      // The identifier is not a valid storage I·D, so no worries.
+      /* do nothing */
+    }
+    if (
+      !/^[a-z](?:[\da-z-]*[\da-z])?(?:\.[a-z](?:[\da-z-]*[\da-z])?)*$/u
+        .test(domainString)
+    ) {
+      // ☡ The domain is invalid.
+      throw new RangeError(`Invalid domain: ${domain}.`);
+    } else if (
+      !/^\d{4}-\d{2}-\d{2}$/u.test(dateString) ||
+      dateString != new Date(dateString).toISOString().split("T")[0]
+    ) {
+      // ☡ The date is invalid.
+      throw new RangeError(`Invalid date: ${date}.`);
+    } else {
+      // The domain and date are 🆗.
+      this.#domain = domainString;
+      this.#date = dateString;
+    }
+  }
+
+  /**
+   * Returns a bound constructor for constructing `Tags` in this
+   * `TagSystem`.
+   */
+  get Tag() {
+    if (this.#Tag != null) {
+      // A bound constructor has already been generated; return it.
+      return this.#Tag;
+    } else {
+      // No bound constructor has been created yet.
+      const storage = this.#storage;
+      const BoundTag = Tag.bind(undefined, this, storage);
+      return this.#Tag = Object.defineProperties(BoundTag, {
+        all: {
+          configurable: true,
+          enumerable: false,
+          value: Tag.all.bind(BoundTag, this, storage),
+          writable: true,
+        },
+        fromIRI: {
+          configurable: true,
+          enumerable: false,
+          value: Tag.fromIRI.bind(BoundTag, this, storage),
+          writable: true,
+        },
+        fromIdentifier: {
+          configurable: true,
+          enumerable: false,
+          value: Tag.fromIdentifier.bind(BoundTag, this, storage),
+          writable: true,
+        },
+        fromTagURI: {
+          configurable: true,
+          enumerable: false,
+          value: Tag.fromTagURI.bind(BoundTag, this, storage),
+          writable: true,
+        },
+        identifiers: {
+          configurable: true,
+          enumerable: false,
+          value: Tag.identifiers.bind(BoundTag, this, storage),
+          writable: true,
+        },
+        name: { value: `${this.tagURI}#${Tag.name}` },
+        prototype: { value: Tag.prototype },
+        [Storage.toInstance]: {
+          configurable: true,
+          enumerable: false,
+          value: Tag[Storage.toInstance].bind(BoundTag, this, storage),
+          writable: true,
+        },
+      });
+    }
+  }
+
+  /** Returns the authority name (domain) for this `TagSystem`. */
+  get authorityName() {
+    return this.#domain;
+  }
+
+  /** Returns the date of this `TagSystem`, as a string. */
+  get date() {
+    return this.#date;
+  }
+
+  /**
+   * Yields the entities in this `TagSystem`.
+   *
+   * ※ Entities can hypothetically be anything. If you specifically
+   * want the `Tag`s, use `::Tag.all` instead.
+   */
+  *entities() {
+    yield* this.#storage.values();
+  }
+
+  /**
+   * Returns the identifier of this `TagSystem`.
+   *
+   * ※ Often this is just the empty string.
+   */
+  get identifier() {
+    return this.#identifier;
+  }
+
+  /** Yields the identifiers in use in this `TagSystem`. */
+  *identifiers() {
+    yield* this.#storage.keys();
+  }
+
+  /** Returns the I·R·I for this `TagSystem`. */
+  get iri() {
+    return `https://${this.authorityName}/${this.tagURI}`;
+  }
+
+  /** Returns the Tag U·R·I for this `TagSystem`. */
+  get tagURI() {
+    return `tag:${this.taggingEntity}:${this.identifier}`;
+  }
+
+  /**
+   * Returns the tagging entity (domain and date) for this `TagSystem`.
+   */
+  get taggingEntity() {
+    return `${this.authorityName},${this.date}`;
+  }
+}
This page took 0.464559 seconds and 4 git commands to generate.