Browse Source

bug-fixes

imwald
Silberengel 2 days ago
parent
commit
4904800365
  1. 3
      assets/app.js
  2. 10
      assets/bootstrap.js
  3. 4
      assets/controllers/article_comments_controller.js
  4. 290
      assets/controllers/article_highlight_controller.js
  5. 4
      assets/controllers/service-worker_controller.js
  6. 7
      assets/styles/app.css
  7. 101
      assets/styles/article.css
  8. 2
      assets/styles/event.css
  9. 44
      assets/styles/layout.css
  10. 11
      assets/styles/theme.css
  11. 7
      src/Command/PrewarmCommand.php
  12. 61
      src/Controller/ArticleController.php
  13. 10
      src/Service/ArticleBodyHighlightInjector.php
  14. 3
      src/Service/MagazineContentService.php
  15. 8
      templates/components/Molecules/ArticleHighlightMetaHead.html.twig
  16. 2
      templates/components/Organisms/HomeHighlightsAside.html.twig
  17. 23
      templates/pages/article.html.twig
  18. 2
      translations/messages.en.yaml

3
assets/app.js

@ -19,6 +19,3 @@ import './styles/form.css';
import './styles/notice.css'; import './styles/notice.css';
import './styles/spinner.css'; import './styles/spinner.css';
import './styles/a2hs.css'; import './styles/a2hs.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

10
assets/bootstrap.js vendored

@ -2,9 +2,10 @@ import { startStimulusApp } from '@symfony/stimulus-bundle';
import ArticleCommentsController from './controllers/article_comments_controller.js'; import ArticleCommentsController from './controllers/article_comments_controller.js';
import CommentReplyController from './controllers/comment_reply_controller.js'; import CommentReplyController from './controllers/comment_reply_controller.js';
import CopyTextController from './controllers/copy_text_controller.js'; import CopyTextController from './controllers/copy_text_controller.js';
import ArticleHighlightController from './controllers/article_highlight_controller.js';
const app = startStimulusApp(); const app = startStimulusApp();
if (typeof app.debug === 'boolean') {
app.debug = false;
}
// Ensure lazy comment loader is registered (Asset Mapper discovery can miss new files until rebuild). // Ensure lazy comment loader is registered (Asset Mapper discovery can miss new files until rebuild).
try { try {
@ -22,8 +23,3 @@ try {
} catch { } catch {
/* already registered by the bundle */ /* already registered by the bundle */
} }
try {
app.register('article-highlight', ArticleHighlightController);
} catch {
/* already registered by the bundle */
}

4
assets/controllers/article_comments_controller.js

@ -79,9 +79,9 @@ export default class extends Controller {
} }
const ms = Math.round(performance.now() - t0); const ms = Math.round(performance.now() - t0);
if (attempt > 1) { if (attempt > 1) {
console.info(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue); console.debug(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue);
} else { } else {
console.info(`[article-comments] fragment OK in ${ms}ms`, this.urlValue); console.debug(`[article-comments] fragment OK in ${ms}ms`, this.urlValue);
} }
window.clearTimeout(timer); window.clearTimeout(timer);
return; return;

290
assets/controllers/article_highlight_controller.js

@ -1,290 +0,0 @@
import { Controller } from '@hotwired/stimulus';
const MARK_SEL = 'mark.article-body-highlight';
/**
* In-article NIP-84 marks: show popover on hover / click; #h-<64-hex> scrolls and focuses the mark.
*/
export default class extends Controller {
static targets = ['article', 'meta', 'popover', 'popoverInner'];
connect() {
this._metaById = {};
if (this.hasMetaTarget) {
try {
this._metaById = JSON.parse(this.metaTarget.textContent || '{}');
} catch {
this._metaById = {};
}
}
this._pinnedId = null;
this._openId = null;
this._hoverLeaveTimer = null;
this._onHash = () => {
this._scrollToHash();
};
this._onScrollReposition = () => {
if (this._openId && this.hasPopoverTarget) {
const a = this._getMarkByEventId(this._openId);
if (a) {
this._placePopover(a);
}
}
};
this._onPopoverPointerEnter = () => {
this._clearHoverLeaveTimer();
};
this._onPopoverPointerLeave = () => {
if (!this._pinnedId) {
this._hoverLeaveTimer = setTimeout(() => {
this._hoverLeaveTimer = null;
this._hideUnpinnedPopover();
}, 200);
}
};
this._onMarkPointerEnter = (e) => {
const a = e.target && e.target.closest && e.target.closest(MARK_SEL);
if (!a) {
return;
}
this._clearHoverLeaveTimer();
this._openForMark(a, false);
};
this._onMarkPointerLeave = (e) => {
const a = e.target && e.target.closest && e.target.closest(MARK_SEL);
if (!a) {
return;
}
this._onMarkLeave(a);
};
this._onMarkClick = (e) => {
const a = e.target && e.target.closest && e.target.closest(MARK_SEL);
if (!a) {
return;
}
e.preventDefault();
e.stopPropagation();
const id = (a.getAttribute('data-event-id') || '').toLowerCase();
if (this._pinnedId === id) {
this._closePinned();
this._hideUnpinnedPopover();
return;
}
this._openForMark(a, true);
};
this._onMarkFocus = (e) => {
const t = e.target;
if (!t || !t.classList || !t.classList.contains('article-body-highlight')) {
return;
}
this._openForMark(t, false);
};
this._onMarkKeydown = (e) => {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
const t = e.target;
if (!t || !t.classList || !t.classList.contains('article-body-highlight')) {
return;
}
e.preventDefault();
this._openForMark(t, true);
};
if (this.hasArticleTarget) {
this.articleTarget.addEventListener('pointerenter', this._onMarkPointerEnter, true);
this.articleTarget.addEventListener('pointerleave', this._onMarkPointerLeave, true);
this.articleTarget.addEventListener('click', this._onMarkClick, true);
this.articleTarget.addEventListener('focusin', this._onMarkFocus, true);
this.articleTarget.addEventListener('keydown', this._onMarkKeydown, true);
}
if (this.hasPopoverTarget) {
this.popoverTarget.addEventListener('pointerenter', this._onPopoverPointerEnter);
this.popoverTarget.addEventListener('pointerleave', this._onPopoverPointerLeave);
}
window.addEventListener('scroll', this._onScrollReposition, true);
window.addEventListener('resize', this._onScrollReposition);
this._scrollToHash();
window.addEventListener('hashchange', this._onHash);
}
disconnect() {
window.removeEventListener('hashchange', this._onHash);
window.removeEventListener('scroll', this._onScrollReposition, true);
window.removeEventListener('resize', this._onScrollReposition);
if (this.hasArticleTarget) {
this.articleTarget.removeEventListener('pointerenter', this._onMarkPointerEnter, true);
this.articleTarget.removeEventListener('pointerleave', this._onMarkPointerLeave, true);
this.articleTarget.removeEventListener('click', this._onMarkClick, true);
this.articleTarget.removeEventListener('focusin', this._onMarkFocus, true);
this.articleTarget.removeEventListener('keydown', this._onMarkKeydown, true);
}
if (this.hasPopoverTarget) {
this.popoverTarget.removeEventListener('pointerenter', this._onPopoverPointerEnter);
this.popoverTarget.removeEventListener('pointerleave', this._onPopoverPointerLeave);
}
this._clearHoverLeaveTimer();
}
closeOnOutside(event) {
if (!this.hasPopoverTarget) {
return;
}
if (!this._pinnedId && !this._openId) {
return;
}
const t = event.target;
if (this.popoverTarget.contains(t)) {
return;
}
if (t && t.closest && t.closest('mark.article-body-highlight')) {
return;
}
if (this._pinnedId) {
this._closePinned();
} else {
this._hideUnpinnedPopover();
}
}
onKeydown(event) {
if (event.key === 'Escape') {
this._closePinned();
this._hideUnpinnedPopover();
}
}
closePopover() {
this._closePinned();
this._hideUnpinnedPopover();
}
_onMarkLeave(mark) {
this._clearHoverLeaveTimer();
if (this._pinnedId) {
return;
}
this._hoverLeaveTimer = setTimeout(() => {
this._hoverLeaveTimer = null;
if (this._openId === (mark.getAttribute('data-event-id') || '').toLowerCase()) {
this._hideUnpinnedPopover();
}
}, 200);
}
_clearHoverLeaveTimer() {
if (this._hoverLeaveTimer) {
clearTimeout(this._hoverLeaveTimer);
this._hoverLeaveTimer = null;
}
}
_getMarkByEventId(id) {
if (!id) {
return null;
}
return (
this.element.querySelector(`#highlight-${id}`) ||
this.element.querySelector(`mark.article-body-highlight[data-event-id="${CSS.escape(id)}"]`)
);
}
_openForMark(mark, pin) {
if (!this.hasPopoverTarget || !this.hasPopoverInnerTarget) {
return;
}
const id = (mark.getAttribute('data-event-id') || '').toLowerCase();
if (!id || 64 !== id.length) {
return;
}
const meta = this._metaById[id];
if (!meta) {
return;
}
this._openId = id;
if (pin) {
this._pinnedId = id;
} else {
this._clearHoverLeaveTimer();
}
const head = meta.headHtml || '';
const body = (meta.bodyHtml || '').trim();
this.popoverInnerTarget.innerHTML =
head + (body !== '' ? `<div class="article-body-highlight__body user-highlight__body">${body}</div>` : '');
this._placePopover(mark);
this.popoverTarget.hidden = false;
}
_placePopover(anchor) {
if (!this.hasPopoverTarget) {
return;
}
const p = this.popoverTarget;
p.style.position = 'fixed';
p.style.zIndex = '200';
p.style.left = '0';
p.style.top = '0';
const r = anchor.getBoundingClientRect();
p.hidden = false;
const pr = p.getBoundingClientRect();
let left = r.left + r.width / 2 - pr.width / 2;
let top = r.bottom + 8;
if (left < 8) {
left = 8;
}
if (left + pr.width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - 8 - pr.width);
}
if (top + pr.height > window.innerHeight - 8) {
top = Math.max(8, r.top - 8 - pr.height);
}
p.style.left = `${Math.round(left)}px`;
p.style.top = `${Math.round(top)}px`;
}
_hideUnpinnedPopover() {
this._openId = null;
this._clearHoverLeaveTimer();
if (this._pinnedId) {
return;
}
if (this.hasPopoverTarget) {
this.popoverTarget.hidden = true;
}
if (this.hasPopoverInnerTarget) {
this.popoverInnerTarget.innerHTML = '';
}
}
_closePinned() {
this._pinnedId = null;
if (this.hasPopoverTarget) {
this.popoverTarget.hidden = true;
}
if (this.hasPopoverInnerTarget) {
this.popoverInnerTarget.innerHTML = '';
}
this._openId = null;
}
_scrollToHash() {
const raw = window.location.hash || '';
if (!raw.startsWith('#h-')) {
return;
}
const id = raw.slice(3).toLowerCase().replace(/[^0-9a-f]/g, '');
if (id.length !== 64) {
return;
}
const el = this.element.querySelector(`#highlight-${id}`) || this._getMarkByEventId(id);
if (!el) {
return;
}
el.classList.remove('article-body-highlight--target');
void el.offsetWidth;
el.classList.add('article-body-highlight--target');
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
this._openForMark(el, false);
}
}

4
assets/controllers/service-worker_controller.js

@ -4,7 +4,9 @@ export default class extends Controller {
connect() { connect() {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js') navigator.serviceWorker.register('/service-worker.js')
.then(reg => console.log('SW registered:', reg)) .then(() => {
/* optional: console.debug('SW registered') */
})
.catch(err => console.error('SW failed:', err)); .catch(err => console.error('SW failed:', err));
} }
} }

7
assets/styles/app.css

@ -243,7 +243,8 @@ svg.icon {
font-weight: 700; font-weight: 700;
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: hsl(var(--tile-hue) 26% 32%); /* Blend hue toward body mid-gray so every tile hue stays ≥4.5:1 on dark head strip */
color: color-mix(in srgb, hsl(var(--tile-hue) 26% 50%) 38%, var(--color-text-mid) 62%);
line-height: 1.35; line-height: 1.35;
} }
@ -355,7 +356,7 @@ svg.icon {
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 400; font-weight: 400;
line-height: 1.35; line-height: 1.35;
color: color-mix(in srgb, var(--color-text-mid) 48%, var(--color-bg) 52%); color: var(--color-text-mid);
margin: 0.15rem 0 0.45rem; margin: 0.15rem 0 0.45rem;
} }
@ -385,7 +386,7 @@ svg.icon {
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 400; font-weight: 400;
line-height: 1.35; line-height: 1.35;
color: color-mix(in srgb, var(--color-text-mid) 48%, var(--color-bg) 52%); color: var(--color-text-mid);
} }
.article-list .metadata p { .article-list .metadata p {

101
assets/styles/article.css

@ -422,79 +422,7 @@
background: #fdecec; background: #fdecec;
} }
/* NIP-84: kind-9802 marks in .article-main + client popover (no separate thread list) */ /* NIP-84: kind-9802 marks in .article-main (fragment id highlight-<event id> for deep links) */
.article-body-highlight {
cursor: pointer;
scroll-margin-top: 6rem;
}
.article-body-highlight--target {
box-shadow: none;
background: transparent;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--color-text) 35%, transparent);
text-underline-offset: 0.12em;
transition: text-decoration-color 0.25s ease;
}
.article-body-highlight:focus-visible {
outline: 2px solid var(--color-focus-ring, var(--color-primary));
outline-offset: 2px;
}
.article-body-highlight__popover[hidden] {
display: none;
}
.article-body-highlight__popover {
position: fixed;
z-index: 200;
max-width: min(24rem, calc(100vw - 1.5rem));
margin: 0;
padding: 0.65rem 2rem 0.85rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--color-border) 75%, var(--color-bg) 25%);
border-radius: 0.45rem;
color: var(--color-text);
/* Slightly above page bg so the card reads as a raised surface */
background: color-mix(in srgb, var(--color-bg) 88%, #fff 12%);
box-shadow: 0 0.4rem 1.25rem rgba(0, 0, 0, 0.14);
pointer-events: auto;
}
.article-body-highlight__close {
position: absolute;
top: 0.2rem;
right: 0.35rem;
margin: 0;
border: 0;
padding: 0.15rem 0.4rem;
line-height: 1;
font-size: 1.35rem;
color: var(--color-text-mid);
background: transparent;
border-radius: 0.2rem;
cursor: pointer;
}
.article-body-highlight__close:hover,
.article-body-highlight__close:focus-visible {
color: var(--color-text);
outline: 2px solid var(--color-focus-ring, var(--color-primary));
}
.article-body-highlight__inner {
position: relative;
}
.article-body-highlight__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.4rem 0.75rem;
margin: 0;
font-size: 0.86rem;
}
/* Full `context` quote + optional <mark> on the `content` substring (body copy, not a box) */ /* Full `context` quote + optional <mark> on the `content` substring (body copy, not a box) */
.user-highlight__body { .user-highlight__body {
@ -505,34 +433,25 @@
font-family: var(--main-body-font), serif; font-family: var(--main-body-font), serif;
} }
/* In-flow article body: interactive but no highlighter fill (cards below own the color) */ /* In-flow + aside: same NIP-84 mark treatment; scroll-margin in article for #highlight-… links */
.article-main mark.user-highlight__marker { .article-main mark.user-highlight__marker,
margin: 0;
padding: 0;
border-radius: 0;
font: inherit;
line-height: inherit;
color: inherit;
background: transparent;
box-shadow: none;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
/* Popover + home aside: full context where present, `content` visibly marked */
.article-body-highlight__body mark.user-highlight__marker,
.home-aside-highlights__quote--html mark.user-highlight__marker { .home-aside-highlights__quote--html mark.user-highlight__marker {
margin: 0; margin: 0;
padding: 0.08em 0.1em 0.12em; padding: 0.08em 0.1em 0.12em;
border-radius: 0.12em; border-radius: 0.12em;
font: inherit; font: inherit;
line-height: inherit; line-height: inherit;
color: inherit; color: var(--color-highlight-mark-fg);
background: color-mix(in srgb, #7ad67a 38%, #f0e8a0 62%); background: color-mix(in srgb, #7ad67a 30%, #f0e8a0 70%);
box-shadow: none;
box-decoration-break: clone; box-decoration-break: clone;
-webkit-box-decoration-break: clone; -webkit-box-decoration-break: clone;
} }
.article-main mark.user-highlight__marker {
scroll-margin-top: calc(var(--site-fixed-header-offset, 140px) + 0.75rem);
}
/* When `content` is not a substring of `context` (rare) */ /* When `content` is not a substring of `context` (rare) */
.user-highlight__marker-orphan { .user-highlight__marker-orphan {
margin: 0.5rem 0 0; margin: 0.5rem 0 0;

2
assets/styles/event.css

@ -98,7 +98,7 @@
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 400; font-weight: 400;
line-height: 1.35; line-height: 1.35;
color: color-mix(in srgb, var(--color-text-mid) 50%, var(--color-bg) 50%); color: var(--color-text-mid);
} }
.event-page a:focus-visible { .event-page a:focus-visible {

44
assets/styles/layout.css

@ -463,8 +463,18 @@ a.nostr-share-menu__action {
} }
/* Main content */ /* Main content */
:root {
/* Clears fixed #site-header; keep in sync with main margin-top per breakpoint below. */
--site-fixed-header-offset: 140px;
}
/* #:target / in-page links: scroll position leaves room under the fixed bar (scroll-margin on inline <mark> is unreliable alone). */
html {
scroll-padding-top: calc(var(--site-fixed-header-offset) + 0.75rem);
}
main { main {
margin-top: 140px; margin-top: var(--site-fixed-header-offset);
flex-grow: 1; flex-grow: 1;
min-width: 0; /* flex item: allow shrinking below wide images / intrinsic min-content */ min-width: 0; /* flex item: allow shrinking below wide images / intrinsic min-content */
padding: 1em; padding: 1em;
@ -480,19 +490,19 @@ main {
} }
@media (min-width: 1025px) { @media (min-width: 1025px) {
/* Match extra header padding-top so content and menu clear the fixed bar */ :root {
main { --site-fixed-header-offset: 152px;
margin-top: 152px;
} }
/* Match extra header padding-top so content and menu clear the fixed bar */
/* In-flow left column: <nav> clears the fixed #site-header. */ /* In-flow left column: <nav> clears the fixed #site-header. */
.layout > nav { .layout > nav {
margin-top: 152px; margin-top: var(--site-fixed-header-offset);
} }
/* Right column: same clearance as <main> so the highlights pane is not under #site-header. */ /* Right column: same clearance as <main> so the highlights pane is not under #site-header. */
.layout > aside { .layout > aside {
margin-top: 152px; margin-top: var(--site-fixed-header-offset);
/* Default: do not stretch — avoids a full-height empty column on pages with blank <aside> (e.g. article). */ /* Default: do not stretch — avoids a full-height empty column on pages with blank <aside> (e.g. article). */
align-self: flex-start; align-self: flex-start;
} }
@ -709,10 +719,30 @@ dt {
aside { aside {
display: none; /* Hide the sidebars on small screens */ display: none; /* Hide the sidebars on small screens */
} }
/* Home: keep highlights — stack <aside> under the featured wall (same DOM order; row layout would squeeze it beside main). */
.layout:has(.home-body--wall) {
flex-direction: column;
align-items: stretch;
}
.layout:has(.home-body--wall) > aside {
display: block;
width: 100%;
max-width: none;
min-width: 0;
flex-shrink: 0;
margin-top: 0.5rem;
padding: 0 1em 1.75rem;
box-sizing: border-box;
}
/* Fixed header is taller than 90px (safe-area + logo row + title padding). Match it or the first /* Fixed header is taller than 90px (safe-area + logo row + title padding). Match it or the first
main content (e.g. featured authors intro) sits under the bar and looks cut off at the top. */ main content (e.g. featured authors intro) sits under the bar and looks cut off at the top. */
:root {
--site-fixed-header-offset: max(7.25rem, calc(4.8rem + env(safe-area-inset-top, 0px)));
}
main { main {
margin-top: max(7.25rem, calc(4.8rem + env(safe-area-inset-top, 0px))); margin-top: var(--site-fixed-header-offset);
width: 100%; width: 100%;
} }
} }

11
assets/styles/theme.css

@ -9,16 +9,19 @@
--color-bg-light: #2a2a2a; /* Slightly lighter charcoal */ --color-bg-light: #2a2a2a; /* Slightly lighter charcoal */
--color-bg-primary: #2e1f2e; /* Muted aubergine for a rich, elegant feel */ --color-bg-primary: #2e1f2e; /* Muted aubergine for a rich, elegant feel */
--color-text: #f5f5f5; /* Soft white for readability */ --color-text: #f5f5f5; /* Soft white for readability */
--color-text-mid: #d8d8d8; /* Warm light gray — use on dark bg for ≥4.5:1 vs --color-bg */ --color-text-mid: #d8d8d8; /* Warm light gray — ≥4.5:1 vs --color-bg for body copy */
--color-text-contrast: #000; /* Black text for contrast */ --color-text-contrast: #000; /* Black text for contrast */
--color-primary: #5F7355; /* Plum primary color */ /* Green accents: tuned for ≥4.5:1 as *text* on --color-bg / --color-bg-light; black (#000) on primary fill ≥4.5:1 */
--color-secondary: #495544; /* secondary color */ --color-primary: #6d8a62;
--color-secondary: #7a9e82;
--color-border: #3a3a3a; /* Subtle gray border */ --color-border: #3a3a3a; /* Subtle gray border */
/* Aliases / derived (WCAG AA on typical surfaces when paired as documented) */ /* Aliases / derived (WCAG AA on typical surfaces when paired as documented) */
--color-text-light: var(--color-text-mid); /* deprecated name: use --color-text-mid */ --color-text-light: var(--color-text-mid); /* deprecated name: use --color-text-mid */
--color-footer-bg: var(--color-bg-light); --color-footer-bg: var(--color-bg-light);
--color-footer-text: var(--color-text); --color-footer-text: var(--color-text);
--color-footer-link: var(--color-primary); --color-footer-link: var(--color-secondary); /* primary on footer bg was below 4.5:1 */
/* NIP-84 highlight mark: light yellow-green fill needs dark ink (not inherited light body text) */
--color-highlight-mark-fg: #1a1a1a;
--color-link: var(--color-secondary); --color-link: var(--color-secondary);
--color-link-hover: var(--color-text); --color-link-hover: var(--color-text);
--color-focus-ring: var(--color-secondary); --color-focus-ring: var(--color-secondary);

7
src/Command/PrewarmCommand.php

@ -73,7 +73,7 @@ final class PrewarmCommand extends Command
->addOption('comments-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to warm comment cache for (0 = all, order: createdAt DESC; excludes generic /articles feed-only rows)', '10') ->addOption('comments-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to warm comment cache for (0 = all, order: createdAt DESC; excludes generic /articles feed-only rows)', '10')
->addOption('comments-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the whole comments phase (Nostr fetches are slow; a single long thread can exceed a short budget; use 1200+ if prewarming many articles)', '600') ->addOption('comments-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the whole comments phase (Nostr fetches are slow; a single long thread can exceed a short budget; use 1200+ if prewarming many articles)', '600')
->addOption('no-highlights', null, InputOption::VALUE_NONE, 'Skip kind-9802 highlight fetch → MySQL') ->addOption('no-highlights', null, InputOption::VALUE_NONE, 'Skip kind-9802 highlight fetch → MySQL')
->addOption('highlights-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine articles to sync highlights for (0 = all)', '0') ->addOption('highlights-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to sync highlights for (0 = all; each Nostr fetch is slow — default 25 keeps prewarm bounded)', '25')
->addOption('highlights-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the highlight sync phase', '600'); ->addOption('highlights-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the highlight sync phase', '600');
} }
@ -491,6 +491,7 @@ final class PrewarmCommand extends Command
} }
$hCount = \count($hList); $hCount = \count($hList);
$hW = 0; $hW = 0;
$hScanned = 0;
if ($hCount === 0) { if ($hCount === 0) {
$io->note('No articles in DB to scan for highlights.'); $io->note('No articles in DB to scan for highlights.');
} else { } else {
@ -503,6 +504,7 @@ final class PrewarmCommand extends Command
$io->warning(sprintf('Highlight phase stopped: highlights-budget reached (%d s).', $hBudget)); $io->warning(sprintf('Highlight phase stopped: highlights-budget reached (%d s).', $hBudget));
break; break;
} }
++$hScanned;
$slug = trim((string) $article->getSlug()); $slug = trim((string) $article->getSlug());
$pubkey = (string) $article->getPubkey(); $pubkey = (string) $article->getPubkey();
if ($slug === '' || strlen($pubkey) !== 64) { if ($slug === '' || strlen($pubkey) !== 64) {
@ -527,8 +529,9 @@ final class PrewarmCommand extends Command
} }
} }
$io->success(sprintf( $io->success(sprintf(
'Highlight rows written/updated: <info>%d</info> (articles scanned: <info>%d</info>, wall time <info>%.0f</info>s / %d s).', 'Highlight rows written/updated: <info>%d</info> (articles scanned: <info>%d</info> of %d, wall time <info>%.0f</info>s / %d s budget).',
$hW, $hW,
$hScanned,
$hCount, $hCount,
microtime(true) - $hStart, microtime(true) - $hStart,
$hBudget $hBudget

61
src/Controller/ArticleController.php

@ -3,7 +3,6 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\ArticleHighlight;
use App\Repository\ArticleHighlightRepository; use App\Repository\ArticleHighlightRepository;
use App\Service\ArticleBodyHighlightInjector; use App\Service\ArticleBodyHighlightInjector;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
@ -407,7 +406,6 @@ class ArticleController extends AbstractController
$highlights = $articleHighlightRepository->findByArticle($article); $highlights = $articleHighlightRepository->findByArticle($article);
$injection = $articleBodyHighlightInjector->inject($html, $highlights); $injection = $articleBodyHighlightInjector->inject($html, $highlights);
$html = $injection['html']; $html = $injection['html'];
$highlightsClientJson = $this->buildHighlightsClientJson($highlights, $injection['injectedEventIds']);
return $this->render('pages/article.html.twig', [ return $this->render('pages/article.html.twig', [
'article' => $article, 'article' => $article,
@ -417,67 +415,9 @@ class ArticleController extends AbstractController
'comments_data' => $commentsData, 'comments_data' => $commentsData,
'comments_preloaded' => $commentsPreloaded, 'comments_preloaded' => $commentsPreloaded,
'comment_reply_context' => $commentReplyContext, 'comment_reply_context' => $commentReplyContext,
'article_highlights_client_json' => $highlightsClientJson,
]); ]);
} }
/**
* @param list<ArticleHighlight> $highlights
* @param list<string> $injectedEventIds
*/
private function buildHighlightsClientJson(array $highlights, array $injectedEventIds): ?string
{
if ($injectedEventIds === []) {
return null;
}
$byId = [];
foreach ($highlights as $h) {
$id = \strtolower($h->getEventId());
if (64 === \strlen($id) && ctype_xdigit($id)) {
$byId[$id] = $h;
}
}
$out = [];
foreach ($injectedEventIds as $eid) {
$eid = \strtolower($eid);
$h = $byId[$eid] ?? null;
if (! $h instanceof ArticleHighlight) {
continue;
}
$out[$eid] = [
'headHtml' => $this->renderView('components/Molecules/ArticleHighlightMetaHead.html.twig', [
'authorPubkey' => $h->getAuthorPubkey(),
'dateLabel' => $this->formatHighlightListDate($h->getEventCreatedAt()),
]),
'bodyHtml' => $h->getBodyHtml(),
];
}
if ($out === []) {
return null;
}
return \json_encode(
$out,
\JSON_THROW_ON_ERROR
| \JSON_UNESCAPED_UNICODE
| \JSON_HEX_TAG
| \JSON_HEX_AMP
| \JSON_HEX_APOS
| \JSON_HEX_QUOT
);
}
private function formatHighlightListDate(int $unix): string
{
if ($unix <= 0) {
return '';
}
$tz = new \DateTimeZone(@\date_default_timezone_get() ?: 'UTC');
$dt = (new \DateTimeImmutable('@'.(string) $unix))->setTimezone($tz);
return $dt->format('F j, Y');
}
/** /**
* Base article-level reply context so the top "Reply" button can render before async comments load. * Base article-level reply context so the top "Reply" button can render before async comments load.
* *
@ -665,7 +605,6 @@ class ArticleController extends AbstractController
'author' => $user->getMetadata(), 'author' => $user->getMetadata(),
'npub' => $previewNpub, 'npub' => $previewNpub,
'comments_preloaded' => false, 'comments_preloaded' => false,
'article_highlights_client_json' => null,
]); ]);
} }

10
src/Service/ArticleBodyHighlightInjector.php

@ -449,7 +449,7 @@ final class ArticleBodyHighlightInjector
} }
if ('mark' === $n) { if ('mark' === $n) {
$cl = (string) $p->getAttribute('class'); $cl = (string) $p->getAttribute('class');
if (\str_contains($cl, 'article-body-highlight')) { if (\str_contains($cl, 'user-highlight__marker')) {
return false; return false;
} }
} }
@ -487,15 +487,9 @@ final class ArticleBodyHighlightInjector
if (! $mark) { if (! $mark) {
return false; return false;
} }
$mark->setAttribute('class', 'user-highlight__marker article-body-highlight'); $mark->setAttribute('class', 'user-highlight__marker');
if ($firstInReadingOrder) { if ($firstInReadingOrder) {
$mark->setAttribute('id', 'highlight-'.$eventId); $mark->setAttribute('id', 'highlight-'.$eventId);
$mark->setAttribute('tabindex', '0');
}
$mark->setAttribute('data-event-id', $eventId);
$mark->setAttribute('data-article-body-highlight', '1');
if (! $firstInReadingOrder) {
$mark->setAttribute('data-article-body-highlight-continuation', '1');
} }
$mark->appendChild($this->dom->createTextNode($match)); $mark->appendChild($this->dom->createTextNode($match));
$parent->insertBefore($mark, $ref); $parent->insertBefore($mark, $ref);

3
src/Service/MagazineContentService.php

@ -521,7 +521,8 @@ final class MagazineContentService
* Union of every article referenced by a category index (root 30040). Use this for magazine-wide * Union of every article referenced by a category index (root 30040). Use this for magazine-wide
* Atom and comment prewarm so "newest" tracks the magazine, not the generic community list. * Atom and comment prewarm so "newest" tracks the magazine, not the generic community list.
* *
* Dedupes by slug (newest {@see Article::getCreatedAt} wins). Only PUBLISHED/ARCHIVED rows. * Each category contributes at most the first page from {@see getCategoryPageData} (default 25
* `a` tags). Dedupes by slug (newest {@see Article::getCreatedAt} wins). Only PUBLISHED/ARCHIVED.
* *
* @return list<Article> Newest first * @return list<Article> Newest first
*/ */

8
templates/components/Molecules/ArticleHighlightMetaHead.html.twig

@ -1,8 +0,0 @@
<div class="article-body-highlight__head">
<div class="article-body-highlight__who">
<twig:Molecules:UserFromNpub ident="{{ authorPubkey }}" />
</div>
{% if dateLabel|default('')|trim != '' %}
<span class="text-subtle article-body-highlight__when">{{ dateLabel }}</span>
{% endif %}
</div>

2
templates/components/Organisms/HomeHighlightsAside.html.twig

@ -12,7 +12,7 @@
<div class="home-aside-highlights__item-inner"> <div class="home-aside-highlights__item-inner">
<a <a
class="home-aside-highlights__hit" class="home-aside-highlights__hit"
href="{{ path('article', { npub: _np, slug: art.slug }) }}" href="{{ path('article', { npub: _np, slug: art.slug }) ~ '#highlight-' ~ h.eventId|lower }}"
aria-label="{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') })|e('html_attr') }}" aria-label="{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') })|e('html_attr') }}"
> >
<span class="visually-hidden">{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') }) }}</span> <span class="visually-hidden">{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') }) }}</span>

23
templates/pages/article.html.twig

@ -59,11 +59,7 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div <div class="article-page-root">
data-controller="article-highlight"
class="article-page-root"
data-action="click@window->article-highlight#closeOnOutside keydown@window->article-highlight#onKeydown"
>
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_ADMIN') %}
<button class="btn btn-primary" onclick="navigator.clipboard.writeText('30023:{{ article.pubkey }}:{{ article.slug }}')"> <button class="btn btn-primary" onclick="navigator.clipboard.writeText('30023:{{ article.pubkey }}:{{ article.slug }}')">
@ -102,7 +98,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="article-main" data-article-highlight-target="article"> <div class="article-main">
{{ content|raw }} {{ content|raw }}
</div> </div>
@ -117,21 +113,6 @@
</div> </div>
{% if article_highlights_client_json|default('')|trim != '' %}
<script type="application/json" id="article-highlights-client-json" data-article-highlight-target="meta">{{ article_highlights_client_json|raw }}</script>
{% endif %}
<div
class="article-body-highlight__popover"
data-article-highlight-target="popover"
data-article-highlight-turbo-permanent
hidden
role="dialog"
aria-label="{{ 'highlight.popover'|trans }}"
>
<button type="button" class="article-body-highlight__close" data-action="article-highlight#closePopover" aria-label="{{ 'highlight.close'|trans }}">×</button>
<div class="article-body-highlight__inner" data-article-highlight-target="popoverInner"></div>
</div>
<hr class="divider" /> <hr class="divider" />
{# <pre>#} {# <pre>#}

2
translations/messages.en.yaml

@ -9,8 +9,6 @@ topic:
highlight: highlight:
section_title: 'Highlights' section_title: 'Highlights'
section_lede: 'NIP-84 (kind 9802). The optional `context` tag is the full passage; `content` is the part marked inside it. If `context` is missing, only `content` is shown in a mark.' section_lede: 'NIP-84 (kind 9802). The optional `context` tag is the full passage; `content` is the part marked inside it. If `context` is missing, only `content` is shown in a mark.'
popover: 'Highlight'
close: 'Close'
text: text:
byline: 'By' byline: 'By'
search: 'Search...' search: 'Search...'

Loading…
Cancel
Save