@enhansome/core 1.7.1 → 1.8.0

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.
package/dist/github.d.ts CHANGED
@@ -8,6 +8,7 @@ export type GithubClient = InstanceType<typeof HardenedOctokit>;
8
8
  export interface RepoInfoDetails {
9
9
  archived: boolean;
10
10
  description: null | string;
11
+ id: number;
11
12
  language: null | string;
12
13
  open_issues_count: number;
13
14
  owner: string;
package/dist/github.js CHANGED
@@ -72,6 +72,7 @@ export async function getRepoInfo(octokit, owner, repo) {
72
72
  return {
73
73
  archived: data.archived,
74
74
  description: data.description ?? null,
75
+ id: data.id,
75
76
  language: data.language,
76
77
  open_issues_count: data.open_issues_count,
77
78
  owner: data.owner.login,
@@ -17,6 +17,7 @@ export interface SortOptions {
17
17
  }
18
18
  export interface RepoInfo {
19
19
  archived: boolean;
20
+ id: number;
20
21
  language: null | string;
21
22
  last_commit: null | string;
22
23
  owner: string;
@@ -29,7 +30,7 @@ export interface JsonItem {
29
30
  children: JsonNode[];
30
31
  description: null | string;
31
32
  node_type: 'item';
32
- repo_info?: RepoInfo;
33
+ repo_info: RepoInfo;
33
34
  title: string;
34
35
  }
35
36
  export interface JsonGroup {
package/dist/markdown.js CHANGED
@@ -10,6 +10,7 @@ import { consoleLog } from './logger.js';
10
10
  export function toRepoInfo(details) {
11
11
  return {
12
12
  archived: details.archived,
13
+ id: details.id,
13
14
  language: details.language,
14
15
  last_commit: details.pushed_at,
15
16
  owner: details.owner,
@@ -282,10 +283,10 @@ function processListRecursively(listNode, repoInfoMap, sortOptions, isNested = f
282
283
  if (!isNested && itemsWithGitHubLinks.length < sortOptions.minLinks) {
283
284
  return [];
284
285
  }
285
- // Zip each item with its JSON node and repo info so one sort orders both the
286
- // rendered AST and the emitted JSON. `json` is null only for non-GitHub
287
- // leaves (no own link, no nested GitHub children): kept in the AST, dropped
288
- // from JSON.
286
+ // Zip each item with its emitted JSON nodes and repo info so one sort orders
287
+ // both the rendered AST and the emitted JSON. `emitted` is empty for
288
+ // non-GitHub leaves (no own link, no nested GitHub children): kept in the
289
+ // AST, dropped from JSON.
289
290
  const entries = [];
290
291
  for (const itemNode of listNode.children) {
291
292
  const githubUrl = findOwnGitHubLink(itemNode);
@@ -314,37 +315,41 @@ function processListRecursively(listNode, repoInfoMap, sortOptions, isNested = f
314
315
  // `repo_info`, that's the identity-borrowing bug. No-own-link, no-child
315
316
  // items are non-GitHub leaves: kept in markdown, dropped from JSON.
316
317
  // TODO(future): preserve non-GitHub leaves in a separate shape.
317
- let jsonData = null;
318
- if (githubUrl) {
319
- const item = {
320
- node_type: 'item',
321
- title,
322
- description: description || null,
323
- children: childrenJson,
324
- };
325
- if (repoInfo) {
326
- item.repo_info = toRepoInfo(repoInfo);
327
- }
328
- jsonData = item;
318
+ let emitted = [];
319
+ if (githubUrl && repoInfo) {
320
+ emitted = [
321
+ {
322
+ node_type: 'item',
323
+ title,
324
+ description: description || null,
325
+ children: childrenJson,
326
+ repo_info: toRepoInfo(repoInfo),
327
+ },
328
+ ];
329
+ }
330
+ else if (githubUrl) {
331
+ // Dead target: the item itself is not emitted; its children lift to this
332
+ // list's level — the nearest live parent.
333
+ emitted = childrenJson;
329
334
  }
330
335
  else if (childrenJson.length > 0) {
331
- jsonData = {
332
- node_type: 'group',
333
- title,
334
- description: description || null,
335
- children: childrenJson,
336
- };
336
+ emitted = [
337
+ {
338
+ node_type: 'group',
339
+ title,
340
+ description: description || null,
341
+ children: childrenJson,
342
+ },
343
+ ];
337
344
  }
338
- entries.push({ json: jsonData, node: itemNode, repoInfo });
345
+ entries.push({ emitted, node: itemNode, repoInfo });
339
346
  }
340
347
  if (sortOptions.by) {
341
348
  entries.sort((a, b) => compareByRepoInfo(sortOptions.by, a.repoInfo, b.repoInfo));
342
349
  }
343
350
  // Reorder the AST to match the sort so rendered markdown and JSON agree.
344
351
  listNode.children = entries.map(entry => entry.node);
345
- return entries
346
- .map(entry => entry.json)
347
- .filter((json) => json !== null);
352
+ return entries.flatMap(entry => entry.emitted);
348
353
  }
349
354
  const INVALID_TITLE_PATTERNS = [
350
355
  /^contributing/i,
@@ -389,6 +394,19 @@ function isValidTitle(title) {
389
394
  }
390
395
  return !INVALID_TITLE_PATTERNS.some(pattern => pattern.test(title.trim()));
391
396
  }
397
+ // Headings that mirror structure rather than delimit it. Unlike
398
+ // INVALID_TITLE_PATTERNS (a title-detection aid — "## Tools" is a perfectly
399
+ // good content section), a TOC heading never owns content: the worst offender
400
+ // is a `# Table of Contents` H1 that would otherwise wrap the whole document
401
+ // as its "section".
402
+ const TOC_TITLE_PATTERNS = [/^contents$/i, /^table of contents$/i];
403
+ // A heading that delimits content structure. Text-less headings (a bare `#`,
404
+ // an image-only heading) are spacers in real docs; TOC headings are structure
405
+ // mirrors. Neither participates in the section tree.
406
+ function isStructuralHeading(node) {
407
+ const title = getNodeText(node);
408
+ return (!!title && !TOC_TITLE_PATTERNS.some(pattern => pattern.test(title.trim())));
409
+ }
392
410
  /** Never duplicates "Awesome": if the title already contains it, append the suffix verbatim; otherwise prefix first. */
393
411
  function brandTitle(title) {
394
412
  const trimmed = title.trim();
@@ -442,6 +460,13 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepository) {
442
460
  let documentTitle = titleHeadingIndex === -1
443
461
  ? ''
444
462
  : getNodeText(tree.children[titleHeadingIndex]);
463
+ // The heading branding owns even when it isn't a *valid* title: a generic
464
+ // first H1 ("# Guides", "# Contents") is still the de-facto title slot —
465
+ // applyBrandingToTree replaces it — so the section tree must not treat it
466
+ // as a section wrapping the whole document.
467
+ const titleSlotIndex = titleHeadingIndex !== -1
468
+ ? titleHeadingIndex
469
+ : tree.children.findIndex((node) => node.type === 'heading' && node.depth === 1);
445
470
  // Derive a subject from the *source* repository name when no valid H1 is
446
471
  // present. Using the source — not the enhanced/mirror repo — keeps the org
447
472
  // name out of the title.
@@ -451,52 +476,146 @@ function processTree(tree, repoInfoMap, sortOptions, originalRepository) {
451
476
  documentTitle = formatRepoNameAsTitle(repoName);
452
477
  }
453
478
  }
479
+ const sectionDepth = findSectionDepth(tree, titleSlotIndex);
454
480
  const sections = [];
455
- let currentSection = null;
456
- for (const node of tree.children) {
457
- if (node.type === 'heading' && node.depth > 1) {
458
- if (currentSection) {
459
- sections.push(currentSection);
481
+ const stack = [];
482
+ for (let i = 0; i < tree.children.length; i++) {
483
+ const node = tree.children[i];
484
+ if (node.type === 'heading') {
485
+ // The title-slot H1 belongs to branding/metadata; non-structural
486
+ // headings (see isStructuralHeading) delimit nothing. Neither
487
+ // participates in the section tree.
488
+ if (i === titleSlotIndex || !isStructuralHeading(node)) {
489
+ continue;
460
490
  }
461
- currentSection = {
462
- description: '',
463
- items: [],
464
- title: getNodeText(node),
465
- };
491
+ closeContainers(stack, node.depth, sections);
492
+ openContainer(stack, node, sectionDepth, repoInfoMap);
466
493
  }
467
- else if (currentSection) {
468
- if (node.type === 'paragraph') {
469
- const paragraphText = getNodeText(node);
470
- // Avoid adding boilerplate "back to top" links to description
471
- if (!paragraphText.includes('back to top')) {
472
- if (currentSection.description) {
473
- currentSection.description += `\n${paragraphText}`;
474
- }
475
- else {
476
- currentSection.description = paragraphText;
477
- }
478
- }
479
- }
480
- else if (node.type === 'list') {
481
- const items = processListRecursively(node, repoInfoMap, sortOptions);
482
- if (items.length > 0) {
483
- currentSection.items = items;
484
- sections.push(currentSection);
485
- }
486
- currentSection = null;
494
+ else if (node.type === 'paragraph' || node.type === 'blockquote') {
495
+ const text = getNodeText(node);
496
+ const container = stack[stack.length - 1];
497
+ // Avoid adding boilerplate "back to top" links to descriptions.
498
+ if (container && text && !text.includes('back to top')) {
499
+ container.description = container.description
500
+ ? `${container.description}\n${text}`
501
+ : text;
487
502
  }
488
503
  }
489
504
  else if (node.type === 'list') {
490
- // No active section: not part of any JSON section, but still sort its AST
491
- // so the rendered markdown matches.
492
- processListRecursively(node, repoInfoMap, sortOptions);
505
+ // Every list inside the open container contributes items — a section is
506
+ // not closed by its first list. With no open container (preamble), the
507
+ // list is not part of any JSON section, but its AST is still sorted so
508
+ // the rendered markdown matches.
509
+ const items = processListRecursively(node, repoInfoMap, sortOptions);
510
+ const container = stack[stack.length - 1];
511
+ if (container) {
512
+ container.children.push(...items);
513
+ }
493
514
  }
494
515
  }
495
- if (currentSection) {
496
- sections.push(currentSection);
497
- }
516
+ closeContainers(stack, 0, sections);
498
517
  return { sections, title: documentTitle, titleHeadingIndex };
499
518
  }
519
+ // The heading depth that opens top-level sections: the shallowest structural
520
+ // heading in the document other than the title slot. H1s count — ~20% of
521
+ // mirror READMEs use `# Section` after the title H1, and hardcoding H2 would
522
+ // drop all their items. Non-structural headings are skipped here too (same
523
+ // rule as the walk). Infinity when there is no such heading (no sections).
524
+ function findSectionDepth(tree, titleSlotIndex) {
525
+ let depth = Infinity;
526
+ tree.children.forEach((node, i) => {
527
+ if (node.type === 'heading' &&
528
+ i !== titleSlotIndex &&
529
+ isStructuralHeading(node) &&
530
+ node.depth < depth) {
531
+ depth = node.depth;
532
+ }
533
+ });
534
+ return depth;
535
+ }
536
+ // A heading whose only link is a live GitHub link represents a resource, not a
537
+ // container — the link-heading pattern (`#### [Repo](github…)`). Badge images
538
+ // wrapped in links or multiple links disqualify (more than one link means the
539
+ // heading is not "the" resource), as does a dead target.
540
+ function soleLiveHeadingLink(heading, repoInfoMap) {
541
+ const links = heading.children.filter((child) => child.type === 'link');
542
+ if (links.length !== 1) {
543
+ return null;
544
+ }
545
+ return repoInfoMap.get(links[0].url) ?? null;
546
+ }
547
+ function openContainer(stack, heading, sectionDepth, repoInfoMap) {
548
+ const title = getNodeText(heading);
549
+ // Sections sit at the section level — and any heading met with an empty
550
+ // stack is promoted: a deeper heading before the first section (orphan
551
+ // subheading) still owns its subtree, and a link-heading at section level
552
+ // becomes a section rather than a top-level item, which the contract has no
553
+ // place for.
554
+ if (stack.length === 0 || heading.depth === sectionDepth) {
555
+ stack.push({
556
+ children: [],
557
+ description: '',
558
+ headingDepth: heading.depth,
559
+ kind: 'section',
560
+ title,
561
+ });
562
+ return;
563
+ }
564
+ const repoInfo = soleLiveHeadingLink(heading, repoInfoMap);
565
+ stack.push({
566
+ children: [],
567
+ description: '',
568
+ headingDepth: heading.depth,
569
+ kind: repoInfo ? 'item' : 'group',
570
+ repoInfo: repoInfo ?? undefined,
571
+ title,
572
+ });
573
+ }
574
+ // Finalize every container a heading of `depth` closes (same-or-shallower),
575
+ // bottom-up so each finalized node lands in its parent. Pruning falls out of
576
+ // the finalize rule: a section/group whose children array is empty (no items
577
+ // anywhere beneath — lists only return item-bearing nodes, and empty children
578
+ // were never appended) is dropped; an item always survives, it IS the content.
579
+ // The stack bottom is always a section (openContainer's promotion guarantees
580
+ // it), so a finalized group/item always has a parent to land in.
581
+ function closeContainers(stack, depth, sections) {
582
+ while (stack.length > 0 &&
583
+ stack[stack.length - 1].headingDepth >= depth) {
584
+ const container = stack.pop();
585
+ if (container.children.length === 0 && container.kind !== 'item') {
586
+ continue;
587
+ }
588
+ const description = container.description || null;
589
+ if (container.kind === 'section') {
590
+ sections.push({
591
+ description,
592
+ items: container.children,
593
+ title: container.title,
594
+ });
595
+ continue;
596
+ }
597
+ const parent = stack[stack.length - 1];
598
+ // Non-section containers always have an open parent (stack invariant), and
599
+ // kind === 'item' exactly when repoInfo is set.
600
+ if (container.repoInfo) {
601
+ parent.children.push({
602
+ children: container.children,
603
+ description,
604
+ node_type: 'item',
605
+ repo_info: toRepoInfo(container.repoInfo),
606
+ title: container.title,
607
+ });
608
+ }
609
+ else {
610
+ parent.children.push({
611
+ children: container.children,
612
+ description,
613
+ node_type: 'group',
614
+ title: container.title,
615
+ });
616
+ }
617
+ }
618
+ }
500
619
  function serializeAst(tree, originalContent) {
501
620
  let finalContent = unified()
502
621
  .use(remarkStringify)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enhansome/core",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Library core for enhansome — enhance markdown with GitHub star counts.",
5
5
  "repository": {
6
6
  "type": "git",