Browse Source

get rid of kind 30004

imwald
Silberengel 1 month ago
parent
commit
0edb527a57
  1. 2
      assets/styles/app.css
  2. 2
      assets/styles/article.css
  3. 2
      config/unfold.yaml
  4. 6
      src/Command/PrewarmCommand.php
  5. 6
      src/Controller/DefaultController.php
  6. 3
      src/Nostr/MagazineEventKeys.php
  7. 149
      src/Service/MagazineContentService.php
  8. 29
      src/Service/MagazineIndexStore.php
  9. 30
      src/Service/MagazineRefresher.php
  10. 2
      src/Service/Nip09DeletionApplier.php
  11. 109
      src/Service/NostrClient.php
  12. 2
      src/Service/NostrKind5DeletionFilter.php
  13. 58
      src/Util/CurationSet30004Home.php
  14. 11
      templates/components/Organisms/HomeMagazineArticleStrip.html.twig
  15. 8
      templates/home.html.twig
  16. 44
      tests/Util/CurationSet30004HomeTest.php

2
assets/styles/app.css

@ -162,7 +162,7 @@ svg.icon { @@ -162,7 +162,7 @@ svg.icon {
}
}
/* Home: NIP-51 30004 “headlines” — editorial section title + full-width article stack (not masonry). */
/* Home: magazine root index headline strip — section title + full-width article stack (not masonry). */
.home-curation-landmark {
width: 100%;
min-width: 0;

2
assets/styles/article.css

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
/* Shared “reading pane” surface: full article pages and home NIP-51 30004 headline bodies. */
/* Shared “reading pane” surface: full article pages and home magazine headline strip bodies. */
:root {
--article-reading-pane-bg: color-mix(in srgb, var(--color-bg) 70%, #ffffff 30%);
--article-reading-prose-color: color-mix(in srgb, var(--color-text-mid) 35%, var(--color-text) 65%);

2
config/unfold.yaml

@ -55,8 +55,6 @@ parameters: @@ -55,8 +55,6 @@ parameters:
# Kind 30040 magazine root #d (NIP-33). Exposed as `d_tag` for backward compatibility.
d_tag_magazine: 'newsroom-magazine-on-imwald-by-laeserin'
d_tag: '%d_tag_magazine%'
# NIP-51 kind 30004 curation set #d for `npub` (home landing stack): optional `title` tag = section heading; ordered `a` for kind 30023 only (full-width article blocks). Other `a` kinds and `e` tags ignored. Empty or `d-tag-goes-here` disables.
d_tag_curation_set: 'nostr-curated-headlines'
# Whether to show community articles on the home page
community_articles: true
# Domain for site-assigned NIP-05 for featured (magazine category) authors; must match the host serving /.well-known/nostr.json

6
src/Command/PrewarmCommand.php

@ -64,7 +64,7 @@ final class PrewarmCommand extends Command @@ -64,7 +64,7 @@ final class PrewarmCommand extends Command
{
$this
->addOption('no-magazine', null, InputOption::VALUE_NONE, 'Skip magazine 30040 index fetch')
->addOption('no-deletions', null, InputOption::VALUE_NONE, 'Skip NIP-09 kind 5 deletion sync (articles + event rows: magazine, curation 30004, cached curation notes, profiles, etc.)')
->addOption('no-deletions', null, InputOption::VALUE_NONE, 'Skip NIP-09 kind 5 deletion sync (articles + magazine 30040 rows, profiles, relay lists, etc.)')
->addOption('deletion-since', null, InputOption::VALUE_REQUIRED, 'strtotime() window start for kind 5 fetch', '-2 month')
->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip batched kind-0 profile prewarm (MySQL event table)')
->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache')
@ -236,7 +236,7 @@ final class PrewarmCommand extends Command @@ -236,7 +236,7 @@ final class PrewarmCommand extends Command
$this->disableCliExecutionTimeLimit();
if (!$input->getOption('no-deletions')) {
$io->section('NIP-09 deletions (kind 5 → 30023/30024 / 30040 / 30004)');
$io->section('NIP-09 deletions (kind 5 → 30023/30024 / 30040 / legacy 30004 rows)');
$sinceStr = (string) $input->getOption('deletion-since');
$since = strtotime($sinceStr);
if ($since === false) {
@ -284,7 +284,7 @@ final class PrewarmCommand extends Command @@ -284,7 +284,7 @@ final class PrewarmCommand extends Command
try {
$st = $this->nip09DeletionApplier->apply($kind5);
$io->writeln(sprintf(
'Kind 5 events: <info>%d</info> (deduped). NIP-23 long-form in DB (30023/30024) removed: <info>%d</info>. Magazine index in cache (30040) removed: root <info>%d</info>, category <info>%d</info>. Home curation (30004) rows removed: <info>%d</info>.',
'Kind 5 events: <info>%d</info> (deduped). NIP-23 long-form in DB (30023/30024) removed: <info>%d</info>. Magazine index in cache (30040) removed: root <info>%d</info>, category <info>%d</info>. Legacy home curation (30004) rows removed: <info>%d</info>.',
\count($kind5),
$st['articles_removed'],
$st['magazine_roots'],

6
src/Controller/DefaultController.php

@ -25,11 +25,11 @@ class DefaultController extends AbstractController @@ -25,11 +25,11 @@ class DefaultController extends AbstractController
public function index(): Response
{
$categoryATags = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly();
$curation = $this->magazineContent->buildHomeCurationWallData();
$magazineStrip = $this->magazineContent->buildHomeMagazineRootHeadlineStripData();
return $this->render('home.html.twig', [
'home_curation_heading' => $curation['heading'],
'home_curation_tiles' => $curation['tiles'],
'home_magazine_strip_heading' => $magazineStrip['heading'],
'home_magazine_strip_tiles' => $magazineStrip['tiles'],
'home_featured_tiles' => $this->magazineContent->buildHomeMixedFeaturedWallTiles($categoryATags),
'home_sidebar_category_recent' => $this->magazineContent->buildHomeSidebarCategorizedRecent($categoryATags),
'home_highlights' => $this->articleHighlightRepository->findRecentForHome(40),

3
src/Nostr/MagazineEventKeys.php

@ -7,7 +7,8 @@ namespace App\Nostr; @@ -7,7 +7,8 @@ namespace App\Nostr;
use App\Service\NostrKeyHelper;
/**
* Stable keys for {@see Event} rows: magazine root/category indices, kind 30004 curation set, and kind-0 profiles in MySQL.
* Stable keys for {@see Event} rows: magazine root/category indices, kind-0 profiles, and legacy kind-30004
* curation keys still used by {@see \App\Service\Nip09DeletionApplier} to clean old MySQL rows.
*/
final class MagazineEventKeys
{

149
src/Service/MagazineContentService.php

@ -10,7 +10,6 @@ use App\Entity\Event; @@ -10,7 +10,6 @@ use App\Entity\Event;
use App\Enum\EventStatusEnum;
use App\Enum\KindsEnum;
use App\Repository\ArticleRepository;
use App\Util\CurationSet30004Home;
use App\Util\NostrEventTags;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RequestStack;
@ -649,51 +648,6 @@ final class MagazineContentService @@ -649,51 +648,6 @@ final class MagazineContentService
}
}
/**
* One relay-backed pass per HTTP request to pull 30004-listed **30023** coordinates into {@see Article}
* (see {@see NostrClient::persistCuration30004ReferencedItems}).
*/
private function maybeHydrateCuration30004ReferencedOncePerRequest(Event $stored): void
{
$r = $this->requestStack->getCurrentRequest();
if ($r === null) {
return;
}
if ($r->attributes->get('_curation_30004_refs_hydrated')) {
return;
}
$r->attributes->set('_curation_30004_refs_hydrated', true);
try {
$this->nostrClient->persistCuration30004ReferencedItems($stored);
} catch (\Throwable) {
}
}
private function ensureCuration30004FromRelays(string $npub, string $dTag): void
{
$r = $this->requestStack->getCurrentRequest();
if ($r !== null && $r->attributes->get('_curation_30004_ensured')) {
return;
}
try {
$e = $this->nostrClient->getCurationSet30004($npub, $dTag);
if ($e !== null) {
$this->store->putCuration30004($npub, $dTag, $e);
try {
$this->nostrClient->persistCuration30004ReferencedItems($e);
} catch (\Throwable) {
}
if ($r !== null) {
$r->attributes->set('_curation_30004_refs_hydrated', true);
}
}
} catch (\Throwable) {
}
if ($r !== null) {
$r->attributes->set('_curation_30004_ensured', true);
}
}
private function ensureCategory30040FromRelays(string $slug): void
{
if (trim($slug) === '') {
@ -755,75 +709,102 @@ final class MagazineContentService @@ -755,75 +709,102 @@ final class MagazineContentService
}
/**
* Home strip from NIP-51 kind 30004 (curation set): `d_tag_curation_set` on `npub`, ordered `a` tags for
* kind **30023** only (other kinds and `e` tags are ignored). Tiles resolve from the local `article` table.
* Home headline strip: kind **30040** magazine root (`npub` + `d_tag`), walking `a` tags **top to bottom**.
* Only kind **30023** / **30024** addresses become tiles; nested **30040** category `a` tags are skipped.
* Section heading comes from the root index `title` tag when present.
*
* @return array{heading: string, tiles: list<array{article: FeaturedArticleCard, body_html: string}>}
*/
public function buildHomeCurationWallData(): array
public function buildHomeMagazineRootHeadlineStripData(): array
{
$d = trim((string) $this->params->get('d_tag_curation_set'));
if ($d === '' || strcasecmp($d, 'd-tag-goes-here') === 0) {
return ['heading' => '', 'tiles' => []];
}
$npub = (string) $this->params->get('npub');
$stored = $this->store->getCuration30004($npub, $d);
if ($stored === null) {
$this->ensureCuration30004FromRelays($npub, $d);
$stored = $this->store->getCuration30004($npub, $d);
$dTag = (string) $this->params->get('d_tag');
$mag = $this->store->getRoot($npub, $dTag);
if ($mag === null) {
$this->ensureRoot30040FromRelays($npub, $dTag);
$mag = $this->store->getRoot($npub, $dTag);
}
if ($stored === null) {
if ($mag === null) {
return ['heading' => '', 'tiles' => []];
}
$this->maybeHydrateCuration30004ReferencedOncePerRequest($stored);
/** @var list<array<int, mixed>> $tagRows */
$tagRows = $stored->getTags();
$parsed = CurationSet30004Home::parseTitleAndOrderedRefs($tagRows);
if ($parsed['items'] === []) {
return ['heading' => '', 'tiles' => []];
$heading = '';
$orderedCoords = [];
$seenAddr = [];
foreach ($mag->getTags() as $tagRow) {
$seq = NostrEventTags::rowToStringList($tagRow);
if ($seq === null) {
continue;
}
$name = strtolower((string) ($seq[0] ?? ''));
if ($name === 'title' && isset($seq[1]) && trim((string) $seq[1]) !== '') {
$heading = trim((string) $seq[1]);
}
if ($name !== 'a' || !isset($seq[1]) || (string) $seq[1] === '') {
continue;
}
$coord = trim((string) $seq[1]);
$parts = explode(':', $coord, 3);
if (\count($parts) < 3) {
continue;
}
$kind = (int) ($parts[0] ?? 0);
if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
continue;
}
$pk = strtolower(trim((string) $parts[1]));
$slug = trim((string) $parts[2]);
if (64 !== \strlen($pk) || !ctype_xdigit($pk) || $slug === '') {
continue;
}
$dedupe = $pk."\0".$slug;
if (isset($seenAddr[$dedupe])) {
continue;
}
$seenAddr[$dedupe] = true;
$orderedCoords[] = $kind.':'.$pk.':'.$slug;
}
if ($orderedCoords === []) {
return ['heading' => $heading, 'tiles' => []];
}
$pairsArg = [];
foreach ($parsed['items'] as $it) {
$pairsArg[] = ['pubkey' => $it['pk'], 'slug' => $it['slug']];
foreach ($orderedCoords as $coord) {
$parts = explode(':', $coord, 3);
$pairsArg[] = ['pubkey' => strtolower((string) $parts[1]), 'slug' => trim((string) $parts[2])];
}
$indexed = $this->articleRepository->findByAuthorAndSlugIndexed($pairsArg);
$missingPairs = [];
foreach ($pairsArg as $pair) {
$missingCoords = [];
foreach ($pairsArg as $i => $pair) {
$k = strtolower((string) $pair['pubkey'])."\0".trim((string) $pair['slug']);
if (!isset($indexed[$k])) {
$missingPairs[] = $pair;
$missingCoords[] = $orderedCoords[$i];
}
}
if ($missingPairs !== []) {
if ($missingCoords !== []) {
try {
$this->nostrClient->ingestLongformForCategoryCoordinates(array_values(array_unique(array_map(
static fn (array $p): string => (string) KindsEnum::LONGFORM->value.':'.strtolower((string) $p['pubkey']).':'.trim((string) $p['slug']),
$missingPairs
))));
$this->nostrClient->ingestLongformForCategoryCoordinates(array_values(array_unique($missingCoords)));
} catch (\Throwable) {
}
$indexed = $this->articleRepository->findByAuthorAndSlugIndexed($pairsArg);
}
$heading = trim($parsed['title']);
$tiles = [];
$seenArticle = [];
foreach ($parsed['items'] as $it) {
$key = $it['pk']."\0".$it['slug'];
if (isset($seenArticle[$key])) {
continue;
}
$article = $indexed[$key] ?? null;
foreach ($pairsArg as $pair) {
$k = strtolower((string) $pair['pubkey'])."\0".trim((string) $pair['slug']);
$article = $indexed[$k] ?? null;
if ($article === null) {
continue;
}
$seenArticle[$key] = true;
$tiles[] = [
'article' => FeaturedArticleCard::fromArticle($article),
'body_html' => $this->articleBodyHtmlRenderer->renderForArticle($article),
];
}
if ($tiles === []) {
return ['heading' => '', 'tiles' => []];
return ['heading' => $heading, 'tiles' => []];
}
return ['heading' => $heading, 'tiles' => $tiles];

29
src/Service/MagazineIndexStore.php

@ -10,7 +10,7 @@ use App\Repository\EventRepository; @@ -10,7 +10,7 @@ use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* Magazine Nostr index events (kind 30040) and the site’s NIP-51 kind 30004 curation set in MySQL {@see Event}.
* Magazine Nostr index events (kind 30040) in MySQL {@see Event}.
* Updated by {@see MagazineRefresher} (`app:prewarm` / cron).
*/
final class MagazineIndexStore
@ -44,20 +44,6 @@ final class MagazineIndexStore @@ -44,20 +44,6 @@ final class MagazineIndexStore
return $this->eventRepository->findOneByCoreRowKey($key);
}
public function getCuration30004(string $npub, string $dTag): ?Event
{
$dTag = trim($dTag);
if ($dTag === '') {
return null;
}
$key = MagazineEventKeys::magazineCuration30004($npub, $dTag);
if ($key === '') {
return null;
}
return $this->eventRepository->findOneByCoreRowKey($key);
}
public function putRoot(string $npub, string $dTag, Event $event): void
{
if ($dTag === '') {
@ -79,19 +65,6 @@ final class MagazineIndexStore @@ -79,19 +65,6 @@ final class MagazineIndexStore
$this->replaceByCoreKey($key, Event::STORAGE_MAGAZINE_CATEGORY, $event);
}
public function putCuration30004(string $npub, string $dTag, Event $event): void
{
$dTag = trim($dTag);
if ($dTag === '') {
return;
}
$key = MagazineEventKeys::magazineCuration30004($npub, $dTag);
if ($key === '') {
return;
}
$this->replaceByCoreKey($key, Event::STORAGE_MAGAZINE_CURATION_30004, $event);
}
public function deleteCategory(string $slug): void
{
if ($slug === '') {

30
src/Service/MagazineRefresher.php

@ -91,8 +91,6 @@ final class MagazineRefresher @@ -91,8 +91,6 @@ final class MagazineRefresher
$this->store->putRoot($npub, $dTag, $root);
$this->refreshCuration30004FromRelays($npub);
$deadline = microtime(true) + $budgetSeconds;
$mergedPrefer = $this->mergePreferSlugsInOrder($preferSlugs, $preferFromEnv);
@ -165,34 +163,6 @@ final class MagazineRefresher @@ -165,34 +163,6 @@ final class MagazineRefresher
$this->touchLastRelayTime();
}
/**
* Persists NIP-51 kind 30004 (home curation strip) when `d_tag_curation_set` is configured.
*/
private function refreshCuration30004FromRelays(string $npub): void
{
$d = trim((string) $this->params->get('d_tag_curation_set'));
if ($d === '' || strcasecmp($d, 'd-tag-goes-here') === 0) {
return;
}
try {
$ev = $this->nostrClient->getCurationSet30004($npub, $d);
if ($ev !== null) {
$this->store->putCuration30004($npub, $d, $ev);
try {
$this->nostrClient->persistCuration30004ReferencedItems($ev);
} catch (\Throwable $e2) {
$this->logger->warning('MagazineRefresher: curation 30004 referenced ingest failed', [
'message' => $e2->getMessage(),
]);
}
}
} catch (\Throwable $e) {
$this->logger->warning('MagazineRefresher: curation set 30004 fetch failed', [
'message' => $e->getMessage(),
]);
}
}
/**
* @throws InvalidArgumentException
*/

2
src/Service/Nip09DeletionApplier.php

@ -16,7 +16,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -16,7 +16,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Applies NIP-09 (kind 5) deletion requests to:
* - MySQL: long-form articles ({@see KindsEnum::LONGFORM} 30023, {@see KindsEnum::LONGFORM_DRAFT} 30024)
* - MySQL {@see Event} rows: kind 30040 magazine indices (root + category), kind 30004 home curation set,
* - MySQL {@see Event} rows: kind 30040 magazine indices (root + category), legacy kind 30004 curation rows,
* kind 0 profile, 10002 relay list, 10133 payto
*
* Handled for `e` tags (with `k` when present) and for NIP-33 `a` tags.

109
src/Service/NostrClient.php

@ -5,7 +5,6 @@ namespace App\Service; @@ -5,7 +5,6 @@ namespace App\Service;
use App\Entity\Article;
use App\Entity\Event as PublicationEventEntity;
use App\Enum\KindsEnum;
use App\Util\CurationSet30004Home;
use App\Factory\ArticleFactory;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
@ -1633,83 +1632,6 @@ class NostrClient @@ -1633,83 +1632,6 @@ class NostrClient
return $this->queryMagazineIndex($npub, $dTag, $pfSet, $relaysForLog2);
}
/**
* Latest NIP-51 kind 30004 curation set for this author and #d (parameterized replaceable).
*
* Relay strategy matches {@see getMagazineIndex}: article relays first, then profile relays only
* if nothing matched.
*/
public function getCurationSet30004(mixed $npub, string $dTag): ?PublicationEventEntity
{
$dTag = trim($dTag);
if ($dTag === '') {
return null;
}
$urls = $this->relayListFactory->getConfiguredArticleRelayUrlList();
$relaysForLog = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $urls));
$result = $this->queryCurationSet30004($npub, $dTag, $this->defaultRelaySet, $relaysForLog);
if ($result !== null) {
return $result;
}
$profileExtra = $this->relayListFactory->getProfileRelayUrlsExcludedFromArticleRelays();
if ($profileExtra === []) {
return null;
}
$pfSet = $this->relayListFactory->createRelaySetFromUrlsOnly($profileExtra);
$relaysForLog2 = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $profileExtra)).' (profile_relays)';
return $this->queryCurationSet30004($npub, $dTag, $pfSet, $relaysForLog2);
}
private function queryCurationSet30004(mixed $npub, string $dTag, RelaySet $relaySet, string $relaysForLog): ?PublicationEventEntity
{
$authorHex = $this->wireMerge->npubToHexPubkey($npub);
if ($authorHex === null) {
$this->logger->warning('Curation set 30004: could not resolve npub to hex pubkey', [
'npub' => $npub,
'dTag' => $dTag,
]);
return null;
}
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
relaySet: $relaySet,
kinds: [KindsEnum::CURATION_SET],
filters: ['authors' => [(string) $npub], 'tag' => ['#d', [$dTag]]],
);
$this->logger->info(sprintf('Curation set 30004 query (relays: %s)', $relaysForLog), [
'npub' => $npub,
'dTag' => $dTag,
'relays' => $relaysForLog,
]);
$response = $request->send();
$events = $this->nostrRelayQuery->processResponse($response, function ($received) {
return $received;
});
if ($events === []) {
return null;
}
$raw = $this->wireMerge->pickLatestNip33ParameterizedForQuery(
$events,
KindsEnum::CURATION_SET->value,
$authorHex,
$dTag
);
if ($raw === null) {
$this->logger->warning('Curation set 30004: no event matched NIP-33 address (kind:pubkey:d) after merge', [
'npub' => $npub,
'dTag' => $dTag,
'relays' => $relaysForLog,
'event_count' => \count($events),
]);
return null;
}
return $this->wireMerge->magazineEventToPublicationEntity($raw);
}
private function queryMagazineIndex(mixed $npub, mixed $dTag, RelaySet $relaySet, string $relaysForLog): ?PublicationEventEntity
{
$authorHex = $this->wireMerge->npubToHexPubkey($npub);
@ -2091,35 +2013,4 @@ class NostrClient @@ -2091,35 +2013,4 @@ class NostrClient
}
$this->logger->info('[longform_ingest] ingestLongform: done (all groups)');
}
/**
* After persisting NIP-51 kind 30004, ingest each listed **30023** `a` coordinate into {@see Article}.
* Non-30023 `a` tags and `e` tags are ignored at parse time ({@see CurationSet30004Home}).
*/
public function persistCuration30004ReferencedItems(PublicationEventEntity $curation30004): void
{
try {
$parsed = CurationSet30004Home::parseTitleAndOrderedRefs($curation30004->getTags());
} catch (\Throwable $e) {
$this->logger->warning('[curation_30004] parse refs failed', ['message' => $e->getMessage()]);
return;
}
$addresses = [];
foreach ($parsed['items'] as $it) {
$addresses[] = (string) KindsEnum::LONGFORM->value.':'.strtolower((string) $it['pk']).':'.trim((string) $it['slug']);
}
$addresses = array_values(array_unique($addresses));
if ($addresses === []) {
return;
}
try {
$this->ingestLongformForCategoryCoordinates($addresses);
} catch (\Throwable $e) {
$this->logger->warning('[curation_30004] longform ingest for curated list failed', [
'message' => $e->getMessage(),
'address_count' => \count($addresses),
]);
}
}
}

2
src/Service/NostrKind5DeletionFilter.php

@ -8,7 +8,7 @@ use App\Enum\KindsEnum; @@ -8,7 +8,7 @@ use App\Enum\KindsEnum;
/**
* NIP-09 kind-5: keep deletion events that may affect MySQL-backed rows (profile, relay list, payto,
* long-form, magazine 30040, home curation 30004). Skips thread/reply deletions to reduce relay payload.
* long-form, magazine 30040, legacy kind 30004). Skips thread/reply deletions to reduce relay payload.
*/
final class NostrKind5DeletionFilter
{

58
src/Util/CurationSet30004Home.php

@ -1,58 +0,0 @@ @@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Util;
use App\Enum\KindsEnum;
/**
* NIP-51 kind 30004 (curation set) for the home strip: ordered `a` tags for kind **30023** only.
* Other address kinds and `e` tags are ignored.
*/
final class CurationSet30004Home
{
/**
* @param iterable<int, mixed> $tags Event tag rows (Nostr JSON shape)
*
* @return array{title: string, items: list<array{type: 'article', pk: string, slug: string}>}
*/
public static function parseTitleAndOrderedRefs(iterable $tags): array
{
$title = '';
$items = [];
foreach ($tags as $tag) {
if (!\is_array($tag) || $tag === []) {
continue;
}
$name = isset($tag[0]) ? strtolower((string) $tag[0]) : '';
$v = isset($tag[1]) ? (string) $tag[1] : '';
if ($v === '') {
continue;
}
if ($name === 'title' && $title === '') {
$title = trim($v);
continue;
}
if ($name !== 'a') {
continue;
}
$parts = explode(':', $v, 3);
if (\count($parts) < 3) {
continue;
}
$kind = (int) $parts[0];
if ($kind !== KindsEnum::LONGFORM->value) {
continue;
}
$pk = strtolower(trim($parts[1]));
$slug = trim($parts[2]);
if (64 !== \strlen($pk) || !ctype_xdigit($pk) || $slug === '') {
continue;
}
$items[] = ['type' => 'article', 'pk' => $pk, 'slug' => $slug];
}
return ['title' => $title, 'items' => $items];
}
}

11
templates/components/Organisms/HomeCurationHeadlines.html.twig → templates/components/Organisms/HomeMagazineArticleStrip.html.twig

@ -1,19 +1,18 @@ @@ -1,19 +1,18 @@
{#
NIP-51 30004 home strip: section title from list `title` tag; each item reads like a static landing
section — illustration (max 400px, natural aspect), headline, then full article body (Markdown +
highlights as on /p/…/d/…). Body is not wrapped in a single <a> (markdown may contain links).
Home: magazine root kind 30040 — long-form `a` tags in index order (top to bottom). Section title from
root `title` tag. Same layout as before: illustration, headline, full article body (Markdown + highlights).
#}
{% if tiles is not empty %}
<section
class="home-curation-landmark"
{% if section_title|default('') != '' %}
aria-labelledby="home-curation-landmark-heading"
aria-labelledby="home-magazine-strip-heading"
{% else %}
aria-label="{{ (website_name ~ ' — curated articles')|e('html_attr') }}"
aria-label="{{ (website_name ~ ' — featured articles')|e('html_attr') }}"
{% endif %}
>
{% if section_title|default('') != '' %}
<h2 id="home-curation-landmark-heading" class="home-curation-landmark__title">{{ section_title|e }}</h2>
<h2 id="home-magazine-strip-heading" class="home-curation-landmark__title">{{ section_title|e }}</h2>
{% endif %}
<div class="home-curation-landmark__articles">
{% for tile in tiles %}

8
templates/home.html.twig

@ -40,10 +40,10 @@ @@ -40,10 +40,10 @@
{% block body %}
<div class="home-body home-body--wall">
{% if home_curation_tiles|default([]) is not empty %}
{% include 'components/Organisms/HomeCurationHeadlines.html.twig' with {
tiles: home_curation_tiles,
section_title: home_curation_heading|default(''),
{% if home_magazine_strip_tiles|default([]) is not empty %}
{% include 'components/Organisms/HomeMagazineArticleStrip.html.twig' with {
tiles: home_magazine_strip_tiles,
section_title: home_magazine_strip_heading|default(''),
} only %}
{% endif %}
{% include 'components/Organisms/FeaturedWall.html.twig' with { tiles: home_featured_tiles|default([]) } only %}

44
tests/Util/CurationSet30004HomeTest.php

@ -1,44 +0,0 @@ @@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Util;
use App\Util\CurationSet30004Home;
use PHPUnit\Framework\TestCase;
final class CurationSet30004HomeTest extends TestCase
{
public function testParsesTitleAndOrdered30023RefsOnly(): void
{
$eid = 'd78ba0d5dce22bfff9db0a9e996c9ef27e2c91051de0c4e1da340e0326b4941e';
$tags = [
['d', 'jvdy9i4'],
['title', 'Yaks'],
['image', 'https://example.com/y.jpg'],
['a', '30023:26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c:slug-one'],
['e', $eid],
['a', '30024:26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c:draft-only'],
['a', '30023:26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c:slug-two'],
];
$out = CurationSet30004Home::parseTitleAndOrderedRefs($tags);
self::assertSame('Yaks', $out['title']);
self::assertSame([
['type' => 'article', 'pk' => '26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c', 'slug' => 'slug-one'],
['type' => 'article', 'pk' => '26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c', 'slug' => 'slug-two'],
], $out['items']);
}
public function testSkipsNon30023ATags(): void
{
$tags = [
['a', '30040:26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c:mag'],
['a', '30023:26dc95542e18b8b7aec2f14610f55c335abebec76f3db9e58c254661d0593a0c:ok'],
];
$out = CurationSet30004Home::parseTitleAndOrderedRefs($tags);
self::assertSame('', $out['title']);
self::assertCount(1, $out['items']);
self::assertSame('article', $out['items'][0]['type']);
self::assertSame('ok', $out['items'][0]['slug']);
}
}
Loading…
Cancel
Save