@terrafirmagreg-team/changelog 0.1.0 → 1.2.1

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/package.json CHANGED
@@ -1,23 +1,40 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/changelog",
3
- "version": "0.1.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
+ "description": "Changelog section",
6
+ "license": "LGPL-3.0-only",
5
7
  "exports": {
8
+ "./components/*": [
9
+ "./src/components/*.tsx",
10
+ "./src/components/*/index.tsx",
11
+ "./src/components/*/index.ts"
12
+ ],
6
13
  "./lib/*": [
7
14
  "./src/lib/*.ts",
8
- "./src/lib/*.tsx",
9
- "./src/lib/*/index.ts",
10
- "./src/lib/*/index.tsx"
15
+ "./src/lib/*/index.ts"
11
16
  ],
17
+ "./types/*": "./src/types/*.ts",
18
+ "./types": "./src/types/index.ts",
12
19
  "./locales": "./src/locales.ts"
13
20
  },
14
21
  "dependencies": {
22
+ "@terrafirmagreg-team/kit": "1.2.1",
23
+ "@terrafirmagreg-team/mdx": "1.2.1",
24
+ "@terrafirmagreg-team/translate": "1.2.1",
25
+ "@terrafirmagreg-team/ui": "1.2.1",
15
26
  "fumadocs-core": "16.17.8",
16
- "@terrafirmagreg-team/kit": "0.1.0",
17
- "@terrafirmagreg-team/translate": "0.1.0"
27
+ "lucide-react": "^0.577.0"
28
+ },
29
+ "peerDependencies": {
30
+ "next": ">=15",
31
+ "react": ">=19"
18
32
  },
19
33
  "devDependencies": {
20
- "@types/node": "^26.6.5"
34
+ "@types/mdx": "^2.0.14",
35
+ "@types/node": "^26.6.5",
36
+ "@types/react": "^19.3.0",
37
+ "@types/react-dom": "^19.3.0"
21
38
  },
22
39
  "repository": {
23
40
  "type": "git",
@@ -28,9 +45,6 @@
28
45
  "access": "public"
29
46
  },
30
47
  "files": [
31
- "src",
32
- "styles",
33
- "styles.css",
34
- "locales"
48
+ "src"
35
49
  ]
36
50
  }
@@ -0,0 +1,75 @@
1
+ import { createRelativeLink } from '@terrafirmagreg-team/kit/components/relative-link';
2
+ import { getMDXComponents } from '@terrafirmagreg-team/mdx/components/mdx-components';
3
+ import { datasetCatalog } from '@terrafirmagreg-team/kit/lib/datasets';
4
+ import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
5
+ import { lookupPagePreview } from '@terrafirmagreg-team/kit/lib/preview/lookup';
6
+ import { LinkPreview } from '@terrafirmagreg-team/ui/components/blocks/link/link-preview';
7
+ import type { SectionSource } from '@terrafirmagreg-team/kit/lib/page';
8
+ import type { SectionPage } from '@terrafirmagreg-team/kit/types';
9
+ import type { LoaderConfig, LoaderOutput, Page } from 'fumadocs-core/source';
10
+ import type { MDXContent } from 'mdx/types';
11
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
12
+ import type { ChangelogTimelineEntry } from './timeline';
13
+
14
+ export async function timelineEntries(
15
+ source: SectionSource,
16
+ route: string,
17
+ dataset: string,
18
+ lang: Lang,
19
+ repo?: `${string}/${string}`,
20
+ ): Promise<ChangelogTimelineEntry[]> {
21
+ const catalog = await datasetCatalog();
22
+ const { defaultLanguage } = loadLocaleRegistry();
23
+ const byFile = new Map<string, { own?: SectionPage; fallback?: SectionPage }>();
24
+ for (const raw of source.getPages()) {
25
+ const page = raw as unknown as SectionPage;
26
+ if (page.slugs[0] !== dataset) continue;
27
+ const pageLang = (page.locale ?? page.slugs[1]) as Lang;
28
+ const key = page.slugs.slice(2).join('/');
29
+ const slot = byFile.get(key) ?? {};
30
+ if (pageLang === lang) slot.own = page;
31
+ else if (pageLang === defaultLanguage) slot.fallback = page;
32
+ byFile.set(key, slot);
33
+ }
34
+ const entries: ChangelogTimelineEntry[] = [];
35
+ for (const { own, fallback } of byFile.values()) {
36
+ const page = own ?? fallback;
37
+ if (!page) continue;
38
+ const pageLang = (page.locale ?? page.slugs[1] ?? lang) as Lang;
39
+ const data = page.data as {
40
+ date?: string | Date;
41
+ frontmatter?: { date?: string | Date };
42
+ };
43
+ const rawDate = data.date ?? data.frontmatter?.date;
44
+ if (!rawDate) continue;
45
+ const { body: MDX } = (await (
46
+ page.data as unknown as {
47
+ load(): Promise<{ body: MDXContent }>;
48
+ }
49
+ ).load()) as { body: MDXContent };
50
+ const link = createRelativeLink(
51
+ source as LoaderOutput<LoaderConfig>,
52
+ page as unknown as Page,
53
+ (props) => (
54
+ <LinkPreview
55
+ preview={lookupPagePreview(catalog, props.href, pageLang)}
56
+ {...props}
57
+ />
58
+ ),
59
+ );
60
+ const body = <MDX components={getMDXComponents({ a: link })} />;
61
+ const fileSlug = page.slugs.slice(2);
62
+ entries.push({
63
+ id: fileSlug.join('/'),
64
+ title: page.data.title,
65
+ date: new Date(rawDate),
66
+ url: `${route}/${dataset}/${fileSlug.join('/')}`,
67
+ releaseUrl: repo
68
+ ? `https://github.com/${repo}/releases/tag/${page.data.title}`
69
+ : undefined,
70
+ untranslated: !own,
71
+ body,
72
+ });
73
+ }
74
+ return entries;
75
+ }
@@ -0,0 +1,9 @@
1
+ 'use client';
2
+ import { useResolvedLang } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
3
+ import { monthLabel } from '@terrafirmagreg-team/changelog/lib/format';
4
+
5
+ export function ChangelogMonthLabel({ monthKey }: { monthKey: string }) {
6
+ const lang = useResolvedLang();
7
+ const [year, month] = monthKey.split('-').map(Number);
8
+ return <>{monthLabel(lang, new Date(Date.UTC(year, month - 1)))}</>;
9
+ }
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { UntranslatedChip } from '@terrafirmagreg-team/ui/components/blocks/lang/untranslated-chip';
4
+ import {
5
+ FloatBar,
6
+ FloatBarItem,
7
+ } from '@terrafirmagreg-team/ui/components/blocks/viewer/float-bar';
8
+
9
+ export type ChangelogNowItem = {
10
+ id: string;
11
+ untranslated?: boolean;
12
+ };
13
+
14
+ export function ChangelogNow({ items }: { items: ChangelogNowItem[] }) {
15
+ const ref = useRef<HTMLDivElement>(null);
16
+ const [activeId, setActiveId] = useState(items[0]?.id ?? null);
17
+
18
+ useEffect(() => {
19
+ const bar = ref.current;
20
+ const root = bar?.closest('[data-changelog-timeline]');
21
+ if (!bar || !root) return;
22
+
23
+ let tops: { id: string; top: number }[] = [];
24
+ const measure = () => {
25
+ const base = root.getBoundingClientRect().top;
26
+ tops = [...root.querySelectorAll<HTMLElement>('[data-release]')].map(
27
+ (li) => ({
28
+ id: li.id,
29
+ top: li.getBoundingClientRect().top - base,
30
+ }),
31
+ );
32
+ };
33
+
34
+ let raf = 0;
35
+ const update = () => {
36
+ raf = 0;
37
+ const y =
38
+ bar.getBoundingClientRect().top - root.getBoundingClientRect().top;
39
+ let active = tops[0]?.id ?? null;
40
+ for (const { id, top } of tops) {
41
+ if (top <= y) active = id;
42
+ else break;
43
+ }
44
+ setActiveId((prev) => (prev === active ? prev : active));
45
+ };
46
+ const schedule = () => {
47
+ if (!raf) raf = requestAnimationFrame(update);
48
+ };
49
+ const remeasure = () => {
50
+ measure();
51
+ schedule();
52
+ };
53
+ measure();
54
+ update();
55
+ document.addEventListener('scroll', schedule, {
56
+ capture: true,
57
+ passive: true,
58
+ });
59
+ window.addEventListener('resize', remeasure);
60
+ const observer = new ResizeObserver(remeasure);
61
+ observer.observe(root);
62
+ return () => {
63
+ document.removeEventListener('scroll', schedule, { capture: true });
64
+ window.removeEventListener('resize', remeasure);
65
+ observer.disconnect();
66
+ cancelAnimationFrame(raf);
67
+ };
68
+ }, []);
69
+
70
+ const current = useMemo(
71
+ () => items.find((entry) => entry.id === activeId) ?? null,
72
+ [items, activeId],
73
+ );
74
+
75
+ return (
76
+ <div
77
+ ref={ref}
78
+ className="not-prose pointer-events-none sticky top-[calc(var(--header-height,0px)+0.75rem)] z-10 h-0"
79
+ >
80
+ {current && (
81
+ <div className="absolute start-[7px] flex -translate-y-1/2 items-center gap-2">
82
+ <span className="size-3 shrink-0 rounded-full border-2 border-primary bg-background" />
83
+ <FloatBar className="pointer-events-auto">
84
+ <FloatBarItem className="gap-1.5">
85
+ <span className="font-semibold">{current.id}</span>
86
+ <span className={current.untranslated ? undefined : 'hidden'}>
87
+ <UntranslatedChip />
88
+ </span>
89
+ </FloatBarItem>
90
+ </FloatBar>
91
+ </div>
92
+ )}
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,155 @@
1
+ import Link from 'next/link';
2
+ import { ExternalLink, Link as LinkIcon } from 'lucide-react';
3
+ import type { ReactNode } from 'react';
4
+ import { dayFormat, monthLabel } from '@terrafirmagreg-team/changelog/lib/format';
5
+ import { ChangelogNow } from './now-bar';
6
+
7
+ export interface ChangelogTimelineEntry {
8
+ id: string;
9
+ title: string;
10
+ date: Date;
11
+ url?: string;
12
+ releaseUrl?: string;
13
+ untranslated?: boolean;
14
+ body: ReactNode;
15
+ }
16
+
17
+ export type ChangelogMonth = {
18
+ key: string;
19
+ label: string;
20
+ items: ChangelogTimelineEntry[];
21
+ };
22
+
23
+ export function groupByMonth(
24
+ entries: ChangelogTimelineEntry[],
25
+ lang = 'en_us',
26
+ ): ChangelogMonth[] {
27
+ const months = new Map<string, ChangelogMonth>();
28
+ for (const entry of [...entries].sort(
29
+ (a, b) => b.date.getTime() - a.date.getTime(),
30
+ )) {
31
+ const key = `${entry.date.getUTCFullYear()}-${String(entry.date.getUTCMonth() + 1).padStart(2, '0')}`;
32
+ const month = months.get(key);
33
+ if (month) month.items.push(entry);
34
+ else
35
+ months.set(key, {
36
+ key,
37
+ label: monthLabel(lang, entry.date),
38
+ items: [entry],
39
+ });
40
+ }
41
+ return [...months.values()];
42
+ }
43
+
44
+ export const monthAnchor = (monthKey: string) => monthKey;
45
+
46
+ export function ChangelogTimeline({
47
+ entries,
48
+ lang = 'en_us',
49
+ }: {
50
+ entries: ChangelogTimelineEntry[];
51
+ lang?: string;
52
+ }) {
53
+ const day = dayFormat(lang);
54
+ const latestId = entries.length
55
+ ? entries.reduce((a, b) => (b.date > a.date ? b : a)).id
56
+ : undefined;
57
+ const months = groupByMonth(entries, lang);
58
+ return (
59
+ <div data-changelog-timeline>
60
+ <ChangelogNow
61
+ items={months.flatMap((month) =>
62
+ month.items.map((entry) => ({
63
+ id: entry.id,
64
+ untranslated: entry.untranslated,
65
+ })),
66
+ )}
67
+ />
68
+ {months.map(({ key, label, items }) => (
69
+ <section
70
+ key={key}
71
+ className="ms-3 border-s-2 border-muted-foreground/25 ps-6 pb-8"
72
+ >
73
+ <h2
74
+ id={monthAnchor(key)}
75
+ className="not-prose scroll-mt-28 py-2 text-[11px] font-semibold tracking-[0.05em] text-muted-foreground/60"
76
+ >
77
+ {label}
78
+ </h2>
79
+ <ol className="mt-4 mb-0 list-none ps-0">
80
+ {items.map((entry) => (
81
+ <ChangelogItem
82
+ key={entry.id}
83
+ entry={entry}
84
+ day={day}
85
+ latest={entry.id === latestId}
86
+ />
87
+ ))}
88
+ </ol>
89
+ </section>
90
+ ))}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ function ChangelogItem({
96
+ entry,
97
+ day,
98
+ latest,
99
+ }: {
100
+ entry: ChangelogTimelineEntry;
101
+ day: Intl.DateTimeFormat;
102
+ latest?: boolean;
103
+ }) {
104
+ return (
105
+ <li
106
+ id={entry.id}
107
+ data-release
108
+ className="group scroll-mt-28 ps-0 pb-12 last:pb-0"
109
+ >
110
+ <time
111
+ dateTime={entry.date.toISOString()}
112
+ className="relative mb-1.5 block text-xs font-medium text-muted-foreground tabular-nums"
113
+ >
114
+ <span className="absolute -start-[25px] top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background rtl:translate-x-1/2" />
115
+ {day.format(entry.date)}
116
+ </time>
117
+ <h3 className="not-prose mb-3 flex items-center gap-2 text-lg font-semibold tracking-tight">
118
+ {latest && (
119
+ <span className="rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
120
+ Latest
121
+ </span>
122
+ )}
123
+ {entry.url ? (
124
+ <Link
125
+ href={entry.url}
126
+ className="underline-offset-4 hover:text-primary hover:underline"
127
+ >
128
+ {entry.title}
129
+ </Link>
130
+ ) : (
131
+ entry.title
132
+ )}
133
+ {entry.releaseUrl && (
134
+ <a
135
+ href={entry.releaseUrl}
136
+ target="_blank"
137
+ rel="noreferrer"
138
+ aria-label="GitHub release"
139
+ className="text-muted-foreground hover:text-primary"
140
+ >
141
+ <ExternalLink className="size-3.5" />
142
+ </a>
143
+ )}
144
+ <a
145
+ href={`#${entry.id}`}
146
+ aria-label="Link to this release"
147
+ className="text-muted-foreground opacity-0 transition-opacity hover:text-primary group-hover:opacity-100"
148
+ >
149
+ <LinkIcon className="size-3.5" />
150
+ </a>
151
+ </h3>
152
+ <div className="text-sm text-muted-foreground">{entry.body}</div>
153
+ </li>
154
+ );
155
+ }
@@ -0,0 +1,25 @@
1
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
2
+ import type { SectionSource } from '@terrafirmagreg-team/kit/lib/page';
3
+ import { timelineEntries } from '../entries';
4
+ import { ChangelogTimeline } from '../timeline';
5
+
6
+ export async function ChangelogTimelineView({
7
+ source,
8
+ route,
9
+ dataset,
10
+ lang,
11
+ repo,
12
+ }: {
13
+ source: SectionSource;
14
+ route: string;
15
+ dataset: string;
16
+ lang: Lang;
17
+ repo?: `${string}/${string}`;
18
+ }) {
19
+ return (
20
+ <ChangelogTimeline
21
+ entries={await timelineEntries(source, route, dataset, lang, repo)}
22
+ lang={lang}
23
+ />
24
+ );
25
+ }
@@ -0,0 +1,20 @@
1
+ export const bcp47 = (lang: string) => lang.replace('_', '-');
2
+
3
+ const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
4
+
5
+ export const monthFormat = (lang: string) =>
6
+ new Intl.DateTimeFormat(bcp47(lang), {
7
+ month: 'long',
8
+ year: 'numeric',
9
+ timeZone: 'UTC',
10
+ });
11
+
12
+ export const monthLabel = (lang: string, date: Date) =>
13
+ capitalize(monthFormat(lang).format(date));
14
+
15
+ export const dayFormat = (lang: string) =>
16
+ new Intl.DateTimeFormat(bcp47(lang), {
17
+ month: 'short',
18
+ day: 'numeric',
19
+ timeZone: 'UTC',
20
+ });
@@ -0,0 +1,38 @@
1
+ type MdNode = {
2
+ type: string;
3
+ depth?: number;
4
+ children?: { type?: string; value?: string; children?: unknown[] }[];
5
+ };
6
+
7
+ const STRIP_HEADING = /^(what'?s changed|new contributors)$/i;
8
+ const FULL_CHANGELOG = /^\*?\*?full changelog\*?\*?/i;
9
+
10
+ const text = (node: MdNode): string =>
11
+ (node.children ?? [])
12
+ .map((child) => child.value ?? text(child as MdNode))
13
+ .join('')
14
+ .trim();
15
+
16
+ export const remarkStripAutoSections =
17
+ () =>
18
+ (tree: { children: MdNode[] }) => {
19
+ const out: MdNode[] = [];
20
+ let skipDepth = 0;
21
+ for (const node of tree.children) {
22
+ if (node.type === 'heading') {
23
+ const depth = node.depth ?? 2;
24
+ if (STRIP_HEADING.test(text(node))) {
25
+ skipDepth = depth;
26
+ continue;
27
+ }
28
+ if (skipDepth && depth <= skipDepth) skipDepth = 0;
29
+ if (skipDepth) continue;
30
+ } else if (skipDepth) {
31
+ continue;
32
+ }
33
+ if (node.type === 'paragraph' && FULL_CHANGELOG.test(text(node)))
34
+ continue;
35
+ out.push(node);
36
+ }
37
+ tree.children = out;
38
+ };
@@ -0,0 +1,83 @@
1
+ import type * as PageTree from 'fumadocs-core/page-tree';
2
+ import type { SectionTrees } from '@terrafirmagreg-team/kit/types';
3
+
4
+ const GROUP_NAME = /^(\d+)\.(\d+)\.x$/;
5
+
6
+ const numbers = (value: string) => value.match(/\d+/g)?.map(Number) ?? [];
7
+
8
+ const descByVersion = (a: PageTree.Node, b: PageTree.Node) => {
9
+ const an = numbers(String(a.name ?? ''));
10
+ const bn = numbers(String(b.name ?? ''));
11
+ for (let i = 0; i < Math.max(an.length, bn.length); i++) {
12
+ const diff = (bn[i] ?? 0) - (an[i] ?? 0);
13
+ if (diff) return diff;
14
+ }
15
+ return 0;
16
+ };
17
+
18
+ function groupVersionItems(root: PageTree.Root): PageTree.Root {
19
+ const children: PageTree.Node[] = [];
20
+ const groups = new Map<string, PageTree.Folder>();
21
+ for (const node of root.children) {
22
+ const title = node.type === 'page' ? String(node.name ?? '') : '';
23
+ const series = /^v?(\d+)\.(\d+)/.exec(title)?.slice(1, 3).join('.');
24
+ if (node.type !== 'page' || !series) {
25
+ children.push(node);
26
+ continue;
27
+ }
28
+ let folder = groups.get(series);
29
+ if (!folder) {
30
+ folder = {
31
+ $id: `${root.$id ?? 'root'}:${series}`,
32
+ type: 'folder',
33
+ name: `${series}.x`,
34
+ children: [],
35
+ };
36
+ groups.set(series, folder);
37
+ children.push(folder);
38
+ }
39
+ folder.children.push(node);
40
+ }
41
+ for (const folder of groups.values())
42
+ folder.children.sort(descByVersion);
43
+ const seriesOf = (node: PageTree.Node) =>
44
+ node.type === 'folder' && typeof node.name === 'string'
45
+ ? GROUP_NAME.exec(node.name)
46
+ : null;
47
+ children.sort((a, b) => {
48
+ const sa = seriesOf(a);
49
+ const sb = seriesOf(b);
50
+ return sa && sb
51
+ ? Number(sb[1]) - Number(sa[1]) || Number(sb[2]) - Number(sa[2])
52
+ : 0;
53
+ });
54
+ return { ...root, children };
55
+ }
56
+
57
+ const TIMELINE_NAMES: Record<string, string> = {
58
+ en_us: 'Timeline',
59
+ ru_ru: 'Таймлайн',
60
+ };
61
+
62
+ export const groupTrees =
63
+ (route: string, names: Record<string, string> = TIMELINE_NAMES) =>
64
+ (trees: SectionTrees): SectionTrees =>
65
+ Object.fromEntries(
66
+ Object.entries(trees).map(([lang, byDataset]) => [
67
+ lang,
68
+ Object.fromEntries(
69
+ Object.entries(byDataset).map(([id, root]) => {
70
+ const grouped = groupVersionItems(root);
71
+ const timeline: PageTree.Item = {
72
+ type: 'page',
73
+ name: names[lang] ?? 'Timeline',
74
+ url: `${route}/${id}`,
75
+ };
76
+ return [
77
+ id,
78
+ { ...grouped, children: [timeline, ...grouped.children] },
79
+ ];
80
+ }),
81
+ ),
82
+ ]),
83
+ );
@@ -1,8 +1,4 @@
1
1
  {
2
- "sections": {
3
- "changelog": "Changelog",
4
- "desc": {
5
- "changelog": "Release history and version notes"
6
- }
7
- }
2
+ "Changelog(sections.changelog)": "Changelog",
3
+ "Release history and version notes(sections.desc.changelog)": "Release history and version notes"
8
4
  }
@@ -1,8 +1,4 @@
1
1
  {
2
- "sections": {
3
- "changelog": "Список изменений",
4
- "desc": {
5
- "changelog": "История релизов и заметки к версиям"
6
- }
7
- }
2
+ "Changelog(sections.changelog)": "Список изменений",
3
+ "Release history and version notes(sections.desc.changelog)": "История релизов и заметки к версиям"
8
4
  }
package/src/locales.ts CHANGED
@@ -3,3 +3,4 @@ import { createLocaleCatalog } from '@terrafirmagreg-team/translate/lib/locales'
3
3
  export const locales = createLocaleCatalog(
4
4
  import.meta.glob('./locales/*.json', { eager: true, import: 'default' }),
5
5
  );
6
+
package/src/lib/route.ts DELETED
@@ -1,2 +0,0 @@
1
-
2
- export const changelogRoute = '/changelog';
package/src/lib/tree.ts DELETED
@@ -1,41 +0,0 @@
1
- import type * as PageTree from 'fumadocs-core/page-tree';
2
- import type { SectionTrees } from '@terrafirmagreg-team/kit/types';
3
-
4
- function groupVersionItems(root: PageTree.Root): PageTree.Root {
5
- const children: PageTree.Node[] = [];
6
- const groups = new Map<string, PageTree.Folder>();
7
- for (const node of root.children) {
8
- const title = node.type === 'page' ? String(node.name ?? '') : '';
9
- const series = /^v?(\d+)\.(\d+)/.exec(title)?.slice(1, 3).join('.');
10
- if (node.type !== 'page' || !series) {
11
- children.push(node);
12
- continue;
13
- }
14
- let folder = groups.get(series);
15
- if (!folder) {
16
- folder = {
17
- $id: `${root.$id ?? 'root'}:${series}`,
18
- type: 'folder',
19
- name: `${series}.x`,
20
- children: [],
21
- };
22
- groups.set(series, folder);
23
- children.push(folder);
24
- }
25
- folder.children.push(node);
26
- }
27
- return { ...root, children };
28
- }
29
-
30
- export const groupTrees = (trees: SectionTrees): SectionTrees =>
31
- Object.fromEntries(
32
- Object.entries(trees).map(([lang, byDataset]) => [
33
- lang,
34
- Object.fromEntries(
35
- Object.entries(byDataset).map(([id, root]) => [
36
- id,
37
- groupVersionItems(root),
38
- ]),
39
- ),
40
- ]),
41
- );