Browse Source

refine magazine editor

imwald
Silberengel 1 month ago
parent
commit
a688761b15
  1. 4
      assets/bootstrap.js
  2. 797
      assets/controllers/magazine_hierarchy_editor_controller.js
  3. 173
      assets/styles/magazine-editor.css
  4. 29
      migrations/Version20260503120000.php
  5. 4
      phpstan-baseline.neon
  6. 2
      src/Command/PrewarmCommand.php
  7. 2
      src/Controller/AuthorController.php
  8. 24
      src/Entity/Event.php
  9. 5
      src/Enum/KindsEnum.php
  10. 60
      src/Service/CacheService.php
  11. 25
      src/Service/MagazineHierarchyEditorService.php
  12. 164
      src/Service/Nip30EmojiCatalogBuilder.php
  13. 82
      src/Service/NostrClient.php
  14. 3
      templates/pages/author.html.twig
  15. 207
      templates/pages/magazine_edit.html.twig
  16. 86
      tests/Service/Nip30EmojiCatalogBuilderTest.php

4
assets/bootstrap.js vendored

@ -45,8 +45,8 @@ try { @@ -45,8 +45,8 @@ try {
}
try {
app.register('magazine-hierarchy-editor', MagazineHierarchyEditorController);
} catch {
/* already registered by the bundle */
} catch (e) {
console.warn('[bootstrap] magazine-hierarchy-editor did not register; editor buttons will not work.', e);
}
try {
app.register('footer-magazine-edit', FooterMagazineEditController);

797
assets/controllers/magazine_hierarchy_editor_controller.js

@ -9,23 +9,120 @@ const KIND_LONGFORM_DRAFT = 30024; @@ -9,23 +9,120 @@ const KIND_LONGFORM_DRAFT = 30024;
* Only signs nodes that differ from the page load snapshot, plus any other index required for
* graph closure (root + every nested kind-30040 `a` reachable from those events).
*/
export default class extends Controller {
static targets = ['status', 'node'];
const DTAG_PATTERN = /^[a-zA-Z0-9_-]+$/;
export default class MagazineHierarchyEditorController extends Controller {
static targets = ['status', 'node', 'nodes', 'newNodeTemplate', 'aRowTemplate'];
static values = {
publishUrl: String,
csrf: String,
ownerHex: String,
rootDTag: String,
defaultPreservedJson: { type: String, default: '[]' },
/** Site-owned NIP-56 `client` name; foreign `client` rows are dropped before signing. */
clientTag: { type: String, default: '' },
};
connect() {
this.nodeBaseline = new WeakMap();
this.captureBaselines();
/**
* Clicks: `document` capture + `this.element.contains(target)` so we still run when bubble
* never reaches the panel (e.g. `stopPropagation` from another listener) or fieldset/legend
* hit-testing is odd.
*/
this._onDocClickCapture = this._onDocClickCapture.bind(this);
this._onPanelFocusOut = this._onPanelFocusOut.bind(this);
document.addEventListener('click', this._onDocClickCapture, true);
this.element.addEventListener('focusout', this._onPanelFocusOut);
}
disconnect() {
document.removeEventListener('click', this._onDocClickCapture, true);
this.element.removeEventListener('focusout', this._onPanelFocusOut);
}
/**
* @param {MouseEvent} ev
*/
_onDocClickCapture(ev) {
if (!(ev.target instanceof Node)) {
return;
}
if (!this.element.contains(ev.target)) {
return;
}
this._onPanelClick(ev);
}
/**
* @param {MouseEvent} ev
*/
_onPanelClick(ev) {
if (!(ev.target instanceof Element)) {
return;
}
const t = ev.target;
if (t.closest('[data-mag-editor-cmd="publish"]')) {
void this._publish();
return;
}
if (t.closest('[data-mag-editor-cmd="add-top-category"]')) {
this.addTopLevelCategory();
return;
}
const addSubCmd = t.closest('[data-mag-editor-cmd="add-subcategory"]');
if (addSubCmd instanceof HTMLElement) {
this.addSubcategory(ev, addSubCmd);
return;
}
const rmCatCmd = t.closest('[data-mag-editor-cmd="remove-category"]');
if (rmCatCmd instanceof HTMLElement) {
this.removeCategory(ev, rmCatCmd);
return;
}
const addArticle = t.closest('[data-mag-a-add]');
if (addArticle instanceof HTMLElement) {
this.addALine(ev, addArticle);
return;
}
const rmArticle = t.closest('.magazine-editor__a-remove-icon');
if (rmArticle instanceof HTMLElement) {
this.removeALine(ev, rmArticle);
return;
}
const sub = t.closest('.magazine-editor__add-sub');
if (sub instanceof HTMLElement) {
this.addSubcategory(ev, sub);
return;
}
const rmCat = t.closest('.magazine-editor__remove-node');
if (rmCat instanceof HTMLElement) {
this.removeCategory(ev, rmCat);
return;
}
}
/**
* @param {FocusEvent} ev
*/
_onPanelFocusOut(ev) {
const inp = ev.target;
if (!(inp instanceof HTMLInputElement)) {
return;
}
if (!inp.matches('[data-magazine-hierarchy-editor-target="dTag"]')) {
return;
}
this.commitDTag(ev);
}
captureBaselines() {
for (const el of this.nodeTargets) {
if (!this.hasNodesTarget) {
return;
}
for (const el of queryEditorNodeFieldsets(this.nodesTarget)) {
this.nodeBaseline.set(el, snapshotFromElement(el));
}
}
@ -34,6 +131,516 @@ export default class extends Controller { @@ -34,6 +131,516 @@ export default class extends Controller {
void this._publish();
}
/**
* @param {Event} event
* @param {HTMLElement} [triggerEl]
*/
addALine(event, triggerEl) {
const trigger =
triggerEl instanceof HTMLElement
? triggerEl
: event.currentTarget instanceof HTMLElement && event.currentTarget.hasAttribute('data-mag-a-add')
? event.currentTarget
: event.target instanceof Element
? event.target.closest('[data-mag-a-add]')
: null;
if (!(trigger instanceof HTMLElement)) {
return;
}
const list = trigger.closest('[data-mag-a-list]');
if (!list || !this.hasARowTemplateTarget) {
return;
}
const addBtn = list.querySelector('[data-mag-a-add]');
if (!addBtn) {
return;
}
const frag = this.aRowTemplateTarget.content.cloneNode(true);
const row = frag.querySelector('[data-mag-a-row]');
if (!row) {
return;
}
list.insertBefore(row, addBtn);
}
/**
* @param {Event} event
* @param {HTMLElement} [triggerEl]
*/
removeALine(event, triggerEl) {
const trigger =
triggerEl instanceof HTMLElement
? triggerEl
: event.currentTarget instanceof HTMLElement && event.currentTarget.classList.contains('magazine-editor__a-remove-icon')
? event.currentTarget
: event.target instanceof Element
? event.target.closest('.magazine-editor__a-remove-icon')
: null;
if (!(trigger instanceof HTMLElement)) {
return;
}
const row = trigger.closest('[data-mag-a-row]');
row?.remove();
}
addTopLevelCategory() {
this.setStatus('');
const rootD = (this.rootDTagValue || '').trim();
if (rootD === '') {
this.setStatus('Missing root index #d.');
return;
}
this.insertNewNode(rootD, 1);
}
/**
* @param {Event} event
* @param {HTMLElement} [triggerEl]
*/
addSubcategory(event, triggerEl) {
this.setStatus('');
const btn =
triggerEl instanceof HTMLElement
? triggerEl
: event.currentTarget instanceof HTMLElement && event.currentTarget.classList.contains('magazine-editor__add-sub')
? event.currentTarget
: event.target instanceof Element
? event.target.closest('.magazine-editor__add-sub')
: null;
if (!(btn instanceof HTMLElement)) {
return;
}
const parentEl = btn.closest('[data-magazine-hierarchy-editor-target="node"]');
if (!parentEl) {
return;
}
const rootD = (this.rootDTagValue || '').trim();
const placed = (parentEl.dataset.magPlacedD || '').trim();
const parentD = (placed !== '' ? placed : readDTag(parentEl)).trim();
if (parentD === '' || parentD === rootD) {
this.setStatus('Use “Add top-level category” for indices linked from the root.');
return;
}
const depth = parseInt(parentEl.dataset.magDepth || '1', 10);
this.insertNewNode(parentD, depth + 1);
}
/**
* Blur on Index #d (new or existing category / subcategory).
* @param {Event} event
*/
commitDTag(event) {
const t =
event.target instanceof HTMLElement
? event.target
: event.currentTarget instanceof HTMLElement
? event.currentTarget
: null;
if (!(t instanceof HTMLElement)) {
return;
}
const el = t.closest('[data-magazine-hierarchy-editor-target="node"]');
if (!el || isRootFieldset(el)) {
return;
}
if (el.dataset.isNewNode === '1') {
this.applySlugChange(el);
} else {
this.commitExistingDTagRename(el);
}
}
/**
* @param {Event} event
* @param {HTMLElement} [triggerEl]
*/
removeCategory(event, triggerEl) {
this.setStatus('');
const btn =
triggerEl instanceof HTMLElement
? triggerEl
: event.currentTarget instanceof HTMLElement && event.currentTarget.classList.contains('magazine-editor__remove-node')
? event.currentTarget
: event.target instanceof Element
? event.target.closest('.magazine-editor__remove-node')
: null;
if (!(btn instanceof HTMLElement)) {
return;
}
const fs = btn.closest('[data-magazine-hierarchy-editor-target="node"]');
if (!fs || isRootFieldset(fs)) {
return;
}
const ownerHex = (this.ownerHexValue || '').toLowerCase().trim();
if (ownerHex.length !== 64) {
this.setStatus('Missing owner pubkey.');
return;
}
if (fs.dataset.isNewNode === '1') {
const parentD = (fs.dataset.magParentD || '').trim();
const placed = (fs.dataset.placedSlug || '').trim();
if (parentD !== '' && placed !== '') {
const parentFs = this.findFieldsetByDTag(parentD);
if (parentFs) {
this.remove30040ChildLine(parentFs, placed, ownerHex);
}
}
fs.remove();
return;
}
const sub = collectSubtreeFieldsets(fs, this.nodesTarget);
sub.sort((a, b) => parseInt(b.dataset.magDepth || '0', 10) - parseInt(a.dataset.magDepth || '0', 10));
for (const n of sub) {
const p = (n.dataset.magParentD || '').trim();
const d = readDTag(n);
if (p === '' || d === '') {
continue;
}
const pFs = this.findFieldsetByDTag(p);
if (pFs) {
this.remove30040ChildLine(pFs, d, ownerHex);
}
}
for (const n of sub) {
n.remove();
}
}
insertNewNode(parentD, depth) {
if (!this.hasNewNodeTemplateTarget || !this.hasNodesTarget) {
this.setStatus('Editor template is incomplete; reload the page.');
return;
}
const ownerHex = (this.ownerHexValue || '').toLowerCase().trim();
if (ownerHex.length !== 64) {
this.setStatus('Missing owner pubkey.');
return;
}
const parentDTrim = parentD.trim();
if (parentDTrim === '') {
return;
}
const frag = this.newNodeTemplateTarget.content.cloneNode(true);
const fs = frag.querySelector('fieldset[data-magazine-hierarchy-editor-target="node"]');
if (!fs) {
this.setStatus('Could not clone new node template.');
return;
}
fs.dataset.magParentD = parentDTrim;
fs.dataset.isNewNode = '1';
fs.dataset.magDepth = String(depth);
const slug = `mag-new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
fs.dataset.placedSlug = slug;
fs.style.setProperty('--mag-node-depth', String(depth));
fs.style.marginLeft = `calc(max(0, var(--mag-node-depth) - 1) * 1.15rem)`;
if (depth > 1) {
fs.classList.add('magazine-editor__node--nested');
}
const lab = fs.querySelector('[data-new-node-legend-label]');
if (lab) {
lab.textContent = depth <= 1 ? 'New category' : 'New subcategory';
}
let preservedStr = '[]';
try {
preservedStr = JSON.stringify(JSON.parse(this.defaultPreservedJsonValue || '[]'));
} catch {
preservedStr = '[]';
}
const preservedEl = fs.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]');
if (preservedEl) {
preservedEl.value = preservedStr;
}
const dIn = fs.querySelector('[data-magazine-hierarchy-editor-target="dTag"]');
if (dIn && 'value' in dIn) {
dIn.value = slug;
}
if (!this.append30040ChildLine(parentDTrim, slug, ownerHex)) {
this.setStatus(
`Could not add the nested kind-30040 link to the parent’s <code>a</code> list (parent #d “${parentDTrim}”). Reload the page if this persists.`,
);
return;
}
const parentFs = this.findFieldsetByDTag(parentDTrim);
const anchor = parentFs ? lastDescendantFieldsetAmongNodes(parentFs, this.nodesTarget) : null;
if (anchor?.parentElement === this.nodesTarget) {
anchor.insertAdjacentElement('afterend', fs);
} else {
this.nodesTarget.appendChild(fs);
}
this.nodeBaseline.set(fs, snapshotFromElement(fs));
}
/**
* @param {HTMLElement} el
*/
applySlugChange(el) {
const ownerHex = (this.ownerHexValue || '').toLowerCase().trim();
const parentD = (el.dataset.magParentD || '').trim();
const placed = (el.dataset.placedSlug || '').trim();
const next = readDTag(el).trim();
if (placed === next) {
return;
}
if (parentD === '' || ownerHex.length !== 64) {
return;
}
const parentFs = this.findFieldsetByDTag(parentD);
if (!parentFs) {
return;
}
if (placed !== '') {
this.remove30040ChildLine(parentFs, placed, ownerHex);
}
if (next !== '') {
if (!this.append30040ChildLine(parentD, next, ownerHex)) {
if (placed !== '') {
this.append30040ChildLine(parentD, placed, ownerHex);
}
this.setStatus(
`Could not add #d “${next}” to the parent’s <code>a</code> list (parent “${parentD}”).`,
);
return;
}
}
el.dataset.placedSlug = next;
}
/**
* @param {HTMLElement} el Fieldset (not root, not new-node).
*/
commitExistingDTagRename(el) {
const ownerHex = (this.ownerHexValue || '').toLowerCase().trim();
if (ownerHex.length !== 64) {
return;
}
const dIn = el.querySelector('[data-magazine-hierarchy-editor-target="dTag"]');
const oldD = (el.dataset.magPlacedD || '').trim();
const next = readDTag(el).trim();
if (oldD === next) {
return;
}
if (next === '') {
this.setStatus('#d cannot be empty.');
if (dIn instanceof HTMLInputElement) {
dIn.value = oldD;
}
return;
}
if (!DTAG_PATTERN.test(next)) {
this.setStatus(`Invalid #d "${next}" (use letters, digits, underscores, and hyphens only).`);
if (dIn instanceof HTMLInputElement) {
dIn.value = oldD;
}
return;
}
const rootD = (this.rootDTagValue || '').trim();
if (next === rootD) {
this.setStatus('A category #d cannot equal the root magazine identifier.');
if (dIn instanceof HTMLInputElement) {
dIn.value = oldD;
}
return;
}
for (const other of queryEditorNodeFieldsets(this.nodesTarget)) {
if (other === el) {
continue;
}
if (readDTag(other) === next) {
this.setStatus(`Duplicate #d "${next}".`);
if (dIn instanceof HTMLInputElement) {
dIn.value = oldD;
}
return;
}
}
const parentD = (el.dataset.magParentD || '').trim();
if (parentD === '') {
return;
}
const parentFs = this.findFieldsetByDTag(parentD);
if (!parentFs) {
return;
}
const list = parentFs.querySelector('[data-mag-a-list]');
if (!list) {
return;
}
if (!rewrite30040ChildLineInList(list, ownerHex, oldD, next)) {
this.setStatus(`Could not find parent link for old #d "${oldD}"; reload the page.`);
if (dIn instanceof HTMLInputElement) {
dIn.value = oldD;
}
return;
}
el.dataset.magPlacedD = next;
this.setStatus('');
}
/**
* @param {string} d
* @returns {HTMLElement | null}
*/
findFieldsetByDTag(d) {
const want = d.trim();
if (!this.hasNodesTarget) {
return null;
}
for (const el of queryEditorNodeFieldsets(this.nodesTarget)) {
if (readDTag(el).trim() === want) {
return el;
}
}
return null;
}
/**
* @param {string} parentD Parent index #d (root or category).
* @param {string} childD Nested kind-30040 child #d.
* @param {string} ownerHex
*/
/**
* @returns {boolean} false if the parent fieldset or a-list could not be updated
*/
append30040ChildLine(parentD, childD, ownerHex) {
const parentFs = this.findFieldsetByDTag(parentD.trim());
if (!parentFs) {
return false;
}
const list = parentFs.querySelector('[data-mag-a-list]');
const addBtn = list?.querySelector('[data-mag-a-add]');
if (!list || !addBtn || !this.hasARowTemplateTarget) {
return false;
}
const oh = ownerHex.toLowerCase();
const cd = childD.trim();
const want = `${KIND_PUBLICATION_INDEX}:${oh}:${cd}`;
for (const line of collectALineValuesFromList(list)) {
if (lineMatches30040Child(line, oh, cd)) {
return true;
}
}
const frag = this.aRowTemplateTarget.content.cloneNode(true);
const row = frag.querySelector('[data-mag-a-row]');
const inp = row?.querySelector('[data-mag-a-line]');
if (!row || !(inp instanceof HTMLInputElement)) {
return false;
}
inp.value = want;
list.insertBefore(row, addBtn);
return true;
}
/**
* @param {HTMLElement} parentFs
* @param {string} childD
* @param {string} ownerHex
*/
remove30040ChildLine(parentFs, childD, ownerHex) {
const list = parentFs.querySelector('[data-mag-a-list]');
if (!list) {
return;
}
const oh = ownerHex.toLowerCase();
const cd = childD.trim();
for (const row of list.querySelectorAll('[data-mag-a-row]')) {
const inp = row.querySelector('[data-mag-a-line]');
if (!(inp instanceof HTMLInputElement)) {
continue;
}
if (lineMatches30040Child(inp.value, oh, cd)) {
row.remove();
return;
}
}
}
/**
* @param {string} rootD
* @returns {string | null}
*/
validateAllNodes(rootD) {
const seen = new Set();
if (!this.hasNodesTarget) {
return 'Editor is missing the nodes list.';
}
for (const el of queryEditorNodeFieldsets(this.nodesTarget)) {
const d = readDTag(el).trim();
if (d === '') {
return 'Every magazine index must have a non-empty #d identifier.';
}
if (isRootFieldset(el)) {
if (d !== rootD) {
return 'Root fieldset #d does not match the configured magazine root.';
}
} else {
if (!DTAG_PATTERN.test(d)) {
return `Invalid #d "${d}" (use letters, digits, underscores, and hyphens only).`;
}
if (d === rootD) {
return 'A category #d cannot equal the root magazine identifier.';
}
}
if (seen.has(d)) {
return `Duplicate #d identifier: ${d}.`;
}
seen.add(d);
}
return null;
}
/**
* @param {HTMLElement} el
*/
finalizeNewNodeFieldset(el) {
const d = readDTag(el).trim();
const depth = parseInt(el.dataset.magDepth || '1', 10);
el.querySelector('[data-new-node-d-row]')?.removeAttribute('data-new-node-d-row');
const legend = el.querySelector('legend.magazine-editor__legend');
if (legend) {
const kindLabel = depth > 1 ? 'Subcategory' : 'Category';
legend.replaceChildren();
legend.className = 'magazine-editor__legend magazine-editor__legend--row';
const main = document.createElement('span');
main.className = 'magazine-editor__legend-main';
main.append(`${kindLabel}`);
legend.appendChild(main);
const actions = document.createElement('span');
actions.className = 'magazine-editor__legend-actions';
const btnSub = document.createElement('button');
btnSub.type = 'button';
btnSub.className = 'btn btn-secondary btn-sm magazine-editor__add-sub';
btnSub.setAttribute('data-mag-editor-cmd', 'add-subcategory');
btnSub.textContent = 'Add subcategory';
actions.appendChild(btnSub);
const btnRm = document.createElement('button');
btnRm.type = 'button';
btnRm.className = 'btn btn-secondary btn-sm magazine-editor__remove-node';
btnRm.setAttribute('data-mag-editor-cmd', 'remove-category');
btnRm.setAttribute('aria-label', 'Remove category');
btnRm.textContent = 'Remove';
actions.appendChild(btnRm);
legend.appendChild(actions);
}
el.dataset.magPlacedD = d;
el.classList.remove('magazine-editor__node--new');
el.removeAttribute('data-is-new-node');
el.removeAttribute('data-placed-slug');
}
async _publish() {
this.setStatus('');
if (!this.hasNip07()) {
@ -51,12 +658,28 @@ export default class extends Controller { @@ -51,12 +658,28 @@ export default class extends Controller {
return;
}
const nodes = this.nodeTargets;
if (!this.hasNodesTarget) {
this.setStatus('Editor is missing the nodes list.');
return;
}
const nodes = queryEditorNodeFieldsets(this.nodesTarget);
if (nodes.length === 0) {
this.setStatus('Nothing to publish.');
return;
}
for (const el of nodes) {
if (el.dataset.isNewNode === '1') {
this.applySlugChange(el);
}
}
const graphErr = this.validateAllNodes(rootD);
if (graphErr !== null) {
this.setStatus(graphErr);
return;
}
const dirty = nodes.filter((el) => this.isNodeDirty(el));
if (dirty.length === 0) {
this.setStatus('No changes to publish.');
@ -92,7 +715,7 @@ export default class extends Controller { @@ -92,7 +715,7 @@ export default class extends Controller {
const title = el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? '';
const summary = el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? '';
const content = el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? '';
const aText = el.querySelector('[data-magazine-hierarchy-editor-target="aLines"]')?.value ?? '';
const aText = readJoinedALinesFromNode(el);
const preservedRaw = el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]';
let preserved;
@ -156,18 +779,25 @@ export default class extends Controller { @@ -156,18 +779,25 @@ export default class extends Controller {
}
const n = Number(data.published);
for (const el of ordered) {
if (el.dataset.isNewNode === '1') {
this.finalizeNewNodeFieldset(el);
}
this.nodeBaseline.set(el, snapshotFromElement(el));
}
this.setStatus(Number.isFinite(n) ? `Published and stored ${n} index event(s).` : 'Published.');
}
isNodeDirty(el) {
if (el.dataset.isNewNode === '1') {
return true;
}
const cur = snapshotFromElement(el);
const base = this.nodeBaseline.get(el);
if (!base) {
return true;
}
return (
cur.dTag !== base.dTag ||
cur.title !== base.title ||
cur.summary !== base.summary ||
cur.content !== base.content ||
@ -187,12 +817,14 @@ export default class extends Controller { @@ -187,12 +817,14 @@ export default class extends Controller {
async expandPublishSet(rootD, dirtyFieldsets, ownerHex) {
/** @type {Map<string, HTMLElement>} */
const dToEl = new Map();
for (const el of this.nodeTargets) {
if (this.hasNodesTarget) {
for (const el of queryEditorNodeFieldsets(this.nodesTarget)) {
const d = readDTag(el);
if (d) {
dToEl.set(d, el);
}
}
}
const W = new Set();
for (const el of dirtyFieldsets) {
@ -251,10 +883,17 @@ export default class extends Controller { @@ -251,10 +883,17 @@ export default class extends Controller {
if (norm[0].toLowerCase() === 'd') {
continue;
}
if (norm[0].toLowerCase() === 'client') {
continue;
}
tags.push(norm);
}
tags.push(['title', title]);
tags.push(['summary', summary]);
const clientName = (this.clientTagValue || '').trim();
if (clientName !== '') {
tags.push(['client', clientName]);
}
const lines = aText
.split('\n')
@ -302,6 +941,85 @@ export default class extends Controller { @@ -302,6 +941,85 @@ export default class extends Controller {
}
}
/**
* Index fieldsets that are direct children of the nodes list (excludes `<template>` fragments).
* @param {HTMLElement} nodesContainer
* @returns {HTMLElement[]}
*/
function queryEditorNodeFieldsets(nodesContainer) {
return [...nodesContainer.querySelectorAll(':scope > [data-magazine-hierarchy-editor-target="node"]')];
}
/**
* @param {HTMLElement} parentFs
* @param {HTMLElement} nodesContainer
*/
function lastDescendantFieldsetAmongNodes(parentFs, nodesContainer) {
const nodes = queryEditorNodeFieldsets(nodesContainer);
const idx = nodes.indexOf(parentFs);
if (idx === -1) {
return parentFs;
}
const pd = parseInt(parentFs.dataset.magDepth || '0', 10);
let last = parentFs;
for (let i = idx + 1; i < nodes.length; i++) {
const nd = parseInt(nodes[i].dataset.magDepth || '0', 10);
if (nd <= pd) {
break;
}
last = nodes[i];
}
return last;
}
/**
* `rootFs` first, then following fieldsets with strictly greater depth (subtree in DOM order).
* @param {HTMLElement} rootFs
* @param {HTMLElement} nodesContainer
* @returns {HTMLElement[]}
*/
function collectSubtreeFieldsets(rootFs, nodesContainer) {
const nodes = queryEditorNodeFieldsets(nodesContainer);
const idx = nodes.indexOf(rootFs);
if (idx === -1) {
return [rootFs];
}
const pd = parseInt(rootFs.dataset.magDepth || '0', 10);
const out = [rootFs];
for (let i = idx + 1; i < nodes.length; i++) {
const nd = parseInt(nodes[i].dataset.magDepth || '0', 10);
if (nd <= pd) {
break;
}
out.push(nodes[i]);
}
return out;
}
/**
* @param {HTMLElement} list
* @param {string} ownerHex
* @param {string} oldD
* @param {string} newD
* @returns {boolean}
*/
function rewrite30040ChildLineInList(list, ownerHex, oldD, newD) {
const oh = ownerHex.toLowerCase();
const o = oldD.trim();
const n = newD.trim();
for (const row of list.querySelectorAll('[data-mag-a-row]')) {
const inp = row.querySelector('[data-mag-a-line]');
if (!(inp instanceof HTMLInputElement)) {
continue;
}
if (lineMatches30040Child(inp.value, oh, o)) {
inp.value = `${KIND_PUBLICATION_INDEX}:${oh}:${n}`;
return true;
}
}
return false;
}
/**
* @param {string[][]} tags
* @param {string} ownerHex
@ -339,7 +1057,8 @@ function ownedNested30040DsFromTags(tags, ownerHex) { @@ -339,7 +1057,8 @@ function ownedNested30040DsFromTags(tags, ownerHex) {
* @param {HTMLElement} el
*/
function readDTag(el) {
return (el.querySelector('[data-magazine-hierarchy-editor-target="dTag"]')?.value || '').trim();
const inp = el.querySelector('input[data-magazine-hierarchy-editor-target="dTag"]');
return inp instanceof HTMLInputElement ? (inp.value || '').trim() : '';
}
/**
@ -347,14 +1066,74 @@ function readDTag(el) { @@ -347,14 +1066,74 @@ function readDTag(el) {
*/
function snapshotFromElement(el) {
return {
dTag: readDTag(el),
title: el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? '',
summary: el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? '',
content: el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? '',
aText: el.querySelector('[data-magazine-hierarchy-editor-target="aLines"]')?.value ?? '',
aText: readJoinedALinesFromNode(el),
preservedRaw: el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]',
};
}
/**
* @param {HTMLElement} list
* @returns {string[]}
*/
function collectALineValuesFromList(list) {
const out = [];
for (const inp of list.querySelectorAll('[data-mag-a-line]')) {
if (inp instanceof HTMLInputElement) {
const v = inp.value.trim();
if (v !== '') {
out.push(v);
}
}
}
return out;
}
/**
* @param {HTMLElement} el Fieldset for one index node.
*/
function readJoinedALinesFromNode(el) {
const list = el.querySelector('[data-mag-a-list]');
if (!list) {
return '';
}
return collectALineValuesFromList(list).join('\n');
}
/**
* @param {HTMLElement} el
*/
function isRootFieldset(el) {
return el.dataset.magazineNodeIsRoot === '1';
}
/**
* @param {string} line
* @param {string} ownerHexLower
* @param {string} childD
*/
function lineMatches30040Child(line, ownerHexLower, childD) {
const s = stripNostrScheme(line);
const parts = splitThree(s);
if (!parts) {
return false;
}
const kind = parseInt(parts.kind, 10);
if (kind !== KIND_PUBLICATION_INDEX) {
return false;
}
if (parts.pubkey.toLowerCase() !== ownerHexLower.toLowerCase()) {
return false;
}
return parts.identifier.trim() === childD;
}
/**
* @param {HTMLElement} el
* @returns {{ dTag: string, title: string, summary: string, content: string, aText: string, preserved: unknown[] }}
@ -364,7 +1143,7 @@ function readFieldsForBuild(el) { @@ -364,7 +1143,7 @@ function readFieldsForBuild(el) {
const title = el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? '';
const summary = el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? '';
const content = el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? '';
const aText = el.querySelector('[data-magazine-hierarchy-editor-target="aLines"]')?.value ?? '';
const aText = readJoinedALinesFromNode(el);
const preservedRaw = el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]';
let preserved;
try {

173
assets/styles/magazine-editor.css

@ -3,12 +3,91 @@ @@ -3,12 +3,91 @@
margin-inline: auto;
}
/* Panel + fieldset stacking: legend controls must stay above sibling content and decorative rails. */
.magazine-editor__panel {
position: relative;
}
.magazine-editor__node > legend.magazine-editor__legend--row {
position: relative;
z-index: 2;
}
.magazine-editor__legend-actions {
position: relative;
z-index: 3;
}
.magazine-editor__toolbar,
.magazine-editor__actions {
position: relative;
z-index: 2;
}
.magazine-editor__node--nested::before {
pointer-events: none;
}
.magazine-editor__panel button {
pointer-events: auto;
cursor: pointer;
touch-action: manipulation;
}
.magazine-editor__intro {
margin: 0 0 1.25rem;
line-height: 1.5;
color: var(--color-text-mid, inherit);
}
.magazine-editor__toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
}
.magazine-editor__legend--row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
box-sizing: border-box;
}
.magazine-editor__legend-main {
flex: 1 1 auto;
min-width: 0;
}
.magazine-editor__add-sub {
flex: 0 0 auto;
}
.magazine-editor__legend-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
flex-shrink: 0;
}
.magazine-editor__label--d-tag {
margin-bottom: 0.65rem;
}
.magazine-editor__node--new {
border-style: dashed;
}
.magazine-editor__input--mono {
font-family: ui-monospace, monospace;
font-size: 0.88rem;
}
.magazine-editor__status {
min-height: 1.5rem;
margin: 0 0 1rem;
@ -22,11 +101,15 @@ @@ -22,11 +101,15 @@
}
.magazine-editor__node {
position: relative;
isolation: isolate;
margin: 0;
padding: 1rem 1rem 1.1rem;
border: 1px solid var(--color-border);
background: var(--color-bg-light, var(--color-bg));
--mag-node-depth: 0;
/* Fieldsets can impose min-content width; allow nested grids to shrink. */
min-width: 0;
}
/* Tree rail for nested kind-30040 sections (depth ≥ 2 under root). */
@ -90,3 +173,93 @@ @@ -90,3 +173,93 @@
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.magazine-editor__a-block {
margin: 0 0 0.9rem;
}
.magazine-editor__a-block-label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.magazine-editor__a-hint {
margin: 0 0 0.65rem;
font-size: 0.8rem;
line-height: 1.45;
color: var(--color-text-mid, inherit);
}
.magazine-editor__a-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Grid: two columns regardless of flex quirks inside fieldsets / % widths on inputs */
.magazine-editor__a-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.35rem;
width: 100%;
min-width: 0;
}
.magazine-editor__a-line-field {
min-width: 0;
max-width: 100%;
}
.magazine-editor__a-line-field .magazine-editor__a-line-input {
width: 100%;
min-width: 0;
}
.magazine-editor__a-row > .magazine-editor__a-remove-icon {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.55rem;
min-width: 1.55rem;
max-width: 1.55rem;
height: 1.55rem;
padding: 0;
margin: 0;
border: 1px solid var(--color-border, rgba(140, 140, 140, 0.4));
border-radius: 3px;
background: var(--color-bg-light, transparent);
color: var(--color-text-mid, inherit);
cursor: pointer;
line-height: 0;
justify-self: end;
/* Override global `button` (primary fill, padding, uppercase) for icon-only control */
text-transform: none;
font-weight: normal;
font-size: 0;
}
.magazine-editor__a-remove-icon svg {
width: 12px;
height: 12px;
display: block;
}
.magazine-editor__a-remove-icon:hover {
color: var(--color-danger, #c44);
border-color: var(--color-danger, #c44);
}
.magazine-editor__a-remove-icon:focus-visible {
outline: 2px solid var(--color-focus, currentColor);
outline-offset: 2px;
}
.magazine-editor__a-add {
align-self: flex-start;
margin-top: 0.15rem;
}

29
migrations/Version20260503120000.php

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* NIP-30 custom emoji catalog merged at prewarm (kind 0 + NIP-51 kind 10030 + NIP-38 kind 30315).
*/
final class Version20260503120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'event.nip30_custom_emoji JSON for kind-0 profile rows (NIP-30 merged emoji catalog)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE event ADD nip30_custom_emoji JSON DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE event DROP nip30_custom_emoji');
}
}

4
phpstan-baseline.neon

@ -79,7 +79,7 @@ parameters: @@ -79,7 +79,7 @@ parameters:
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''kind0_tags'' on array\{content\: stdClass, kind0_tags\: list\<list\<string\>\>\} on left side of \?\? always exists and is not nullable\.$#'
message: '#^Offset ''kind0_tags'' on array\{content\: stdClass, kind0_tags\: list\<list\<string\>\>, nip30_custom_emojis\: list\<array\{shortcode\: string, url\: string, set\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
@ -501,7 +501,7 @@ parameters: @@ -501,7 +501,7 @@ parameters:
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 6
count: 5
path: src/Service/NostrClient.php
-

2
src/Command/PrewarmCommand.php

@ -354,7 +354,7 @@ final class PrewarmCommand extends Command @@ -354,7 +354,7 @@ final class PrewarmCommand extends Command
$bar->start();
try {
foreach (array_chunk($toWarm, $batchSize) as $chunk) {
$fetched = $this->nostrClient->fetchKind0WireEventsForAuthors($chunk, $batchSize);
$fetched = $this->nostrClient->fetchProfilePrewarmWireBundlesForAuthors($chunk, $batchSize);
$n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched);
$bar->advance(\count($chunk));
$p0 = (string) ($chunk[0] ?? '');

2
src/Controller/AuthorController.php

@ -46,6 +46,7 @@ class AuthorController extends AbstractController @@ -46,6 +46,7 @@ class AuthorController extends AbstractController
$bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags'];
$nip30Emojis = $bundle['nip30_custom_emojis'] ?? [];
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$total = $articleRepository->countByPubkey($pubkey);
@ -79,6 +80,7 @@ class AuthorController extends AbstractController @@ -79,6 +80,7 @@ class AuthorController extends AbstractController
'is_author_profile' => true,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileNip05,
'profile_nip30_emojis' => $nip30Emojis,
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'pagination' => [
'page' => $page,

24
src/Entity/Event.php

@ -49,6 +49,14 @@ class Event @@ -49,6 +49,14 @@ class Event
#[ORM\Column(length: 32, nullable: true)]
private ?string $storageRole = null;
/**
* NIP-30 merged emoji catalog for {@see STORAGE_PROFILE_KIND0} rows (prewarm); list of {shortcode, url, set?}.
*
* @var list<array{shortcode: string, url: string, set?: string}>|null
*/
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $nip30CustomEmoji = null;
public function getId(): string
{
return $this->id;
@ -151,6 +159,22 @@ class Event @@ -151,6 +159,22 @@ class Event
$this->storageRole = $storageRole;
}
/**
* @return list<array{shortcode: string, url: string, set?: string}>|null
*/
public function getNip30CustomEmoji(): ?array
{
return $this->nip30CustomEmoji;
}
/**
* @param list<array{shortcode: string, url: string, set?: string}>|null $nip30CustomEmoji
*/
public function setNip30CustomEmoji(?array $nip30CustomEmoji): void
{
$this->nip30CustomEmoji = $nip30CustomEmoji;
}
public function getTitle(): ?string
{
foreach ($this->getTags() as $tag) {

5
src/Enum/KindsEnum.php

@ -6,7 +6,8 @@ enum KindsEnum: int @@ -6,7 +6,8 @@ enum KindsEnum: int
{
case METADATA = 0; // metadata, NIP-01
case DELETION_REQUEST = 5; // NIP-09
case TEXT_NOTE = 1; // text note, NIP-01, will not implement
case TEXT_NOTE = 1; // text note, NIP-01
case REACTION = 7; // NIP-25, NIP-30 emoji in content
case FOLLOWS = 3;
case REPOST = 6; // Only wraps kind 1, NIP-18, will not implement
case GENERIC_REPOST = 16; // Generic repost, original kind signalled in a "k" tag, NIP-18
@ -20,6 +21,8 @@ enum KindsEnum: int @@ -20,6 +21,8 @@ enum KindsEnum: int
case ZAP = 9735; // NIP-57, Zaps
case HIGHLIGHTS = 9802;
case RELAY_LIST = 10002; // NIP-65, Relay list metadata
case EMOJI_LIST = 10030; // NIP-51 standard list, NIP-30 emoji tags
case PAYMENT_TARGETS = 10133; // NIP-A3, payto: payment targets (replaceable)
case APP_DATA = 30078; // NIP-78, Arbitrary custom app data
case USER_STATUS = 30315; // NIP-38, NIP-30 emoji in content
}

60
src/Service/CacheService.php

@ -19,6 +19,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -19,6 +19,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
private LoggerInterface $logger,
private NostrKeyHelper $nostrKeyHelper,
private NostrNip65RelayUrls $nip65RelayUrls,
private Nip30EmojiCatalogBuilder $nip30EmojiCatalogBuilder,
) {
}
@ -28,7 +29,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -28,7 +29,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
}
/**
* @return array{content: \stdClass, kind0_tags: list<list<string>>}
* @return array{content: \stdClass, kind0_tags: list<list<string>>, nip30_custom_emojis: list<array{shortcode: string, url: string, set?: string}>}
*/
public function getMetadataBundle(string $npub): array
{
@ -45,10 +46,12 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -45,10 +46,12 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
if (!\is_object($ev)) {
return $this->placeholderMetadataBundle($npub);
}
$nip30 = $this->nip30EmojiCatalogBuilder->buildMergedCatalog($ev, null, []);
$this->replaceByCoreKey(
MagazineEventKeys::profileKind0($authorHex),
Event::STORAGE_PROFILE_KIND0,
$ev
$ev,
$nip30,
);
$tags = self::normalizeEventTagsList($ev->tags ?? null);
$content = $this->decodeKind0ContentObject($ev);
@ -56,7 +59,11 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -56,7 +59,11 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
$content = $this->namePlaceholderNpubObject($npub);
}
return ['content' => $content, 'kind0_tags' => $tags];
return [
'content' => $content,
'kind0_tags' => $tags,
'nip30_custom_emojis' => $nip30,
];
} catch (\Exception $e) {
$this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [
'npub' => $npub,
@ -68,12 +75,12 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -68,12 +75,12 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
}
/**
* Prewarm: batch upsert of kind-0 profile rows in {@see Event}.
* Prewarm: batch upsert of kind-0 profile rows in {@see Event} with merged NIP-30 emoji catalog.
*
* @param list<string> $authorPubkeyHex
* @param array<string, object> $wireByLowerHex from {@see NostrClient::fetchKind0WireEventsForAuthors} (keys are lowercase 64-hex)
* @param array<string, array<string, mixed>> $bundlesByLowerHex from {@see NostrClient::fetchProfilePrewarmWireBundlesForAuthors}
*/
public function putPrewarmMetadataBatch(array $authorPubkeyHex, array $wireByLowerHex): int
public function putPrewarmMetadataBatch(array $authorPubkeyHex, array $bundlesByLowerHex): int
{
$n = 0;
foreach ($authorPubkeyHex as $hex) {
@ -81,13 +88,28 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -81,13 +88,28 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
continue;
}
$h = strtolower($hex);
if (!isset($wireByLowerHex[$h]) || !\is_object($wireByLowerHex[$h])) {
if (!isset($bundlesByLowerHex[$h]) || !\is_array($bundlesByLowerHex[$h])) {
continue;
}
$bundle = $bundlesByLowerHex[$h];
$k0 = $bundle['kind0'] ?? null;
if (!\is_object($k0)) {
continue;
}
$emojiList = $bundle['emoji_list'] ?? null;
if (!\is_object($emojiList)) {
$emojiList = null;
}
$statuses = $bundle['statuses'] ?? [];
if (!\is_array($statuses)) {
$statuses = [];
}
$nip30 = $this->nip30EmojiCatalogBuilder->buildMergedCatalog($k0, $emojiList, $statuses);
$this->replaceByCoreKey(
MagazineEventKeys::profileKind0($h),
Event::STORAGE_PROFILE_KIND0,
$wireByLowerHex[$h]
$k0,
$nip30,
);
++$n;
}
@ -165,7 +187,10 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -165,7 +187,10 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
return null;
}
private function replaceByCoreKey(string $coreKey, string $storageRole, object $rawWire): void
/**
* @param list<array{shortcode: string, url: string, set?: string}>|null $nip30Catalog profile rows only
*/
private function replaceByCoreKey(string $coreKey, string $storageRole, object $rawWire, ?array $nip30Catalog = null): void
{
$entity = $this->wireToEventEntity($rawWire);
if ($entity === null) {
@ -173,6 +198,11 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -173,6 +198,11 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
}
$entity->setCoreRowKey($coreKey);
$entity->setStorageRole($storageRole);
if ($storageRole === Event::STORAGE_PROFILE_KIND0) {
$entity->setNip30CustomEmoji($nip30Catalog ?? $this->nip30EmojiCatalogBuilder->buildMergedCatalog($rawWire, null, []));
} else {
$entity->setNip30CustomEmoji(null);
}
if ($entity->getEventId() === null) {
$entity->setEventId($entity->getId());
}
@ -186,6 +216,11 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -186,6 +216,11 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
$prev->setSig($entity->getSig());
$prev->setCoreRowKey($coreKey);
$prev->setStorageRole($storageRole);
if ($storageRole === Event::STORAGE_PROFILE_KIND0) {
$prev->setNip30CustomEmoji($entity->getNip30CustomEmoji());
} else {
$prev->setNip30CustomEmoji(null);
}
if ($entity->getEventId() !== null) {
$prev->setEventId($entity->getEventId());
}
@ -236,9 +271,15 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -236,9 +271,15 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
$content = $this->namePlaceholderNpubObject($npub);
}
$nip30 = $row->getNip30CustomEmoji();
if (!\is_array($nip30) || $nip30 === []) {
$nip30 = $this->nip30EmojiCatalogBuilder->catalogFromTagsOnly($row->getTags());
}
return [
'content' => $content,
'kind0_tags' => self::normalizeEventTagsList($row->getTags()),
'nip30_custom_emojis' => $nip30,
];
}
@ -281,6 +322,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -281,6 +322,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
return [
'content' => $this->namePlaceholderNpubObject($npub),
'kind0_tags' => [],
'nip30_custom_emojis' => [],
];
}

25
src/Service/MagazineHierarchyEditorService.php

@ -27,6 +27,7 @@ final class MagazineHierarchyEditorService @@ -27,6 +27,7 @@ final class MagazineHierarchyEditorService
* owner_hex: string,
* root_d_tag: string,
* site_name: string,
* default_category_preserved_tags: list<list<string>>,
* nodes: list<array{
* d_tag: string,
* is_root: bool,
@ -35,7 +36,8 @@ final class MagazineHierarchyEditorService @@ -35,7 +36,8 @@ final class MagazineHierarchyEditorService
* summary: string,
* content: string,
* preserved_tags: list<list<string>>,
* a_coordinates: list<string>
* a_coordinates: list<string>,
* parent_d_tag: string|null
* }>
* }
*/
@ -62,9 +64,13 @@ final class MagazineHierarchyEditorService @@ -62,9 +64,13 @@ final class MagazineHierarchyEditorService
$nodes = [];
if ($root !== null) {
$nodes[] = $this->withDepth($this->nodeFromEvent($root, $dTag, true, $ownerHex, $siteName), 0);
$rootNode = $this->nodeFromEvent($root, $dTag, true, $ownerHex, $siteName);
$rootNode['parent_d_tag'] = null;
$nodes[] = $this->withDepth($rootNode, 0);
} else {
$nodes[] = $this->withDepth($this->emptyRootNode($dTag, $ownerHex, $siteName), 0);
$rootNode = $this->emptyRootNode($dTag, $ownerHex, $siteName);
$rootNode['parent_d_tag'] = null;
$nodes[] = $this->withDepth($rootNode, 0);
}
foreach ($this->orderedCategorySlugsForEditor($npub, $dTag, $ownerHex) as $slug) {
@ -72,15 +78,20 @@ final class MagazineHierarchyEditorService @@ -72,15 +78,20 @@ final class MagazineHierarchyEditorService
if ($slug === '') {
continue;
}
$parentD = $parentByChild[$slug] ?? $dTag;
$cat = $this->store->getCategory($slug);
if ($cat !== null) {
$catNode = $this->nodeFromEvent($cat, $slug, false, $ownerHex, $siteName);
$catNode['parent_d_tag'] = $parentD;
$nodes[] = $this->withDepth(
$this->nodeFromEvent($cat, $slug, false, $ownerHex, $siteName),
$catNode,
$this->depthForCategorySlug($slug, $dTag, $parentByChild),
);
} else {
$emptyNode = $this->emptyCategoryNode($slug, $ownerHex, $siteName);
$emptyNode['parent_d_tag'] = $parentD;
$nodes[] = $this->withDepth(
$this->emptyCategoryNode($slug, $ownerHex, $siteName),
$emptyNode,
$this->depthForCategorySlug($slug, $dTag, $parentByChild),
);
}
@ -91,6 +102,7 @@ final class MagazineHierarchyEditorService @@ -91,6 +102,7 @@ final class MagazineHierarchyEditorService
'root_d_tag' => $dTag,
'site_name' => $siteName,
'nodes' => $nodes,
'default_category_preserved_tags' => $this->defaultCategoryPreservedTags($ownerHex, $siteName),
];
}
@ -157,7 +169,8 @@ final class MagazineHierarchyEditorService @@ -157,7 +169,8 @@ final class MagazineHierarchyEditorService
* summary: string,
* content: string,
* preserved_tags: list<list<string>>,
* a_coordinates: list<string>
* a_coordinates: list<string>,
* parent_d_tag?: string|null
* }
*/
private function withDepth(array $node, int $depth): array

164
src/Service/Nip30EmojiCatalogBuilder.php

@ -0,0 +1,164 @@ @@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Util\NostrEventTags;
/**
* NIP-30 custom emoji: {@code ["emoji", shortcode, image-url, optional emoji-set "a" coordinate]]}.
* Merges definitions from kind 0, NIP-51 kind 10030 (emoji list), and NIP-38 kind 30315 (status) events.
*/
final class Nip30EmojiCatalogBuilder
{
private const SHORTCODE_PATTERN = '/^[a-zA-Z0-9_-]+$/';
/**
* Kinds whose events may carry NIP-30 {@code emoji} tags on the wire.
*
* @return list<int>
*/
public static function kindsWithEmojiTags(): array
{
return [0, 1, 7, 30_315];
}
public static function kindSupportsEmojiTags(int $kind): bool
{
return \in_array($kind, self::kindsWithEmojiTags(), true);
}
/**
* @param list<object> $statuses30315 NIP-38 status events (append newer events later so duplicate shortcodes resolve to the latest)
*
* @return list<array{shortcode: string, url: string, set?: string}>
*/
public function buildMergedCatalog(object $kind0Wire, ?object $kind10030Wire, array $statuses30315): array
{
/** @var array<string, array{shortcode: string, url: string, set?: string}> $byShort */
$byShort = [];
$this->mergeWireTagsIntoMap($byShort, $kind0Wire);
if ($kind10030Wire !== null) {
$this->mergeWireTagsIntoMap($byShort, $kind10030Wire);
}
foreach ($statuses30315 as $ev) {
$this->mergeWireTagsIntoMap($byShort, $ev);
}
return array_values($byShort);
}
/**
* @param array<int, mixed> $tags Raw event.tags (JSON column shape)
*
* @return list<array{shortcode: string, url: string, set?: string}>
*/
public function catalogFromTagsOnly(array $tags): array
{
/** @var array<string, array{shortcode: string, url: string, set?: string}> $byShort */
$byShort = [];
$this->appendRawTagsIntoMap($tags, $byShort);
return array_values($byShort);
}
/**
* NIP-30 emoji tags on kind 0, 1, 7, or 30315 wires (for ingest paths other than profile prewarm).
*
* @return list<array{shortcode: string, url: string, set?: string}>
*/
public function catalogFromWireIfNip30Kind(object $wire): array
{
$k = (int) ($wire->kind ?? -1);
if (!self::kindSupportsEmojiTags($k)) {
return [];
}
$tags = $wire->tags ?? null;
if (!\is_array($tags)) {
return [];
}
return $this->catalogFromTagsOnly($tags);
}
/**
* @param array<string, array{shortcode: string, url: string, set?: string}> $byShort
*/
private function mergeWireTagsIntoMap(array &$byShort, object $wire): void
{
$tags = $wire->tags ?? null;
if (!\is_array($tags)) {
return;
}
$this->appendRawTagsIntoMap($tags, $byShort);
}
/**
* @param array<int, mixed> $tags
* @param array<string, array{shortcode: string, url: string, set?: string}> $byShort
*/
private function appendRawTagsIntoMap(array $tags, array &$byShort): void
{
foreach ($tags as $row) {
$parsed = $this->tryParseOneEmojiRow($row);
if ($parsed !== null) {
$byShort[$parsed['shortcode']] = $parsed;
}
}
}
/**
* @return array{shortcode: string, url: string, set?: string}|null
*/
private function tryParseOneEmojiRow(mixed $row): ?array
{
$seq = NostrEventTags::rowToStringList($row);
if ($seq === null || $seq === []) {
return null;
}
if (strtolower((string) $seq[0]) !== 'emoji') {
return null;
}
$shortcode = isset($seq[1]) ? trim((string) $seq[1]) : '';
$url = isset($seq[2]) ? trim((string) $seq[2]) : '';
if ($shortcode === '' || $url === '' || 1 !== preg_match(self::SHORTCODE_PATTERN, $shortcode)) {
return null;
}
if (!$this->looksLikeEmojiImageUrl($url)) {
return null;
}
$item = ['shortcode' => $shortcode, 'url' => $url];
if (isset($seq[3])) {
$set = trim((string) $seq[3]);
if ($set !== '' && self::looksLikeAddressableCoordinate($set)) {
$item['set'] = $set;
}
}
return $item;
}
private function looksLikeEmojiImageUrl(string $url): bool
{
if (\strlen($url) < 8 || \strlen($url) > 2048) {
return false;
}
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
return false;
}
return false === strpbrk($url, "\r\n\0");
}
private static function looksLikeAddressableCoordinate(string $s): bool
{
$parts = explode(':', $s, 3);
return 3 === \count($parts)
&& ctype_digit(trim($parts[0], " \t\n\r\0\x0B"))
&& 64 === \strlen($parts[1])
&& ctype_xdigit($parts[1])
&& trim($parts[2]) !== '';
}
}

82
src/Service/NostrClient.php

@ -207,6 +207,24 @@ class NostrClient @@ -207,6 +207,24 @@ class NostrClient
* @return array<string, object> Keyed by lowercase 64-hex pubkey
*/
public function fetchKind0WireEventsForAuthors(array $authorPubkeyHex, int $authorsPerRequest = 50): array
{
$bundles = $this->fetchProfilePrewarmWireBundlesForAuthors($authorPubkeyHex, $authorsPerRequest);
$byPub = [];
foreach ($bundles as $pk => $bundle) {
$byPub[$pk] = $bundle['kind0'];
}
return $byPub;
}
/**
* Prewarm: kind 0 + NIP-51 kind 10030 (emoji list) + NIP-38 kind 30315 (status), one REQ per chunk.
*
* @param list<string> $authorPubkeyHex
*
* @return array<string, array{kind0: object, emoji_list: ?object, statuses: list<object>}> keyed by lowercase 64-hex pubkey (only authors with a kind-0 hit in this response)
*/
public function fetchProfilePrewarmWireBundlesForAuthors(array $authorPubkeyHex, int $authorsPerRequest = 50): array
{
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
@ -216,14 +234,21 @@ class NostrClient @@ -216,14 +234,21 @@ class NostrClient
return [];
}
$authorsPerRequest = max(1, min(200, $authorsPerRequest));
$byPub = [];
$relaysTried = $this->relayListFactory->getProfileMetadataQueryRelayUrlList();
$relaySet = $this->relayListFactory->getRelaySetForProfileMetadataFetch();
$chunks = array_chunk($authorPubkeyHex, $authorsPerRequest);
$bundles = [];
foreach ($chunks as $chunk) {
$chunkLower = [];
foreach ($chunk as $h) {
$chunkLower[strtolower($h)] = true;
}
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::METADATA],
kinds: [
KindsEnum::METADATA,
KindsEnum::EMOJI_LIST,
KindsEnum::USER_STATUS,
],
filters: ['authors' => $chunk],
relaySet: $relaySet
);
@ -231,18 +256,59 @@ class NostrClient @@ -231,18 +256,59 @@ class NostrClient
$request->send(),
static fn ($ev) => $ev,
);
foreach ($this->wireMerge->mergeKind0EventsByReplaceableAddress($events) as $addr => $ev) {
if (!\is_object($ev)) {
continue;
$kind0Only = [];
foreach ($events as $ev) {
if (\is_object($ev) && (int) ($ev->kind ?? 0) === KindsEnum::METADATA->value) {
$kind0Only[] = $ev;
}
}
$byAddr0 = $this->wireMerge->mergeKind0EventsByReplaceableAddress($kind0Only);
$kind0ByPk = [];
foreach ($byAddr0 as $addr => $ev) {
$pk = \substr((string) $addr, 2);
if (64 === \strlen($pk) && ctype_xdigit($pk)) {
$byPub[strtolower($pk)] = $ev;
$kind0ByPk[strtolower($pk)] = $ev;
}
}
$k10030 = KindsEnum::EMOJI_LIST->value;
$k30315 = KindsEnum::USER_STATUS->value;
/** @var array<string, object> $by10030 */
$by10030 = [];
/** @var array<string, list<object>> $by30315 */
$by30315 = [];
foreach ($events as $ev) {
if (!\is_object($ev)) {
continue;
}
$k = (int) ($ev->kind ?? 0);
$pk = strtolower((string) ($ev->pubkey ?? ''));
if (64 !== \strlen($pk) || !ctype_xdigit($pk) || !isset($chunkLower[$pk])) {
continue;
}
if ($k === $k10030) {
if (!isset($by10030[$pk]) || $this->wireMerge->wireEventSupersedes($ev, $by10030[$pk])) {
$by10030[$pk] = $ev;
}
} elseif ($k === $k30315) {
$by30315[$pk][] = $ev;
}
}
foreach ($by30315 as $pk => $list) {
\usort($list, function (object $a, object $b): int {
return $this->wireMerge->magazineEventCreatedAt($b) <=> $this->wireMerge->magazineEventCreatedAt($a);
});
$by30315[$pk] = \array_slice($list, 0, 28);
}
foreach ($kind0ByPk as $pk => $k0) {
$bundles[$pk] = [
'kind0' => $k0,
'emoji_list' => $by10030[$pk] ?? null,
'statuses' => $by30315[$pk] ?? [],
];
}
}
return $byPub;
return $bundles;
}
/**

3
templates/pages/author.html.twig

@ -10,6 +10,9 @@ @@ -10,6 +10,9 @@
profile_nip05: profile_nip05,
profile_payment_links: profile_payment_links,
} only %}
{% if profile_nip30_emojis is defined and profile_nip30_emojis is not empty %}
<script type="application/json" id="profile-nip30-emoji-catalog">{{ profile_nip30_emojis|json_encode(constant('JSON_UNESCAPED_UNICODE'))|raw }}</script>
{% endif %}
<hr class="author-profile__divider" />
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>

207
templates/pages/magazine_edit.html.twig

@ -1,5 +1,59 @@ @@ -1,5 +1,59 @@
{% extends 'base.html.twig' %}
{% block stylesheets %}
{{ parent() }}
{# Linked sheet (digested URL). If this 404s on some hosts, JS entry still imports the same file; critical <style> below is the last resort. #}
<link rel="stylesheet" href="{{ asset('styles/magazine-editor.css') }}">
{# In-document fallback: global `button` (button.css) wins when magazine-editor.css never loads (e.g. asset URL / SW / Docker static). #}
<style id="magazine-editor-a-row-critical">
.card.magazine-editor [data-mag-a-row] {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.35rem;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.card.magazine-editor [data-mag-a-row] > .magazine-editor__a-line-field {
min-width: 0;
max-width: 100%;
}
.card.magazine-editor [data-mag-a-row] > .magazine-editor__a-line-field .magazine-editor__a-line-input {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.card.magazine-editor [data-mag-a-row] > button.magazine-editor__a-remove-icon {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.55rem;
min-width: 1.55rem;
max-width: 1.55rem;
height: 1.55rem;
padding: 0;
margin: 0;
border: 1px solid var(--color-border, #3a3a3a);
border-radius: 3px;
background: var(--color-bg-light, #2a2a2a) !important;
color: var(--color-text-mid, #d8d8d8) !important;
border-color: var(--color-border, #3a3a3a) !important;
text-transform: none !important;
font-weight: normal !important;
font-size: 0 !important;
line-height: 0 !important;
cursor: pointer;
}
.card.magazine-editor [data-mag-a-row] > button.magazine-editor__a-remove-icon svg {
width: 12px;
height: 12px;
display: block;
}
</style>
{% endblock %}
{% block title %}Magazine index editor — {{ website_name }}{% endblock %}
{% block meta_description %}
@ -13,7 +67,7 @@ @@ -13,7 +67,7 @@
</div>
<div class="card-body">
<p class="magazine-editor__intro">
Edit kind <strong>30040</strong> indices (root, categories, subcategories), then sign with your Nostr extension. Only indices you changed are signed; any other index required so nested <code>a</code> links stay consistent is still included in the same batch (same rules as before).
Edit kind <strong>30040</strong> indices (root, categories, subcategories), then sign with your Nostr extension. Use <strong>Add top-level category</strong> or <strong>Add subcategory</strong> to create new indices; the parent index’s <code>a</code> list is updated automatically so the tree stays linked. Only indices you changed are signed; any other index required so nested <code>a</code> links stay consistent is still included in the same batch (same rules as before).
</p>
<div
@ -23,28 +77,139 @@ @@ -23,28 +77,139 @@
data-magazine-hierarchy-editor-csrf-value="{{ magazine_edit_csrf }}"
data-magazine-hierarchy-editor-owner-hex-value="{{ editor_payload.owner_hex }}"
data-magazine-hierarchy-editor-root-d-tag-value="{{ editor_payload.root_d_tag|e('html_attr') }}"
data-magazine-hierarchy-editor-default-preserved-json-value="{{ editor_payload.default_category_preserved_tags|json_encode(constant('JSON_UNESCAPED_UNICODE'))|e('html_attr') }}"
data-magazine-hierarchy-editor-client-tag-value="{{ website_short_name|e('html_attr') }}"
>
<p class="magazine-editor__status" data-magazine-hierarchy-editor-target="status" aria-live="polite"></p>
<div class="magazine-editor__nodes">
<div class="magazine-editor__toolbar">
<button type="button" class="btn btn-secondary btn-sm" data-mag-editor-cmd="add-top-category">
Add top-level category
</button>
</div>
<template data-magazine-hierarchy-editor-target="aRowTemplate">
<div class="magazine-editor__a-row" data-mag-a-row>
<div class="magazine-editor__a-line-field">
<input type="text" class="magazine-editor__input magazine-editor__input--mono magazine-editor__a-line-input" data-mag-a-line value="" spellcheck="false" autocomplete="off">
</div>
<button type="button" class="magazine-editor__a-remove-icon" aria-label="Remove article">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
<line x1="10" x2="10" y1="11" y2="17"/>
<line x1="14" x2="14" y1="11" y2="17"/>
</svg>
</button>
</div>
</template>
<template data-magazine-hierarchy-editor-target="newNodeTemplate">
<fieldset
class="magazine-editor__node magazine-editor__node--new"
data-magazine-hierarchy-editor-target="node"
>
<legend class="magazine-editor__legend magazine-editor__legend--row">
<span class="magazine-editor__legend-main">
<span data-new-node-legend-label>New category</span>
</span>
<span class="magazine-editor__legend-actions">
<button type="button" class="btn btn-secondary btn-sm magazine-editor__add-sub" data-mag-editor-cmd="add-subcategory">
Add subcategory
</button>
<button type="button" class="btn btn-secondary btn-sm magazine-editor__remove-node" data-mag-editor-cmd="remove-category" aria-label="Discard new category">
Remove
</button>
</span>
</legend>
<label class="magazine-editor__label" data-new-node-d-row>
<span class="magazine-editor__label-text">Index <code>#d</code> (identifier)</span>
<input
type="text"
class="magazine-editor__input magazine-editor__input--mono"
data-magazine-hierarchy-editor-target="dTag"
spellcheck="false"
autocomplete="off"
>
</label>
<input type="hidden" data-magazine-hierarchy-editor-target="preservedJson" value="[]">
<label class="magazine-editor__label">
<span class="magazine-editor__label-text">Title</span>
<input type="text" class="magazine-editor__input" data-magazine-hierarchy-editor-target="title" value="" autocomplete="off">
</label>
<label class="magazine-editor__label">
<span class="magazine-editor__label-text">Summary</span>
<textarea class="magazine-editor__textarea" data-magazine-hierarchy-editor-target="summary" rows="3"></textarea>
</label>
<label class="magazine-editor__label">
<span class="magazine-editor__label-text">Content</span>
<textarea class="magazine-editor__textarea" data-magazine-hierarchy-editor-target="content" rows="2"></textarea>
</label>
<div class="magazine-editor__a-block">
<span class="magazine-editor__label-text magazine-editor__a-block-label"><code>a</code> tags (addressable coordinates)</span>
<p class="magazine-editor__a-hint">Each field is one <code>a</code> value: <code>kind:hex64pubkey:identifier</code>, long-form <code>30023</code>/<code>30024</code>, or NIP-19 <code>naddr1…</code> / <code>nostr:naddr1…</code> (expanded when signing).</p>
<div class="magazine-editor__a-list" data-mag-a-list>
<button type="button" class="btn btn-secondary btn-sm magazine-editor__a-add" data-mag-a-add>Add article</button>
</div>
</div>
</fieldset>
</template>
<div class="magazine-editor__nodes" data-magazine-hierarchy-editor-target="nodes">
{% for node in editor_payload.nodes %}
{% set depth = node.depth|default(0) %}
<fieldset
class="magazine-editor__node{% if depth > 1 %} magazine-editor__node--nested{% endif %}"
class="magazine-editor__node{% if depth > 1 %} magazine-editor__node--nested{% endif %}{% if node.is_root %} magazine-editor__node--root{% endif %}"
style="--mag-node-depth: {{ depth }}; margin-left: calc(max(0, var(--mag-node-depth) - 1) * 1.15rem);"
data-magazine-hierarchy-editor-target="node"
data-mag-depth="{{ depth }}"
{% if node.is_root %}data-magazine-node-is-root="1"{% else %}data-mag-parent-d="{{ node.parent_d_tag|default('')|e('html_attr') }}" data-mag-placed-d="{{ node.d_tag|e('html_attr') }}"{% endif %}
>
<legend class="magazine-editor__legend">
<legend class="magazine-editor__legend magazine-editor__legend--row">
<span class="magazine-editor__legend-main">
{% if node.is_root %}
Root index
Root index <code class="magazine-editor__slug">{{ node.d_tag }}</code>
{% elseif depth > 1 %}
Subcategory
{% else %}
Category
{% endif %}
<code class="magazine-editor__slug">{{ node.d_tag }}</code>
</span>
{% if not node.is_root %}
<span class="magazine-editor__legend-actions">
<button type="button" class="btn btn-secondary btn-sm magazine-editor__add-sub" data-mag-editor-cmd="add-subcategory">
Add subcategory
</button>
<button type="button" class="btn btn-secondary btn-sm magazine-editor__remove-node" data-mag-editor-cmd="remove-category" aria-label="Remove category">
Remove
</button>
</span>
{% endif %}
</legend>
{% if node.is_root %}
<input type="hidden" data-magazine-hierarchy-editor-target="dTag" value="{{ node.d_tag|e('html_attr') }}">
{% else %}
<label class="magazine-editor__label magazine-editor__label--d-tag" data-mag-d-row>
<span class="magazine-editor__label-text">Index <code>#d</code> (identifier)</span>
<input
type="text"
class="magazine-editor__input magazine-editor__input--mono"
data-magazine-hierarchy-editor-target="dTag"
value="{{ node.d_tag|e('html_attr') }}"
spellcheck="false"
autocomplete="off"
>
</label>
{% endif %}
<input type="hidden" data-magazine-hierarchy-editor-target="preservedJson" value="{{ node.preserved_tags|json_encode(constant('JSON_UNESCAPED_UNICODE'))|e('html_attr') }}">
<label class="magazine-editor__label">
@ -62,18 +227,36 @@ @@ -62,18 +227,36 @@
<textarea class="magazine-editor__textarea" data-magazine-hierarchy-editor-target="content" rows="2">{{ node.content }}</textarea>
</label>
<label class="magazine-editor__label">
<span class="magazine-editor__label-text"><code>a</code> coordinates (one per line: <code>kind:hex64pubkey:identifier</code>, long-form <code>30023</code>/<code>30024</code>, or NIP-19 <code>naddr1…</code> / <code>nostr:naddr1…</code> — naddrs are expanded before signing)</span>
<textarea class="magazine-editor__textarea magazine-editor__textarea--mono" data-magazine-hierarchy-editor-target="aLines" rows="8" spellcheck="false">{{ node.a_coordinates|join("\n") }}</textarea>
</label>
<div class="magazine-editor__a-block">
<span class="magazine-editor__label-text magazine-editor__a-block-label"><code>a</code> tags (addressable coordinates)</span>
<p class="magazine-editor__a-hint">Each field is one <code>a</code> value: <code>kind:hex64pubkey:identifier</code>, long-form <code>30023</code>/<code>30024</code>, or NIP-19 <code>naddr1…</code> / <code>nostr:naddr1…</code> (expanded when signing).</p>
<div class="magazine-editor__a-list" data-mag-a-list>
{% for coord in node.a_coordinates %}
<div class="magazine-editor__a-row" data-mag-a-row>
<div class="magazine-editor__a-line-field">
<input type="text" class="magazine-editor__input magazine-editor__input--mono magazine-editor__a-line-input" data-mag-a-line value="{{ coord|e('html_attr') }}" spellcheck="false" autocomplete="off">
</div>
<button type="button" class="magazine-editor__a-remove-icon" aria-label="Remove article">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
<line x1="10" x2="10" y1="11" y2="17"/>
<line x1="14" x2="14" y1="11" y2="17"/>
</svg>
</button>
</div>
{% endfor %}
<button type="button" class="btn btn-secondary btn-sm magazine-editor__a-add" data-mag-a-add>Add article</button>
</div>
</div>
<input type="hidden" data-magazine-hierarchy-editor-target="dTag" value="{{ node.d_tag|e('html_attr') }}">
</fieldset>
{% endfor %}
</div>
<div class="magazine-editor__actions">
<button type="button" data-action="click->magazine-hierarchy-editor#publish">
<button type="button" class="btn btn-primary" data-mag-editor-cmd="publish">
Sign and publish all changed events
</button>
</div>

86
tests/Service/Nip30EmojiCatalogBuilderTest.php

@ -0,0 +1,86 @@ @@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\Nip30EmojiCatalogBuilder;
use PHPUnit\Framework\TestCase;
final class Nip30EmojiCatalogBuilderTest extends TestCase
{
private Nip30EmojiCatalogBuilder $b;
protected function setUp(): void
{
$this->b = new Nip30EmojiCatalogBuilder();
}
public function testCatalogFromTagsParsesValidEmojiRow(): void
{
$tags = [
['emoji', 'soapbox', 'https://gleasonator.com/emoji/Gleasonator/soapbox.png'],
];
$out = $this->b->catalogFromTagsOnly($tags);
$this->assertCount(1, $out);
$this->assertSame('soapbox', $out[0]['shortcode']);
$this->assertSame('https://gleasonator.com/emoji/Gleasonator/soapbox.png', $out[0]['url']);
$this->assertArrayNotHasKey('set', $out[0]);
}
public function testOptionalFourthSetCoordinate(): void
{
$pk = str_repeat('b', 64);
$tags = [
['emoji', 'blob', 'https://example.com/x.png', '30030:'.$pk.':blobcats'],
];
$out = $this->b->catalogFromTagsOnly($tags);
$this->assertSame('30030:'.$pk.':blobcats', $out[0]['set'] ?? null);
}
public function testInvalidShortcodeSkipped(): void
{
$tags = [
['emoji', 'bad shortcode', 'https://example.com/a.png'],
];
$this->assertSame([], $this->b->catalogFromTagsOnly($tags));
}
public function testMergedCatalogLaterSourceOverridesShortcode(): void
{
$pk = str_repeat('c', 64);
$k0 = (object) [
'kind' => 0,
'tags' => [
['emoji', 'x', 'https://example.com/old.png'],
],
];
$list = (object) [
'kind' => 10030,
'tags' => [
['emoji', 'x', 'https://example.com/new.png'],
],
];
$merged = $this->b->buildMergedCatalog($k0, $list, []);
$this->assertCount(1, $merged);
$this->assertSame('https://example.com/new.png', $merged[0]['url']);
}
public function testCatalogFromWireIfNip30KindIgnoresUnsupportedKind(): void
{
$wire = (object) ['kind' => 30023, 'tags' => [['emoji', 'nope', 'https://example.com/n.png']]];
$this->assertSame([], $this->b->catalogFromWireIfNip30Kind($wire));
}
public function testCatalogFromWireIfNip30KindAcceptsKind7(): void
{
$wire = (object) [
'kind' => 7,
'tags' => [
['emoji', 'dezh', 'https://raw.githubusercontent.com/dezh-tech/brand-assets/main/dezh/logo/black-normal.svg'],
],
];
$out = $this->b->catalogFromWireIfNip30Kind($wire);
$this->assertSame('dezh', $out[0]['shortcode'] ?? '');
}
}
Loading…
Cancel
Save