@nstarkman/data-resolver 0.0.0-stage → 20261023.0.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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/package.json +23 -4
- package/src/bibtex.js +135 -0
- package/src/collect.js +83 -0
- package/src/inline.js +123 -0
- package/src/mentees.js +89 -0
- package/src/places.js +14 -0
- package/src/readers.js +56 -0
- package/src/store.js +469 -0
- package/src/urls.js +9 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Versions are dates, `YYYYMMDD.0.N`; the four research-kit packages are released together at one
|
|
4
|
+
version. A breaking change says how to migrate.
|
|
5
|
+
|
|
6
|
+
## 20261023.0.1
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- `collect()` put the co-authors of every item it reached on the paper's collaborator map — its
|
|
11
|
+
packages' papers' too; it now puts only the paper's own co-authors.
|
|
12
|
+
|
|
13
|
+
## 20261023.0.0
|
|
14
|
+
|
|
15
|
+
First release.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathaniel Starkman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nstarkman/data-resolver
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Part of [@nstarkman/research-kit](https://www.npmjs.com/package/@nstarkman/research-kit), which says how to
|
|
4
|
+
use it; released with it at one version. Developed in
|
|
5
|
+
[nstarman/nstarman.github.io](https://github.com/nstarman/nstarman.github.io) (`packages/data-resolver/`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nstarkman/data-resolver",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "20261023.0.1",
|
|
4
|
+
"description": "The nstarkman.space data layer as a store, and the collector a paper site snapshots from.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Nathaniel Starkman",
|
|
8
|
+
"homepage": "https://nstarkman.space",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/nstarman/nstarman.github.io.git",
|
|
12
|
+
"directory": "packages/data-resolver"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
"./*": "./src/*"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"provenance": true
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/bibtex.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Renders publication items to BibTeX.
|
|
2
|
+
//
|
|
3
|
+
// This is why the schema stores authors as family/given rather than display
|
|
4
|
+
// strings, requires an explicit `entryType`, and names venue fields after
|
|
5
|
+
// BibTeX's own — the mapping here is direct rather than guessed.
|
|
6
|
+
|
|
7
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun',
|
|
8
|
+
'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
9
|
+
|
|
10
|
+
// TeX spells its own dashes; a literal em dash breaks a pdflatex run that has
|
|
11
|
+
// not been told the input is UTF-8.
|
|
12
|
+
const TEX = {
|
|
13
|
+
"\\": "\\textbackslash{}",
|
|
14
|
+
"&": "\\&",
|
|
15
|
+
"%": "\\%",
|
|
16
|
+
"$": "\\$",
|
|
17
|
+
"#": "\\#",
|
|
18
|
+
"_": "\\_",
|
|
19
|
+
"{": "\\{",
|
|
20
|
+
"}": "\\}",
|
|
21
|
+
"~": "\\textasciitilde{}",
|
|
22
|
+
"^": "\\textasciicircum{}",
|
|
23
|
+
"—": "---",
|
|
24
|
+
"–": "--",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Escape the characters that are syntax in TeX.
|
|
29
|
+
*
|
|
30
|
+
* One pass, so every character of the input is replaced exactly once and no
|
|
31
|
+
* replacement can be re-escaped by a later step. The chained version needed a
|
|
32
|
+
* sentinel to hold the backslash — otherwise the brace pass escaped
|
|
33
|
+
* `\textbackslash{}`'s own braces and turned `\star` into
|
|
34
|
+
* `\textbackslash\{\}star` — and that sentinel was itself a hole: a NUL in the
|
|
35
|
+
* input came back out as a backslash.
|
|
36
|
+
*/
|
|
37
|
+
function esc(value) {
|
|
38
|
+
return String(value).replace(/[\\&%$#_{}~^–—]/g, (ch) => TEX[ch]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Abstracts arrive from arXiv already written in LaTeX — `$M_\star$` and the
|
|
43
|
+
* like. Escaping them as prose destroys the maths, so only a bare `&` or `%`
|
|
44
|
+
* is escaped here; everything else is passed through as the author wrote it.
|
|
45
|
+
*/
|
|
46
|
+
function escAbstract(value) {
|
|
47
|
+
return String(value).replace(/(?<!\\)([&%])/g, "\\$1");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* "Family, Given", which is what BibTeX needs to tell the two apart — a plain
|
|
52
|
+
* "Nathaniel Starkman" would be guessed at, and "van der Waals" guessed wrong.
|
|
53
|
+
* A `literal` byline is braced so BibTeX treats it as one indivisible name.
|
|
54
|
+
*/
|
|
55
|
+
function bibName(a) {
|
|
56
|
+
if (a.literal) return `{${esc(a.literal)}}`;
|
|
57
|
+
const family = [a.family, a.suffix].filter(Boolean).join(' ');
|
|
58
|
+
return `${esc(family)}, ${esc(a.given)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function names(item) {
|
|
62
|
+
const list = (item.authors ?? []).map(bibName);
|
|
63
|
+
// The collaboration leads the byline when it is set.
|
|
64
|
+
if (item.collaboration) list.unshift(`{${esc(item.collaboration)}}`);
|
|
65
|
+
return list.join(' and ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function toBibtex(item) {
|
|
69
|
+
if (item.type !== 'publication') return null;
|
|
70
|
+
|
|
71
|
+
const v = item.venue ?? {};
|
|
72
|
+
// The journal's issue date, where it differs from date.start; its month is
|
|
73
|
+
// never mixed with date.start's year.
|
|
74
|
+
const [startYear, startMonth] = (item.date?.start ?? '').split('-');
|
|
75
|
+
const year = v.year ?? startYear;
|
|
76
|
+
const month = v.year ? v.month : startMonth;
|
|
77
|
+
|
|
78
|
+
const fields = [
|
|
79
|
+
['author', names(item)],
|
|
80
|
+
// Doubled braces protect the capitalisation BibTeX would otherwise flatten.
|
|
81
|
+
['title', `{${esc(item.title)}}`],
|
|
82
|
+
['journal', v.journal && esc(v.journal)],
|
|
83
|
+
['booktitle', v.booktitle && esc(v.booktitle)],
|
|
84
|
+
['school', v.school && esc(v.school)],
|
|
85
|
+
['publisher', v.publisher && esc(v.publisher)],
|
|
86
|
+
['series', v.series && esc(v.series)],
|
|
87
|
+
['volume', v.volume && esc(v.volume)],
|
|
88
|
+
['number', v.number && esc(v.number)],
|
|
89
|
+
['pages', v.pages && esc(v.pages)],
|
|
90
|
+
['address', v.address && esc(v.address)],
|
|
91
|
+
['year', year],
|
|
92
|
+
['month', month ? MONTHS[Number(month) - 1] : null],
|
|
93
|
+
['doi', item.doi && esc(item.doi)],
|
|
94
|
+
['eprint', item.arxiv],
|
|
95
|
+
['archivePrefix', item.arxiv ? 'arXiv' : null],
|
|
96
|
+
['primaryClass', item.primaryClass],
|
|
97
|
+
['adsurl', item.bibcode ? `https://ui.adsabs.harvard.edu/abs/${item.bibcode}` : null],
|
|
98
|
+
['abstract', item.abstract && escAbstract(item.abstract)],
|
|
99
|
+
// `status` is the only place "submitted"/"in prep" is recorded, so it has
|
|
100
|
+
// to reach the reader somehow; note is where BibTeX puts that.
|
|
101
|
+
['note', item.status && item.status !== 'published'
|
|
102
|
+
? item.status.replace('-', ' ')
|
|
103
|
+
: null],
|
|
104
|
+
].filter(([, value]) => value !== null && value !== undefined && value !== '');
|
|
105
|
+
|
|
106
|
+
const width = Math.max(...fields.map(([k]) => k.length));
|
|
107
|
+
const body = fields
|
|
108
|
+
.map(([k, value]) => ` ${k.padEnd(width)} = {${value}}`)
|
|
109
|
+
.join(',\n');
|
|
110
|
+
|
|
111
|
+
return `@${item.entryType}{${citekey(item)},\n${body}\n}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ascii = (s) =>
|
|
115
|
+
s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^A-Za-z0-9-]/g, '');
|
|
116
|
+
|
|
117
|
+
// The key is derived, never stored: [<Collab>+]<Lead>:<year>:<citeslug ?? id>.
|
|
118
|
+
// <Lead> is one family name, two joined by "+", or the first with a trailing "+".
|
|
119
|
+
// See constitution Article VI.
|
|
120
|
+
export function citekey(item) {
|
|
121
|
+
const names = item.authors.map((a) => ascii(a.family ?? a.literal ?? ''));
|
|
122
|
+
const lead = names.length > 2 ? `${names[0]}+` : names.slice(0, 2).join('+');
|
|
123
|
+
const collab = item.collaboration
|
|
124
|
+
? `${ascii(item.collaboration.replace(/^the\s+/i, '').replace(/\s+collaboration$/i, ''))}+`
|
|
125
|
+
: '';
|
|
126
|
+
return `${collab}${lead}:${item.date.start.slice(0, 4)}:${item.citeslug ?? item.id}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function toBibliography(items) {
|
|
130
|
+
const entries = items
|
|
131
|
+
.filter((i) => i.type === 'publication')
|
|
132
|
+
.map(toBibtex)
|
|
133
|
+
.filter(Boolean);
|
|
134
|
+
return `${entries.join('\n\n')}\n`;
|
|
135
|
+
}
|
package/src/collect.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// What a paper site keeps of the website's data to draw its card, its collaborator
|
|
2
|
+
// map, its funding and its software citations: the item and the items it reaches,
|
|
3
|
+
// as they are in data/ (but `internal`), and the slices of config/ they use.
|
|
4
|
+
// createStore(snapshot) then runs the website's own functions on it.
|
|
5
|
+
|
|
6
|
+
import { sortItems } from './store.js';
|
|
7
|
+
|
|
8
|
+
export const SCHEMA = 1;
|
|
9
|
+
const CONFIG = ['collaborators', 'journals', 'stars', 'places'];
|
|
10
|
+
|
|
11
|
+
/** cards/<id>.json, the item's card choices, where it has one. */
|
|
12
|
+
async function readCard(reader, id) {
|
|
13
|
+
let text;
|
|
14
|
+
try {
|
|
15
|
+
text = await reader.read(`cards/${id}.json`);
|
|
16
|
+
} catch (e) {
|
|
17
|
+
if (e.notFound) return null; // none: the default
|
|
18
|
+
throw e; // a failed read is not "no card"
|
|
19
|
+
}
|
|
20
|
+
const { $schema, ...card } = JSON.parse(text);
|
|
21
|
+
return card;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The snapshot for item `id`, read through `reader` (readers.js). */
|
|
25
|
+
export async function collect(id, reader, { card } = {}) {
|
|
26
|
+
const index = await reader.index();
|
|
27
|
+
const items = new Map();
|
|
28
|
+
const get = async (ref, from) => {
|
|
29
|
+
if (items.has(ref)) return items.get(ref);
|
|
30
|
+
const path = index.get(ref);
|
|
31
|
+
if (!path) throw new Error(`no data file for "${ref}"${from ? ` (referenced by ${from})` : ''}`);
|
|
32
|
+
let item;
|
|
33
|
+
try {
|
|
34
|
+
item = JSON.parse(await reader.read(path));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
throw new Error(`${path}${from ? ` (referenced by ${from})` : ''}: ${e.message}`);
|
|
37
|
+
}
|
|
38
|
+
delete item.internal;
|
|
39
|
+
delete item.$schema;
|
|
40
|
+
items.set(ref, item);
|
|
41
|
+
return item;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const root = await get(id);
|
|
45
|
+
// What it points at: its software (in its order), grants, highlights.
|
|
46
|
+
for (const ref of root.refs ?? []) {
|
|
47
|
+
const r = await get(ref, id);
|
|
48
|
+
// A package's own papers: its card's paper buttons and how to cite it.
|
|
49
|
+
if (r.type === 'software') for (const p of r.refs ?? []) await get(p, ref);
|
|
50
|
+
}
|
|
51
|
+
// My students among its authors, for their marks and profile links.
|
|
52
|
+
for (const a of root.authors ?? []) if (a.mentee) await get(a.mentee, id);
|
|
53
|
+
|
|
54
|
+
const config = Object.fromEntries(await Promise.all(CONFIG.map(async (k) => [k, JSON.parse(await reader.read(`config/${k}.json`))])));
|
|
55
|
+
const all = [...items.values()];
|
|
56
|
+
// The map is the paper's own co-authors: not those of the papers it reaches (its packages').
|
|
57
|
+
const orcids = new Set((root.authors ?? []).map((a) => a.orcid).filter(Boolean));
|
|
58
|
+
const people = config.collaborators.people.filter((p) => orcids.has(p.orcid));
|
|
59
|
+
const orgs = new Set([
|
|
60
|
+
...people.flatMap((p) => p.affiliations.map((a) => a.organization)),
|
|
61
|
+
...all.flatMap((i) => (i.journey ?? []).map((j) => j.organization)),
|
|
62
|
+
]);
|
|
63
|
+
const places = Object.fromEntries(Object.entries(config.places.places).filter(([name]) => orgs.has(name)));
|
|
64
|
+
// A sameAs entry needs what it points at.
|
|
65
|
+
for (const p of Object.values(places)) if (p.sameAs) places[p.sameAs] = config.places.places[p.sameAs];
|
|
66
|
+
const repos = new Set(all.map((i) => i.repo).filter(Boolean));
|
|
67
|
+
const venues = new Set(all.flatMap((i) => [i.venue?.journal, i.venue?.booktitle]).filter(Boolean));
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
schema: SCHEMA,
|
|
71
|
+
root: id,
|
|
72
|
+
source: reader.source,
|
|
73
|
+
items: sortItems(all),
|
|
74
|
+
config: {
|
|
75
|
+
collaborators: { ...config.collaborators, people },
|
|
76
|
+
journals: Object.fromEntries(Object.entries(config.journals).filter(([k]) => venues.has(k))),
|
|
77
|
+
stars: { ...config.stars, stars: Object.fromEntries(Object.entries(config.stars.stars).filter(([k]) => repos.has(k))) },
|
|
78
|
+
places: { ...config.places, places },
|
|
79
|
+
acknowledgements: { papers: [] },
|
|
80
|
+
},
|
|
81
|
+
card: card ?? (await readCard(reader, id)) ?? { preset: 'paperSite' },
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/inline.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// A deliberately tiny inline-link syntax for `long` and `short`.
|
|
2
|
+
//
|
|
3
|
+
// The CV's detail lines carry links — the NSERC fellowships, a grant call, a
|
|
4
|
+
// conference page — and rendering them as buttons underneath detaches them from
|
|
5
|
+
// the words they belong to. Rather than adding rich text to the schema, `long`
|
|
6
|
+
// may contain `[text](url)` and this splits it into spans.
|
|
7
|
+
//
|
|
8
|
+
// Spans, not HTML: the same model feeds the website and the Typst CV, and only
|
|
9
|
+
// one of those understands markup.
|
|
10
|
+
|
|
11
|
+
import { dateLabel } from '@nstarkman/research-store';
|
|
12
|
+
import { sortAchievements } from './mentees.js';
|
|
13
|
+
|
|
14
|
+
// `[text](https://…)` for the web, and `[text](item:some-id)` to point at
|
|
15
|
+
// another entry on the same CV — the CV cross-references itself (a position
|
|
16
|
+
// citing the grants that funded it) and a bare URL cannot express that.
|
|
17
|
+
// `[](item:some-id)`, with no text, is for /research/'s topic introductions:
|
|
18
|
+
// the span carries `item`, and the page fills in that paper's nickTitle.
|
|
19
|
+
const LINK = /\[([^\]]*)\]\((https?:\/\/[^)\s]+|item:[a-z0-9-]+)\)/g;
|
|
20
|
+
|
|
21
|
+
// `base` is where the cross-referenced entry lives. Empty on the CV, which
|
|
22
|
+
// holds every entry itself; '/cv/' from any other page, so the reference is a
|
|
23
|
+
// link there too rather than being flattened to plain text.
|
|
24
|
+
const href = (target, base = '') =>
|
|
25
|
+
target.startsWith('item:') ? `${base}#item-${target.slice(5)}` : target;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string|string[]} text
|
|
29
|
+
* @param {string} [base] prefix for `item:` targets, e.g. '/cv/'
|
|
30
|
+
* @returns {{t: string, url?: string, item?: string}[]}
|
|
31
|
+
*/
|
|
32
|
+
export function spans(text, base = '') {
|
|
33
|
+
if (!text) return [];
|
|
34
|
+
if (Array.isArray(text)) return text.flatMap((t) => spans(t, base));
|
|
35
|
+
const out = [];
|
|
36
|
+
let last = 0;
|
|
37
|
+
for (const m of text.matchAll(LINK)) {
|
|
38
|
+
if (m.index > last) out.push({ t: text.slice(last, m.index) });
|
|
39
|
+
const item = m[2].startsWith('item:') ? m[2].slice(5) : undefined;
|
|
40
|
+
out.push({ t: m[1], url: href(m[2], base), ...(item && { item }) });
|
|
41
|
+
last = m.index + m[0].length;
|
|
42
|
+
}
|
|
43
|
+
if (last < text.length) out.push({ t: text.slice(last) });
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** "1 talk", "3 talks", "2 entries". */
|
|
48
|
+
export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
49
|
+
|
|
50
|
+
/** 1 -> "1st", 2 -> "2nd", 11 -> "11th", 23 -> "23rd". */
|
|
51
|
+
export const ordinal = (n) => {
|
|
52
|
+
const teen = n % 100 >= 11 && n % 100 <= 13;
|
|
53
|
+
return `${n}${(!teen && ['th', 'st', 'nd', 'rd'][n % 10]) || 'th'}`;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The same text with the link syntax stripped, for places that cannot link. */
|
|
57
|
+
export const plain = (text) => (text ?? '').replace(LINK, '$1');
|
|
58
|
+
|
|
59
|
+
/** Each line of a `long` as its own span list. A plain string is one line. */
|
|
60
|
+
export function lines(long, base = '') {
|
|
61
|
+
return (Array.isArray(long) ? long : [long]).filter(Boolean).map((l) => spans(l, base));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* An item's elaboration, one span array per line, in the order both renderers
|
|
66
|
+
* must use. Shared so the page and the PDF cannot disagree about what an
|
|
67
|
+
* entry's lines are — or, once the builder can tick them individually, about
|
|
68
|
+
* which line index a tick refers to.
|
|
69
|
+
*
|
|
70
|
+
* `complete` adds the lines only the complete CV shows. It is off by default so
|
|
71
|
+
* every caller that has not thought about it gets the discreet answer: a field
|
|
72
|
+
* whose whole purpose is to appear in one place should not leak into a fourth
|
|
73
|
+
* renderer by being forgotten about.
|
|
74
|
+
*
|
|
75
|
+
* The extra lines go last, after the thesis and supervisors, so a line index
|
|
76
|
+
* means the same thing whether or not they are included — the builder ticks
|
|
77
|
+
* lines by index, and inserting in the middle would silently move its ticks.
|
|
78
|
+
*/
|
|
79
|
+
export function detailLines(item, { complete = false } = {}) {
|
|
80
|
+
return [
|
|
81
|
+
...lines(item.details),
|
|
82
|
+
...(item.thesis ? [spans(`Thesis: ${item.thesis}`)] : []),
|
|
83
|
+
...(item.supervisors?.length ? [spans(`Supervisors: ${item.supervisors.join(', ')}`)] : []),
|
|
84
|
+
...(complete ? lines(item.detailsComplete) : []),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A mentee's marked achievements, one span array per line, oldest first so the
|
|
90
|
+
* entry reads as a progression. They follow everything `detailLines` returns,
|
|
91
|
+
* so existing line indexes keep their meaning, and are gated by their own
|
|
92
|
+
* `cvs` rather than by the section's detail level: a short CV that drops
|
|
93
|
+
* details can still carry the one achievement worth a line. `complete` shows all.
|
|
94
|
+
*/
|
|
95
|
+
export function achievementLines(item, preset, { complete = false } = {}) {
|
|
96
|
+
return sortAchievements(item.achievements).reverse()
|
|
97
|
+
.filter((a) => complete || a.cvs?.includes(preset))
|
|
98
|
+
.map((a) => {
|
|
99
|
+
const what = a.url ? `[${a.title}](${a.url})` : a.title;
|
|
100
|
+
const where = a.event ?? a.outlet;
|
|
101
|
+
return spans(`${what}${where ? ` — ${where}` : ''} (${dateLabel({ date: { start: a.date } })})`);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Each achievement's `cvs`, in the order `achievementLines` prints them. */
|
|
106
|
+
export const achievementCvs = (item) =>
|
|
107
|
+
sortAchievements(item.achievements).reverse().map((a) => a.cvs ?? []);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether an achievement's line starts ticked when the builder is seeded from
|
|
111
|
+
* `preset`: the complete CV prints them all, any other only those it is marked for.
|
|
112
|
+
*/
|
|
113
|
+
export const achievementTicked = (cvs, preset, unabridged) =>
|
|
114
|
+
Boolean(unabridged) || (cvs ?? []).includes(preset);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What kind of line index `i` is on an entry whose lines run: `base` detail
|
|
118
|
+
* lines, then complete-only ones up to `detailCount`, then achievements. The
|
|
119
|
+
* builder ticks by this — only the complete-only kind starts unticked on a
|
|
120
|
+
* preset that does not show it; a marked achievement is printed everywhere.
|
|
121
|
+
*/
|
|
122
|
+
export const lineTier = (i, baseCount, detailCount) =>
|
|
123
|
+
i < baseCount ? 'base' : i < detailCount ? 'complete' : 'achievement';
|
package/src/mentees.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Mentees: the mentoring items that are a person, with a profile at /people/<slug>/.
|
|
2
|
+
//
|
|
3
|
+
// A mentee's papers and software are not listed on their record; they are found
|
|
4
|
+
// from the other side, by the `mentee` id on a paper's author. The logic lives in
|
|
5
|
+
// pure functions over arrays so the tests need no data; the wrappers at the
|
|
6
|
+
// bottom hand them the database.
|
|
7
|
+
|
|
8
|
+
import { byType, resolve, titleOf } from '@nstarkman/research-store';
|
|
9
|
+
import { siteHref } from './urls.js';
|
|
10
|
+
import { spans } from './inline.js';
|
|
11
|
+
|
|
12
|
+
// Partial dates compare correctly as strings: "2026-04" > "2026" > "2025-07",
|
|
13
|
+
// so the more precise date of two sharing a prefix sorts first, newest first.
|
|
14
|
+
const newestFirst = (key) => (a, b) => (key(a) < key(b) ? 1 : key(a) > key(b) ? -1 : 0);
|
|
15
|
+
const byStart = newestFirst((i) => i.date?.start ?? '');
|
|
16
|
+
|
|
17
|
+
export const menteeName = (m) => `${m.person.given} ${m.person.family}`;
|
|
18
|
+
/** What the collaborator map calls them: their ORCID, else their slug. */
|
|
19
|
+
export const menteeKey = (m) => m.person.orcid ?? m.slug;
|
|
20
|
+
export const profileHref = (m) => `/people/${m.slug}/`;
|
|
21
|
+
// How a level reads in a sentence. A summer associate is not a student — Reem
|
|
22
|
+
// wrote code with me, not research — so the label says what the role was.
|
|
23
|
+
const LEVELS = { undergraduate: 'Undergraduate', graduate: 'Graduate', postbac: 'Postbac', postdoc: 'Postdoc', 'summer-associate': 'Summer associate' };
|
|
24
|
+
export const levelLabel = (m) => LEVELS[m.person.level];
|
|
25
|
+
|
|
26
|
+
/** The mentoring items that have a `person`, newest first. */
|
|
27
|
+
export const pickMentees = (all) => all.filter((i) => i.type === 'mentoring' && i.person).sort(byStart);
|
|
28
|
+
|
|
29
|
+
/** Publications with an author who is this mentee, newest first. */
|
|
30
|
+
export const papersOf = (m, pubs) =>
|
|
31
|
+
pubs.filter((p) => p.authors?.some((a) => a.mentee === m.id)).sort(byStart);
|
|
32
|
+
|
|
33
|
+
/** The packages that are theirs: each whose own paper (a package `refs` its
|
|
34
|
+
* paper) is one of the mentee's, then any they are an author of, each once.
|
|
35
|
+
* Not the packages their papers merely used — a paper refs those too, and a
|
|
36
|
+
* student who ran galax did not write it. */
|
|
37
|
+
export function softwareOf(m, papers, software) {
|
|
38
|
+
const ids = new Set(papers.map((p) => p.id));
|
|
39
|
+
const theirs = software.filter((s) => s.refs?.some((r) => ids.has(r)));
|
|
40
|
+
const authored = software.filter((s) => s.authors?.some((a) => a.mentee === m.id));
|
|
41
|
+
return [...new Set([...theirs, ...authored])];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const sortAchievements = (list) => [...(list ?? [])].sort(newestFirst((a) => a.date));
|
|
45
|
+
|
|
46
|
+
/** Awards first, then the rest, each newest first, cut to `n`. */
|
|
47
|
+
export function pickTop(list, n = 3) {
|
|
48
|
+
const sorted = sortAchievements(list);
|
|
49
|
+
const isAward = (a) => a.kind === 'award';
|
|
50
|
+
return [...sorted.filter(isAward), ...sorted.filter((a) => !isAward(a))].slice(0, n);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const achievements = (m) => sortAchievements(m.achievements);
|
|
54
|
+
export const topAchievements = (m, n = 3) => pickTop(m.achievements, n);
|
|
55
|
+
|
|
56
|
+
export const mentees = () => pickMentees(byType('mentoring'));
|
|
57
|
+
export const menteeById = (id) => mentees().find((m) => m.id === id);
|
|
58
|
+
export const menteeBySlug = (slug) => mentees().find((m) => m.slug === slug);
|
|
59
|
+
export const menteePapers = (m) => papersOf(m, byType('publication'));
|
|
60
|
+
export const menteeSoftware = (m) => softwareOf(m, menteePapers(m), byType('software'));
|
|
61
|
+
|
|
62
|
+
/** `research` as spans with a link each: a `[](item:id)` is named by the item's
|
|
63
|
+
* nickTitle and points at its place on this site, as /research/'s introductions
|
|
64
|
+
* name theirs. An id naming no item throws, so a typo fails the build. */
|
|
65
|
+
export const researchSpans = (text, lookup = resolve) =>
|
|
66
|
+
spans(text).map((s) => {
|
|
67
|
+
if (!s.item) return s;
|
|
68
|
+
const p = lookup(s.item);
|
|
69
|
+
if (!p) throw new Error(`research: [](item:${s.item}) names no item`);
|
|
70
|
+
return { ...s, t: s.t || titleOf(p, 'nick'), url: siteHref(p) };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** The first sentence of resolved spans, so an item link keeps its name. A stop
|
|
74
|
+
* is not the end after "et al.", "e.g.", "i.e." or a lone initial like "J.". */
|
|
75
|
+
export function firstSentenceSpans(list) {
|
|
76
|
+
const text = list.map((s) => s.t).join('');
|
|
77
|
+
let end = text.length;
|
|
78
|
+
for (const m of text.matchAll(/[.!?](?=\s|$)/g)) {
|
|
79
|
+
if (/(?:\bet al|\be\.g|\bi\.e|(?<![A-Za-z])[A-Z])$/.test(text.slice(0, m.index))) continue;
|
|
80
|
+
end = m.index + 1;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
let at = 0;
|
|
84
|
+
return list.flatMap((s) => {
|
|
85
|
+
const t = s.t.slice(0, Math.max(0, end - at));
|
|
86
|
+
at += s.t.length;
|
|
87
|
+
return t ? [{ ...s, t }] : [];
|
|
88
|
+
});
|
|
89
|
+
}
|
package/src/places.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Where a named place is on Earth.
|
|
2
|
+
//
|
|
3
|
+
// `config/places.json` holds each coordinate once. A name that is the same
|
|
4
|
+
// campus as another — "University of Chicago" and "The University of Chicago",
|
|
5
|
+
// SAO and the CfA — is written `{ "sameAs": "<other name>" }` instead of
|
|
6
|
+
// repeating coordinates, so the two can never drift apart.
|
|
7
|
+
|
|
8
|
+
import { config } from '@nstarkman/research-store';
|
|
9
|
+
|
|
10
|
+
/** `{ lat, lon }` for a place name, or undefined when it is not placed. */
|
|
11
|
+
export function locate(name) {
|
|
12
|
+
const p = config.places.places[name];
|
|
13
|
+
return p?.sameAs ? config.places.places[p.sameAs] : p;
|
|
14
|
+
}
|
package/src/readers.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Where collect() reads the website's files from: a checkout on disk, or GitHub
|
|
2
|
+
// at a ref. A reader is { index(): Map<id, path of data/<date>-<id>.json>, read(path): text }.
|
|
3
|
+
|
|
4
|
+
const DATA = /^data\/(\d{4}(?:-\d{2}){0,2})-([a-z0-9-]+)\.json$/;
|
|
5
|
+
|
|
6
|
+
const indexOf = (paths) => new Map(paths.flatMap((p) => {
|
|
7
|
+
const m = p.match(DATA);
|
|
8
|
+
return m ? [[m[2], p]] : [];
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
/** A file that is not there, told apart from a read that failed. */
|
|
12
|
+
const notFound = (path) => Object.assign(new Error(`${path}: not found`), { notFound: true });
|
|
13
|
+
|
|
14
|
+
/** The repository checked out at `root`. */
|
|
15
|
+
export function fromDisk(root) {
|
|
16
|
+
return {
|
|
17
|
+
source: { root },
|
|
18
|
+
async index() {
|
|
19
|
+
const fs = await import('node:fs');
|
|
20
|
+
return indexOf(fs.readdirSync(`${root}/data`).map((f) => `data/${f}`));
|
|
21
|
+
},
|
|
22
|
+
async read(path) {
|
|
23
|
+
const fs = await import('node:fs');
|
|
24
|
+
try {
|
|
25
|
+
return fs.readFileSync(`${root}/${path}`, 'utf8');
|
|
26
|
+
} catch (e) {
|
|
27
|
+
throw e.code === 'ENOENT' ? notFound(path) : e;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `repo` (owner/name) on GitHub at `ref`: one listing from the tree API, then
|
|
34
|
+
* raw files from the commit it resolved to, so every file is of one commit. */
|
|
35
|
+
export function fromGitHub({ repo, ref = 'main', fetch = globalThis.fetch }) {
|
|
36
|
+
let sha;
|
|
37
|
+
const get = async (url, as, path) => {
|
|
38
|
+
const r = await fetch(url);
|
|
39
|
+
if (r.status === 404 && path) throw notFound(path);
|
|
40
|
+
if (!r.ok) throw new Error(`GET ${url}: ${r.status}`);
|
|
41
|
+
return as === 'json' ? r.json() : r.text();
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
source: { repo, ref },
|
|
45
|
+
async index() {
|
|
46
|
+
const commit = await get(`https://api.github.com/repos/${repo}/commits/${ref}`, 'json');
|
|
47
|
+
sha = commit.sha;
|
|
48
|
+
const tree = await get(`https://api.github.com/repos/${repo}/git/trees/${sha}?recursive=1`, 'json');
|
|
49
|
+
this.source = { repo, ref, sha };
|
|
50
|
+
return indexOf(tree.tree.map((t) => t.path));
|
|
51
|
+
},
|
|
52
|
+
async read(path) {
|
|
53
|
+
return get(`https://raw.githubusercontent.com/${repo}/${sha ?? ref}/${path}`, 'text', path);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// The nstarkman.space data layer as a store: the functions every renderer sorts,
|
|
2
|
+
// filters and formats items through, over whatever items and config snapshots it is
|
|
3
|
+
// given — the website's whole data/ (its src/lib/data.js), or the slice of it a paper
|
|
4
|
+
// site keeps (collect.js). Moved here from src/lib/data.js unchanged but for that.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Partial dates (YYYY, YYYY-MM, YYYY-MM-DD) compare correctly as strings, so a
|
|
8
|
+
* month orders items inside a year even though only the year is ever shown.
|
|
9
|
+
*
|
|
10
|
+
* Work in preparation has no date to speak of — the year on the record is a
|
|
11
|
+
* guess that keeps the filename honest — so it sorts ahead of everything that
|
|
12
|
+
* has actually happened.
|
|
13
|
+
*/
|
|
14
|
+
const sortKey = (i) => (i.status === 'in-prep' ? '9999' : (i.date?.start ?? ''));
|
|
15
|
+
const byDateDesc = (a, b) => sortKey(b).localeCompare(sortKey(a));
|
|
16
|
+
|
|
17
|
+
/** Items newest first, as every list on the site has them. */
|
|
18
|
+
export const sortItems = (items) => [...items].sort(byDateDesc);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param data.items the items, newest first (sortItems)
|
|
22
|
+
* @param data.lists the bare enumerations of data/lists/, by id
|
|
23
|
+
* @param data.config the config/ snapshots the functions read: collaborators,
|
|
24
|
+
* acknowledgements, journals, stars (and places, for places.js)
|
|
25
|
+
*/
|
|
26
|
+
export function createStore({ items: given, lists = new Map(), config }) {
|
|
27
|
+
// Newest first, as every list has them (a no-op for the website's, already sorted).
|
|
28
|
+
const items = sortItems(given);
|
|
29
|
+
const { collaborators, acknowledgements, journals } = config;
|
|
30
|
+
|
|
31
|
+
const byId = new Map(items.map((i) => [i.id, i]));
|
|
32
|
+
|
|
33
|
+
const byType = (...types) => items.filter((i) => types.includes(i.type));
|
|
34
|
+
const featured = (...types) => byType(...types).filter((i) => i.featured);
|
|
35
|
+
const resolve = (id) => byId.get(id);
|
|
36
|
+
|
|
37
|
+
/** An item's title at one of three lengths: `full`, `short` (shortTitle) or
|
|
38
|
+
* `nick` (nickTitle), each falling back to the next longer one. */
|
|
39
|
+
function titleOf(item, which = 'full') {
|
|
40
|
+
if (which === 'nick') return item.nickTitle ?? item.shortTitle ?? item.title;
|
|
41
|
+
if (which === 'short') return item.shortTitle ?? item.title;
|
|
42
|
+
return item.title;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The full title cut around its short title — [before, short, after], the
|
|
46
|
+
* short part in the full title's own casing. Where the short title is not part
|
|
47
|
+
* of the full one, the text before the first colon stands in; null if there is
|
|
48
|
+
* neither. */
|
|
49
|
+
function splitTitle(item) {
|
|
50
|
+
let short = item.shortTitle;
|
|
51
|
+
let i = short ? item.title.toLowerCase().indexOf(short.toLowerCase()) : -1;
|
|
52
|
+
if (i < 0) {
|
|
53
|
+
i = 0;
|
|
54
|
+
short = item.title.split(': ')[0];
|
|
55
|
+
}
|
|
56
|
+
if (short === item.title) return null;
|
|
57
|
+
return [item.title.slice(0, i), item.title.slice(i, i + short.length), item.title.slice(i + short.length)];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Research-highlight topics in the order /research/ shows them; a topic left
|
|
61
|
+
// out is not shown at all: `software` cards wait here. Anchored there as
|
|
62
|
+
// `#hl-topic-<key>`, which /publications/ links to.
|
|
63
|
+
//
|
|
64
|
+
// The third entry is the topic's introduction, one string per paragraph.
|
|
65
|
+
// `[](item:<id>)` puts that paper's inline-card into the sentence, named by its
|
|
66
|
+
// `nickTitle` and linking to its card below.
|
|
67
|
+
const HIGHLIGHT_TOPICS = [
|
|
68
|
+
['extragalactic', 'Extragalactic', [
|
|
69
|
+
'Weighing a galaxy’s dark matter halo is comparatively easy: its rotation curve tells us how much gravitational mass is present. [](item:maximum-discs) turned decades of by-eye mass modelling into an algorithm for measuring how much of that gravity can be supplied by stars, while [](item:sparc-halo-density) found that haloes have remarkably similar characteristic densities across galaxies spanning five orders of magnitude in brightness. The harder—and more revealing—question is shape. Cold dark matter predicts haloes that are flattened and triaxial; self-interacting dark matter makes them rounder. Stellar streams are ideal probes because their paths trace the gravitational field, preserving a visible record of the halo geometry.',
|
|
70
|
+
'Our work turns that record into a scalable test of dark matter. [](item:potamides-apj) showed that projected stream tracks around nearby galaxies can constrain halo shape, using 15 systems; [](item:potamides-joss) made that inference run in minutes on a laptop. [](item:euclid-eggs-pilot) then took the method beyond the local Universe: it was the first analysis of stellar streams around more distant galaxies, and the first to combine multiple streams in a single joint halo-shape measurement—13 galaxies in Euclid’s first data. The real payoff is statistical: in a round halo, streams cannot curve away from their host galaxy’s centre in projection, while non-spherical haloes can produce such “wrong-way” curves. [](item:stream-convexity-rate) turns that signature into a population test: across about 10,000 streams, within reach of Euclid, Rubin and Roman, how often those curves appear can distinguish cold dark matter from self-interacting dark matter at up to 5σ.',
|
|
71
|
+
]],
|
|
72
|
+
['galactic', 'Galactic', [
|
|
73
|
+
'Galaxies forget. Stars that arrived in the same merger are gradually stirred into the Milky Way’s background, erasing the record of how it was assembled. [](item:galactic-amnesia) measures this loss of memory: radial velocities forget a merger’s mass and timing within about 5 billion years, but orbital energies retain the signal for more than 10—provided we know the Galaxy’s gravitational potential. Stellar streams are the exception. They are stars stripped from one cluster and stretched along nearly a single orbit, so one snapshot reveals a path that would take a single star hundreds of millions of years to trace. [](item:pal5-gaia-dr2) nearly doubled the known length of Palomar 5’s leading tail; [](item:stream-members-only) identifies a stream’s members star by star; and [](item:characterizing-stream-tracks) maps a stream’s path in about a second, without assuming a model for the Milky Way.',
|
|
74
|
+
'Streams probe dark matter on both large and small scales: the Galaxy-wide gravitational potential that guides their orbits, and the compact dark-matter clumps that perturb them. [](item:streamsculptor) finds that GD-1 may have been struck by up to a hundred subhaloes too small to form stars, leaving its stars moving about three times more randomly than an undisturbed stream would—just what cold dark matter predicts. [](item:pinns-mnras) learns the potential itself from measured accelerations: tested on simulated Milky Ways, it recovers the influence of both the Galactic bar and the Large Magellanic Cloud even when neither is included in the starting model.',
|
|
75
|
+
]],
|
|
76
|
+
['cmb', 'Cosmic Microwave Background', [
|
|
77
|
+
'The cosmic microwave background is almost a perfect blackbody, but its tiny spectral distortions contain information that its temperature map cannot provide. In [](item:cmb-spectrum-distortions), we calculate a signal that standard cosmology must produce. As the Universe became transparent, photons diffused out of hotter and colder regions, mixing blackbodies with slightly different temperatures. That mixing created a faint Compton y-distortion that is largest where the temperatures being mixed differ most, so it correlates with the squared temperature map. That correlation should already be detectable with ACT and SPT, at a signal-to-noise of about 12, opening a new observational test of early-Universe physics.',
|
|
78
|
+
]],
|
|
79
|
+
['dm-direct-detection', 'Dark Matter Direct Detection', [
|
|
80
|
+
'Direct detection usually means placing a shielded detector underground and waiting for a dark-matter particle to scatter. But if dark matter comes in macroscopic objects—far heavier, and therefore far rarer, than ordinary particle candidates—no laboratory detector is large enough to expect an encounter. In [](item:macro-lightning), we turn the atmosphere into the detector instead. A macro passing through a thunderstorm would leave a long, straight channel of ionized air that could seed a lightning bolt straight as a ruler, unlike the jagged bolts produced by ordinary storms; the odds of ordinary lightning running even ten steps that straight are about 3 in 10 trillion. Searching for straight lightning on Earth, or even on Jupiter, turns thunderstorms into planet-sized dark-matter experiments.',
|
|
81
|
+
]],
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The areas of /software/, in order: [group, heading, introduction]. A
|
|
86
|
+
* package's `group` puts it in one; its `tier` sets its card's size there. The
|
|
87
|
+
* introduction may carry [text](url), as an item's `details` does.
|
|
88
|
+
*/
|
|
89
|
+
const SOFTWARE_GROUPS = [
|
|
90
|
+
['astropy', 'Astropy', 'The community core package for astronomy in Python, and cosmology.api, a standard interface for cosmology libraries.'],
|
|
91
|
+
['foundations', 'Foundations: Arrays, Units & Coordinates', 'The layers the rest is built on, in JAX: quax lets custom array types through JAX’s primitives, with plum’s multiple dispatch beneath it; unxt adds units, and coordinax vectors, frames and transformations.'],
|
|
92
|
+
['dynamics', 'Galactic Dynamics & Streams', 'Orbits, potentials and the stellar streams that trace them: galax integrates orbits and generates streams, and the rest measure streams and learn potentials — most of it in JAX, on the foundations above.'],
|
|
93
|
+
['dark-matter', 'Dark Matter Direct Detection', 'The code behind searching thunderstorms for macroscopic dark matter.'],
|
|
94
|
+
['numerics', 'Numerics', 'Numerical pieces JAX was missing: extras for Diffrax, scipy.special, bounded loops and kernel density estimates.'],
|
|
95
|
+
['utilities', 'Utilities', 'Small, general-purpose packages that fell out of the work above.'],
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
/** "Nathaniel" -> "N."; "Adrian M." -> "A. M." */
|
|
99
|
+
function initials(given) {
|
|
100
|
+
return given
|
|
101
|
+
.split(/\s+/)
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.map((part) => (part.endsWith('.') ? part : `${part[0].toUpperCase()}.`))
|
|
104
|
+
.join(' ');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Display form. BibTeX uses "Family, Given" instead — see lib/bibtex.js. */
|
|
108
|
+
function displayName(a) {
|
|
109
|
+
if (a.literal) return a.literal;
|
|
110
|
+
return [initials(a.given), a.family, a.suffix].filter(Boolean).join(' ');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const POSITION_NAMES = { first: 1, second: 2, third: 3, fourth: 4 };
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Which author position the owner holds, 1-based, or null if he is not on it.
|
|
117
|
+
*
|
|
118
|
+
* Derived from the index of the author marked `me` rather than stored, so it
|
|
119
|
+
* cannot drift out of step with the author list. `collaboration` is its own
|
|
120
|
+
* field and takes no slot, which is why the Euclid paper comes out first-author
|
|
121
|
+
* even though it prints as "Euclid Collaboration, N. Starkman, …".
|
|
122
|
+
*
|
|
123
|
+
* A record can still override with `authorPosition` for the case derivation
|
|
124
|
+
* cannot see: a byline that understates the credit.
|
|
125
|
+
*/
|
|
126
|
+
function authorPosition(item) {
|
|
127
|
+
const explicit = item.authorPosition;
|
|
128
|
+
if (typeof explicit === 'number') return explicit;
|
|
129
|
+
if (typeof explicit === 'string') return POSITION_NAMES[explicit] ?? null;
|
|
130
|
+
const i = (item.authors ?? []).findIndex((a) => a.me);
|
|
131
|
+
return i === -1 ? null : i + 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The article's page at the journal, or null.
|
|
136
|
+
*
|
|
137
|
+
* Only for published work: a submitted paper has no article page, and pointing
|
|
138
|
+
* its venue at an arXiv DOI would claim otherwise.
|
|
139
|
+
*
|
|
140
|
+
* The record's own `paper` link wins over the DOI because it is the curated
|
|
141
|
+
* one — often the publisher's own reader rather than the doi.org redirect. The
|
|
142
|
+
* ADS link that `links()` synthesises from a bibcode is deliberately not used:
|
|
143
|
+
* ADS is a database record about the paper, not the journal's page for it.
|
|
144
|
+
*/
|
|
145
|
+
function venueUrl(item) {
|
|
146
|
+
if (item.status !== 'published') return null;
|
|
147
|
+
const curated = (item.links ?? []).find((l) => l.rel === 'paper' && l.url);
|
|
148
|
+
if (curated) return curated.url;
|
|
149
|
+
// 10.48550 is arXiv's own prefix — a preprint DOI, not a journal article.
|
|
150
|
+
if (item.doi && !item.doi.startsWith('10.48550/')) return `https://doi.org/${item.doi}`;
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A package's papers, where the record points at any, oldest first.
|
|
156
|
+
*
|
|
157
|
+
* `unxt` refs `unxt-joss`, `astropy` refs the v5 paper, `trackstream` refs the
|
|
158
|
+
* stream-tracks paper. The software entry itself carries only code and docs
|
|
159
|
+
* links, so without following the ref a published package looks unpublished.
|
|
160
|
+
*
|
|
161
|
+
* `refs` is not a paper field: `coordinax` refs `unxt`, another package. Hence
|
|
162
|
+
* the type check — it is what stops a package being called published because
|
|
163
|
+
* it happens to point at a sibling.
|
|
164
|
+
*
|
|
165
|
+
* The package's own date is when the repository started; each paper carries
|
|
166
|
+
* its own year, so a package with a second paper shows two. Nothing is copied
|
|
167
|
+
* onto the software record, so the dates cannot drift from the papers'.
|
|
168
|
+
*/
|
|
169
|
+
function softwarePapers(sw) {
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const id of sw.refs ?? []) {
|
|
172
|
+
const ref = resolve(id);
|
|
173
|
+
if (ref?.type !== 'publication') continue;
|
|
174
|
+
// In prep has no year to claim — dateLabel says the same.
|
|
175
|
+
const year = ref.status === 'in-prep' ? null : String(ref.date.start).slice(0, 4);
|
|
176
|
+
const paper = { rel: 'paper', label: year ? `paper ${year}` : 'paper', id: ref.id, status: ref.status, year };
|
|
177
|
+
// The article at the journal first. Taking the first citation link instead
|
|
178
|
+
// sent Astropy to its ADS record and macro_lightning to its arXiv preprint.
|
|
179
|
+
const article = venueUrl(ref);
|
|
180
|
+
// Nothing published yet: a preprint or a review thread is what there is.
|
|
181
|
+
const url = article ?? primaryLink(ref)?.url;
|
|
182
|
+
if (url) out.push({ ...paper, url });
|
|
183
|
+
}
|
|
184
|
+
return out.sort((a, b) => (a.year ?? '9999').localeCompare(b.year ?? '9999'));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The packages of mine a set of papers used, most-used first, and otherwise
|
|
189
|
+
* in the order the papers name them — a paper names its main package first,
|
|
190
|
+
* so for one paper that leads.
|
|
191
|
+
*
|
|
192
|
+
* The other direction from softwarePapers: a paper's `refs` name the packages
|
|
193
|
+
* it used, read from its software section, so /research/ can list a topic's
|
|
194
|
+
* software under its papers. A package's own paper refs it too (`unxt-joss`
|
|
195
|
+
* refs `unxt`), which is the use it was written for.
|
|
196
|
+
*/
|
|
197
|
+
function softwareUsed(papers) {
|
|
198
|
+
const n = new Map();
|
|
199
|
+
for (const p of papers) {
|
|
200
|
+
for (const id of p.refs ?? []) {
|
|
201
|
+
const s = resolve(id);
|
|
202
|
+
if (s?.type === 'software') n.set(s, (n.get(s) ?? 0) + 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// A Map keeps first-seen order and sort is stable, so ties keep it.
|
|
206
|
+
return [...n].sort((a, b) => b[1] - a[1]).map(([s]) => s);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Where a co-author's name points. ORCID is the identifier, so it is the link. */
|
|
210
|
+
const orcidUrl = (orcid) => (orcid ? `https://orcid.org/${orcid}` : null);
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Where a collaborator was working on a given date, from the dated employment
|
|
214
|
+
* history in config/collaborators.json.
|
|
215
|
+
*
|
|
216
|
+
* Returns null rather than a guess in three cases worth keeping separate in the
|
|
217
|
+
* caller's mind: the person is not in the database, they list no employment on
|
|
218
|
+
* ORCID, or nothing they list covers that date. A gap in a career is real —
|
|
219
|
+
* people leave posts before starting the next — and papering over one would be
|
|
220
|
+
* inventing a fact.
|
|
221
|
+
*
|
|
222
|
+
* `date` is "YYYY" or "YYYY-MM"; the comparisons are string comparisons, which
|
|
223
|
+
* is why the database stores dates in that order.
|
|
224
|
+
*/
|
|
225
|
+
function affiliationAt(orcid, date) {
|
|
226
|
+
if (!orcid || !date) return null;
|
|
227
|
+
const person = collaborators.people.find((p) => p.orcid === orcid);
|
|
228
|
+
if (!person) return null;
|
|
229
|
+
const held = person.affiliations.filter((a) => {
|
|
230
|
+
if (!a.start) return false; // undated: cannot claim it covered this date
|
|
231
|
+
if (a.start > date) return false;
|
|
232
|
+
return !a.end || a.end >= date;
|
|
233
|
+
});
|
|
234
|
+
if (held.length === 0) return null;
|
|
235
|
+
// Concurrent posts happen; the most recently taken up is the one a paper of
|
|
236
|
+
// that date would most likely have printed.
|
|
237
|
+
return held.reduce((best, a) => (a.start > best.start ? a : best));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const ELLIPSIS = { name: '…', me: false, student: null, url: null, affiliation: null };
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Authors for display, truncated per preset. The data always holds the full
|
|
244
|
+
* list — truncating there would corrupt the BibTeX — so it happens here.
|
|
245
|
+
*
|
|
246
|
+
* `url` is the author's ORCID page, and is null for the owner: this is his own
|
|
247
|
+
* site, his ORCID is already in the CV header, and a self-link in every byline
|
|
248
|
+
* would be noise rather than navigation.
|
|
249
|
+
*/
|
|
250
|
+
function authors(item, max = Infinity, { fill = false } = {}) {
|
|
251
|
+
const all = item.authors ?? [];
|
|
252
|
+
// Where they were at the time. The paper's own printed affiliation wins where
|
|
253
|
+
// there is one — that is what the paper actually claimed — and the employment
|
|
254
|
+
// history answers for the rest.
|
|
255
|
+
const at = item.date?.start ?? null;
|
|
256
|
+
// Cutting at `max` would drop the owner into "et al." — the one name a CV's
|
|
257
|
+
// reader is looking for. Keep the first author and elide to the owner instead: "J. Nibauer, …, N. Starkman, et al."
|
|
258
|
+
// At one name, there is room only for mine: "…, N. Starkman, et al." With
|
|
259
|
+
// `fill`, the cut is exactly `max` names, those before the elision filling
|
|
260
|
+
// it: at three, "J. Nibauer, A. Bonaca, …, N. Starkman, et al."
|
|
261
|
+
const meAt = all.findIndex((a) => a.me);
|
|
262
|
+
const elide = max >= 1 && meAt >= max;
|
|
263
|
+
const before = fill ? all.slice(0, max - 1) : all.slice(0, Math.min(1, max - 1));
|
|
264
|
+
const picked = !elide ? all.slice(0, max) : [...before, null, all[meAt]];
|
|
265
|
+
const shown = picked.map((a) => a === null ? ELLIPSIS : ({
|
|
266
|
+
name: displayName(a),
|
|
267
|
+
me: Boolean(a.me),
|
|
268
|
+
student: a.student ?? null,
|
|
269
|
+
orcid: a.me ? null : (a.orcid ?? null),
|
|
270
|
+
url: a.me ? null : orcidUrl(a.orcid),
|
|
271
|
+
affiliation: a.me ? null : (a.affiliation ?? affiliationAt(a.orcid, at)?.organization ?? null),
|
|
272
|
+
}));
|
|
273
|
+
return { shown, etal: all.length > (elide ? meAt + 1 : max), collaboration: item.collaboration };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Author-position stops for a "first author / first two / …" filter. A stop
|
|
278
|
+
* that admits the same papers as the one before it, or as all of them, is a
|
|
279
|
+
* control that does nothing — with positions 1, 2 and 4 that leaves 1 and 2.
|
|
280
|
+
* Items with no position (not a publication, or no author marked `me`) are
|
|
281
|
+
* ignored.
|
|
282
|
+
*/
|
|
283
|
+
function positionStops(items) {
|
|
284
|
+
const positions = items.map(authorPosition).filter((n) => n != null);
|
|
285
|
+
const stops = [];
|
|
286
|
+
for (let n = 1; n <= 4; n += 1) {
|
|
287
|
+
const count = positions.filter((p) => p <= n).length;
|
|
288
|
+
const previous = stops.length ? stops[stops.length - 1].count : 0;
|
|
289
|
+
if (count > previous && count < positions.length) stops.push({ n, count });
|
|
290
|
+
}
|
|
291
|
+
return stops;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** "USD 10,000", "CAD 20,000\u201330,000 p.a.", or null. */
|
|
295
|
+
function money(a) {
|
|
296
|
+
if (!a) return null;
|
|
297
|
+
const n = (v) => v.toLocaleString('en-US');
|
|
298
|
+
const span = a.valueMax ? `${n(a.value)}\u2013${n(a.valueMax)}` : n(a.value);
|
|
299
|
+
return `${a.currency} ${span}${a.perAnnum ? ' p.a.' : ''}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** "The Astrophysical Journal 979, 155"; short, "ApJ 979, 155", where
|
|
303
|
+
* config/journals.json has the journal's short name. */
|
|
304
|
+
function venueLine(item, { short = false } = {}) {
|
|
305
|
+
const v = item.venue;
|
|
306
|
+
if (!v) return '';
|
|
307
|
+
const full = v.journal ?? v.booktitle ?? v.school ?? '';
|
|
308
|
+
const title = short ? journals[full] ?? full : full;
|
|
309
|
+
const tail = [v.volume, v.pages].filter(Boolean).join(', ');
|
|
310
|
+
return [title, tail].filter(Boolean).join(' ');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const year = (item) => (item.date?.start ?? '').slice(0, 4);
|
|
314
|
+
|
|
315
|
+
/** "2024 –", "2018 – 2024", "2025" */
|
|
316
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
317
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {object} item
|
|
321
|
+
* @param {{month?: boolean}} [opts]
|
|
322
|
+
* `month` spells a single date as "Feb 2024" where the record has one. Ranges
|
|
323
|
+
* stay year-only either way — "Feb 2018 – Jun 2024" is noise, and the CV this
|
|
324
|
+
* replaces wrote them as years too.
|
|
325
|
+
*/
|
|
326
|
+
function dateLabel(item, { month = false } = {}) {
|
|
327
|
+
// A year on an in-preparation paper would be a claim it cannot support.
|
|
328
|
+
if (item.status === 'in-prep') return 'In Prep';
|
|
329
|
+
const { start, end, present } = item.date ?? {};
|
|
330
|
+
const y = (d) => (d ?? '').slice(0, 4);
|
|
331
|
+
if (present) return `${y(start)} –`;
|
|
332
|
+
if (end) return `${y(start)} – ${y(end)}`;
|
|
333
|
+
const [, mm] = (start ?? '').split('-');
|
|
334
|
+
return month && mm ? `${MONTHS[Number(mm) - 1]} ${y(start)}` : y(start);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Order links land in. The paper itself leads: the article at the journal,
|
|
338
|
+
// then the records of it, then everything that is code or data.
|
|
339
|
+
const REL_ORDER = ['paper', 'doi', 'ads', 'preprint', 'repo', 'code',
|
|
340
|
+
'docs', 'data', 'slides', 'event', 'homepage'];
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The vocabulary key for a link. ADS is synthesised as a `paper` rel, so the
|
|
344
|
+
* label is what distinguishes it — ordering and iconography must agree on that
|
|
345
|
+
* or the ADS link sorts first and then draws the generic paper glyph.
|
|
346
|
+
*/
|
|
347
|
+
const relKey = (l) => (l.label === 'ADS' ? 'ads' : l.rel);
|
|
348
|
+
|
|
349
|
+
// Real marks where one exists, a drawn glyph otherwise. Keyed by the same
|
|
350
|
+
// closed vocabulary, so a new rel is a visible gap rather than a silent
|
|
351
|
+
// fallback everywhere.
|
|
352
|
+
const REL_ICON = {
|
|
353
|
+
ads: 'ads', preprint: 'arxiv', paper: 'paper', doi: 'paper', repo: 'repo',
|
|
354
|
+
code: 'github', stars: 'star', docs: 'docs', data: 'data', slides: 'slides',
|
|
355
|
+
event: 'link', homepage: 'link',
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The one link a title points at: the article, else the preprint, else its
|
|
360
|
+
* ADS record, else null. The ADS link is synthesised as a `paper` rel, so it
|
|
361
|
+
* is told apart by relKey — taking the first `paper` sent titles to ADS ahead
|
|
362
|
+
* of arXiv and the DOI. No fallback to code or docs — a paper's title linking
|
|
363
|
+
* to its repository would be a surprise, and a submitted paper with nothing
|
|
364
|
+
* public stays a bare title rather than a dead '#'.
|
|
365
|
+
*/
|
|
366
|
+
function primaryLink(item) {
|
|
367
|
+
const all = links(item);
|
|
368
|
+
const by = (k) => all.find((l) => relKey(l) === k);
|
|
369
|
+
const article = venueUrl(item);
|
|
370
|
+
return by('paper') ?? (article ? { rel: 'doi', url: article, label: item.doi } : null)
|
|
371
|
+
?? by('doi') ?? by('preprint') ?? by('ads') ?? null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Links for rendering, in REL_ORDER. The ADS entry is synthesised from
|
|
376
|
+
* `bibcode` rather than stored, so a paper can never carry a bibcode and a
|
|
377
|
+
* contradicting ADS URL.
|
|
378
|
+
*/
|
|
379
|
+
function links(item) {
|
|
380
|
+
// Copies: the label set below is the rendering's, not the record's.
|
|
381
|
+
const out = (item.links ?? []).map((l) => ({ ...l }));
|
|
382
|
+
if (item.bibcode) {
|
|
383
|
+
out.push({
|
|
384
|
+
rel: 'paper',
|
|
385
|
+
url: `https://ui.adsabs.harvard.edu/abs/${item.bibcode}/abstract`,
|
|
386
|
+
label: 'ADS',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (item.arxiv && !out.some((l) => l.rel === 'preprint')) {
|
|
390
|
+
out.push({
|
|
391
|
+
rel: 'preprint',
|
|
392
|
+
url: `https://arxiv.org/abs/${item.arxiv}`,
|
|
393
|
+
label: `arXiv:${item.arxiv}`,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
for (const l of out) {
|
|
397
|
+
if ((l.rel === 'paper' || l.rel === 'doi') && !l.label && item.doi) l.label = item.doi;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const rank = (l) => {
|
|
401
|
+
const i = REL_ORDER.indexOf(relKey(l));
|
|
402
|
+
return i === -1 ? REL_ORDER.length : i;
|
|
403
|
+
};
|
|
404
|
+
return out.sort((a, b) => rank(a) - rank(b));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Papers that thank him in their acknowledgements, newest first: the Assists
|
|
409
|
+
* on /publications/. Not items — he did not write them — but given `links`
|
|
410
|
+
* so links() and primaryLink() read them like one. arXiv's own DOI prefix is a
|
|
411
|
+
* preprint, which `arxiv` already links, not a journal article.
|
|
412
|
+
*/
|
|
413
|
+
const assists = acknowledgements.papers.map((p) => ({
|
|
414
|
+
...p,
|
|
415
|
+
links: p.doi && !p.doi.startsWith('10.48550/') ? [{ rel: 'paper', url: `https://doi.org/${p.doi}` }] : [],
|
|
416
|
+
}));
|
|
417
|
+
|
|
418
|
+
/** An Assist as a card reads a paper: published, dated and placed as an item
|
|
419
|
+
* is. Its venue is ADS's short name, set as the journal. */
|
|
420
|
+
const assistItem = (a) => ({
|
|
421
|
+
...a, id: `assist-${a.bibcode ?? a.arxiv}`, type: 'publication', status: 'published',
|
|
422
|
+
date: { start: a.date }, venue: { journal: a.venue },
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
/** Whether roles at one institution group by default: when every one opts in
|
|
426
|
+
* with `groupRoles`. The CV builder may choose otherwise, per institution. */
|
|
427
|
+
const groupedByDefault = (items) => items.every((i) => i.groupRoles);
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Positions at one institution, grouped where `grouped(items)` says so — by
|
|
431
|
+
* default where every role opts in with `groupRoles`. Appointments in the same
|
|
432
|
+
* organization then read as one place with several roles. A group sits where
|
|
433
|
+
* its earliest member does, as its dates start there; every other item stays
|
|
434
|
+
* where it is. Returns `[{ items }]` for a group, `[{ item }]` otherwise.
|
|
435
|
+
*/
|
|
436
|
+
function institutionGroups(list, grouped = groupedByDefault) {
|
|
437
|
+
const at = new Map();
|
|
438
|
+
for (const item of list) {
|
|
439
|
+
if (item.institution) at.set(item.institution, [...(at.get(item.institution) ?? []), item]);
|
|
440
|
+
}
|
|
441
|
+
const groups = new Map([...at].filter(([, items]) => items.length > 1 && grouped(items)));
|
|
442
|
+
const out = [];
|
|
443
|
+
for (const item of list) {
|
|
444
|
+
const items = groups.get(item.institution);
|
|
445
|
+
if (!items) out.push({ item });
|
|
446
|
+
else if (item === items.at(-1)) out.push({ items });
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** A group's roles joined as one title — "Coordinator (2025 – 2028) & Core
|
|
452
|
+
* Developer" — a role's own dates given where they differ from the group's. */
|
|
453
|
+
function groupTitle(items) {
|
|
454
|
+
const span = dateLabel({ date: groupDate(items) });
|
|
455
|
+
const names = items.map((i) => (dateLabel(i) === span ? i.title : `${i.title} (${dateLabel(i)})`));
|
|
456
|
+
return names.length > 1 ? `${names.slice(0, -1).join(', ')} & ${names.at(-1)}` : names[0];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The span a group of dated items covers, as one item's `date`: earliest start,
|
|
460
|
+
* and open-ended if any member is, else the latest end. */
|
|
461
|
+
function groupDate(items) {
|
|
462
|
+
const start = items.map((i) => i.date.start).sort()[0];
|
|
463
|
+
if (items.some((i) => i.date.present)) return { start, present: true };
|
|
464
|
+
const end = items.map((i) => i.date.end ?? i.date.start).sort().at(-1);
|
|
465
|
+
return { start, end };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { items, lists, config, byType, featured, resolve, titleOf, splitTitle, HIGHLIGHT_TOPICS, SOFTWARE_GROUPS, displayName, authorPosition, venueUrl, softwarePapers, softwareUsed, affiliationAt, authors, positionStops, money, venueLine, year, dateLabel, relKey, REL_ICON, primaryLink, links, assists, assistItem, groupedByDefault, institutionGroups, groupTitle, groupDate };
|
|
469
|
+
}
|
package/src/urls.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Where an item's entry is on nstarkman.space — site-absolute, so a paper site at
|
|
2
|
+
// nstarkman.space/<repo>/ links back into the website.
|
|
3
|
+
|
|
4
|
+
/** Where it links with title:…:site — the item's entry on this site, where
|
|
5
|
+
* it has one: a paper on /publications/ (an in-prep one is not listed), a
|
|
6
|
+
* package on /software/, a synthesis on /research/. */
|
|
7
|
+
export const siteHref = (item) => (item.type === 'publication' ? (item.status !== 'in-prep' ? `/publications/#pub-${item.id}` : null)
|
|
8
|
+
: item.type === 'software' ? `/software/#sw-${item.id}` : item.type === 'highlight' ? `/research/#hl-${item.id}` : null);
|
|
9
|
+
|