@enhansome/core 1.10.1 → 1.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ import type { JsonOutput } from './markdown.js';
2
+ export type FirstSeen = (repoId: number) => string;
3
+ export declare function firstSeenFor(previous: JsonOutput | undefined, now: Date): FirstSeen;
@@ -0,0 +1,20 @@
1
+ export function firstSeenFor(previous, now) {
2
+ const index = new Map();
3
+ if (previous) {
4
+ for (const section of previous.items) {
5
+ collect(section.items, index);
6
+ }
7
+ }
8
+ return repoId => index.get(repoId) ?? now.toISOString();
9
+ }
10
+ function collect(nodes, index) {
11
+ for (const node of nodes) {
12
+ if (node.node_type === 'item' && node.first_seen) {
13
+ const existing = index.get(node.repo_info.id);
14
+ if (existing === undefined || node.first_seen < existing) {
15
+ index.set(node.repo_info.id, node.first_seen);
16
+ }
17
+ }
18
+ collect(node.children, index);
19
+ }
20
+ }
@@ -30,6 +30,7 @@ export declare function toRepoInfo(details: RepoInfoDetails): RepoInfo;
30
30
  export interface JsonItem {
31
31
  children: JsonNode[];
32
32
  description: null | string;
33
+ first_seen?: string;
33
34
  node_type: 'item';
34
35
  repo_info: RepoInfo;
35
36
  title: string;
@@ -54,7 +55,7 @@ export interface JsonSection {
54
55
  items: JsonNode[];
55
56
  title: string;
56
57
  }
57
- export declare function processMarkdownContent(originalContent: string, token: string, replacements?: ReplacementRule[], sortOptions?: SortOptions, relativeLinkPrefix?: string, enhancedRepository?: string, enhancedRepositoryDescription?: string, originalRepositorySha?: string, originalRepositoryInfo?: null | RepoInfoDetails, now?: Date, log?: Logger): Promise<{
58
+ export declare function processMarkdownContent(originalContent: string, token: string, replacements?: ReplacementRule[], sortOptions?: SortOptions, relativeLinkPrefix?: string, enhancedRepository?: string, enhancedRepositoryDescription?: string, originalRepositorySha?: string, originalRepositoryInfo?: null | RepoInfoDetails, previousJson?: JsonOutput, now?: Date, log?: Logger): Promise<{
58
59
  finalContent: string;
59
60
  jsonData: JsonOutput;
60
61
  }>;
package/dist/markdown.js CHANGED
@@ -4,6 +4,7 @@ import remarkParse from 'remark-parse';
4
4
  import remarkStringify from 'remark-stringify';
5
5
  import { unified } from 'unified';
6
6
  import { visit } from 'unist-util-visit';
7
+ import { firstSeenFor } from './first-seen.js';
7
8
  import { formatRequestError, getRepoInfo as fetchRepoInfo, makeOctokit, parseGitHubUrl, } from './github.js';
8
9
  import { consoleLog } from './logger.js';
9
10
  /** The sole crossing from the API shape to the emitted one, so both agree on the renames. */
@@ -79,7 +80,7 @@ async function fetchTargetData(urls, repos) {
79
80
  log.info(`Target fetch: ${repoInfoMap.size}/${urls.size} repo-info ok in ${Date.now() - fetchStart}ms (concurrency ${FETCH_CONCURRENCY}).`);
80
81
  return repoInfoMap;
81
82
  }
82
- export async function processMarkdownContent(originalContent, token, replacements = [], sortOptions = { by: '', minLinks: 2 }, relativeLinkPrefix = '', enhancedRepository, enhancedRepositoryDescription, originalRepositorySha, originalRepositoryInfo, now = new Date(), log = consoleLog) {
83
+ export async function processMarkdownContent(originalContent, token, replacements = [], sortOptions = { by: '', minLinks: 2 }, relativeLinkPrefix = '', enhancedRepository, enhancedRepositoryDescription, originalRepositorySha, originalRepositoryInfo, previousJson, now = new Date(), log = consoleLog) {
83
84
  const repos = createRepoInfoLookup(token, log);
84
85
  const brandingEnabled = replacements.some(rule => rule.type === 'branding');
85
86
  const contentAfterReplacements = applyTextReplacements(originalContent, replacements.filter(rule => rule.type !== 'branding'), log);
@@ -88,7 +89,8 @@ export async function processMarkdownContent(originalContent, token, replacement
88
89
  normalizeGitHubUrls(tree);
89
90
  const githubUrls = collectGitHubLinks(tree);
90
91
  const repoInfoMap = await fetchTargetData(githubUrls, repos);
91
- const { sections, title: rawTitle, titleHeadingIndex, } = processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo);
92
+ const firstSeen = firstSeenFor(previousJson, now);
93
+ const { sections, title: rawTitle, titleHeadingIndex, } = processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo, firstSeen);
92
94
  // Single source of truth for the document title: brand it once and use the
93
95
  // same value for the markdown H1 and metadata.title (parity).
94
96
  const title = brandingEnabled ? brandTitle(rawTitle) : rawTitle;
@@ -439,7 +441,7 @@ function splitEntryText(inlines) {
439
441
  // group — never a `repo_info`, that's the identity-borrowing bug; no own link
440
442
  // and no children is a non-GitHub leaf, kept in markdown, dropped from JSON.
441
443
  // TODO(future): preserve non-GitHub leaves in a separate shape.
442
- function emitEntryNodes(githubUrl, repoInfo, text, childrenJson) {
444
+ function emitEntryNodes(githubUrl, repoInfo, text, childrenJson, firstSeen) {
443
445
  if (githubUrl && repoInfo) {
444
446
  return [
445
447
  {
@@ -448,6 +450,7 @@ function emitEntryNodes(githubUrl, repoInfo, text, childrenJson) {
448
450
  description: text.description || null,
449
451
  children: childrenJson,
450
452
  repo_info: toRepoInfo(repoInfo),
453
+ first_seen: firstSeen(repoInfo.id),
451
454
  },
452
455
  ];
453
456
  }
@@ -466,7 +469,7 @@ function emitEntryNodes(githubUrl, repoInfo, text, childrenJson) {
466
469
  }
467
470
  return [];
468
471
  }
469
- function processListRecursively(listNode, repoInfoMap, sortOptions, isNested = false,
472
+ function processListRecursively(listNode, repoInfoMap, sortOptions, firstSeen, isNested = false,
470
473
  // The caller's section-scope gate decision (sectionGatePasses). Absent for
471
474
  // nested lists (emitted under their parent regardless) and for top-level
472
475
  // lists with no open container (preamble), which gate per list.
@@ -493,8 +496,8 @@ sectionGateOpen) {
493
496
  // regardless of the gate: the top-level call already gated the section.
494
497
  const nestedContent = itemNode.children.filter((child) => child.type === 'list' || child.type === 'table');
495
498
  const childrenJson = nestedContent.flatMap(child => child.type === 'list'
496
- ? processListRecursively(child, repoInfoMap, sortOptions, true)
497
- : processTableRows(child, repoInfoMap, true));
499
+ ? processListRecursively(child, repoInfoMap, sortOptions, firstSeen, true)
500
+ : processTableRows(child, repoInfoMap, true, firstSeen));
498
501
  // Title/description split on the FIRST paragraph only — a paper-list
499
502
  // entry's identity link may live in a later paragraph (findOwnGitHubLink)
500
503
  // while its title text stays the leading one.
@@ -508,7 +511,7 @@ sectionGateOpen) {
508
511
  // The shared title fallbacks (an inline-code link label carries no text
509
512
  // nodes, so the split alone can leave an empty title).
510
513
  entryText.title = entryTitle(entryText.title, ownLink, repoInfo);
511
- const emitted = emitEntryNodes(githubUrl, repoInfo, entryText, childrenJson);
514
+ const emitted = emitEntryNodes(githubUrl, repoInfo, entryText, childrenJson, firstSeen);
512
515
  entries.push({ emitted, node: itemNode, repoInfo });
513
516
  }
514
517
  if (sortOptions.by) {
@@ -536,18 +539,7 @@ function entryTitle(base, ownLink, repoInfo) {
536
539
  }
537
540
  return repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : base;
538
541
  }
539
- // Table rows are entries under the nearest open container. The common shape —
540
- // scala's `[name](repo) | description`, spec tables with the link in a later
541
- // column — holds ONE repo per row: the title is the first cell's text (the
542
- // own link's text, then the repo name, when the first cell is empty), the
543
- // description the remaining cells' text (badge images carry no text nodes, so
544
- // they never pollute it). Card grids pin several repos per row, each cell its
545
- // own card, so those emit one entry per link-bearing cell. A linked row above
546
- // the delimiter is content like any other (grid tables have no label header;
547
- // pure-label header rows carry no links and emit nothing). Rows stay in source
548
- // order — unlike the unranked lists the product sorts, a table's row order is
549
- // part of its meaning.
550
- function processTableRows(tableNode, repoInfoMap, gateOpen) {
542
+ function processTableRows(tableNode, repoInfoMap, gateOpen, firstSeen) {
551
543
  if (!gateOpen) {
552
544
  return [];
553
545
  }
@@ -589,7 +581,7 @@ function processTableRows(tableNode, repoInfoMap, gateOpen) {
589
581
  items.push(...emitEntryNodes(ownLink.url, repoInfo, {
590
582
  title: entryTitle(titleText.title, ownLink, repoInfo),
591
583
  description,
592
- }, []));
584
+ }, [], firstSeen));
593
585
  continue;
594
586
  }
595
587
  const emittedUrls = new Set();
@@ -603,34 +595,15 @@ function processTableRows(tableNode, repoInfoMap, gateOpen) {
603
595
  items.push(...emitEntryNodes(ownLink.url, repoInfo, {
604
596
  title: entryTitle(cellText.title, ownLink, repoInfo),
605
597
  description: cellText.description,
606
- }, []));
598
+ }, [], firstSeen));
607
599
  }
608
600
  }
609
601
  return items;
610
602
  }
611
- // A top-level paragraph can BE an entry, not only feed container prose. Two
612
- // corpus families qualify (progress/empty-tree-parses.md, step 4): a GitHub
613
- // link LEADING the paragraph behind a short entry label, or the paragraph
614
- // ending in a tag cluster whose GitHub link carries the identity — the
615
- // paper-list shape whose first link points at the paper and a [Code]/[Github]
616
- // tag at the end, name/author lines ending in that tag, and dated lines
617
- // ("… [Github] 4 Feb 2023"). Prose that mentions a repo ("Please see
618
- // CONTRIBUTING", "See also [repo]", intro text ending in a bare repo URL) is
619
- // neither and stays description. Calibrated on the 2,293-README corpus plus
620
- // the fixture fleet, not intuition: an entry label is a TAG (empty, CJK/emoji,
621
- // bracketed, or colon-terminated) — never bare English prose — and the
622
- // identity link must carry text, so the ubiquitous image-only awesome badge
623
- // is not an entry.
624
603
  const ENTRY_LABEL_MAX = 15;
625
604
  const ENTRY_TRAILING_MAX = 3;
626
605
  const TAG_LINK_TEXT = /^[\[\]()*\s:_-]*(?:source\s+code|code|github|repo|source|src|project|paper|page|web|site|home|official|notebook|demo|data|docs|implementation|arxiv)\b[\[\]()*\s:_-]*$/i;
627
606
  const URL_LINK_TEXT = /^https?:\/\/\S+$/i;
628
- // A title that names nothing — the degenerate families every entry source can
629
- // emit: a pure number/punctuation run (a table's rank or year column, "15."
630
- // / "2023" / "2025-05" / "-"), a URL (a link whose label is the URL itself,
631
- // scheme or scheme-less `github.com/…`), or a bare tag word ("GitHub",
632
- // "Source code" — best-of's generated lines). CJK labels are letters
633
- // (`\p{L}`) and stay titles.
634
607
  const URL_TITLE = /^(?:[a-z][a-z0-9+.-]*:\/\/|www\.)\S+$|^github\.com\/\S+$/i;
635
608
  function isDegenerateTitle(title) {
636
609
  const trimmed = title.trim();
@@ -639,9 +612,6 @@ function isDegenerateTitle(title) {
639
612
  TAG_LINK_TEXT.test(trimmed) ||
640
613
  !/[\p{L}]/u.test(trimmed)));
641
614
  }
642
- // A link label that can serve as a title. The degenerate shapes are excluded
643
- // twice over — a URL label stays a URL, and a tag label names the link's role
644
- // ("[Github](repo)"), not the repo.
645
615
  function isMeaningfulLinkText(text) {
646
616
  return text !== '' && !isDegenerateTitle(text);
647
617
  }
@@ -757,13 +727,13 @@ function blockquoteEntries(blockquote) {
757
727
  // The shared entry emission for the non-list sources (paragraph entries,
758
728
  // blockquote cards): resolve, split, title-fallback — the same decisions the
759
729
  // list and table paths make through the same helpers.
760
- function entryNodesFor(ownLink, inlines, repoInfoMap) {
730
+ function entryNodesFor(ownLink, inlines, repoInfoMap, firstSeen) {
761
731
  const repoInfo = repoInfoMap.get(ownLink.url) ?? null;
762
732
  const entryText = splitEntryText(inlines);
763
733
  return emitEntryNodes(ownLink.url, repoInfo, {
764
734
  title: entryTitle(entryText.title, ownLink, repoInfo),
765
735
  description: entryText.description,
766
- }, []);
736
+ }, [], firstSeen);
767
737
  }
768
738
  // The <details><summary>…</summary> collapsible-section idiom: the summary
769
739
  // text delimits structure like a heading would (java's generated README,
@@ -894,7 +864,7 @@ function applyBrandingToTree(tree, title, titleHeadingIndex) {
894
864
  tree.children.unshift(heading);
895
865
  }
896
866
  }
897
- function processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo) {
867
+ function processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo, firstSeen) {
898
868
  // Remember the title H1's index so branding replaces the exact same node;
899
869
  // scope matches branding/section-building so they can't drift apart.
900
870
  const titleHeadingIndex = findTitleHeadingIndex(tree);
@@ -925,7 +895,7 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo) {
925
895
  openImplicitSection(stack, atIndex);
926
896
  }
927
897
  if (gateForSection(stack[0])) {
928
- stack[stack.length - 1].children.push(...entryNodesFor(ownLink, inlines, repoInfoMap));
898
+ stack[stack.length - 1].children.push(...entryNodesFor(ownLink, inlines, repoInfoMap, firstSeen));
929
899
  }
930
900
  };
931
901
  // A standalone entry restating the repo of an open item container (crypto's
@@ -952,7 +922,7 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo) {
952
922
  const promotedEntry = headingEntry && getInlineText(headingEntry.link.children)
953
923
  ? headingEntry
954
924
  : null;
955
- closeContainers(stack, node.depth, sectionRecords, !!promotedEntry);
925
+ closeContainers(stack, node.depth, sectionRecords, firstSeen, !!promotedEntry);
956
926
  openContainer(stack, node, i, sectionDepth, promotedEntry, headingEntry);
957
927
  }
958
928
  else if (node.type === 'paragraph') {
@@ -1004,7 +974,7 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo) {
1004
974
  // section, which keeps collecting after the collapsible block.
1005
975
  const summaryTitle = detailsSummaryTitle(node.value);
1006
976
  if (summaryTitle) {
1007
- closeInnermostDetails(stack, sectionRecords);
977
+ closeInnermostDetails(stack, sectionRecords, firstSeen);
1008
978
  // Container depths never decrease going up the stack. When the open
1009
979
  // containers sit deeper than sectionDepth (a mid-document H1 defines
1010
980
  // sectionDepth while the content sections run deeper), the
@@ -1025,7 +995,7 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo) {
1025
995
  });
1026
996
  }
1027
997
  else if (DETAILS_CLOSE.test(node.value)) {
1028
- closeInnermostDetails(stack, sectionRecords);
998
+ closeInnermostDetails(stack, sectionRecords, firstSeen);
1029
999
  }
1030
1000
  }
1031
1001
  else if (node.type === 'list') {
@@ -1039,7 +1009,7 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo) {
1039
1009
  // Every list inside the open container contributes items — a section is
1040
1010
  // not closed by its first list — and the minLinks gate is decided per
1041
1011
  // section, against the whole section subtree.
1042
- const items = processListRecursively(node, repoInfoMap, sortOptions, false, gateForSection(stack[0]));
1012
+ const items = processListRecursively(node, repoInfoMap, sortOptions, firstSeen, false, gateForSection(stack[0]));
1043
1013
  stack[stack.length - 1].children.push(...items);
1044
1014
  }
1045
1015
  else if (node.type === 'table') {
@@ -1049,11 +1019,11 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo) {
1049
1019
  if (stack.length === 0) {
1050
1020
  openImplicitSection(stack, i);
1051
1021
  }
1052
- const items = processTableRows(node, repoInfoMap, gateForSection(stack[0]));
1022
+ const items = processTableRows(node, repoInfoMap, gateForSection(stack[0]), firstSeen);
1053
1023
  stack[stack.length - 1].children.push(...items);
1054
1024
  }
1055
1025
  }
1056
- closeContainers(stack, 0, sectionRecords);
1026
+ closeContainers(stack, 0, sectionRecords, firstSeen);
1057
1027
  const sections = sectionRecords
1058
1028
  .sort((a, b) => a.headingIndex - b.headingIndex)
1059
1029
  .map(record => record.section);
@@ -1314,7 +1284,7 @@ function sectionGatePasses(tree, titleSlotIndex, sectionDepth, repoInfoMap, sort
1314
1284
  // item-bearing nodes) is dropped; an item always survives, it IS the content.
1315
1285
  // Non-section containers always have an open parent (stack invariant), and
1316
1286
  // kind === 'item' exactly when repoInfo is set.
1317
- function finalizeContainer(container, stack, sectionRecords) {
1287
+ function finalizeContainer(container, stack, sectionRecords, firstSeen) {
1318
1288
  if (container.children.length === 0 && container.kind !== 'item') {
1319
1289
  return;
1320
1290
  }
@@ -1334,6 +1304,7 @@ function finalizeContainer(container, stack, sectionRecords) {
1334
1304
  node_type: 'item',
1335
1305
  repo_info: toRepoInfo(container.repoInfo),
1336
1306
  title: container.title,
1307
+ first_seen: firstSeen(container.repoInfo.id),
1337
1308
  });
1338
1309
  }
1339
1310
  else {
@@ -1351,13 +1322,13 @@ function finalizeContainer(container, stack, sectionRecords) {
1351
1322
  // group/item always has a parent to land in. stopAtSynthesized: the pop an
1352
1323
  // entry heading triggers must stop at the synthesized section wrapping its
1353
1324
  // run — the next entry heading of the run lands back inside it.
1354
- function closeContainers(stack, depth, sectionRecords, stopAtSynthesized = false) {
1325
+ function closeContainers(stack, depth, sectionRecords, firstSeen, stopAtSynthesized = false) {
1355
1326
  while (stack.length > 0 &&
1356
1327
  stack[stack.length - 1].headingDepth >= depth) {
1357
1328
  if (stopAtSynthesized && stack[stack.length - 1].openedBySynthesis) {
1358
1329
  break;
1359
1330
  }
1360
- finalizeContainer(stack.pop(), stack, sectionRecords);
1331
+ finalizeContainer(stack.pop(), stack, sectionRecords, firstSeen);
1361
1332
  }
1362
1333
  }
1363
1334
  // Ends the innermost open details-section and everything opened inside it,
@@ -1365,7 +1336,7 @@ function closeContainers(stack, depth, sectionRecords, stopAtSynthesized = false
1365
1336
  // details boundary never ends its parent section, so content after the
1366
1337
  // collapsible block keeps collecting under it. A stray </details> (no
1367
1338
  // details-section open) is a no-op.
1368
- function closeInnermostDetails(stack, sectionRecords) {
1339
+ function closeInnermostDetails(stack, sectionRecords, firstSeen) {
1369
1340
  let detailsIndex = -1;
1370
1341
  for (let s = stack.length - 1; s >= 0; s--) {
1371
1342
  if (stack[s].openedByDetails) {
@@ -1377,7 +1348,7 @@ function closeInnermostDetails(stack, sectionRecords) {
1377
1348
  return;
1378
1349
  }
1379
1350
  while (stack.length > detailsIndex) {
1380
- finalizeContainer(stack.pop(), stack, sectionRecords);
1351
+ finalizeContainer(stack.pop(), stack, sectionRecords, firstSeen);
1381
1352
  }
1382
1353
  }
1383
1354
  function serializeAst(tree, originalContent) {
@@ -6,11 +6,11 @@ export interface EnhanceOptions {
6
6
  disableBranding?: boolean;
7
7
  enhancedRepository?: string;
8
8
  enhancedRepositoryDescription?: string;
9
- /** Defaults to the console sink; pass your own (e.g. an Actions workflow-command sink) to route diagnostics. */
10
9
  log?: Logger;
11
10
  now?: Date;
12
11
  originalRepositoryInfo?: null | RepoInfoDetails;
13
12
  originalRepositorySha?: string;
13
+ previousJson?: JsonOutput;
14
14
  relativeLinkPrefix?: string;
15
15
  replacements?: ReplacementRule[];
16
16
  sortBy?: '' | 'last_commit' | 'stars';
@@ -1,6 +1,6 @@
1
1
  import { processMarkdownContent, } from './markdown.js';
2
2
  export async function enhance(options) {
3
- const { content, disableBranding = false, log, now = new Date(), originalRepositoryInfo, originalRepositorySha, relativeLinkPrefix = '', replacements = [], sortBy = '', enhancedRepository, enhancedRepositoryDescription, token, } = options;
3
+ const { content, disableBranding = false, log, now = new Date(), originalRepositoryInfo, originalRepositorySha, previousJson, relativeLinkPrefix = '', replacements = [], sortBy = '', enhancedRepository, enhancedRepositoryDescription, token, } = options;
4
4
  // Branding is an internal rule prepended to the caller's own; build a fresh
5
5
  // array so the caller's `replacements` is never mutated.
6
6
  const branding = { type: 'branding' };
@@ -9,7 +9,7 @@ export async function enhance(options) {
9
9
  by: sortBy,
10
10
  minLinks: 2,
11
11
  };
12
- const { finalContent, jsonData } = await processMarkdownContent(content, token, rules, sortOptions, relativeLinkPrefix, enhancedRepository, enhancedRepositoryDescription, originalRepositorySha, originalRepositoryInfo, now, log);
12
+ const { finalContent, jsonData } = await processMarkdownContent(content, token, rules, sortOptions, relativeLinkPrefix, enhancedRepository, enhancedRepositoryDescription, originalRepositorySha, originalRepositoryInfo, previousJson, now, log);
13
13
  return {
14
14
  finalContent,
15
15
  jsonData,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enhansome/core",
3
- "version": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "description": "Library core for enhansome — enhance markdown with GitHub star counts.",
5
5
  "repository": {
6
6
  "type": "git",