Mercurial
diff mrjunejune/src/jrpg/jrpg.js @ 262:0f45474c1b1a
Add cyberpunk JRPG blog browser
Show the latest posts in the JRPG preview and render the full blog archive and sanitized post details in the Inspect modal.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <mrjunejune@users.noreply.github.com> |
|---|---|
| date | Thu, 06 Aug 2026 04:08:45 -0700 |
| parents | b401627fc49e |
| children | ee04e4e69fed |
line wrap: on
line diff
--- a/mrjunejune/src/jrpg/jrpg.js Wed Aug 05 20:38:32 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.js Thu Aug 06 04:08:45 2026 -0700 @@ -435,14 +435,62 @@ } } +function sanitizeFetchedMain(main) { + for (const unsafe of main.querySelectorAll( + "embed, form, iframe, object, script, style, svg", + )) { + unsafe.remove(); + } + for (const element of main.querySelectorAll("*")) { + for (const attribute of [...element.attributes]) { + if (attribute.name.toLowerCase().startsWith("on")) { + element.removeAttribute(attribute.name); + } + } + } + for (const anchor of main.querySelectorAll("a")) { + const target = new URL( + anchor.getAttribute("href") || "", + window.location.href, + ); + anchor.href = target.href; + if (target.origin !== window.location.origin) { + anchor.target = "_blank"; + anchor.rel = "noreferrer"; + } + } + for (const image of main.querySelectorAll("img")) { + const source = image.getAttribute("src") || ""; + if (source.startsWith("/public/") && source.endsWith(".png")) { + image.src = source.replace(/\.png$/i, ".webp"); + } + image.loading = "lazy"; + } + return main; +} + class MjjJrpgPreview extends HTMLElement { connectedCallback() { this._onClick = event => { - if ( - event.target.closest("[data-zen-trigger]") && - this.dataset.selection === "resume" - ) { - void this.loadResume(); + const trigger = event.target.closest("[data-zen-trigger]"); + if (trigger) { + if (this.dataset.selection === "resume") void this.loadResume(); + if (this.dataset.selection === "blog") { + this.show("blog"); + void this.loadBlogs(); + } + return; + } + const blogEntry = event.target.closest("[data-blog-entry]"); + if (blogEntry) { + void this.loadBlogDetail(blogEntry.dataset.blogUrl); + return; + } + const latestBlog = event.target.closest("[data-latest-blog]"); + if (latestBlog && !event.metaKey && !event.ctrlKey && !event.shiftKey) { + event.preventDefault(); + this.querySelector("zen-dialog").open(); + void this.loadBlogDetail(latestBlog.dataset.blogUrl); } }; this.addEventListener("click", this._onClick); @@ -462,13 +510,44 @@ this.querySelector("[data-preview-type]").textContent = preview.type; this.querySelector("[data-dialog-title]").textContent = preview.title; this.querySelector("[data-dialog-copy]").textContent = preview.copy; + this.renderShowcase(preview.works); + const link = this.querySelector("[data-preview-link]"); + link.href = preview.url; + link.setAttribute("aria-label", `Open ${preview.title}`); + const resumeDossier = this.querySelector("[data-resume-dossier]"); + const resumeDownload = this.querySelector("[data-resume-download]"); + const blogBrowser = this.querySelector("[data-blog-browser]"); + const isResume = selection === "resume"; + const isBlog = selection === "blog"; + resumeDossier.hidden = !isResume; + resumeDownload.hidden = !isResume; + blogBrowser.hidden = !isBlog; + this.querySelector("[data-dialog-copy]").hidden = isResume || isBlog; + if (!isBlog) this._blogRequest = (this._blogRequest || 0) + 1; + if (isBlog) { + const status = this.querySelector("[data-blog-status]"); + status.hidden = false; + status.textContent = "Select a blog to inspect."; + this.querySelector("[data-blog-content]").replaceChildren(); + for (const button of this.querySelectorAll("[data-blog-entry]")) { + button.setAttribute("aria-pressed", "false"); + } + void this.loadBlogs(); + } + } + + renderShowcase(works, latestBlogs = false) { const showcase = this.querySelector("[data-work-showcase]"); - showcase.replaceChildren(...preview.works.map(work => { + showcase.replaceChildren(...works.map(work => { const item = document.createElement("li"); const link = document.createElement("a"); const label = document.createElement("span"); const detail = document.createElement("small"); link.href = work.url; + if (latestBlogs) { + link.dataset.latestBlog = ""; + link.dataset.blogUrl = work.url; + } if (new URL(work.url, window.location.href).origin !== window.location.origin) { link.target = "_blank"; link.rel = "noreferrer"; @@ -479,15 +558,6 @@ item.append(link); return item; })); - const link = this.querySelector("[data-preview-link]"); - link.href = preview.url; - link.setAttribute("aria-label", `Open ${preview.title}`); - const resumeDossier = this.querySelector("[data-resume-dossier]"); - const resumeDownload = this.querySelector("[data-resume-download]"); - const isResume = selection === "resume"; - resumeDossier.hidden = !isResume; - resumeDownload.hidden = !isResume; - this.querySelector("[data-dialog-copy]").hidden = isResume; } async loadResume() { @@ -508,25 +578,7 @@ ); const resume = documentCopy.querySelector("main"); if (!resume) throw new Error("Resume content is unavailable"); - for (const unsafe of resume.querySelectorAll( - "embed, iframe, object, script, style, svg", - )) { - unsafe.remove(); - } - for (const element of resume.querySelectorAll("*")) { - for (const attribute of [...element.attributes]) { - if (attribute.name.toLowerCase().startsWith("on")) { - element.removeAttribute(attribute.name); - } - } - } - for (const anchor of resume.querySelectorAll("a")) { - const target = new URL(anchor.href, window.location.href); - if (target.origin !== window.location.origin) { - anchor.target = "_blank"; - anchor.rel = "noreferrer"; - } - } + sanitizeFetchedMain(resume); content.replaceChildren(...resume.childNodes); status.hidden = true; this._resumeLoaded = true; @@ -536,6 +588,127 @@ this._resumeLoading = false; } } + + async loadBlogs() { + if (this._blogs) { + if (this.dataset.selection === "blog") { + this.renderShowcase(this._blogs.slice(0, 4), true); + } + return true; + } + if (this._blogsLoading) return this._blogsLoading; + const list = this.querySelector("[data-blog-list]"); + this._blogsLoading = (async () => { + const response = await fetch("/blog", { + headers: { Accept: "text/html" }, + }); + if (!response.ok) { + throw new Error(`Blog archive request failed (${response.status})`); + } + const documentCopy = new DOMParser().parseFromString( + await response.text(), + "text/html", + ); + this._blogs = [...documentCopy.querySelectorAll( + 'main li a[href^="/blog/"]', + )].map(anchor => ({ + date: anchor.closest("li")?.querySelector("span")?.textContent.trim() || + "Archive", + detail: anchor.closest("li")?.querySelector("span")?.textContent.trim() || + "Archive", + label: anchor.textContent.trim(), + url: anchor.getAttribute("href"), + })); + if (!this._blogs.length) throw new Error("No blog entries were found"); + this.renderBlogList(); + if (this.dataset.selection === "blog") { + this.renderShowcase(this._blogs.slice(0, 4), true); + } + return true; + })().catch(error => { + list.replaceChildren(); + const item = document.createElement("li"); + item.textContent = `Unable to load blogs: ${error.message}`; + list.append(item); + return false; + }).finally(() => { + this._blogsLoading = null; + }); + return this._blogsLoading; + } + + renderBlogList() { + const list = this.querySelector("[data-blog-list]"); + list.replaceChildren(...this._blogs.map(blog => { + const item = document.createElement("li"); + const owner = document.createElement("zen-button"); + const button = document.createElement("button"); + const title = document.createElement("span"); + const date = document.createElement("small"); + owner.setAttribute("appearance", "plain"); + owner.setAttribute("size", "sm"); + button.type = "button"; + button.dataset.blogEntry = ""; + button.dataset.blogUrl = blog.url; + title.textContent = blog.label; + date.textContent = blog.date; + button.append(title, date); + owner.append(button); + item.append(owner); + return item; + })); + } + + async loadBlogDetail(url) { + if (!await this.loadBlogs()) return; + const blog = this._blogs.find(item => item.url === url); + const status = this.querySelector("[data-blog-status]"); + const content = this.querySelector("[data-blog-content]"); + if (!blog) { + status.hidden = false; + status.textContent = "Unable to load blog: unknown entry."; + return; + } + const request = (this._blogRequest || 0) + 1; + this._blogRequest = request; + status.hidden = false; + status.textContent = `Loading ${blog.label}...`; + content.replaceChildren(); + this.querySelector("[data-dialog-title]").textContent = blog.label; + const fullPage = this.querySelector("[data-preview-link]"); + fullPage.href = blog.url; + fullPage.setAttribute("aria-label", `Open ${blog.label}`); + for (const button of this.querySelectorAll("[data-blog-entry]")) { + button.setAttribute( + "aria-pressed", + String(button.dataset.blogUrl === blog.url), + ); + } + try { + const response = await fetch(blog.url, { + headers: { Accept: "text/html" }, + }); + if (!response.ok) { + throw new Error(`Blog request failed (${response.status})`); + } + const documentCopy = new DOMParser().parseFromString( + await response.text(), + "text/html", + ); + const article = documentCopy.querySelector("main"); + if (!article) throw new Error("Blog content is unavailable"); + sanitizeFetchedMain(article); + if (request !== this._blogRequest || this.dataset.selection !== "blog") { + return; + } + content.replaceChildren(...article.childNodes); + status.hidden = true; + } catch (error) { + if (request !== this._blogRequest) return; + status.hidden = false; + status.textContent = `Unable to load blog: ${error.message}`; + } + } } class MjjJrpgShell extends HTMLElement {}