From a1464bb8652581b8f54ebf3aa421883a28f75cfc Mon Sep 17 00:00:00 2001 From: lqdev Date: Wed, 21 Feb 2024 23:17:19 +0100 Subject: [PATCH] newsfeed --- content/treehouse/new.tree | 11 ++ crates/treehouse/src/cli/generate.rs | 96 +++++++---- crates/treehouse/src/html/tree.rs | 11 +- crates/treehouse/src/tree/attributes.rs | 10 ++ static/css/main.css | 65 +++++++- static/css/new.css | 103 ++++++++++++ static/js/components/literate-programming.js | 1 + static/js/news.js | 70 ++++++++ static/js/settings.js | 35 ++++ static/js/thanks-webkit.js | 2 +- static/js/tree.js | 23 +-- template/README.md | 8 + template/_new.hbs | 77 +++++++++ template/_tree.hbs | 164 ++----------------- template/components/_footer.hbs | 49 ++++++ template/components/_head.hbs | 37 +++++ template/components/_nav.hbs | 23 +++ template/components/_noscript.hbs | 20 +++ template/components/_tree.hbs | 17 ++ template/components/_webkit.hbs | 7 + 20 files changed, 636 insertions(+), 193 deletions(-) create mode 100644 content/treehouse/new.tree create mode 100644 static/css/new.css create mode 100644 static/js/news.js create mode 100644 static/js/settings.js create mode 100644 template/README.md create mode 100644 template/_new.hbs create mode 100644 template/components/_footer.hbs create mode 100644 template/components/_head.hbs create mode 100644 template/components/_nav.hbs create mode 100644 template/components/_noscript.hbs create mode 100644 template/components/_tree.hbs create mode 100644 template/components/_webkit.hbs diff --git a/content/treehouse/new.tree b/content/treehouse/new.tree new file mode 100644 index 0000000..1e9661e --- /dev/null +++ b/content/treehouse/new.tree @@ -0,0 +1,11 @@ +%% template = "_new.hbs" + title = "a curated feed of updates to the house" + styles = ["new.css"] + feed = "news" + +% id = "01HQ6G30PTVT5H0Z04VVRHEZQF" +- ever wondered how Terraria renders its worlds? or how editors like Tiled manage to make painting tiles so easy? + +### tairu - an interactive exploration of 2D autotiling techniques + +[read][page:programming/blog/tairu] diff --git a/crates/treehouse/src/cli/generate.rs b/crates/treehouse/src/cli/generate.rs index 84c5050..371094a 100644 --- a/crates/treehouse/src/cli/generate.rs +++ b/crates/treehouse/src/cli/generate.rs @@ -43,6 +43,40 @@ struct ParsedTree { target_path: PathBuf, } +#[derive(Serialize)] +struct Feed { + branches: Vec, +} + +#[derive(Serialize)] +pub struct Page { + pub title: String, + pub thumbnail: Option, + pub scripts: Vec, + pub styles: Vec, + pub breadcrumbs: String, + pub tree_path: Option, + pub tree: String, +} + +#[derive(Serialize)] +pub struct Thumbnail { + pub url: String, + pub alt: Option, +} + +#[derive(Serialize)] +struct StaticTemplateData<'a> { + config: &'a Config, +} + +#[derive(Serialize)] +pub struct PageTemplateData<'a> { + pub config: &'a Config, + pub page: Page, + pub feeds: &'a HashMap, +} + impl Generator { fn add_directory_rec(&mut self, directory: &Path) -> anyhow::Result<()> { for entry in WalkDir::new(directory) { @@ -172,14 +206,14 @@ impl Generator { config: &Config, paths: &Paths<'_>, navigation_map: &NavigationMap, - parsed_trees: impl IntoIterator, + parsed_trees: Vec, ) -> anyhow::Result<()> { let mut handlebars = Handlebars::new(); let mut config_derived_data = ConfigDerivedData::default(); let mut template_file_ids = HashMap::new(); for entry in WalkDir::new(paths.template_dir) { - let entry = entry?; + let entry = entry.context("cannot read directory entry")?; let path = entry.path(); if !entry.file_type().is_dir() && path.extension() == Some(OsStr::new("hbs")) { let relative_path = path @@ -194,12 +228,8 @@ impl Generator { std::fs::create_dir_all(paths.template_target_dir)?; for (name, &file_id) in &template_file_ids { - if !name.starts_with('_') { - #[derive(Serialize)] - struct StaticTemplateData<'a> { - config: &'a Config, - } - + let filename = name.rsplit_once('/').unwrap_or(("", name)).1; + if !filename.starts_with('_') { let templated_html = match handlebars.render(name, &StaticTemplateData { config }) { Ok(html) => html, Err(error) => { @@ -220,6 +250,24 @@ impl Generator { } } + let mut feeds = HashMap::new(); + + for parsed_tree in &parsed_trees { + let roots = &treehouse.roots[&parsed_tree.tree_path]; + + if let Some(feed_name) = &roots.attributes.feed { + let mut feed = Feed { + branches: Vec::new(), + }; + for &root in &roots.branches { + let branch = treehouse.tree.branch(root); + feed.branches.push(branch.attributes.id.clone()); + } + dbg!(&feed.branches); + feeds.insert(feed_name.to_owned(), feed); + } + } + for parsed_tree in parsed_trees { let breadcrumbs = breadcrumbs_to_html(config, navigation_map, &parsed_tree.tree_path); @@ -238,28 +286,6 @@ impl Generator { &roots.branches, ); - #[derive(Serialize)] - pub struct Page { - pub title: String, - pub thumbnail: Option, - pub scripts: Vec, - pub styles: Vec, - pub breadcrumbs: String, - pub tree_path: Option, - pub tree: String, - } - - #[derive(Serialize)] - pub struct Thumbnail { - pub url: String, - pub alt: Option, - } - - #[derive(Serialize)] - pub struct PageTemplateData<'a> { - pub config: &'a Config, - pub page: Page, - } let template_data = PageTemplateData { config, page: Page { @@ -280,16 +306,22 @@ impl Generator { .map(|s| s.to_owned()), tree, }, + feeds: &feeds, }; + let template_name = roots + .attributes + .template + .clone() + .unwrap_or_else(|| "_tree.hbs".into()); treehouse.roots.insert(parsed_tree.tree_path, roots); - let templated_html = match handlebars.render("_tree.hbs", &template_data) { + let templated_html = match handlebars.render(&template_name, &template_data) { Ok(html) => html, Err(error) => { Self::wrangle_handlebars_error_into_diagnostic( treehouse, - template_file_ids["_tree.hbs"], + template_file_ids[&template_name], error.line_no, error.column_no, error.desc, diff --git a/crates/treehouse/src/html/tree.rs b/crates/treehouse/src/html/tree.rs index e921d1e..af1d0f7 100644 --- a/crates/treehouse/src/html/tree.rs +++ b/crates/treehouse/src/html/tree.rs @@ -114,15 +114,22 @@ pub fn branch_to_html( .map(|&branch_id| { ( format!( - "/b?{}", + "{}/b?{}", + config.site, treehouse.tree.branch(branch_id).attributes.id ) .into(), "".into(), ) }), + "page" => { + Some((format!("{}/{}.html", config.site, linked).into(), "".into())) + } "pic" => config.pics.get(linked).map(|filename| { - (format!("/static/pic/{}", &filename).into(), "".into()) + ( + format!("{}/static/pic/{}", config.site, &filename).into(), + "".into(), + ) }), _ => None, }) diff --git a/crates/treehouse/src/tree/attributes.rs b/crates/treehouse/src/tree/attributes.rs index dd0c087..1415ad5 100644 --- a/crates/treehouse/src/tree/attributes.rs +++ b/crates/treehouse/src/tree/attributes.rs @@ -3,6 +3,11 @@ use serde::{Deserialize, Serialize}; /// Top-level `%%` root attributes. #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct RootAttributes { + /// Template to use for generating the page. + /// Defaults to `_tree.hbs`. + #[serde(default)] + pub template: Option, + /// Title of the generated .html page. /// /// The page's tree path is used if empty. @@ -26,6 +31,11 @@ pub struct RootAttributes { /// These are relative to the /static/css directory. #[serde(default)] pub styles: Vec, + + /// When specified, branches coming from this root will be added to a _feed_ with the given name. + /// Feeds can be read by Handlebars templates to generate content based on them. + #[serde(default)] + pub feed: Option, } /// A picture reference. diff --git a/static/css/main.css b/static/css/main.css index 3faabb5..03b4e35 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -369,6 +369,16 @@ th { --recursive-casl: 0.5; } +/* Horizontal rules */ + +hr { + width: 100%; + border: none; + border-top: 1px solid var(--border-1); + margin-top: 2em; + margin-bottom: 2em; +} + /* Style the noscript box a little more prettily. */ .noscript { @@ -413,6 +423,7 @@ nav { nav .nav-page { display: flex; + flex-grow: 1; flex-direction: column; } @@ -435,8 +446,49 @@ h1.page-title { font-size: 1.25rem; } -/* Style the footer */ +/* Style the `new` link on the homepage */ +a[is="th-new"] { + flex-shrink: 0; + color: var(--text-color); + opacity: 50%; + &.has-news { + opacity: 100%; + text-decoration: none; + + & .new-text { + text-decoration: underline; + } + } + + & .badge { + margin-left: 8px; + text-decoration: none; + } +} + +/* Style new badges */ +span.badge { + --recursive-wght: 800; + --recursive-slnt: 0; + --recursive-mono: 1.0; + --recursive-casl: 0; + + border-radius: 999px; + padding: 2px 6px; + font-size: 0.9em; + + &.red { + color: white; + background-color: #d01243; + } + + &.before-content { + margin-right: 6px; + } +} + +/* Style the footer */ footer { margin-top: 4rem; text-align: right; @@ -561,6 +613,9 @@ th-literate-program[data-mode="output"] { border-style: none; border-radius: 4px; display: block; + } + + & img.placeholder.js { transition: opacity var(--transition-duration); } @@ -709,3 +764,11 @@ th-literate-program[data-mode="output"] { } } } + +/* Style settings sections */ + +section[is="th-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 new file mode 100644 index 0000000..e26ed00 --- /dev/null +++ b/static/css/new.css @@ -0,0 +1,103 @@ +/* Give the intro and outro some breathing room. */ +section { + padding: 1em 2em; +} + +/* Style all links in the last paragraph as big buttons. */ +.tree th-bc>p:last-child { + --transition-duration: 0.2s; + + margin-top: 12px; + margin-bottom: 4px; + + &>a { + padding: 0.5em 1.5em; + + color: var(--text-color); + background-color: transparent; + border: 1px solid var(--border-1); + border-radius: 2em; + text-decoration: none; + + transition: + color var(--transition-duration), + background-color var(--transition-duration), + border-color var(--transition-duration); + + &:hover, + &:focus { + color: white; + background-color: #058ef0; + border-color: white; + } + } +} + +section[is="th-settings"] { + & h3 { + display: inline; + } + + & details>summary { + --recursive-wght: 700; + + list-style: none; + cursor: pointer; + + opacity: 50%; + transition: opacity var(--transition-duration); + + &::-webkit-details-marker { + display: none; + } + + &::before { + --recursive-casl: 0.0; + --recursive-mono: 1.0; + --recursive-slnt: 0.0; + + content: '+'; + margin-right: 0.3em; + + opacity: 50%; + } + + &:hover { + opacity: 100%; + } + } + + & details[open]>summary { + opacity: 100%; + + &::before { + content: '-'; + } + } + + & p { + margin-bottom: 8px; + } + + & button { + border: 1px solid var(--border-1); + border-radius: 999px; + padding: 4px 12px; + background: none; + color: var(--text-color); + font-size: 1rem; + + cursor: pointer; + + transition: + color var(--transition-duration), + background-color var(--transition-duration), + border-color var(--transition-duration); + + &:hover { + color: white; + background-color: #058ef0; + border-color: white; + } + } +} diff --git a/static/js/components/literate-programming.js b/static/js/components/literate-programming.js index 7a46160..e11d976 100644 --- a/static/js/components/literate-programming.js +++ b/static/js/components/literate-programming.js @@ -184,6 +184,7 @@ class OutputMode { }); if (this.frame.placeholderImage != null) { + this.frame.placeholderImage.classList.add("js"); this.frame.placeholderImage.classList.add("loading"); } diff --git a/static/js/news.js b/static/js/news.js new file mode 100644 index 0000000..6415846 --- /dev/null +++ b/static/js/news.js @@ -0,0 +1,70 @@ +// news.js because new.js makes the TypeScript language server flip out. +// Likely because `new` is a keyword, but also, what the fuck. + +import { getSettingValue } from "./settings.js"; +import { Branch } from "./tree.js"; + +const seenStatesKey = "treehouse.news.seenBranches"; +const seenStates = new Set(JSON.parse(localStorage.getItem(seenStatesKey)) || []); + +let seenCount = seenStates.size; +let unseenCount = TREEHOUSE_NEWS_COUNT - seenCount; + +function saveSeenStates() { + localStorage.setItem(seenStatesKey, JSON.stringify(Array.from(seenStates))); +} + +function markAsRead(branch) { + if (!seenStates.has(branch.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); + } + + seenStates.add(branch.namedID); +} + +export function initNewsPage() { + for (let [_, branch] of Branch.branchesByNamedID) { + markAsRead(branch); + } + saveSeenStates(); + + // If any branches are added past the initial load, add them to the seen set too. + Branch.onAdded.push(branch => { + markAsRead(branch); + saveSeenStates(); + }) +} + +export function markAllAsUnread() { + localStorage.removeItem(seenStatesKey); +} + +class New extends HTMLAnchorElement { + connectedCallback() { + // 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 + // checkbox at the bottom. + let userSawNews = seenCount > 0; + let userWantsToSeeNews = getSettingValue("showNewPostIndicator"); + 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.badge = document.createElement("span"); + this.badge.classList.add("badge", "red"); + this.badge.textContent = unseenCount.toString(); + this.appendChild(this.badge); + this.classList.add("has-news"); + } + } +} + +customElements.define("th-new", New, { extends: "a" }); diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 0000000..6b18a2a --- /dev/null +++ b/static/js/settings.js @@ -0,0 +1,35 @@ +const settingsKey = "treehouse.settings"; +const settings = JSON.parse(localStorage.getItem(settingsKey)) || {}; + +const defaultSettingValues = { + showNewPostIndicator: true, +}; + +function saveSettings() { + localStorage.setItem(settingsKey, JSON.stringify(settings)); +} + +export function getSettingValue(setting) { + return settings[setting] ?? defaultSettingValues[setting]; +} + +class SettingCheckbox extends HTMLInputElement { + connectedCallback() { + this.checked = getSettingValue(this.id); + + this.addEventListener("change", () => { + settings[this.id] = this.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" }); diff --git a/static/js/thanks-webkit.js b/static/js/thanks-webkit.js index a2c99a9..efcfcf5 100644 --- a/static/js/thanks-webkit.js +++ b/static/js/thanks-webkit.js @@ -1,5 +1,5 @@ // Detect if we can have crucial functionality (ie. custom elements call constructors). -// This doesn't seem to happen in Epiphany, and possibly also other Webkit-based browsers. +// This doesn't seem to happen in Epiphany, and also other Webkit-based browsers. let works = false; class WebkitMoment extends HTMLLIElement { constructor() { diff --git a/static/js/tree.js b/static/js/tree.js index 9100b1f..003d21d 100644 --- a/static/js/tree.js +++ b/static/js/tree.js @@ -17,12 +17,11 @@ function branchIsOpen(branchID) { return branchState[branchID]; } -class Branch extends HTMLLIElement { +export class Branch extends HTMLLIElement { static branchesByNamedID = new Map(); + static onAdded = []; - constructor() { - super(); - + connectedCallback() { this.isLeaf = this.classList.contains("leaf"); this.details = this.childNodes[0]; @@ -48,16 +47,20 @@ class Branch extends HTMLLIElement { }); } - let namedID = this.id.split(':')[1]; - Branch.branchesByNamedID.set(namedID, this); + this.namedID = this.id.split(':')[1]; + Branch.branchesByNamedID.set(this.namedID, this); - if (ulid.isCanonicalUlid(namedID)) { - let timestamp = ulid.getTimestamp(namedID); + if (ulid.isCanonicalUlid(this.namedID)) { + let timestamp = ulid.getTimestamp(this.namedID); let date = document.createElement("span"); date.classList.add("branch-date"); date.innerText = timestamp.toLocaleDateString(); this.buttonBar.insertBefore(date, this.buttonBar.firstChild); } + + for (let callback of Branch.onAdded) { + callback(this); + } } } @@ -68,8 +71,8 @@ customElements.define("th-b", Branch, { extends: "li" }); class LinkedBranch extends Branch { static byLink = new Map(); - constructor() { - super(); + connectedCallback() { + super.connectedCallback(); this.linkedTree = this.getAttribute("data-th-link"); LinkedBranch.byLink.set(this.linkedTree, this); diff --git a/template/README.md b/template/README.md new file mode 100644 index 0000000..eb5a8bc --- /dev/null +++ b/template/README.md @@ -0,0 +1,8 @@ +# Templates + +This directory houses Handlebars templates, which are mostly used for reusable bits of the house. + +Files that are not prefixed with a `_` are generated into their own `.html` files. +All other files are only loaded into Handlebars for use by other templates (or the generator itself.) + +In particular, `_tree.hbs` is used as the default page template. This can be changed by including a `%% template = "_whatever.hbs"` at the top of your .tree file. diff --git a/template/_new.hbs b/template/_new.hbs new file mode 100644 index 0000000..a48ce51 --- /dev/null +++ b/template/_new.hbs @@ -0,0 +1,77 @@ + + + + + + {{> components/_head.hbs }} + + + + {{#> components/_nav.hbs }} + + {{!-- For /index, include a "new" link that goes to the curated news feed page. --}} + {{#if (eq page.tree_path "index")}} + new + {{/if}} + + {{/ components/_nav.hbs }} + + {{> components/_noscript.hbs }} + {{> components/_webkit.hbs }} + +
+

welcome!

+

since you clicked here, you must be curious as to what's been going on since your last visit to the house. so + here's a recap just for you - enjoy!

+
+ +
+ + {{> components/_tree.hbs }} + +
+ +
+

note that this page does not include any updates that were made to the website itself - for that, you can + visit the changelog. +

+
+ +
+
+ + settings + +
+

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

+

+ + +

+

+ +

+
+
+
+ + {{!-- For all pages except the one linked from the footer, include the footer icon. --}} + {{#if (ne page.tree_path "treehouse")}} + {{> components/_footer.hbs }} + {{/if}} + + + + + diff --git a/template/_tree.hbs b/template/_tree.hbs index 976532a..3f1c4ce 100644 --- a/template/_tree.hbs +++ b/template/_tree.hbs @@ -3,161 +3,31 @@ - - - {{#if (ne page.title config.user.title)}}{{ page.title }} · {{/if}}{{ config.user.title }} - - - - - - - - - - - - - - - - - - {{!-- - This is a bit of a hack to quickly insert metadata into generated pages without going through Handlebars, which - would involve registering, parsing, and generating a page from a template. - Yes it would be more flexible that way, but it doesn't need to be. - It just needs to be a string replacement. - --}} - - {{#if page.thumbnail}} - - - {{/if}} + {{> components/_head.hbs }} - + {{/ components/_nav.hbs }} - + {{!-- + NOTE: ~ because components/_tree.hbss must not include any extra indentation, because it may + contain pre elements which shouldn't be indented. + --}} + {{~> components/_tree.hbs }} -
-

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!

-
- -
- {{!-- Append page styles and scripts into the main content, such that they can be inlined - into linked branches when those are loaded in. Putting them in the page's head would make - extracting them way more painful than it needs to be. --}} - - {{#each page.styles}} - - {{/each}} - - {{#each page.scripts}} - - {{/each}} - - {{{ page.tree }}} -
- - - - {{#if (ne page.tree_path 'treehouse')}} - + {{!-- For all pages except the one linked from the footer, include the footer icon. --}} + {{#if (ne page.tree_path "treehouse")}} + {{> components/_footer.hbs }} {{/if}} diff --git a/template/components/_footer.hbs b/template/components/_footer.hbs new file mode 100644 index 0000000..ba5942c --- /dev/null +++ b/template/components/_footer.hbs @@ -0,0 +1,49 @@ + diff --git a/template/components/_head.hbs b/template/components/_head.hbs new file mode 100644 index 0000000..7facde4 --- /dev/null +++ b/template/components/_head.hbs @@ -0,0 +1,37 @@ + + +{{#if (ne page.title config.user.title)}}{{ page.title }} · {{/if}}{{ config.user.title }} + + + + + + + + + + + + + + + + + + + +{{!-- +This is a bit of a hack to quickly insert metadata into generated pages without going through Handlebars, which +would involve registering, parsing, and generating a page from a template. +Yes it would be more flexible that way, but it doesn't need to be. +It just needs to be a string replacement. +--}} + +{{#if page.thumbnail}} + + +{{/if}} diff --git a/template/components/_nav.hbs b/template/components/_nav.hbs new file mode 100644 index 0000000..3518db2 --- /dev/null +++ b/template/components/_nav.hbs @@ -0,0 +1,23 @@ + diff --git a/template/components/_noscript.hbs b/template/components/_noscript.hbs new file mode 100644 index 0000000..47e72c3 --- /dev/null +++ b/template/components/_noscript.hbs @@ -0,0 +1,20 @@ + diff --git a/template/components/_tree.hbs b/template/components/_tree.hbs new file mode 100644 index 0000000..0b2bde8 --- /dev/null +++ b/template/components/_tree.hbs @@ -0,0 +1,17 @@ +
+ {{!-- Append page styles and scripts into the main content, such that they can be inlined + into linked branches when those are loaded in. Putting them in the page's head would make + extracting them way more painful than it needs to be. --}} + + {{#each page.styles}} + + {{/each}} + + {{#each page.scripts}} + + {{/each}} + + {{{ page.tree }}} +
+ + diff --git a/template/components/_webkit.hbs b/template/components/_webkit.hbs new file mode 100644 index 0000000..1de9824 --- /dev/null +++ b/template/components/_webkit.hbs @@ -0,0 +1,7 @@ +
+

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!

+