📄 src/selectors.ts
export interface IssueRef {
  titleId: string;
  issueId: string;
}

/**
 * Primary selector: the reader's own URL contract (stable across site builds):
 *
 *   /p/<title-slug>/<date>/p/<title_id>/<issue_id>                    (default view)
 *   /p/<title-slug>/<date>/r/<part>/<spread>/<title_id>/<issue_id>    (replica view)
 *
 * The issue id is always the last path segment, the title id second to last.
 * Article links (/a/…) are intentionally not matched.
 */
const ISSUE_HREF = /^\/p\/[^/]+\/\d{4}-\d{2}-\d{2}\/(?:p|r)(?:\/\d+)*\/(\d+)\/(\d+)\/?$/;

/** Article-mode links are never issue links, even in the loose fallback. */
const ARTICLE_HREF = /^\/p\/[^/]+\/\d{4}-\d{2}-\d{2}\/a\//;

/**
 * Fallback classes observed on the site (Nov 2026). styled-components class
 * names regenerate on every build of the site, so these are only a secondary
 * signal — the href pattern above is the main selector.
 */
const FALLBACK_CELL_CLASS = 'sc-djvmMF';
const FALLBACK_LINK_CLASS = 'sc-iTONeN';

/** Loose shape: /p/… ending in two numeric segments (title id + issue id). */
const LOOSE_ISSUE_HREF = /^\/p\/.+\/(\d+)\/(\d+)\/?$/;

export function parseIssueAnchor(anchor: HTMLAnchorElement): IssueRef | null {
  const href = anchor.getAttribute('href');
  if (!href) return null;
  const path = href.split(/[?#]/, 1)[0] ?? href;

  const strict = ISSUE_HREF.exec(path);
  if (strict) {
    return { titleId: strict[1] ?? '', issueId: strict[2] ?? '' };
  }

  if (ARTICLE_HREF.test(path)) return null;

  // Fallback for future URL tweaks: same rough shape + known carousel classes.
  const hasClassSignal =
    anchor.classList.contains(FALLBACK_LINK_CLASS) ||
    anchor.closest(`.${FALLBACK_CELL_CLASS}`) !== null;
  if (hasClassSignal) {
    const loose = LOOSE_ISSUE_HREF.exec(path);
    if (loose) {
      return { titleId: loose[1] ?? '', issueId: loose[2] ?? '' };
    }
  }
  return null;
}