Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ PR, or it renders as nothing.

`repo` is the *paper's* repository; `code` is the software itself.

A software card's last button is its stars (for a package with at least `STARS_MIN`, 40) — `stars` among the `buttons` keys — from `config/stars.json`,
A software card's last button is its stars (for a package with at least `STARS_MIN`, 40; under it the link is `optional`: left out of `buttons:all`, shown where a name lists it — `buttons:code,docs,stars` — and a pill in the Card Builder, unticked) — `stars` among the `buttons` keys — from `config/stars.json`,
a snapshot of each `repo`'s GitHub stars that `scripts/collect-stars.mjs` rewrites and
`.github/workflows/refresh-stars.yml` proposes monthly as a pull request. Do not edit it by hand.

Expand Down
2 changes: 1 addition & 1 deletion src/components/Card.astro
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const { summary: sum, details: det } = cardText(item);
// The paper button's links, each, for the embed page to choose from.
const paperTo = { auto: paperHref(item), journal: paperHref(item, 'journal'), arxiv: paperHref(item, 'arxiv'), ads: paperHref(item, 'ads'), site: paperHref(item, 'site') };
const paperWord = face.paper && face.paper.label !== 'icon' ? face.paper.label : 'paper';
const linkList = cardLinks(item);
const linkList = cardLinks(item, allCounts);
const contextLabel = topic && `${topic[1]} research highlights`;
---
<article class:list={['card', cls]} id={id} {...attrs} data-type={item.type} {...data} style={style}
Expand Down
4 changes: 2 additions & 2 deletions src/lib/cardbuilder/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function mountCardBuilder({ form, data }) {

// The controls as the model reads them.
const snap = () => new FormData(form);
const state = () => ({ it: items[form.elements.card.value], figureOn: el('eb-figure-on').checked, space, areasOn, btnOrder, groups, allLinks: [...form.querySelectorAll('[name=link]')].map((b) => b.value), limits });
const state = () => ({ it: items[form.elements.card.value], figureOn: el('eb-figure-on').checked, space, areasOn, btnOrder, groups, allLinks: [...form.querySelectorAll('[name=link]:not([data-optional])')].map((b) => b.value), limits });
const spec = () => readSpec(snap(), state());
const stackedIn = (a) => stackedInModel(snap(), state(), a);
const canTuneRow = (r) => canTune(r.dataset.tune, snap(), state());
Expand All @@ -97,7 +97,7 @@ export function mountCardBuilder({ form, data }) {
el('eb-extras-drop').hidden = !extras.innerHTML;
// The paper button first, as it is in the box, where it has somewhere
// to link; then each link's.
el('eb-links').innerHTML = (Object.values(it.paperTo).some(Boolean) ? '<label title="A button of words, paper, first among the buttons: to the article, else arXiv"><input type="checkbox" class="eb-check" name="paperbtn" />paper</label>' : '') + it.links.map(([k, name, title]) => `<label title="${esc(title)}"><input type="checkbox" class="eb-check" name="link" value="${k}" checked />${esc(name)}</label>`).join('');
el('eb-links').innerHTML = (Object.values(it.paperTo).some(Boolean) ? '<label title="A button of words, paper, first among the buttons: to the article, else arXiv"><input type="checkbox" class="eb-check" name="paperbtn" />paper</label>' : '') + it.links.map(([k, name, title, optional]) => `<label title="${esc(optional ? `${title}: not on a card of its own accord — tick it to show it` : title)}"><input type="checkbox" class="eb-check" name="link" value="${k}"${optional ? ' data-optional' : ' checked'} />${esc(name)}</label>`).join('');
}

// A part among the buttons shows where its extra is ticked; the paper
Expand Down
7 changes: 4 additions & 3 deletions src/lib/cardbuilder/facts.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ export const linkPlace = (l) => {
const toPublisher = (i) => i.type === 'publication' && ['paper', 'doi'].includes(ownLink(i)?.rel) && !/adsabs|arxiv/.test(ownLink(i).url);
export const linkChoices = (i) => {
const byKey = new Map();
for (const l of cardLinks(i)) {
for (const l of cardLinks(i, true)) {
const k = relKey(l);
const o = byKey.get(k) ?? { names: new Set(), titles: new Set() };
const o = byKey.get(k) ?? { names: new Set(), titles: new Set(), optional: !!l.optional };
o.names.add(linkPlace(l));
o.titles.add(l.label ?? l.rel);
byKey.set(k, o);
}
return [...byKey].map(([k, o]) => [k, [...o.names].join(' · '), [...o.titles].join(', ')]);
// Last, whether the card leaves it out of its own accord (a package's few stars): offered, not ticked.
return [...byKey].map(([k, o]) => [k, [...o.names].join(' · '), [...o.titles].join(', '), o.optional]);
};

/** The facts the builder keeps of one item. */
Expand Down
17 changes: 11 additions & 6 deletions src/lib/cardbuilder/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ export function readSpec(f, st) {
return { id: f.get('card'), it, slug, width, height, format: f.get('format'), theme: f.get('theme') };
}

/** The link keys an item's card shows of its own accord: all but the optional ones (a package's
* stars, under STARS_MIN), which are in `it.links` last-flagged, to be named. */
export const ownKeys = (it) => it.links.filter((l) => !l[3]).map(([k]) => k);

/** The first group's keys, less any another group has: a key is in one. The
* builder has controls for the first group; another is carried as the name
* has it, until it has controls of its own. */
Expand Down Expand Up @@ -304,9 +308,10 @@ export function controlOps(c, { it, stepPx, framePx }) {
set('background', c.background);
set('text', c.text);
ops.push(['checks', 'extra', c.extras]);
ops.push(['checks', 'link', c.links === 'all' ? null : c.links]); // null: every one
const keys = it.links.map(([k]) => k);
const listed = c.links === 'all' ? keys : c.links.filter((k) => ['empty', 'paperbutton', ...Object.values(CELL)].includes(k) || keys.includes(k));
// `all` is the keys a card shows of its own accord; the optional ones are ticked only where named.
const keys = it.links.map(([k]) => k), own = ownKeys(it);
ops.push(['checks', 'link', c.links === 'all' ? own : c.links]);
const listed = c.links === 'all' ? own : c.links.filter((k) => ['empty', 'paperbutton', ...Object.values(CELL)].includes(k) || keys.includes(k));
const btnOrder = [...(listed.includes('paperbutton') ? [] : ['paperbutton']), ...listed, ...keys.filter((k) => !listed.includes(k))];
// A part the name lists among the buttons has its area there.
for (const [k] of SMALL) if (listed.includes(CELL[k])) radio(`${k}at`, 'list');
Expand All @@ -319,14 +324,14 @@ export function controlOps(c, { it, stepPx, framePx }) {
export function fitName(name, it) {
const c = parseName(name);
// A list of buttons to keep, read against the links this item has.
const keys = it.links.map(([k]) => k);
const keys = it.links.map(([k]) => k), own = ownKeys(it);
// The paper button where this item can have one; a part among the
// buttons where it shows; and the paper button first, as left out.
const paperButton = it.byline && Object.values(it.paperTo).some(Boolean) ? c.paperButton : undefined;
const listed = c.links === 'all' ? keys : c.links.filter((k) => k === 'empty' || keys.includes(k) || (k === 'paperbutton' && paperButton) || (Object.values(CELL).includes(k) && c.extras.includes(k) && has(it, k)));
const listed = c.links === 'all' ? own : c.links.filter((k) => k === 'empty' || keys.includes(k) || (k === 'paperbutton' && paperButton) || (Object.values(CELL).includes(k) && c.extras.includes(k) && has(it, k)));
const kept = listed[0] === 'paperbutton' ? listed.slice(1) : listed;
// Another group of buttons keeps the keys the item has, and goes if it has none.
const groups = (c.groups ?? []).map((g) => (g.links === 'all' ? g : { ...g, links: g.links.filter((k) => k === 'empty' || keys.includes(k)) })).filter((g) => g.links === 'all' || g.links.some((k) => k !== 'empty'));
return formatName({ ...c, groups, paperButton, links: sameOrder(kept, keys) ? 'all' : kept, extras: c.extras.filter((x) => has(it, x)), authors: authorsFor(it, c.authors), titleLink: linkFor(it, c.titleLink), titleStatus: c.titleStatus && !!it.tstatus ? true : undefined, figure: it.figure ? c.figure : 'none',
return formatName({ ...c, groups, paperButton, links: sameOrder(kept, own) ? 'all' : kept, extras: c.extras.filter((x) => has(it, x)), authors: authorsFor(it, c.authors), titleLink: linkFor(it, c.titleLink), titleStatus: c.titleStatus && !!it.tstatus ? true : undefined, figure: it.figure ? c.figure : 'none',
rest: c.rest ?? 'split', text: it.text ? c.text : 'none' });
}
3 changes: 2 additions & 1 deletion src/lib/cardname.js
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,8 @@ export function cardFace(spec, f) {
var seqOf = function (g, n) {
var seq = [];
if (n === 0 && paperLi && !inList('paperbutton')) seq.push({ paper: true, kept: !!pb });
if (g.links === 'all') f.keys.forEach(function (k, i) { if (owner(k) === n) seq.push({ link: i, kept: true }); });
// An optional link is in an all, hidden: a name that lists it shows it.
if (g.links === 'all') f.keys.forEach(function (k, i) { if (owner(k) === n) seq.push({ link: i, kept: (f.optional || []).indexOf(k) < 0 }); });
else {
g.links.forEach(function (k) {
if (k === 'empty') seq.push({ empty: true, kept: true });
Expand Down
19 changes: 12 additions & 7 deletions src/lib/cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,24 @@ export const starLabel = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : Strin
export const STARS_MIN = 40;

/** A package's stars, as a link to who gave them — none where the count is
* unknown, rather than a button that says 0, or is under STARS_MIN.
* unknown, rather than a button that says 0. Under STARS_MIN it is `optional`:
* left out of a card's own buttons (`all`), and offered — named, or ticked in the
* Card Builder — where `every` asks for every link a card could show.
* config/stars.json is refreshed monthly, so the number is a month old at worst. */
function starLink(item) {
function starLink(item, every) {
const n = item.repo && starCounts.stars[item.repo];
return n == null || n < STARS_MIN ? [] : [{ rel: 'stars', label: `${starLabel(n)} stars`, count: starLabel(n), url: `https://github.com/${item.repo}/stargazers` }];
if (n == null || (n < STARS_MIN && !every)) return [];
return [{ rel: 'stars', label: `${starLabel(n)} stars`, count: starLabel(n), url: `https://github.com/${item.repo}/stargazers`, ...(n < STARS_MIN && { optional: true }) }];
}

/** The links a card carries, in order: for software its papers lead, each
* with its year. */
export function cardLinks(item) {
return item.type === 'software' ? [...softwarePapers(item), ...links(item), ...starLink(item)] : links(item);
export function cardLinks(item, every = false) {
return item.type === 'software' ? [...softwarePapers(item), ...links(item), ...starLink(item, every)] : links(item);
}

/** The link keys a card can be cut down to — `ads`, `preprint`, `code` … */
export const linkKeys = (item) => [...new Set(cardLinks(item).map(relKey))];
export const linkKeys = (item, every = false) => [...new Set(cardLinks(item, every).map(relKey))];

/** What an embed shows when its URL asks for nothing in particular: the card
* the website draws for it, in a fixed box at the standard look, so an
Expand Down Expand Up @@ -163,7 +166,9 @@ export function cardFacts(item, every = false) {
role: hasRole(item), students: hasStudents(item), venue: hasVenue(item), status: hasStatus(item), tstatus: !!titleStatus(item), split: !!splitTitle(item),
byline: item.type === 'publication', lead: hasPosition(item) && authorPosition(item) === 1,
varxiv: item.type === 'publication' && !venueLine(item), refs: item.type === 'highlight' ? item.refs.length : 0,
keys: cardLinks(item).map(relKey),
keys: cardLinks(item, every).map(relKey),
// Links a card offers but does not show of its own accord: named, they show.
optional: cardLinks(item, every).filter((l) => l.optional).map(relKey),
paper: Object.fromEntries(['auto', 'journal', 'arxiv', 'ads', 'site'].map((k) => [k, !!paperHref(item, k === 'auto' ? undefined : k)])),
};
}
2 changes: 1 addition & 1 deletion src/pages/embed/index.json.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 31 additions & 2 deletions tests/cardbuilder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { items } from '../src/lib/data.js';
import { itemFacts, linkPlace } from '../src/lib/cardbuilder/facts.js';
import { between, cellGrid, named, partZones, sum, venueDrops } from '../src/lib/cardbuilder/geometry.js';
import {
boxHeight, canTune, clampInt, controlOps, defaultsOf, figWidth, fitName, hiddenKeys, inBox, linksOf, namesN, presetWidths, readSpec, shapeHeight, shownKeys, sideHas, smallBox, stackedIn, stretch, typedPx, venueArea,
ownKeys, boxHeight, canTune, clampInt, controlOps, defaultsOf, figWidth, fitName, hiddenKeys, inBox, linksOf, namesN, presetWidths, readSpec, shapeHeight, shownKeys, sideHas, smallBox, stackedIn, stretch, typedPx, venueArea,
} from '../src/lib/cardbuilder/model.js';
import { parseSettings, serializeSettings } from '../src/lib/cardbuilder/settings.js';

Expand All @@ -35,7 +35,7 @@ function roundTrip(slug, i) {
const it = itemFacts(i);
const { ops, state } = controlOps(parseName(fitName(slug, it)), { it, stepPx: STEP_PX, framePx: FRAME_PX });
const { f, figureOn } = formOf(i.id, it, ops);
return readSpec(f, { it, figureOn, ...state, allLinks: it.links.map(([k]) => k), limits }).slug;
return readSpec(f, { it, figureOn, ...state, allLinks: ownKeys(it), limits }).slug;
}

describe('controls ⇄ name', () => {
Expand Down Expand Up @@ -233,3 +233,32 @@ describe('what the page knows of each item', () => {
expect(linkPlace({ rel: 'docs', url: 'https://example.org/' })).toBe('docs');
});
});

describe('the stars of a package with few', () => {
const few = items.find((i) => i.id === 'coordinax');
const it0 = itemFacts(few);
const slug = (links) => `size:fill:fit-figure:none-title:full:whole:link-authors:none-text:details-extras:none-buttons:${links}`;

it('are a pill of their own, last, flagged to be ticked — and not among the card’s own buttons', () => {
expect(it0.links.at(-1)[0]).toBe('stars');
expect(it0.links.at(-1)[3]).toBe(true);
expect(it0.links.slice(0, -1).every((l) => !l[3])).toBe(true);
expect(ownKeys(it0)).toEqual(it0.links.slice(0, -1).map(([k]) => k));
});

it('read back from the controls as the card’s own: all, and not the stars', () => {
expect(roundTrip(slug('all'), few)).toBe(slug('all'));
});

it('read back as named where the name lists them', () => {
expect(roundTrip(slug('code,docs,stars'), few)).toBe(slug('code,docs,stars'));
expect(roundTrip(slug('stars,code'), few)).toBe(slug('stars,code'));
});

it('tick as the controls have them: the card’s own, and the stars where named', () => {
const ticked = (name) => controlOps(parseName(slug(name)), { it: it0, stepPx: STEP_PX, framePx: FRAME_PX }).ops.find((o) => o[0] === 'checks' && o[1] === 'link')[2];
expect(ticked('all')).toEqual(ownKeys(it0));
expect(ticked('code,stars')).toEqual(['code', 'stars']);
});
});

24 changes: 24 additions & 0 deletions tests/cards.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -711,4 +711,28 @@ describe('the stars button', () => {
// Every package that has a button has at least that many.
for (const i of software.filter((x) => linkKeys(x).includes('stars'))) expect(counts[i.repo], i.id).toBeGreaterThanOrEqual(STARS_MIN);
});

it('is offered, flagged optional, where every link a card could show is asked for — and left out of `all`, shown where named', () => {
const few = software.filter((x) => counts[x.repo] < STARS_MIN);
const base = 'size:fill:fit-figure:none-title:full:whole:link-authors:none-text:details-extras:none';
for (const i of few) {
expect(linkKeys(i, true).at(-1), i.id).toBe('stars');
expect(cardLinks(i, true).at(-1)).toMatchObject({ rel: 'stars', optional: true, count: starLabel(counts[i.repo]) });
expect(cardFacts(i, true).optional, i.id).toEqual(['stars']);
expect(cardFacts(i).optional, i.id).toEqual([]);
// The card's own buttons: the stars are there to be shown, hidden.
const all = cardFace(parseName(`${base}-buttons:all`), cardFacts(i, true));
const star = all.seq.find((x) => cardFacts(i, true).keys[x.link] === 'stars');
expect(star, i.id).toMatchObject({ kept: false });
expect(all.seq.filter((x) => x.kept)).toHaveLength(all.seq.length - 1);
// Named, they show, in the place they are named.
const named = cardFace(parseName(`${base}-buttons:code,stars`), cardFacts(i, true));
expect(named.seq.filter((x) => x.kept).map((x) => cardFacts(i, true).keys[x.link])).toEqual(['code', 'stars']);
}
// A package with enough has it in its own, nothing optional.
for (const i of software.filter((x) => counts[x.repo] >= STARS_MIN)) {
expect(cardFacts(i, true).optional, i.id).toEqual([]);
expect(cardFace(parseName(`${base}-buttons:all`), cardFacts(i, true)).seq.every((x) => x.kept)).toBe(true);
}
});
});
Loading
Loading