# HG changeset patch # User MrJuneJune # Date 1786014525 25200 # Node ID 0f45474c1b1aa9a8ec6ec782a9c43c022d133e73 # Parent b401627fc49e67870bcadf8a59e0c2eb4dcd54e2 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 <223556219+Copilot@users.noreply.github.com> diff -r b401627fc49e -r 0f45474c1b1a mrjunejune/src/jrpg/index.html --- a/mrjunejune/src/jrpg/index.html Wed Aug 05 20:38:32 2026 -0700 +++ b/mrjunejune/src/jrpg/index.html Thu Aug 06 04:08:45 2026 -0700 @@ -107,7 +107,7 @@ - +

DESTINATION DATA

@@ -126,6 +126,23 @@

Loading resume dossier...

+
diff -r b401627fc49e -r 0f45474c1b1a mrjunejune/src/jrpg/jrpg.css --- a/mrjunejune/src/jrpg/jrpg.css Wed Aug 05 20:38:32 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.css Thu Aug 06 04:08:45 2026 -0700 @@ -534,13 +534,13 @@ margin-bottom: var(--zenbu-sys-space-content); } -.jrpg-preview-dialog dialog.jrpg-resume-dialog { +.jrpg-preview-dialog dialog.jrpg-detail-dialog { width: min(94vw, 72rem); max-height: calc(100dvh - var(--zenbu-sys-space-container)); overflow: hidden; } -.jrpg-preview-dialog dialog.jrpg-resume-dialog[open] { +.jrpg-preview-dialog dialog.jrpg-detail-dialog[open] { display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; gap: var(--zenbu-sys-space-group); @@ -556,7 +556,9 @@ } .jrpg-resume-dossier[hidden], +.jrpg-blog-browser[hidden], [data-dialog-copy][hidden], +[data-blog-status][hidden], [data-resume-status][hidden], [data-resume-download][hidden] { display: none; @@ -657,6 +659,102 @@ gap: var(--zenbu-sys-space-control); } +.jrpg-blog-browser { + display: grid; + grid-template-columns: minmax(12rem, 0.35fr) minmax(0, 1fr); + gap: var(--zenbu-sys-space-group); + min-height: 0; + overflow: hidden; +} + +.jrpg-blog-browser > aside, +.jrpg-blog-browser > article { + min-height: 0; + padding: var(--zenbu-sys-padding-md); + overflow: auto; + border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); + background: var(--mjj-jrpg-surface-sunken); + scrollbar-color: var(--mjj-jrpg-frame) transparent; +} + +.jrpg-blog-browser h3, +.jrpg-blog-browser [data-blog-status] { + margin-top: 0; + color: var(--mjj-jrpg-highlight); +} + +.jrpg-blog-browser [data-blog-list] { + display: grid; + gap: var(--zenbu-sys-space-control); + margin: 0; + padding: 0; + list-style: none; +} + +.jrpg-blog-browser [data-blog-list] zen-button, +.jrpg-blog-browser [data-blog-list] button { + width: 100%; +} + +.jrpg-blog-browser [data-blog-list] button { + display: grid; + justify-items: start; + gap: var(--zenbu-sys-space-icon-label); + border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); + border-radius: 0; + background: var(--mjj-jrpg-panel); + color: var(--mjj-jrpg-primary); + text-align: left; + cursor: pointer; +} + +.jrpg-blog-browser [data-blog-list] button:hover, +.jrpg-blog-browser [data-blog-list] button[aria-pressed="true"] { + border-color: var(--mjj-jrpg-accent); + background: var(--mjj-jrpg-surface-raised); +} + +.jrpg-blog-browser [data-blog-list] small { + color: var(--mjj-jrpg-text-muted); + font-size: var(--zenbu-sys-font-size-xs); +} + +.jrpg-blog-browser [data-blog-content] { + color: var(--mjj-jrpg-text); + line-height: var(--zenbu-sys-line-height-lg); +} + +.jrpg-blog-browser [data-blog-content] :where(h1, h2, h3, h4) { + color: var(--mjj-jrpg-primary); +} + +.jrpg-blog-browser [data-blog-content] a { + color: var(--mjj-jrpg-primary); + text-decoration-color: var(--mjj-jrpg-accent); + text-underline-offset: var(--zenbu-sys-space-icon-label); +} + +.jrpg-blog-browser [data-blog-content] :where(img, video) { + display: block; + max-width: 100%; + height: auto; + margin-inline: auto; +} + +.jrpg-blog-browser [data-blog-content] :where(pre, blockquote) { + max-width: 100%; + padding: var(--zenbu-sys-padding-md); + overflow: auto; + border-left: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-accent); + background: var(--mjj-jrpg-panel); +} + +.jrpg-blog-browser [data-blog-content] code { + color: var(--mjj-jrpg-success); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + mjj-jrpg-composer { grid-area: composer; display: block; @@ -948,6 +1046,15 @@ width: 100%; } + .jrpg-blog-browser { + grid-template-columns: 1fr; + overflow: auto; + } + + .jrpg-blog-browser > aside { + max-height: 16rem; + } + mjj-jrpg-menu ul { grid-template-columns: 1fr; } diff -r b401627fc49e -r 0f45474c1b1a mrjunejune/src/jrpg/jrpg.js --- 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 {} diff -r b401627fc49e -r 0f45474c1b1a mrjunejune/src/sw.js --- a/mrjunejune/src/sw.js Wed Aug 05 20:38:32 2026 -0700 +++ b/mrjunejune/src/sw.js Thu Aug 06 04:08:45 2026 -0700 @@ -1,5 +1,5 @@ // Root-scoped Service Worker for MrJuneJune PWA -const CACHE_VERSION = 'v13-turn-resume'; +const CACHE_VERSION = 'v14-blog-browser'; const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`; // Files to cache immediately on install diff -r b401627fc49e -r 0f45474c1b1a mrjunejune/test/snapshots/jrpg.snapshot --- a/mrjunejune/test/snapshots/jrpg.snapshot Wed Aug 05 20:38:32 2026 -0700 +++ b/mrjunejune/test/snapshots/jrpg.snapshot Thu Aug 06 04:08:45 2026 -0700 @@ -136,7 +136,7 @@ - +

DESTINATION DATA

@@ -155,6 +155,23 @@

Loading resume dossier...

+
diff -r b401627fc49e -r 0f45474c1b1a mrjunejune/test/theme_and_webp_test.js --- a/mrjunejune/test/theme_and_webp_test.js Wed Aug 05 20:38:32 2026 -0700 +++ b/mrjunejune/test/theme_and_webp_test.js Thu Aug 06 04:08:45 2026 -0700 @@ -734,6 +734,58 @@ await page.getByRole('button', { name: 'Close destination details', }).click(); + await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); + + await page.getByRole('button', { name: 'Blogs', exact: true }).click(); + await page.waitForFunction(() => + document.querySelectorAll('[data-latest-blog]').length === 4 + ); + assert.match( + await page.locator('[data-work-showcase]').textContent(), + /Websocket Demystified/, + ); + assert.match( + await page.locator('[data-work-showcase]').textContent(), + /Creating Network Library in C/, + ); + await page.locator('[data-latest-blog]').first().click(); + await page.locator('[data-blog-content] h1').waitFor(); + assert.equal( + await page.locator('[data-blog-content] h1').textContent(), + 'WebSocket Demystified', + ); + assert.equal( + await page.locator('[data-preview-link]').getAttribute('href'), + '/blog/websocket-demystified', + ); + assert.equal( + await page.locator('[data-blog-content]').evaluate( + article => getComputedStyle(article).fontFamily, + ), + '"Pixel Mplus"', + ); + await page.getByRole('button', { + name: 'Close destination details', + }).click(); + await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); + + await page.getByRole('button', { name: /Inspect/ }).click(); + await page.waitForFunction(() => + document.querySelectorAll('[data-blog-entry]').length === 9 + ); + assert.equal(await page.locator('[data-blog-entry]').count(), 9); + await page.locator('[data-blog-entry]').nth(1).click(); + await page.waitForFunction(() => + document.querySelector('[data-blog-content] h1')?.textContent === + 'Creating Network Library in C' + ); + assert.equal( + await page.locator('[data-preview-link]').getAttribute('href'), + '/blog/my-seobeo-journey', + ); + await page.getByRole('button', { + name: 'Close destination details', + }).click(); await page.evaluate(() => { window.__jrpgStreamEvents = []; @@ -1209,7 +1261,7 @@ for (const source of [home, dogGame, manifest]) { assert.doesNotMatch(source, /\.png(?:["')]|$)/i); } - assert.match(serviceWorker, /v13-turn-resume/); + assert.match(serviceWorker, /v14-blog-browser/); assert.match( await ( await fetch(`${baseUrl}/public/pwa-register.js`)