@enhansome/core 1.10.0 → 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
+ }
package/dist/github.d.ts CHANGED
@@ -77,11 +77,12 @@ export interface MakeOctokitOptions {
77
77
  export declare function makeOctokit(token: string, { log, maxRetries, maxWaitSeconds, throttle, }?: MakeOctokitOptions): GithubClient;
78
78
  export declare function getRepoInfo(octokit: GithubClient, owner: string, repo: string): Promise<RepoInfoDetails>;
79
79
  /**
80
- * The repo's numeric GitHub id, for the JSON metadata — consumers key stable
81
- * node ids on it. Returns null (logged) instead of failing the run: the id is
82
- * metadata, not a gate; consumers fall back when it is missing.
80
+ * The repo's full info, for the JSON metadata — the numeric id consumers key
81
+ * stable node ids on, plus the stars/language/last_commit the root's own
82
+ * metadata row is built from. Returns null (logged) instead of failing the
83
+ * run: metadata is not a gate; consumers fall back when it is missing.
83
84
  */
84
- export declare function getRepoId(octokit: GithubClient, owner: string, repo: string): Promise<null | number>;
85
+ export declare function getRepoInfoOrNull(octokit: GithubClient, owner: string, repo: string): Promise<null | RepoInfoDetails>;
85
86
  export declare function getReadme(octokit: GithubClient, owner: string, repo: string, format?: 'html' | 'raw'): Promise<string>;
86
87
  /** Root file/directory names — the compile-manifest gate reads them to tell a
87
88
  * directory of resources from a repo that IS the deliverable. A `path: ''`
package/dist/github.js CHANGED
@@ -83,16 +83,17 @@ export async function getRepoInfo(octokit, owner, repo) {
83
83
  };
84
84
  }
85
85
  /**
86
- * The repo's numeric GitHub id, for the JSON metadata — consumers key stable
87
- * node ids on it. Returns null (logged) instead of failing the run: the id is
88
- * metadata, not a gate; consumers fall back when it is missing.
86
+ * The repo's full info, for the JSON metadata — the numeric id consumers key
87
+ * stable node ids on, plus the stars/language/last_commit the root's own
88
+ * metadata row is built from. Returns null (logged) instead of failing the
89
+ * run: metadata is not a gate; consumers fall back when it is missing.
89
90
  */
90
- export async function getRepoId(octokit, owner, repo) {
91
+ export async function getRepoInfoOrNull(octokit, owner, repo) {
91
92
  try {
92
- return (await getRepoInfo(octokit, owner, repo)).id;
93
+ return await getRepoInfo(octokit, owner, repo);
93
94
  }
94
95
  catch (error) {
95
- octokit.log.error(`Failed to fetch repo id for ${owner}/${repo}: ${formatRequestError(error)}`);
96
+ octokit.log.error(`Failed to fetch repo info for ${owner}/${repo}: ${formatRequestError(error)}`);
96
97
  return null;
97
98
  }
98
99
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { formatRequestError, getLatestCommitSha, getReadme, getRepoId, getRepoInfo, getRootEntryNames, makeOctokit, parseGitHubUrl, parseOwnerRepo, } from './github.js';
1
+ export { formatRequestError, getLatestCommitSha, getReadme, getRepoInfo, getRepoInfoOrNull, getRootEntryNames, makeOctokit, parseGitHubUrl, parseOwnerRepo, } from './github.js';
2
2
  export type { GithubClient, MakeOctokitOptions, RepoIdentifier, RepoInfoDetails, ThrottleOptions, } from './github.js';
3
3
  export type { Logger } from './logger.js';
4
4
  export { consoleLog, silentLog } from './logger.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { formatRequestError, getLatestCommitSha, getReadme, getRepoId, getRepoInfo, getRootEntryNames, makeOctokit, parseGitHubUrl, parseOwnerRepo, } from './github.js';
1
+ export { formatRequestError, getLatestCommitSha, getReadme, getRepoInfo, getRepoInfoOrNull, getRootEntryNames, makeOctokit, parseGitHubUrl, parseOwnerRepo, } from './github.js';
2
2
  export { consoleLog, silentLog } from './logger.js';
3
3
  export { toRepoInfo } from './markdown.js';
4
4
  export { enhance } from './orchestrator.js';
@@ -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;
@@ -45,8 +46,7 @@ export interface JsonMetadata {
45
46
  enhanced_repository: null | string;
46
47
  enhanced_repository_description: null | string;
47
48
  last_updated: string;
48
- original_repository: string;
49
- original_repository_id: null | number;
49
+ original_repository_info: null | RepoInfo;
50
50
  original_repository_sha: null | string;
51
51
  title: string;
52
52
  }
@@ -55,7 +55,7 @@ export interface JsonSection {
55
55
  items: JsonNode[];
56
56
  title: string;
57
57
  }
58
- export declare function processMarkdownContent(originalContent: string, token: string, replacements: ReplacementRule[] | undefined, sortOptions: SortOptions | undefined, originalRepository: string, relativeLinkPrefix?: string, enhancedRepository?: string, enhancedRepositoryDescription?: string, originalRepositorySha?: string, originalRepositoryId?: number, 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<{
59
59
  finalContent: string;
60
60
  jsonData: JsonOutput;
61
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 }, originalRepository, relativeLinkPrefix = '', enhancedRepository, enhancedRepositoryDescription, originalRepositorySha, originalRepositoryId, 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,9 +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
- // The title derives from the *source* repository (originalRepository), never
92
- // the enhanced/mirror repo — otherwise the org name doubles into the title.
93
- const { sections, title: rawTitle, titleHeadingIndex, } = processTree(tree, repoInfoMap, sortOptions, originalRepository);
92
+ const firstSeen = firstSeenFor(previousJson, now);
93
+ const { sections, title: rawTitle, titleHeadingIndex, } = processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo, firstSeen);
94
94
  // Single source of truth for the document title: brand it once and use the
95
95
  // same value for the markdown H1 and metadata.title (parity).
96
96
  const title = brandingEnabled ? brandTitle(rawTitle) : rawTitle;
@@ -99,8 +99,9 @@ export async function processMarkdownContent(originalContent, token, replacement
99
99
  }
100
100
  const metadata = {
101
101
  last_updated: now.toISOString(),
102
- original_repository: originalRepository.trim(),
103
- original_repository_id: originalRepositoryId ?? null,
102
+ original_repository_info: originalRepositoryInfo
103
+ ? toRepoInfo(originalRepositoryInfo)
104
+ : null,
104
105
  original_repository_sha: (originalRepositorySha?.trim() ?? '') || null,
105
106
  enhanced_repository: (enhancedRepository?.trim() ?? '') || null,
106
107
  enhanced_repository_description: (enhancedRepositoryDescription?.trim() ?? '') || null,
@@ -440,7 +441,7 @@ function splitEntryText(inlines) {
440
441
  // group — never a `repo_info`, that's the identity-borrowing bug; no own link
441
442
  // and no children is a non-GitHub leaf, kept in markdown, dropped from JSON.
442
443
  // TODO(future): preserve non-GitHub leaves in a separate shape.
443
- function emitEntryNodes(githubUrl, repoInfo, text, childrenJson) {
444
+ function emitEntryNodes(githubUrl, repoInfo, text, childrenJson, firstSeen) {
444
445
  if (githubUrl && repoInfo) {
445
446
  return [
446
447
  {
@@ -449,6 +450,7 @@ function emitEntryNodes(githubUrl, repoInfo, text, childrenJson) {
449
450
  description: text.description || null,
450
451
  children: childrenJson,
451
452
  repo_info: toRepoInfo(repoInfo),
453
+ first_seen: firstSeen(repoInfo.id),
452
454
  },
453
455
  ];
454
456
  }
@@ -467,7 +469,7 @@ function emitEntryNodes(githubUrl, repoInfo, text, childrenJson) {
467
469
  }
468
470
  return [];
469
471
  }
470
- function processListRecursively(listNode, repoInfoMap, sortOptions, isNested = false,
472
+ function processListRecursively(listNode, repoInfoMap, sortOptions, firstSeen, isNested = false,
471
473
  // The caller's section-scope gate decision (sectionGatePasses). Absent for
472
474
  // nested lists (emitted under their parent regardless) and for top-level
473
475
  // lists with no open container (preamble), which gate per list.
@@ -494,8 +496,8 @@ sectionGateOpen) {
494
496
  // regardless of the gate: the top-level call already gated the section.
495
497
  const nestedContent = itemNode.children.filter((child) => child.type === 'list' || child.type === 'table');
496
498
  const childrenJson = nestedContent.flatMap(child => child.type === 'list'
497
- ? processListRecursively(child, repoInfoMap, sortOptions, true)
498
- : processTableRows(child, repoInfoMap, true));
499
+ ? processListRecursively(child, repoInfoMap, sortOptions, firstSeen, true)
500
+ : processTableRows(child, repoInfoMap, true, firstSeen));
499
501
  // Title/description split on the FIRST paragraph only — a paper-list
500
502
  // entry's identity link may live in a later paragraph (findOwnGitHubLink)
501
503
  // while its title text stays the leading one.
@@ -509,7 +511,7 @@ sectionGateOpen) {
509
511
  // The shared title fallbacks (an inline-code link label carries no text
510
512
  // nodes, so the split alone can leave an empty title).
511
513
  entryText.title = entryTitle(entryText.title, ownLink, repoInfo);
512
- const emitted = emitEntryNodes(githubUrl, repoInfo, entryText, childrenJson);
514
+ const emitted = emitEntryNodes(githubUrl, repoInfo, entryText, childrenJson, firstSeen);
513
515
  entries.push({ emitted, node: itemNode, repoInfo });
514
516
  }
515
517
  if (sortOptions.by) {
@@ -537,18 +539,7 @@ function entryTitle(base, ownLink, repoInfo) {
537
539
  }
538
540
  return repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : base;
539
541
  }
540
- // Table rows are entries under the nearest open container. The common shape —
541
- // scala's `[name](repo) | description`, spec tables with the link in a later
542
- // column — holds ONE repo per row: the title is the first cell's text (the
543
- // own link's text, then the repo name, when the first cell is empty), the
544
- // description the remaining cells' text (badge images carry no text nodes, so
545
- // they never pollute it). Card grids pin several repos per row, each cell its
546
- // own card, so those emit one entry per link-bearing cell. A linked row above
547
- // the delimiter is content like any other (grid tables have no label header;
548
- // pure-label header rows carry no links and emit nothing). Rows stay in source
549
- // order — unlike the unranked lists the product sorts, a table's row order is
550
- // part of its meaning.
551
- function processTableRows(tableNode, repoInfoMap, gateOpen) {
542
+ function processTableRows(tableNode, repoInfoMap, gateOpen, firstSeen) {
552
543
  if (!gateOpen) {
553
544
  return [];
554
545
  }
@@ -590,7 +581,7 @@ function processTableRows(tableNode, repoInfoMap, gateOpen) {
590
581
  items.push(...emitEntryNodes(ownLink.url, repoInfo, {
591
582
  title: entryTitle(titleText.title, ownLink, repoInfo),
592
583
  description,
593
- }, []));
584
+ }, [], firstSeen));
594
585
  continue;
595
586
  }
596
587
  const emittedUrls = new Set();
@@ -604,34 +595,15 @@ function processTableRows(tableNode, repoInfoMap, gateOpen) {
604
595
  items.push(...emitEntryNodes(ownLink.url, repoInfo, {
605
596
  title: entryTitle(cellText.title, ownLink, repoInfo),
606
597
  description: cellText.description,
607
- }, []));
598
+ }, [], firstSeen));
608
599
  }
609
600
  }
610
601
  return items;
611
602
  }
612
- // A top-level paragraph can BE an entry, not only feed container prose. Two
613
- // corpus families qualify (progress/empty-tree-parses.md, step 4): a GitHub
614
- // link LEADING the paragraph behind a short entry label, or the paragraph
615
- // ending in a tag cluster whose GitHub link carries the identity — the
616
- // paper-list shape whose first link points at the paper and a [Code]/[Github]
617
- // tag at the end, name/author lines ending in that tag, and dated lines
618
- // ("… [Github] 4 Feb 2023"). Prose that mentions a repo ("Please see
619
- // CONTRIBUTING", "See also [repo]", intro text ending in a bare repo URL) is
620
- // neither and stays description. Calibrated on the 2,293-README corpus plus
621
- // the fixture fleet, not intuition: an entry label is a TAG (empty, CJK/emoji,
622
- // bracketed, or colon-terminated) — never bare English prose — and the
623
- // identity link must carry text, so the ubiquitous image-only awesome badge
624
- // is not an entry.
625
603
  const ENTRY_LABEL_MAX = 15;
626
604
  const ENTRY_TRAILING_MAX = 3;
627
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;
628
606
  const URL_LINK_TEXT = /^https?:\/\/\S+$/i;
629
- // A title that names nothing — the degenerate families every entry source can
630
- // emit: a pure number/punctuation run (a table's rank or year column, "15."
631
- // / "2023" / "2025-05" / "-"), a URL (a link whose label is the URL itself,
632
- // scheme or scheme-less `github.com/…`), or a bare tag word ("GitHub",
633
- // "Source code" — best-of's generated lines). CJK labels are letters
634
- // (`\p{L}`) and stay titles.
635
607
  const URL_TITLE = /^(?:[a-z][a-z0-9+.-]*:\/\/|www\.)\S+$|^github\.com\/\S+$/i;
636
608
  function isDegenerateTitle(title) {
637
609
  const trimmed = title.trim();
@@ -640,9 +612,6 @@ function isDegenerateTitle(title) {
640
612
  TAG_LINK_TEXT.test(trimmed) ||
641
613
  !/[\p{L}]/u.test(trimmed)));
642
614
  }
643
- // A link label that can serve as a title. The degenerate shapes are excluded
644
- // twice over — a URL label stays a URL, and a tag label names the link's role
645
- // ("[Github](repo)"), not the repo.
646
615
  function isMeaningfulLinkText(text) {
647
616
  return text !== '' && !isDegenerateTitle(text);
648
617
  }
@@ -758,13 +727,13 @@ function blockquoteEntries(blockquote) {
758
727
  // The shared entry emission for the non-list sources (paragraph entries,
759
728
  // blockquote cards): resolve, split, title-fallback — the same decisions the
760
729
  // list and table paths make through the same helpers.
761
- function entryNodesFor(ownLink, inlines, repoInfoMap) {
730
+ function entryNodesFor(ownLink, inlines, repoInfoMap, firstSeen) {
762
731
  const repoInfo = repoInfoMap.get(ownLink.url) ?? null;
763
732
  const entryText = splitEntryText(inlines);
764
733
  return emitEntryNodes(ownLink.url, repoInfo, {
765
734
  title: entryTitle(entryText.title, ownLink, repoInfo),
766
735
  description: entryText.description,
767
- }, []);
736
+ }, [], firstSeen);
768
737
  }
769
738
  // The <details><summary>…</summary> collapsible-section idiom: the summary
770
739
  // text delimits structure like a heading would (java's generated README,
@@ -812,22 +781,6 @@ const INVALID_TITLE_PATTERNS = [
812
781
  /^video$/i,
813
782
  /^science/i,
814
783
  ];
815
- function repoNameFromIdentifier(identifier) {
816
- const trimmed = identifier.trim();
817
- if (!trimmed) {
818
- return '';
819
- }
820
- if (/^https?:\/\//i.test(trimmed)) {
821
- const parts = trimmed
822
- .replace(/\.git$/i, '')
823
- .replace(/[?#].*$/, '')
824
- .split('/')
825
- .filter(Boolean);
826
- return parts[parts.length - 1] ?? '';
827
- }
828
- const slashIndex = trimmed.lastIndexOf('/');
829
- return slashIndex === -1 ? trimmed : trimmed.slice(slashIndex + 1);
830
- }
831
784
  function formatRepoNameAsTitle(repoName) {
832
785
  const cleaned = repoName.replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim();
833
786
  if (/^awesome\s+/i.test(cleaned)) {
@@ -911,7 +864,7 @@ function applyBrandingToTree(tree, title, titleHeadingIndex) {
911
864
  tree.children.unshift(heading);
912
865
  }
913
866
  }
914
- function processTree(tree, repoInfoMap, sortOptions, originalRepository) {
867
+ function processTree(tree, repoInfoMap, sortOptions, originalRepositoryInfo, firstSeen) {
915
868
  // Remember the title H1's index so branding replaces the exact same node;
916
869
  // scope matches branding/section-building so they can't drift apart.
917
870
  const titleHeadingIndex = findTitleHeadingIndex(tree);
@@ -922,11 +875,8 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepository) {
922
875
  // Derive a subject from the *source* repository name when no valid H1 is
923
876
  // present. Using the source — not the enhanced/mirror repo — keeps the org
924
877
  // name out of the title.
925
- if (documentTitle === '' && originalRepository) {
926
- const repoName = repoNameFromIdentifier(originalRepository);
927
- if (repoName) {
928
- documentTitle = formatRepoNameAsTitle(repoName);
929
- }
878
+ if (documentTitle === '' && originalRepositoryInfo) {
879
+ documentTitle = formatRepoNameAsTitle(originalRepositoryInfo.repo);
930
880
  }
931
881
  const sectionDepth = findSectionDepth(tree, titleSlotIndex);
932
882
  const gateForSection = sectionGatePasses(tree, titleSlotIndex, sectionDepth, repoInfoMap, sortOptions);
@@ -945,7 +895,7 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepository) {
945
895
  openImplicitSection(stack, atIndex);
946
896
  }
947
897
  if (gateForSection(stack[0])) {
948
- stack[stack.length - 1].children.push(...entryNodesFor(ownLink, inlines, repoInfoMap));
898
+ stack[stack.length - 1].children.push(...entryNodesFor(ownLink, inlines, repoInfoMap, firstSeen));
949
899
  }
950
900
  };
951
901
  // A standalone entry restating the repo of an open item container (crypto's
@@ -972,7 +922,7 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepository) {
972
922
  const promotedEntry = headingEntry && getInlineText(headingEntry.link.children)
973
923
  ? headingEntry
974
924
  : null;
975
- closeContainers(stack, node.depth, sectionRecords, !!promotedEntry);
925
+ closeContainers(stack, node.depth, sectionRecords, firstSeen, !!promotedEntry);
976
926
  openContainer(stack, node, i, sectionDepth, promotedEntry, headingEntry);
977
927
  }
978
928
  else if (node.type === 'paragraph') {
@@ -1024,7 +974,7 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepository) {
1024
974
  // section, which keeps collecting after the collapsible block.
1025
975
  const summaryTitle = detailsSummaryTitle(node.value);
1026
976
  if (summaryTitle) {
1027
- closeInnermostDetails(stack, sectionRecords);
977
+ closeInnermostDetails(stack, sectionRecords, firstSeen);
1028
978
  // Container depths never decrease going up the stack. When the open
1029
979
  // containers sit deeper than sectionDepth (a mid-document H1 defines
1030
980
  // sectionDepth while the content sections run deeper), the
@@ -1045,7 +995,7 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepository) {
1045
995
  });
1046
996
  }
1047
997
  else if (DETAILS_CLOSE.test(node.value)) {
1048
- closeInnermostDetails(stack, sectionRecords);
998
+ closeInnermostDetails(stack, sectionRecords, firstSeen);
1049
999
  }
1050
1000
  }
1051
1001
  else if (node.type === 'list') {
@@ -1059,7 +1009,7 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepository) {
1059
1009
  // Every list inside the open container contributes items — a section is
1060
1010
  // not closed by its first list — and the minLinks gate is decided per
1061
1011
  // section, against the whole section subtree.
1062
- const items = processListRecursively(node, repoInfoMap, sortOptions, false, gateForSection(stack[0]));
1012
+ const items = processListRecursively(node, repoInfoMap, sortOptions, firstSeen, false, gateForSection(stack[0]));
1063
1013
  stack[stack.length - 1].children.push(...items);
1064
1014
  }
1065
1015
  else if (node.type === 'table') {
@@ -1069,11 +1019,11 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepository) {
1069
1019
  if (stack.length === 0) {
1070
1020
  openImplicitSection(stack, i);
1071
1021
  }
1072
- const items = processTableRows(node, repoInfoMap, gateForSection(stack[0]));
1022
+ const items = processTableRows(node, repoInfoMap, gateForSection(stack[0]), firstSeen);
1073
1023
  stack[stack.length - 1].children.push(...items);
1074
1024
  }
1075
1025
  }
1076
- closeContainers(stack, 0, sectionRecords);
1026
+ closeContainers(stack, 0, sectionRecords, firstSeen);
1077
1027
  const sections = sectionRecords
1078
1028
  .sort((a, b) => a.headingIndex - b.headingIndex)
1079
1029
  .map(record => record.section);
@@ -1334,7 +1284,7 @@ function sectionGatePasses(tree, titleSlotIndex, sectionDepth, repoInfoMap, sort
1334
1284
  // item-bearing nodes) is dropped; an item always survives, it IS the content.
1335
1285
  // Non-section containers always have an open parent (stack invariant), and
1336
1286
  // kind === 'item' exactly when repoInfo is set.
1337
- function finalizeContainer(container, stack, sectionRecords) {
1287
+ function finalizeContainer(container, stack, sectionRecords, firstSeen) {
1338
1288
  if (container.children.length === 0 && container.kind !== 'item') {
1339
1289
  return;
1340
1290
  }
@@ -1354,6 +1304,7 @@ function finalizeContainer(container, stack, sectionRecords) {
1354
1304
  node_type: 'item',
1355
1305
  repo_info: toRepoInfo(container.repoInfo),
1356
1306
  title: container.title,
1307
+ first_seen: firstSeen(container.repoInfo.id),
1357
1308
  });
1358
1309
  }
1359
1310
  else {
@@ -1371,13 +1322,13 @@ function finalizeContainer(container, stack, sectionRecords) {
1371
1322
  // group/item always has a parent to land in. stopAtSynthesized: the pop an
1372
1323
  // entry heading triggers must stop at the synthesized section wrapping its
1373
1324
  // run — the next entry heading of the run lands back inside it.
1374
- function closeContainers(stack, depth, sectionRecords, stopAtSynthesized = false) {
1325
+ function closeContainers(stack, depth, sectionRecords, firstSeen, stopAtSynthesized = false) {
1375
1326
  while (stack.length > 0 &&
1376
1327
  stack[stack.length - 1].headingDepth >= depth) {
1377
1328
  if (stopAtSynthesized && stack[stack.length - 1].openedBySynthesis) {
1378
1329
  break;
1379
1330
  }
1380
- finalizeContainer(stack.pop(), stack, sectionRecords);
1331
+ finalizeContainer(stack.pop(), stack, sectionRecords, firstSeen);
1381
1332
  }
1382
1333
  }
1383
1334
  // Ends the innermost open details-section and everything opened inside it,
@@ -1385,7 +1336,7 @@ function closeContainers(stack, depth, sectionRecords, stopAtSynthesized = false
1385
1336
  // details boundary never ends its parent section, so content after the
1386
1337
  // collapsible block keeps collecting under it. A stray </details> (no
1387
1338
  // details-section open) is a no-op.
1388
- function closeInnermostDetails(stack, sectionRecords) {
1339
+ function closeInnermostDetails(stack, sectionRecords, firstSeen) {
1389
1340
  let detailsIndex = -1;
1390
1341
  for (let s = stack.length - 1; s >= 0; s--) {
1391
1342
  if (stack[s].openedByDetails) {
@@ -1397,7 +1348,7 @@ function closeInnermostDetails(stack, sectionRecords) {
1397
1348
  return;
1398
1349
  }
1399
1350
  while (stack.length > detailsIndex) {
1400
- finalizeContainer(stack.pop(), stack, sectionRecords);
1351
+ finalizeContainer(stack.pop(), stack, sectionRecords, firstSeen);
1401
1352
  }
1402
1353
  }
1403
1354
  function serializeAst(tree, originalContent) {
@@ -1,18 +1,17 @@
1
1
  import { Logger } from './logger.js';
2
+ import type { RepoInfoDetails } from './github.js';
2
3
  import { JsonOutput, ReplacementRule } from './markdown.js';
3
4
  export interface EnhanceOptions {
4
5
  content: string;
5
6
  disableBranding?: boolean;
6
7
  enhancedRepository?: string;
7
8
  enhancedRepositoryDescription?: string;
8
- /** Defaults to the console sink; pass your own (e.g. an Actions workflow-command sink) to route diagnostics. */
9
9
  log?: Logger;
10
10
  now?: Date;
11
- originalRepository: string;
12
- originalRepositoryId?: number;
11
+ originalRepositoryInfo?: null | RepoInfoDetails;
13
12
  originalRepositorySha?: string;
13
+ previousJson?: JsonOutput;
14
14
  relativeLinkPrefix?: string;
15
- /** Text substitutions applied to the source before it is parsed. */
16
15
  replacements?: ReplacementRule[];
17
16
  sortBy?: '' | 'last_commit' | 'stars';
18
17
  token: string;
@@ -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(), originalRepository, originalRepositoryId, 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, originalRepository, relativeLinkPrefix, enhancedRepository, enhancedRepositoryDescription, originalRepositorySha, originalRepositoryId, 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.0",
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",