diff --git a/crates/treehouse/src/html/markdown.rs b/crates/treehouse/src/html/markdown.rs
index 7b20875..e29a5b5 100644
--- a/crates/treehouse/src/html/markdown.rs
+++ b/crates/treehouse/src/html/markdown.rs
@@ -527,7 +527,8 @@ where
escape_html(&mut self.writer, &branch.attributes.id)?;
self.writer.write_str("\">")?;
}
- self.writer.write_str("",
+ "
",
EscapeAttribute(&branch.html_id)
)
.unwrap();
diff --git a/crates/treehouse/src/tree/attributes.rs b/crates/treehouse/src/tree/attributes.rs
index 1415ad5..6a317ca 100644
--- a/crates/treehouse/src/tree/attributes.rs
+++ b/crates/treehouse/src/tree/attributes.rs
@@ -107,7 +107,7 @@ pub enum Content {
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
pub struct Classes {
- /// Classes to append to the branch itself ().
+ /// Classes to append to the branch itself ().
#[serde(default)]
pub branch: String,
diff --git a/jsconfig.json b/jsconfig.json
index 5eb03be..b6fa674 100644
--- a/jsconfig.json
+++ b/jsconfig.json
@@ -8,6 +8,7 @@
"tairu/*": [
"./components/tairu/*"
]
- }
+ },
+ "target": "ES2020",
},
}
diff --git a/static/css/main.css b/static/css/main.css
index fae54f8..46047ff 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -447,7 +447,7 @@ h1.page-title {
}
/* Style the `new` link on the homepage */
-a[is="th-new"] {
+a[data-cast~="new"] {
flex-shrink: 0;
color: var(--text-color);
opacity: 50%;
@@ -509,7 +509,7 @@ footer {
/* Style emojis to be readable */
-img[is="th-emoji"] {
+img[data-cast~="emoji"] {
max-width: 1.5em;
max-height: 1.5em;
vertical-align: bottom;
@@ -777,7 +777,7 @@ th-literate-program[data-mode="output"] {
/* Style settings sections */
-section[is="th-settings"] {
+section[data-cast~="settings"] {
/* Don't display settings when JavaScript is disabled.
JS overrides this value on the element itself. */
display: none;
diff --git a/static/css/new.css b/static/css/new.css
index 5ff71ea..1df466c 100644
--- a/static/css/new.css
+++ b/static/css/new.css
@@ -64,7 +64,7 @@ section {
}
}
-section[is="th-settings"] {
+section.settings {
& h3 {
display: inline;
}
@@ -131,4 +131,4 @@ section[is="th-settings"] {
border-color: white;
}
}
-}
\ No newline at end of file
+}
diff --git a/static/js/emoji.js b/static/js/emoji.js
index 3e689ce..d82fb89 100644
--- a/static/js/emoji.js
+++ b/static/js/emoji.js
@@ -1,10 +1,13 @@
// Emoji zoom-in functionality.
+import { addSpell } from "treehouse/spells.js";
+
class EmojiTooltip extends HTMLElement {
- constructor(emoji, { onClosed }) {
+ constructor(emoji, element, { onClosed }) {
super();
this.emoji = emoji;
+ this.emojiElement = element;
this.onClosed = onClosed;
}
@@ -12,12 +15,12 @@ class EmojiTooltip extends HTMLElement {
this.role = "tooltip";
this.image = new Image();
- this.image.src = this.emoji.src;
+ this.image.src = this.emojiElement.src;
this.description = document.createElement("p");
this.description.textContent = `${this.emoji.emojiName}`;
- let emojiBoundingBox = this.emoji.getBoundingClientRect();
+ let emojiBoundingBox = this.emojiElement.getBoundingClientRect();
this.style.left = `${emojiBoundingBox.left + emojiBoundingBox.width / 2}px`;
this.style.top = `calc(${emojiBoundingBox.top}px + 1.5em)`;
@@ -68,8 +71,8 @@ class EmojiTooltips extends HTMLElement {
this.abortController.abort();
}
- openTooltip(emoji) {
- let tooltip = new EmojiTooltip(emoji, {
+ openTooltip(emoji, element) {
+ let tooltip = new EmojiTooltip(emoji, element, {
onClosed: () => {
this.removeChild(tooltip);
this.tooltips.delete(tooltip);
@@ -95,21 +98,21 @@ class EmojiTooltips extends HTMLElement {
customElements.define("th-emoji-tooltips", EmojiTooltips);
-class Emoji extends HTMLImageElement {
- connectedCallback() {
- this.emojiName = this.title;
+class Emoji {
+ constructor(element) {
+ this.emojiName = element.title;
// title makes the browser add a tooltip. We replace browser tooltips with our own,
// so remove the title.
- this.title = "";
+ element.title = "";
- this.addEventListener("mouseenter", () => this.openTooltip());
- this.addEventListener("mouseleave", () => this.closeTooltip());
- this.addEventListener("scroll", () => this.closeTooltip());
+ element.addEventListener("mouseenter", () => this.openTooltip(element));
+ element.addEventListener("mouseleave", () => this.closeTooltip());
+ element.addEventListener("scroll", () => this.closeTooltip());
}
- openTooltip() {
- this.tooltip = emojiTooltips.openTooltip(this);
+ openTooltip(element) {
+ this.tooltip = emojiTooltips.openTooltip(this, element);
}
closeTooltip() {
@@ -118,4 +121,4 @@ class Emoji extends HTMLImageElement {
}
}
-customElements.define("th-emoji", Emoji, { extends: "img" });
+addSpell("emoji", Emoji);
diff --git a/static/js/news.js b/static/js/news.js
index 8cbcd52..d7763a8 100644
--- a/static/js/news.js
+++ b/static/js/news.js
@@ -1,6 +1,7 @@
// news.js because new.js makes the TypeScript language server flip out.
// Likely because `new` is a keyword, but also, what the fuck.
+import { addSpell, spell } from "treehouse/spells.js";
import { getSettingValue } from "treehouse/settings.js";
import { Branch } from "treehouse/tree.js";
@@ -15,15 +16,17 @@ function saveSeenStates() {
}
function markAsRead(branch) {
- if (!seenStates.has(branch.namedID) && seenCount > 0) {
+ let branchData = spell(branch, Branch);
+
+ if (!seenStates.has(branchData.namedID) && seenCount > 0) {
let badge = document.createElement("span");
badge.classList.add("badge", "red", "before-content");
badge.textContent = "new";
- branch.branchContent.firstChild.insertBefore(badge, branch.branchContent.firstChild.firstChild);
+ branchData.branchContent.firstChild.insertBefore(badge, branchData.branchContent.firstChild.firstChild);
}
- seenStates.add(branch.namedID);
+ seenStates.add(branchData.namedID);
}
export function initNewsPage() {
@@ -43,8 +46,8 @@ export function markAllAsUnread() {
localStorage.removeItem(seenStatesKey);
}
-class New extends HTMLAnchorElement {
- connectedCallback() {
+addSpell("new", class New {
+ constructor(element) {
// Do not show the badge to people who have never seen any news.
// It's just annoying in that case.
// In case you do not wish to see the badge anymore, go to the news page and uncheck the
@@ -54,17 +57,15 @@ class New extends HTMLAnchorElement {
if (userSawNews && userWantsToSeeNews && unseenCount > 0) {
this.newText = document.createElement("span");
this.newText.classList.add("new-text");
- this.newText.textContent = this.textContent;
- this.textContent = "";
- this.appendChild(this.newText);
+ this.newText.textContent = element.textContent;
+ element.textContent = "";
+ element.appendChild(this.newText);
this.badge = document.createElement("span");
this.badge.classList.add("badge", "red");
this.badge.textContent = unseenCount.toString();
- this.appendChild(this.badge);
- this.classList.add("has-news");
+ element.appendChild(this.badge);
+ element.classList.add("has-news");
}
}
-}
-
-customElements.define("th-new", New, { extends: "a" });
+});
diff --git a/static/js/settings.js b/static/js/settings.js
index 6b18a2a..64e432d 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -1,3 +1,5 @@
+import { addSpell } from "treehouse/spells.js";
+
const settingsKey = "treehouse.settings";
const settings = JSON.parse(localStorage.getItem(settingsKey)) || {};
@@ -13,23 +15,15 @@ export function getSettingValue(setting) {
return settings[setting] ?? defaultSettingValues[setting];
}
-class SettingCheckbox extends HTMLInputElement {
- connectedCallback() {
- this.checked = getSettingValue(this.id);
+class SettingCheckbox {
+ constructor(element) {
+ element.checked = getSettingValue(element.id);
- this.addEventListener("change", () => {
- settings[this.id] = this.checked;
+ element.addEventListener("change", () => {
+ settings[element.id] = element.checked;
saveSettings();
});
}
}
-customElements.define("th-setting-checkbox", SettingCheckbox, { extends: "input" });
-
-class Settings extends HTMLElement {
- connectedCallback() {
- this.style.display = "block";
- }
-}
-
-customElements.define("th-settings", Settings, { extends: "section" });
+addSpell("setting-checkbox", SettingCheckbox);
diff --git a/static/js/spells.js b/static/js/spells.js
new file mode 100644
index 0000000..1699a5c
--- /dev/null
+++ b/static/js/spells.js
@@ -0,0 +1,85 @@
+// Spells are a simplistic, composition-based replacement for the is="" attribute, which is not
+// supported on Webkit due to Apple's engineering being a bunch of obstinate idiots who explicitly
+// choose not to follow web standards.
+
+// Limitations:
+// - The data-cast attribute cannot be added dynamically.
+// - There is no disconnectedCallback. Only a constructor which initializes a spell.
+
+let spells = new Map();
+let elementsWithUnknownSpells = new Map();
+
+const sSpells = Symbol("spells");
+
+function castSpellOnElement(spellName, element) {
+ element[sSpells] ??= new Map();
+ if (!elementsWithUnknownSpells.has(spellName)) {
+ elementsWithUnknownSpells.set(spellName, new Set());
+ }
+
+ let Spell = spells.get(spellName);
+ if (Spell != null && !element[sSpells].has(Spell)) {
+ element[sSpells].set(Spell, new Spell(element));
+ elementsWithUnknownSpells.get(spellName).delete(element);
+ } else {
+ elementsWithUnknownSpells.get(spellName).add(element);
+ }
+}
+
+function applySpells(elements) {
+ for (let element of elements) {
+ if (element instanceof Element) {
+ let spellListString = element.getAttribute("data-cast");
+ if (spellListString != null) {
+ let spellList = spellListString.split(' ');
+ for (let spellName of spellList) {
+ castSpellOnElement(spellName, element);
+ }
+ }
+ }
+ }
+}
+
+export function addSpell(name, spell) {
+ spells.set(name, spell);
+ let elementsWithThisSpell = elementsWithUnknownSpells.get(name);
+ if (elementsWithThisSpell != null) {
+ for (let element of elementsWithThisSpell) {
+ castSpellOnElement(name, element);
+ }
+ }
+}
+
+// Returns a spell's data. Gotchas: the spell needs to already be on the element.
+// Therefore, if this is used from within a spell, the requested spell must have already been
+// applied by this point.
+// Someday I may change this to an async function that resumes whenever the spell is available to
+// iron over this limitation. But today is not that day.
+export function spell(element, spell) {
+ return element[sSpells].get(spell);
+}
+
+// Apply spells to elements which have them and have been loaded so far.
+let loadedSoFar = document.querySelectorAll("[data-cast]");
+applySpells(loadedSoFar);
+
+// For all other elements, add a mutation observer that will handle them.
+let mutationObserver = new MutationObserver(records => {
+ for (let record of records) {
+ let mutatedNodes = new Set();
+ // NOTE: Added nodes may contain children which also need to be processed.
+ // Collect those that have [data-cast] on them and apply spells to them.
+ for (let addedNode of record.addedNodes) {
+ addedNode.querySelectorAll("[data-cast]").forEach(element => mutatedNodes.add(element));
+ }
+ applySpells(mutatedNodes);
+ }
+});
+mutationObserver.observe(document, { subtree: true, childList: true });
+
+// ------------ Common spells ------------
+
+// js makes things visible only when JavaScript is enabled.
+addSpell("js", function (element) {
+ element.style.display = "block";
+});
diff --git a/static/js/thanks-webkit.js b/static/js/thanks-webkit.js
deleted file mode 100644
index efcfcf5..0000000
--- a/static/js/thanks-webkit.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// Detect if we can have crucial functionality (ie. custom elements call constructors).
-// This doesn't seem to happen in Epiphany, and also other Webkit-based browsers.
-let works = false;
-class WebkitMoment extends HTMLLIElement {
- constructor() {
- super();
- works = true;
- }
-}
-
-customElements.define("th-webkit-moment", WebkitMoment, { extends: "li" });
-
-let willItWorkOrWillItNot = document.createElement("div");
-willItWorkOrWillItNot.innerHTML = ``;
-
-// If my takeoff fails
-// tell my mother I'm sorry
-let box = document.getElementById("webkit-makes-me-go-insane");
-if (!works) {
- box.style = "display: block";
-}
diff --git a/static/js/tree.js b/static/js/tree.js
index 3dd9d2f..414f973 100644
--- a/static/js/tree.js
+++ b/static/js/tree.js
@@ -1,5 +1,6 @@
// This is definitely not a three.js ripoff.
+import { addSpell } from "treehouse/spells.js";
import { navigationMap } from "/navmap.js";
import * as ulid from "treehouse/ulid.js";
@@ -17,18 +18,20 @@ function branchIsOpen(branchID) {
return branchState[branchID];
}
-export class Branch extends HTMLLIElement {
+export class Branch {
static branchesByNamedID = new Map();
static onAdded = [];
- connectedCallback() {
- this.isLeaf = this.classList.contains("leaf");
+ constructor(element) {
+ this.element = element;
- this.details = this.childNodes[0];
+ this.isLeaf = element.classList.contains("leaf");
+
+ this.details = element.childNodes[0];
this.innerUL = this.details.childNodes[1];
if (this.isLeaf) {
- this.contentContainer = this.childNodes[0];
+ this.contentContainer = element.childNodes[0];
} else {
this.contentContainer = this.details.childNodes[0];
}
@@ -36,19 +39,19 @@ export class Branch extends HTMLLIElement {
this.branchContent = this.contentContainer.childNodes[1];
this.buttonBar = this.contentContainer.childNodes[2];
- let doPersist = !this.hasAttribute("data-th-do-not-persist");
- let isOpen = branchIsOpen(this.id);
+ let doPersist = !element.hasAttribute("data-th-do-not-persist");
+ let isOpen = branchIsOpen(element.id);
if (doPersist && isOpen !== undefined) {
this.details.open = isOpen;
}
if (!this.isLeaf) {
this.details.addEventListener("toggle", _ => {
- saveBranchIsOpen(this.id, this.details.open);
+ saveBranchIsOpen(element.id, this.details.open);
});
}
- this.namedID = this.id.split(':')[1];
- Branch.branchesByNamedID.set(this.namedID, this);
+ this.namedID = element.id.split(':')[1];
+ Branch.branchesByNamedID.set(this.namedID, element);
if (ulid.isCanonicalUlid(this.namedID)) {
let timestamp = ulid.getTimestamp(this.namedID);
@@ -59,22 +62,22 @@ export class Branch extends HTMLLIElement {
}
for (let callback of Branch.onAdded) {
- callback(this);
+ callback(element, this);
}
}
}
-customElements.define("th-b", Branch, { extends: "li" });
+addSpell("b", Branch);
/* Linked branches */
class LinkedBranch extends Branch {
static byLink = new Map();
- connectedCallback() {
- super.connectedCallback();
+ constructor(element) {
+ super(element);
- this.linkedTree = this.getAttribute("data-th-link");
+ this.linkedTree = element.getAttribute("data-th-link");
LinkedBranch.byLink.set(this.linkedTree, this);
this.loadingText = document.createElement("p");
@@ -115,11 +118,10 @@ class LinkedBranch extends Branch {
let styles = main.getElementsByTagName("link");
let scripts = main.getElementsByTagName("script");
-
this.loadingText.remove();
this.innerUL.innerHTML = ul.innerHTML;
- this.append(...styles);
+ this.element.append(...styles);
for (let script of scripts) {
// No need to await for the import because we don't use the resulting module.
// Just fire and forger 💀
@@ -139,7 +141,7 @@ class LinkedBranch extends Branch {
}
}
-customElements.define("th-b-linked", LinkedBranch, { extends: "li" });
+addSpell("b-linked", LinkedBranch);
/* Fragment navigation */
diff --git a/static/js/usability.js b/static/js/usability.js
index ffa84b8..46318e4 100644
--- a/static/js/usability.js
+++ b/static/js/usability.js
@@ -7,12 +7,3 @@ document.addEventListener("click", event => {
event.preventDefault();
}
})
-
-// Certain words don't make sense if scripts are disabled.
-class YesScript extends HTMLElement {
- connectedCallback() {
- this.classList.add("yes-indeed");
- }
-}
-
-customElements.define("th-yesscript", YesScript);
diff --git a/template/_new.hbs b/template/_new.hbs
index dfc40fd..d2efad8 100644
--- a/template/_new.hbs
+++ b/template/_new.hbs
@@ -11,13 +11,12 @@
{{!-- For /index, include a "new" link that goes to the curated news feed page. --}}
{{#if (eq page.tree_path "index")}}
- new
+ new
{{/if}}
{{/ components/_nav.hbs }}
{{> components/_noscript.hbs }}
- {{> components/_webkit.hbs }}
welcome!
@@ -33,7 +32,7 @@
-
+
settings
@@ -41,7 +40,7 @@