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 @@

    if you find the newsfeed annoying, you can customize some aspects of it.

    - + @@ -70,4 +69,4 @@ - \ No newline at end of file + diff --git a/template/_tree.hbs b/template/_tree.hbs index 3f1c4ce..9dfa2e9 100644 --- a/template/_tree.hbs +++ b/template/_tree.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 }} {{!-- NOTE: ~ because components/_tree.hbss must not include any extra indentation, because it may diff --git a/template/components/_head.hbs b/template/components/_head.hbs index d1f00d1..eb257f3 100644 --- a/template/components/_head.hbs +++ b/template/components/_head.hbs @@ -20,12 +20,12 @@ const TREEHOUSE_NEWS_COUNT = {{ len feeds.news.branches }}; + - diff --git a/template/components/_webkit.hbs b/template/components/_webkit.hbs deleted file mode 100644 index 1de9824..0000000 --- a/template/components/_webkit.hbs +++ /dev/null @@ -1,7 +0,0 @@ -

    -

    hey! looks like you're using a weird or otherwise quirky web browser. this basically means, the website will - not work for you correctly. I might fix it in the future but I have very limited time to work on this - website and so don't have an estimate on when that might happen.

    -

    in the meantime I suggest switching to something more modern.

    -

    sorry for the inconvenience!

    -