<script>
import { Button } from '$lib/components/ui/button/index.js'
import { Input } from '$lib/components/ui/input/index.js'
import { Select } from '$lib/components/ui/select/index.js'
import { Card, CardContent } from '$lib/components/ui/card/index.js'
import { setupI18n } from '$lib/i18n.js'
import { _, locale } from '@sveltia/i18n'
import Icon from '@iconify/svelte'
import orcidSvg from './assets/orcid.svg'
import rorSvg from './assets/ror.svg'
import 'katex/dist/katex.min.css'
import WorkCard from './WorkCard.svelte'
import OrgCard from './OrgCard.svelte'
import PersonCard from './PersonCard.svelte'
import ContainerCard from './ContainerCard.svelte'
import HistorySheet from './HistorySheet.svelte'
import { BOOKMARKS_PARAM, decodeBookmarkIds } from '$lib/bookmark-link.js'
import Homepage from './index.md'
import { Pagination } from '$lib/components/ui/pagination/index.js'
import { SEARCH_ALL, SEARCH_FACETS, SEARCH_KINDS, anySearchFilter, isSearchFilterPicked, isSearchableQuery, noSearchFilters, searchFacetsFor, searchFilterValues, searchHrefFor, searchStateFromSearch, toggleSearchFilter } from '$lib/search-url.js'
import { typeLabel, languageLabel, formatCount, decodeEntities, fosCode, fosLabel, humanizeRelationType, isFosScheme, RELATION_TYPE_ORDER, WORK_TYPES, entityPath, bareBookmarkId, bookmarkRecord, recordId } from '$lib/bib-utils.js'
// ── Entity page init (set by server via window.__DRAGOMAN_INIT__) ─────────
const _initData = typeof window !== 'undefined' ? (window.__DRAGOMAN_INIT__ ?? null) : null
const isEntityPage = Boolean(_initData)
// ── App config ────────────────────────────────────────────────────────────
let isDark = $state(document.documentElement.classList.contains('dark'))
function toggleDark() {
isDark = !isDark
document.documentElement.classList.toggle('dark', isDark)
localStorage.setItem('app-dark', String(isDark))
}
// ── Bibliography ──────────────────────────────────────────────────────────
let bibDoi = $state(
(!isEntityPage && typeof window !== 'undefined')
? (new URLSearchParams(window.location.search).get('q') ?? '')
: ''
)
let bibStyle = $state(localStorage.getItem('dragoman-style') ?? 'apa')
const initialLocale = localStorage.getItem('dragoman-locale') ?? (() => {
const supported = ['en-US','de-DE','fr-FR','es-ES','it-IT','ja-JP','ko-KR','nl-NL','pt-BR','sv-SE','zh-CN']
const lang = navigator.language
return supported.find(v => v === lang)
?? supported.find(v => v.startsWith(lang.split('-')[0] + '-'))
?? 'en-US'
})()
setupI18n(initialLocale)
let bibLocale = $state(initialLocale)
let bibliography = $state([])
const PAGE_SIZE = 10
const MAX_SAVED = 50
// Relation types shown as "versions" on the work card — always resolved in full
// (like references) even on public deployments, since they are few and directly related.
const VERSION_RELATION_TYPES = new Set(['HasVersion', 'IsVersionOf', 'IsNewVersionOf', 'IsPreviousVersionOf'])
let liveWorksCount = $state(null) // actual DB count for org/person index card
let liveCiteCount = $state(null) // actual DB count for work citation card
// A container entity page whose container is a Blog — label its works "Blog Posts".
const isBlogContainer = $derived.by(() =>
isEntityPage && bibliography.length > 0 && parseContainerMeta(bibliography[0].data)?.type === 'Blog'
)
const worksHeadingKey = $derived(isBlogContainer ? 'bibliography.blog_posts_heading' : 'bibliography.works_heading')
let allRefDois = $state([]) // all DOI strings from rawData.references
let refCache = $state({}) // { doi: entry } for fetched refs
let refFetching = $state(false)
let allRelationDoisByType = $state({}) // { relationType: string[] } from rawData.relations
let relationCacheByType = $state({}) // { relationType: { doi: entry } }
let relationPageByType = $state({}) // { relationType: number }
let relationFetchingByType = $state({}) // { relationType: boolean }
let refPage = $state(1)
let worksPage = $state(1)
let worksTypeFilter = $state(null)
let worksYearFilter = $state(null)
// { types: [{type, works}], years: [{year, works}] } counted over the whole
// list, or null when the server did not count it — too long a list, or a
// store that cannot answer. No counts means no filters offered.
let worksFacets = $state(null)
let refTypeFilter = $state(null)
let refYearFilter = $state(null)
let citeTypeFilter = $state(null)
let citeYearFilter = $state(null)
// A work's citations are paged, filtered and counted by the server, as an
// entity's works are: /api/citations answers one page, /api/citations/facets
// the counts of the whole list. Fetching every citing work one request at a
// time to do this here ran a well-cited work's page into the request limit.
let citePageEntries = $state([]) // the citing works on the current page
let citeTotal = $state(null) // the list's length under the current filters
let citeFacets = $state(null) // { counted, total, types, years } of the whole list
let citeLoading = $state(false)
const orderedRelTypes = $derived.by(() => {
const available = new Set(Object.keys(allRelationDoisByType))
const ordered = RELATION_TYPE_ORDER.filter(t => available.has(t))
const rest = [...available].filter(t => !RELATION_TYPE_ORDER.includes(t)).sort()
return [...ordered, ...rest]
})
// The DOI whose citations the page lists: a work's, on its entity page.
const citeDoi = $derived.by(() => {
if (!isEntityPage || bibliography.length === 0) return null
if (detectEntityType(bibliography[0].data) !== 'Work') return null
const id = entityId(bibliography[0].data)
return id?.startsWith('https://doi.org/') ? id : null
})
// Whether there is anything to list: the record names a citing work, or the
// store counts one. A boolean, so the count arriving does not fetch again.
const citeAny = $derived(
(allRelationDoisByType['IsReferencedBy']?.length ?? 0) > 0 || (liveCiteCount ?? 0) > 0
)
let personWorksLoading = $state(false)
// Entity works are paged by the server: one request per window of WORKS_PAGE,
// shown PAGE_SIZE at a time. The pager spans the server's full count, so it
// offers a page for every work the entity has, not for the ones loaded — page
// 700 of 11,982 costs the one request that fetches its window.
const WORKS_PAGE = 50
let worksTotal = $state(0)
// Offset of the loaded window: the works in `bibliography` are the entity's
// worksWindow … worksWindow + WORKS_PAGE.
let worksWindow = $state(0)
// The entity whose works the server can export in one request, spelled as its
// page path spells it (ROR id / ORCID iD / container ISSN, DOI or source id).
let worksExportId = $state(null)
// { exported, total } when the last export hit the server's ceiling.
let exportTruncated = $state(null)
let worksEndpoint = $state(null)
let worksPageLoading = $state(false)
let bibLoading = $state(false)
let bibFetchingRemote = $state(false)
let bibError = $state('')
let _remoteTimer = null
let randomLoading = $state(false)
let hasDb = $state(false)
// What the store holds of each kind, for the line above the homepage. It
// rides along on the status request rather than costing one of its own.
let storeCounts = $state(null)
fetch('/api/status')
.then(r => r.json())
.then(d => { hasDb = d.db; storeCounts = d.counts ?? null })
.catch(() => {})
const isLocalhost = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
let copiedCiteId = $state(null)
let addedId = $state(null)
let historyOpen = $state(false)
// The header's options menu, which holds history, dark mode and language on
// a phone, where the three no longer fit beside the brand.
let optionsOpen = $state(false)
let historyTab = $state('history')
let searchInputEl = $state(null)
let historySheet = $state(null)
// Which entity kind the search box queries. Works by default: Crossref and
// DataCite records are the bulk of a store and the one kind every store has.
let searchKind = $state('works')
// The select renders from SEARCH_KINDS too, so the ⌘K cycle order and the
// list of options cannot drift apart.
// The heading counts what was searched by name — "929 works", not "929
// results" — so it agrees with the kind selector beside it. Singular and
// plural are separate keys, as elsewhere in the UI.
const SEARCH_COUNT_LABELS = {
works: ['search.count_work', 'search.count_works'],
people: ['search.count_person', 'search.count_people'],
organizations: ['search.count_organization', 'search.count_organizations'],
containers: ['search.count_container', 'search.count_containers'],
}
const SEARCH_KIND_LABELS = {
works: 'bibliography.search_works',
people: 'bibliography.search_people',
organizations: 'bibliography.search_organizations',
containers: 'bibliography.search_containers',
}
// ── Search results ────────────────────────────────────────────────────────
//
// Full-text search over the tantivy index commonmeta builds beside the store,
// rendered in the body: one page of ranked results, the facets of the whole
// list beside them, and what the reader picked in the URL — so a result list
// can be linked, reloaded and stepped back through. A typed PID still goes
// straight to its page, resolved by the submit handler.
const SEARCH_PAGE_SIZE = 10
/// How many alternate names a result row lists before it trails off.
const SEARCH_ALSO_KNOWN_AS = 3
/// How many identifiers a container's row lists. Four covers a complete
/// container — OpenAlex, ISSN, Wikidata and a DOI, less the one the row links
/// by — and caps the ones that have collected a DOI per work: a container in
/// this store carries 1,548 of them, and the row would be a wall of links.
const SEARCH_IDENTIFIERS = 4
let searchHits = $state([]) // one page, from /api/search
let searchEntries = $state([]) // the same page as records, for the cards
let searchTotal = $state(0)
let searchFacets = $state(null)
let searchLoading = $state(false)
let searchSettled = $state(false)
let _searchTimer = null
// Which facet values are in force, and which page. The URL is what holds
// them: these mirror it, and every change goes through the URL first.
let searchFilters = $state(noSearchFilters())
let searchPage = $state(1)
/// The URL this search reads as, defaulting to what is on screen now.
const searchHref = ({ q = bibDoi, kind = searchKind, filters = searchFilters, page = searchPage } = {}) =>
searchHrefFor({ q, kind, filters, page })
/// Adopt the URL's state. Called on load and whenever the reader steps back.
function applySearchUrl() {
const state = searchStateFromSearch(window.location.search)
bibDoi = state.q
searchKind = state.kind
searchFilters = state.filters
searchPage = state.page
}
/// Replace whole facets. The page goes back to the first: page 7 of the
/// unfiltered list is rarely a page of the filtered one, and an out-of-range
/// page would land on nothing.
function setSearchFilter(change) {
searchFilters = { ...searchFilters, ...change }
searchPage = 1
history.pushState({}, '', searchHref())
}
/// Pick one value of one facet, or unpick it if it was already picked —
/// which is what a click in the sidebar, or on a chip, means. The values of
/// a facet are ORed, so this widens that facet and leaves the others alone.
function toggleSearchFacet(key, value) {
searchFilters = toggleSearchFilter(searchFilters, key, value)
searchPage = 1
history.pushState({}, '', searchHref())
}
function goToSearchPage(n) {
searchPage = n
history.pushState({}, '', searchHref())
}
function clearSearchFilters() {
setSearchFilter(noSearchFilters())
}
const searchFiltersActive = $derived(anySearchFilter(searchFilters))
const searchPageCount = $derived(Math.max(1, Math.ceil(searchTotal / SEARCH_PAGE_SIZE)))
// Which of the kind's two count labels the total calls for.
const searchCountLabel = $derived.by(() => {
const labels = SEARCH_COUNT_LABELS[searchKind] ?? SEARCH_COUNT_LABELS.works
return labels[searchTotal === 1 ? 0 : 1]
})
// The sidebar is drawn only where there is something to draw: works carry the
// facets, and an index built before facets existed counts nothing and says so
// (`counted`), which is not the same as a query whose results happen to share
// one value.
// The facets this kind carries, each with the heading the kind gives it.
const searchKindFacets = $derived(searchFacetsFor(searchKind))
const searchFacetsShown = $derived(
searchFacets?.counted === true &&
searchKindFacets.some(({ counts }) => (searchFacets[counts] ?? []).length > 0)
)
// The results take the place of the documentation, so they show while there
// is a query to show them for — including while the first page is still
// loading, so the reader sees the list being built rather than the docs
// flashing past.
// Stays up once a query has been answered, even when the answer is none —
// but only while something is picked. A filter that matches nothing used to
// take the heading, the sidebar and the filters themselves down with it,
// which left no way to undo the filter: picking one looked like the page had
// given up rather than narrowed. A query that simply matches nothing has
// nothing to undo, so the page goes back to the documentation and the
// no-results message is left to say what happened — "0 organizations" over
// an empty list says it twice, and says it less clearly.
const searchShown = $derived(
!isEntityPage && isSearchableQuery(bibDoi) &&
(searchHits.length > 0 || searchLoading || (searchSettled && searchFiltersActive))
)
/// One of a container's other identifiers, labelled and linked where it is
/// described — the container card's own list, in its order.
function searchIdentifierLink(identifier, type) {
const bare = prefix => identifier.replace(prefix, '')
if (type === 'DOI') {
const url = identifier.startsWith('http') ? identifier : `https://doi.org/${identifier}`
return { label: url, href: url, doi: true }
}
if (type === 'ISSN') {
return { label: `ISSN ${identifier}`, href: `https://portal.issn.org/resource/ISSN/${identifier}` }
}
if (type === 'OpenAlex') {
const id = bare('https://openalex.org/')
return { label: `OpenAlex ${id}`, href: `https://openalex.org/${id}` }
}
if (type === 'DataCite') {
const id = identifier.replace(/^https?:\/\/commons\.datacite\.org\/repositories\//, '')
return { label: `DataCite ${id}`, href: `https://commons.datacite.org/repositories/${id}` }
}
// A type the card has no case for — Wikidata, ISNI — reads as the type and
// the id, not as a bare URL: "Wikidata Q27726163".
const id = identifier.startsWith('http')
? identifier.replace(/\/$/, '').split('/').pop()
: identifier
return {
label: `${type} ${id}`.trim(),
href: identifier.startsWith('http') ? identifier : null,
}
}
/// A ROR type with no label of its own, shown as the card shows it.
function capitalizeWord(s) {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s
}
/// A result row's location. The server sends a place name where the record
/// has one and the country code where it does not, so a bare code is turned
/// into the country's name in the page's language — "Germany", not "DE".
function searchLocationLabel(value, locale) {
if (!/^[A-Z]{2}$/.test(value)) return value
try {
return new Intl.DisplayNames([locale], { type: 'region' }).of(value) ?? value
} catch {
return value
}
}
/// The value a facet's link asks for, which is what the index counted
/// except for a subject: the facet counts the OECD vocabulary's code, and an
/// index built before it counted the term. Either way the link carries the
/// code, so a shared link outlives the next reindex.
function searchFacetValue(key, value) {
return key === 'fos' ? fosCode(value) || value : value
}
/// A facet value as a reader reads it: a type, a language and an OECD
/// subject have names in the page's locale, a year is already a word.
function searchFacetLabel(key, value) {
// An organization's types have their own labels; a work's are commonmeta
// types. The index counts both under one root, so the kind decides.
if (key === 'type') {
return searchKind === 'organizations'
? _(`org.type.${value}`, { default: capitalizeWord(value) })
: typeLabel(value, bibLocale)
}
if (key === 'lang') return languageLabel(value, bibLocale) || value
// A subject is counted by the vocabulary's code, which names the field in
// every language it is worded in.
if (key === 'fos') return fosLabel(value, bibLocale)
if (key === 'country') return searchLocationLabel(value, bibLocale)
// A year is never grouped: 2024, not 2,024.
return value
}
let savedItems = $state((() => {
try { return normalizeSaved(JSON.parse(localStorage.getItem('dragoman-saved') ?? '[]')) }
catch { return [] }
})())
// Bookmarks saved before every bookmark held one record under its PID URL:
// entity cards stored the page's one-element array, and a container, which
// has no `id`, went in under the card's random entry id.
function normalizeSaved(items) {
const seen = new Set()
return items.flatMap(item => {
const record = bookmarkRecord(item.data)
const id = recordId(record)
if (!id || seen.has(id)) return []
seen.add(id)
return [{ id, data: JSON.stringify(record) }]
})
}
$effect(() => { localStorage.setItem('dragoman-saved', JSON.stringify(savedItems)) })
$effect(() => { localStorage.setItem('dragoman-style', bibStyle) })
$effect(() => { localStorage.setItem('dragoman-locale', bibLocale); locale.set(bibLocale) })
// One page of results, under whatever the reader picked. A newer request
// supersedes an older one still in flight, so a quick run of clicks — or of
// keystrokes — shows the page asked for last.
let _searchRequest = 0
$effect(() => {
// Read synchronously so the effect tracks them: a read inside the debounce
// callback below runs after the effect returns and would not register, so
// changing the selector or a facet would not re-run the search.
const q = bibDoi.trim()
const kind = searchKind
const filters = { ...searchFilters }
const page = searchPage
clearTimeout(_searchTimer)
if (!isSearchableQuery(q) || isEntityPage) {
searchHits = []
searchEntries = []
searchTotal = 0
searchSettled = false
return
}
if (!hasDb) {
// No DB: settle after the debounce so the no-results message can appear.
_searchTimer = setTimeout(() => { searchSettled = true }, 250)
return
}
const request = ++_searchRequest
searchLoading = true
// Typing is debounced; a facet or a page is not — it was clicked, and the
// list should move at once.
_searchTimer = setTimeout(async () => {
const params = new URLSearchParams({
q, kind,
limit: String(SEARCH_PAGE_SIZE),
offset: String((page - 1) * SEARCH_PAGE_SIZE),
})
for (const { key, param } of SEARCH_FACETS) {
// One parameter per value picked: the server reads every occurrence,
// and within a facet they ask for any of them.
for (const value of searchFilterValues(filters, key)) params.append(param, value)
}
try {
const res = await fetch(`/api/search?${params}`)
if (res.ok) {
const total = Number(res.headers.get('X-Total-Count') ?? 0)
const hits = await res.json()
if (request !== _searchRequest) return
searchHits = Array.isArray(hits) ? hits : []
searchTotal = Number.isFinite(total) ? total : searchHits.length
// Works are shown as the cards every other list uses, which needs the
// records themselves; the index stores only a title and a pid.
if (kind === 'works' && searchHits.length > 0) {
const works = await fetchWorks(searchHits.map(h => h.id), true)
if (request !== _searchRequest) return
searchEntries = works
.map((work, i) => (work ? workEntry(work, searchHits[i].id) : null))
.filter(Boolean)
} else {
searchEntries = []
}
}
} catch { /* a failed search shows no results, like a failed lookup did */ }
if (request === _searchRequest) {
searchLoading = false
searchSettled = true
}
}, 250)
})
// The counts of the whole list, once per query — they do not depend on which
// facet values are in force, so picking one does not refetch them.
$effect(() => {
const q = bibDoi.trim()
const kind = searchKind
// Asked for alongside the page, not after it. A query too broad to count is
// refused by the server from the term dictionary — before it walks
// anything — so the page need not be waited on to find out, and the answer
// is the same whether the query arrived bare or with a facet value already
// picked. `counted` says which happened; a facet with no values is simply
// left out of the sidebar.
if (!isSearchableQuery(q) || isEntityPage || !hasDb || searchFacetsFor(kind).length === 0) {
searchFacets = null
return
}
let current = true
const timer = setTimeout(() => {
fetch(`/api/search/facets?q=${encodeURIComponent(q)}&kind=${kind}`)
.then(r => r.ok ? r.json() : null)
.then(f => { if (current) searchFacets = f })
.catch(() => {})
}, 250)
return () => { current = false; clearTimeout(timer) }
})
// ── Export ────────────────────────────────────────────────────────────────
let expFormat = $state('commonmeta')
let expError = $state('')
let expLoading = $state(false)
let expCopied = $state(false)
// ── Style picker ──────────────────────────────────────────────────────────
let stylePickerOpen = $state(false)
let styleSearch = $state('')
// ── Data ──────────────────────────────────────────────────────────────────
const commonStyles = [
{ value: 'apa', label: 'American Psychological Association 7th edition' },
{ value: 'chicago-author-date', label: 'Chicago Manual of Style 18th Edition (Author-Date)' },
{ value: 'harvard-cite-them-right', label: 'Cite Them Right 12th Edition (Harvard)' },
{ value: 'ieee', label: 'IEEE' },
{ value: 'modern-language-association', label: 'MLA Handbook 9th Edition' },
{ value: 'vancouver', label: 'Vancouver' },
]
const allCitationStyles = [
{ value: 'apa', label: 'American Psychological Association 7th edition' },
{ value: 'alphanumeric', label: 'Alphanumeric' },
{ value: 'american-anthropological-association', label: 'American Anthropological Association' },
{ value: 'american-chemical-society', label: 'American Chemical Society' },
{ value: 'american-geophysical-union', label: 'American Geophysical Union' },
{ value: 'american-institute-of-aeronautics-and-astronautics', label: 'American Institute of Aeronautics and Astronautics' },
{ value: 'american-institute-of-physics', label: 'American Institute of Physics' },
{ value: 'american-medical-association', label: 'American Medical Association' },
{ value: 'american-meteorological-society', label: 'American Meteorological Society' },
{ value: 'american-physics-society', label: 'American Physical Society' },
{ value: 'american-physiological-society', label: 'American Physiological Society' },
{ value: 'american-political-science-association', label: 'American Political Science Association' },
{ value: 'american-society-for-microbiology', label: 'American Society for Microbiology' },
{ value: 'american-society-of-civil-engineers', label: 'American Society of Civil Engineers' },
{ value: 'american-society-of-mechanical-engineers', label: 'American Society of Mechanical Engineers' },
{ value: 'american-sociological-association', label: 'American Sociological Association' },
{ value: 'angewandte-chemie', label: 'Angewandte Chemie International Edition' },
{ value: 'annual-reviews', label: 'Annual Reviews' },
{ value: 'annual-reviews-author-date', label: 'Annual Reviews (Author-Date)' },
{ value: 'associacao-brasileira-de-normas-tecnicas', label: 'Associação Brasileira de Normas Técnicas' },
{ value: 'association-for-computing-machinery', label: 'Association for Computing Machinery' },
{ value: 'biomed-central', label: 'BioMed Central' },
{ value: 'bmj', label: 'BMJ' },
{ value: 'bristol-university-press', label: 'Bristol University Press' },
{ value: 'cell', label: 'Cell' },
{ value: 'chicago-author-date', label: 'Chicago Manual of Style 18th Edition (Author-Date)' },
{ value: 'chicago-notes', label: 'Chicago Manual of Style 18th Edition (Notes & Bibliography)' },
{ value: 'chicago-shortened-notes', label: 'Chicago Manual of Style 18th Edition (Shortened Notes)' },
{ value: 'copernicus', label: 'Copernicus Publications' },
{ value: 'council-of-science-editors', label: 'Council of Science Editors (Citation-Sequence)' },
{ value: 'council-of-science-editors-author-date', label: 'Council of Science Editors (Name-Year)' },
{ value: 'current-opinion', label: 'Current Opinion' },
{ value: 'deutsche-gesellschaft-für-psychologie', label: 'Deutsche Gesellschaft für Psychologie (Deutsch)' },
{ value: 'deutsche-sprache', label: 'Deutsche Sprache (Deutsch)' },
{ value: 'elsevier-harvard', label: 'Elsevier (Harvard)' },
{ value: 'elsevier-vancouver', label: 'Elsevier (Vancouver)' },
{ value: 'elsevier-with-titles', label: 'Elsevier (Numeric, With Titles)' },
{ value: 'frontiers', label: 'Frontiers' },
{ value: 'future-medicine', label: 'Future Medicine' },
{ value: 'future-science', label: 'Future Science Group' },
{ value: 'gb-7714-2005-numeric', label: 'GB/T 7714-2005 (Numeric)' },
{ value: 'gb-7714-2015-author-date', label: 'GB/T 7714-2015 (Author-Date)' },
{ value: 'gb-7714-2015-note', label: 'GB/T 7714-2015 (Note)' },
{ value: 'gb-7714-2015-numeric', label: 'GB/T 7714-2015 (Numeric)' },
{ value: 'gost-r-705-2008-numeric', label: 'GOST R 7.0.5-2008 (Numeric)' },
{ value: 'harvard-cite-them-right', label: 'Cite Them Right 12th Edition (Harvard)' },
{ value: 'ieee', label: 'IEEE' },
{ value: 'institute-of-physics-numeric', label: 'Institute of Physics (Numeric)' },
{ value: 'iso-690-author-date', label: 'ISO 690 (Author-Date)' },
{ value: 'iso-690-numeric', label: 'ISO 690 (Numeric)' },
{ value: 'karger', label: 'Karger' },
{ value: 'mary-ann-liebert-vancouver', label: 'Mary Ann Liebert (Vancouver)' },
{ value: 'modern-humanities-research-association', label: 'MHRA Style Guide 4th Edition (Notes)' },
{ value: 'mla', label: 'MLA Handbook 9th Edition' },
{ value: 'multidisciplinary-digital-publishing-institute', label: 'Multidisciplinary Digital Publishing Institute' },
{ value: 'nature', label: 'Nature' },
{ value: 'vancouver', label: 'Vancouver' },
{ value: 'vancouver-superscript', label: 'NLM/Vancouver (Superscript)' },
{ value: 'pensoft', label: 'Pensoft Journals' },
{ value: 'plos', label: 'Public Library of Science' },
{ value: 'royal-society-of-chemistry', label: 'Royal Society of Chemistry' },
{ value: 'sage-vancouver', label: 'SAGE (Vancouver)' },
{ value: 'sist02', label: 'SIST02' },
{ value: 'spie', label: 'SPIE' },
{ value: 'springer-basic', label: 'Springer Basic (Numeric)' },
{ value: 'springer-basic-author-date', label: 'Springer Basic (Author-Date)' },
{ value: 'springer-fachzeitschriften-medizin-psychologie', label: 'Springer Fachzeitschriften Medizin Psychologie (Deutsch)' },
{ value: 'springer-humanities-author-date', label: 'Springer Humanities (Author-Date)' },
{ value: 'springer-lecture-notes-in-computer-science', label: 'Springer Lecture Notes in Computer Science' },
{ value: 'springer-mathphys', label: 'Springer MathPhys (Numeric)' },
{ value: 'springer-socpsych-author-date', label: 'Springer SocPsych (Author-Date)' },
{ value: 'springer-vancouver', label: 'Springer (Vancouver)' },
{ value: 'taylor-and-francis-chicago-author-date', label: 'Taylor & Francis (Chicago Author-Date)' },
{ value: 'taylor-and-francis-national-library-of-medicine', label: 'Taylor & Francis (NLM/Vancouver)' },
{ value: 'the-institution-of-engineering-and-technology', label: 'Institution of Engineering and Technology' },
{ value: 'the-lancet', label: 'The Lancet' },
{ value: 'thieme', label: 'Thieme' },
{ value: 'trends', label: 'Trends' },
{ value: 'turabian-author-date', label: 'Chicago Manual of Style 17th Edition (Author-Date)' },
{ value: 'turabian-fullnote-8', label: 'Chicago Manual of Style 17th Edition (Notes & Bibliography)' },
]
const locales = [
{ value: 'en-US', label: 'English (US)' },
{ value: 'de-DE', label: 'Deutsch' },
{ value: 'fr-FR', label: 'Français' },
{ value: 'es-ES', label: 'Español' },
{ value: 'it-IT', label: 'Italiano' },
{ value: 'ja-JP', label: '日本語' },
{ value: 'ko-KR', label: '한국어' },
{ value: 'nl-NL', label: 'Nederlands' },
{ value: 'pt-BR', label: 'Português (Brasil)' },
{ value: 'sv-SE', label: 'Svenska' },
{ value: 'zh-CN', label: '中文(简体)' },
]
const expFormats = [
{ value: 'bibtex', label: 'BibTeX' },
{ value: 'commonmeta', label: 'Commonmeta' },
{ value: 'crossref', label: 'Crossref' },
{ value: 'crossref_xml', label: 'Crossref XML' },
{ value: 'csl', label: 'CSL-JSON' },
{ value: 'datacite', label: 'DataCite JSON' },
{ value: 'datacite_xml', label: 'DataCite XML' },
{ value: 'citation', i18n: 'format.citation' },
{ value: 'inveniordm', label: 'InvenioRDM' },
{ value: 'ris', label: 'RIS' },
{ value: 'schemaorg', label: 'Schema.org' },
]
// ── Helpers ───────────────────────────────────────────────────────────────
function cleanDoi(raw) {
return raw.trim().replace(/^https?:\/\/(?:dx\.)?doi\.org\//, '')
}
// Returns { type, id } for the given raw identifier string, or null if empty.
// Supports CURIEs (doi:, ror:, orcid:) and full URLs.
function parseIdentifier(raw) {
const s = raw.trim()
if (!s) return null
// Self-referential URL: strip own origin or canonical commonmeta.org
let selfPath = null
if (s.startsWith(location.origin + '/')) selfPath = s.slice(location.origin.length + 1)
else { const m = s.match(/^https?:\/\/commonmeta\.org\/(.+)/); if (m) selfPath = m[1] }
if (selfPath) return parseIdentifier(selfPath)
// ROR: ror:0xxxxxxx or https://ror.org/0xxxxxxx
const rorM = s.match(/^(?:ror:|https?:\/\/ror\.org\/)([a-z0-9]+)\/?$/i)
if (rorM) return { type: 'ror', id: rorM[1] }
// ORCID: bare XXXX-XXXX-XXXX-XXXZ, orcid:..., or https://orcid.org/...
const orcidM = s.match(/^(?:orcid:|https?:\/\/orcid\.org\/)?(\d{4}-\d{4}-\d{4}-\d{3}[\dX])\/?$/i)
if (orcidM) return { type: 'orcid', id: orcidM[1] }
// DOI: doi:xxx or URL or bare 10.xxx
const doi = s.replace(/^doi:/i, '').replace(/^https?:\/\/(?:dx\.)?doi\.org\//, '')
return { type: 'doi', id: doi }
}
function stripHtml(html) {
const doc = new DOMParser().parseFromString(html, 'text/html')
return doc.body.textContent ?? ''
}
function parseOrgMeta(cmData) {
try {
const raw = JSON.parse(cmData)
const d = Array.isArray(raw) ? raw[0] : raw
if (!d?.id?.startsWith('https://ror.org/')) return null
return {
entityType: 'Organization',
id: d.id ?? '',
name: d.name ?? d.title ?? '',
additional_names: [d.acronym, ...(d.additional_names ?? [])].filter(Boolean),
country: d.country ?? '',
location: d.location ?? null,
urls: (d.urls ?? []).filter(u => u?.url),
identifiers: (d.identifiers ?? []),
types: d.types ?? [],
relations: d.relations ?? [],
}
} catch { return null }
}
function entityId(cmData) {
return recordId(bookmarkRecord(cmData)) ?? ''
}
// Keep-predicate: a citation that is itself a version of another work (relation
// IsVersionOf) is a duplicate of its concept/parent record — drop it so citations
// aren't listed once per version. Malformed data errs toward keeping the entry.
function notAVersion(entry) {
try { return !(JSON.parse(entry.data)?.relations ?? []).some(r => r.type === 'IsVersionOf') }
catch { return true }
}
function parsePersonAffiliations(cmData) {
try {
const raw = JSON.parse(cmData)
const d = Array.isArray(raw) ? raw[0] : raw
return (d?.affiliations ?? []).filter(a => a.name)
} catch { return [] }
}
function formatAffDate(iso) {
if (!iso) return ''
const parts = iso.split('-')
if (parts.length === 1) return parts[0]
try {
const y = parseInt(parts[0], 10)
const m = parseInt(parts[1], 10) - 1
if (parts.length === 2) {
return new Intl.DateTimeFormat(bibLocale, { year: 'numeric', month: 'long' }).format(new Date(y, m, 1))
}
const d = parseInt(parts[2], 10)
return new Intl.DateTimeFormat(bibLocale, { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(y, m, d))
} catch { return iso }
}
function countWorksFor(id) {
if (!id) return 0
return savedItems.filter(wi => detectEntityType(wi.data) === 'Work' && wi.data.includes(id)).length
}
// A commonmeta container entity has no top-level `id` (it is keyed by its
// identifiers) and carries an `identifiers` array — distinct from a work,
// which always has an `id`.
function isContainerObj(d) {
return Boolean(d) && !d.id && Array.isArray(d.identifiers) && d.identifiers.length > 0
}
function parseContainerMeta(cmData) {
try {
const raw = JSON.parse(cmData)
const d = Array.isArray(raw) ? raw[0] : raw
if (!isContainerObj(d)) return null
return {
entityType: 'Container',
title: d.title ?? '',
additional_titles: d.additional_titles ?? [],
type: d.type ?? '',
publisher: d.publisher ?? null,
country: d.country ?? '',
url: d.url ?? '',
identifiers: d.identifiers ?? [],
}
} catch { return null }
}
function detectEntityType(cmData) {
try {
const raw = JSON.parse(cmData)
const d = Array.isArray(raw) ? raw[0] : raw
if (d?.id?.startsWith('https://ror.org/')) return 'Organization'
if (d?.id?.startsWith('https://orcid.org/') || d?.given_name || d?.family_name) return 'Person'
if (isContainerObj(d)) return 'Container'
return 'Work'
} catch { return 'Work' }
}
function bibGroups() {
const groups = []
bibliography.forEach((entry, i) => {
const type = detectEntityType(entry.data)
if (type === 'Work' && groups.length > 0 && groups[groups.length - 1].type === 'Work') {
groups[groups.length - 1].entries.push({ entry, idx: i })
} else {
groups.push({ type, entries: [{ entry, idx: i }] })
}
})
return groups
}
function parseMeta(cmData) {
try {
const d = JSON.parse(cmData)
// Build raw author list (no affiliation indices yet)
const rawAuthors = (d.contributors ?? [])
.filter(c => !c.roles?.length || c.roles.includes('Author'))
.map(c => {
if (c.type === 'Person' && c.person) {
const p = c.person
const name = decodeEntities([p.given_name, p.family_name].filter(Boolean).join(' '))
if (!name) return null
return { name, orcid: p.id?.startsWith('https://orcid.org/') ? p.id : '', rawAffs: p.affiliations ?? [] }
}
const name = decodeEntities(c.organization?.name ?? '')
return name ? { name, orcid: '', rawAffs: [] } : null
})
.filter(Boolean)
// Truncate to display set: first 19 + last if > 20
const truncated = rawAuthors.length > 20
const displayRaw = truncated
? [...rawAuthors.slice(0, 19), rawAuthors[rawAuthors.length - 1]]
: rawAuthors
// Build affiliations only from displayed authors
const affMap = new Map()
const affiliations = []
function affIndex(aff) {
const key = aff.id || aff.name
if (!key) return null
if (!affMap.has(key)) {
affiliations.push({ name: decodeEntities(aff.name || aff.id), ror: aff.id?.startsWith('https://ror.org/') ? aff.id : '' })
affMap.set(key, affiliations.length)
}
return affMap.get(key)
}
const toAuthor = r => ({ name: r.name, orcid: r.orcid, affIndices: r.rawAffs.map(affIndex).filter(Boolean) })
const authorList = truncated
? [...displayRaw.slice(0, 19).map(toAuthor), { isEllipsis: true }, toAuthor(displayRaw[displayRaw.length - 1])]
: displayRaw.map(toAuthor)
const funders = (d.funding_references ?? [])
.filter(f => f.funder_name || f.funder_id)
// Link the container to its entity page when it has a title and a resolvable
// identifier. The container router accepts a bare ISSN, DOI, OpenAlex source
// id, or DataCite client id — search all identifiers (not just the first) and
// strip any URL prefix. A blog post from Rogue Scholar embeds its blog by
// title alone and names it by an IsPartOf relation to the blog's DOI, which
// opens the blog's page as well.
const containerTitle = decodeEntities(d.container?.title ?? '')
const linkableId = (d.container?.identifiers ?? []).find(i =>
['ISSN', 'DOI', 'OPENALEX', 'DATACITE'].includes((i.identifier_type ?? '').toUpperCase()))
const blogDoi = d.type === 'BlogPost'
? (d.relations ?? []).find(r => r.type === 'IsPartOf' && r.id?.startsWith('https://doi.org/'))?.id
: undefined
const containerId = linkableId?.identifier ?? blogDoi ?? ''
const containerPagePath = (containerTitle && containerId)
? entityPath(containerId
// commonmeta names an ISSN by the URI of its linked data profile
// since 0.9.115, and by the portal's URL before that.
.replace(/^https?:\/\/(?:portal\.)?issn\.org\/resource\/ISSN\//, '')
.replace(/^https?:\/\/openalex\.org\/(?:sources\/)?/, ''))
: ''
return {
title: d.title ?? '',
type: d.type ?? '',
version: d.version ?? '',
datePublished: d.date_published ?? '',
containerTitle,
containerPagePath,
authorList,
affiliations,
funders,
description: d.description ?? '',
// A subject reads as its label, and carries the FOS term the search
// can filter by when its scheme says Fields of Science — which is what
// decides, since the term alone is no evidence of the classification.
subjects: (d.subjects ?? [])
.filter(s => s?.subject)
.map(s => ({
label: decodeEntities(s.subject),
// The vocabulary's code, which is what the facet counts; a term
// under a FOS scheme that the vocabulary does not hold gets none,
// and so is shown without a link rather than with a dead one.
fos: isFosScheme(s.scheme ?? '', s.scheme_uri ?? '') ? fosCode(s.subject) : '',
})),
referenceCount: (d.references ?? []).length,
relationCounts: (d.relations ?? []).reduce((acc, r) => {
if (r?.type) acc[r.type] = (acc[r.type] ?? 0) + 1
return acc
}, {}),
language: d.language ?? '',
// The files a record points at, each with the media type it declares.
// A work may list the same type more than once — Wiley deposits a PDF
// twice, once as its text-and-data-mining endpoint and once as the
// page a reader opens — and both are its own row.
files: (d.files ?? [])
.filter(f => f?.url)
.map(f => ({ url: f.url, mimeType: (f.mime_type ?? '').trim() })),
licenseTitle: decodeEntities(d.license?.title ?? ''),
licenseId: d.license?.id ?? '',
licenseUrl: d.license?.url ?? '',
isVersionOf: (d.relations ?? []).find(r => r.type === 'IsVersionOf')?.id ?? '',
identicalIds: (d.relations ?? []).filter(r => r.type === 'IsIdenticalTo').map(r => r.id).filter(Boolean),
pmid: (() => {
const v = (d.identifiers ?? []).find(i => i.identifier_type === 'PMID')?.identifier ?? ''
const m = v.match(/(\d+)\/?$/) // bare number or pubmed URL
return m ? m[1] : ''
})(),
openalex: (() => {
const v = (d.identifiers ?? []).find(i => i.identifier_type === 'OpenAlex')?.identifier ?? ''
const m = v.match(/(W\d+)\/?$/i) // bare W… or an openalex.org URL
return m ? m[1].toUpperCase() : ''
})(),
pmcid: (() => {
const v = (d.identifiers ?? []).find(i => i.identifier_type === 'PMCID')?.identifier ?? ''
const m = v.match(/(PMC\d+)\/?$/i) // PMC-prefixed or pmc URL
if (m) return m[1].toUpperCase()
const n = v.match(/^(\d+)$/) // bare digits → add PMC prefix
return n ? `PMC${n[1]}` : ''
})(),
id: d.id ?? '',
}
} catch { return null }
}
// ── Navigation ────────────────────────────────────────────────────────────
$effect(() => {
function handleNavClick(e) {
// A click already handled in the page is not ours to navigate. The facet
// links and the pager are real links so they can be opened in a new tab
// or shared, and they call preventDefault to swap the list in place
// instead — without this they were followed as well, reloading the page,
// and to whichever address the link had come to carry: a facet just
// picked rewrites its own href to the one that *un*picks it, so the
// reload landed on the unfiltered list, with the unfiltered address in
// the bar. Picking a facet looked like it did nothing at all.
if (e.defaultPrevented) return
const a = e.target.closest('a[href]')
if (!a) return
if (a.target === '_blank') return
let url
try { url = new URL(a.href) } catch { return }
if (url.origin !== location.origin) return
if (url.pathname === location.pathname && url.search === location.search) return
e.preventDefault()
bibFetchingRemote = true
const dest = a.href
requestAnimationFrame(() => requestAnimationFrame(() => { location.href = dest }))
}
document.addEventListener('click', handleNavClick)
return () => document.removeEventListener('click', handleNavClick)
})
// Escape clears the box, which clears the result list with it. There is no
// list to walk with the arrow keys any more: the results are in the page, so
// Tab reaches them and a screen reader reads them in order.
function onSearchKeydown(e) {
if (e.key === 'Escape' && bibDoi) {
e.preventDefault()
bibDoi = ''
}
}
// A new query is a new list: the page goes back to the first and the facet
// values picked for the old one are dropped, since a year that had results
// under the old query may have none under this one. Written with
// replaceState, so a run of keystrokes leaves one history entry rather than
// one per letter — the facets and pages below use pushState, because those
// are steps worth going back through.
let _searchLastQuery = null
$effect(() => {
if (isEntityPage || typeof window === 'undefined') return
const key = `${searchKind}\n${bibDoi.trim()}`
if (_searchLastQuery === null) { _searchLastQuery = key; return }
if (_searchLastQuery === key) return
_searchLastQuery = key
const cleared = noSearchFilters()
searchFilters = cleared
searchPage = 1
history.replaceState(
{},
'',
searchHref({ q: bibDoi, kind: searchKind, filters: cleared, page: 1 }),
)
})
// Back and forward step through the search the same way they step through an
// entity's works: the URL is the state, and whatever it names is shown.
$effect(() => {
if (isEntityPage || typeof window === 'undefined') return
const onPopState = () => {
applySearchUrl()
_searchLastQuery = `${searchKind}\n${bibDoi.trim()}`
}
window.addEventListener('popstate', onPopState)
return () => window.removeEventListener('popstate', onPopState)
})
// The server sends an id it cannot resolve back to search, as /?q=<id>, and a
// ?q= is resolved as an id on load (below) — so the two would hand the same
// word back and forth forever, the page reloading as fast as it can. The id
// the page last sent the reader to is remembered for the tab; if it comes
// back as ?q=, it is searched for instead.
const NAV_KEY = 'dragoman-navigated-to'
function navigateToId(parsed) {
if (!parsed?.id) return
try { sessionStorage.setItem(NAV_KEY, parsed.id) } catch { /* storage blocked: no guard */ }
bibLoading = true
bibFetchingRemote = false
clearTimeout(_remoteTimer)
_remoteTimer = setTimeout(() => { bibFetchingRemote = true }, 300)
window.location.href = entityPath(parsed.id)
}
/// Whether `parseIdentifier` really recognised something, as opposed to
/// falling through.
///
/// Its last branch returns every unrecognised string as a DOI, so a typed
/// word comes back as `{ type: 'doi', id: 'autophagy' }`. Acted on, that
/// sends the reader to /autophagy, which the server bounces back to
/// /?q=autophagy — and the bounce keeps only `q`, so a shared link's facet
/// values and page were dropped on the way. Only a DOI-shaped id counts,
/// which is the same rule the server resolves by.
function isResolvableIdentifier(parsed) {
if (!parsed?.id) return false
if (parsed.type !== 'doi') return true
return /^10\.\d{4,9}\/\S+$/.test(parsed.id)
}
function goToId(e) {
e.preventDefault()
// Submitting an empty box asks for nothing, so nothing happens. It is the
// clear button's job to empty the query, and that already rewrites the
// address; without this, Enter on an empty box on a record's page would
// leave it for the plain homepage.
if (!bibDoi.trim()) return
const parsed = parseIdentifier(bibDoi.trim())
if (isResolvableIdentifier(parsed)) {
navigateToId(parsed)
return
}
// A text query is searched where the reader is: the results are already
// being fetched, so submitting only commits the search to the URL.
//
// Except on a record's own page, where nothing is searching — the list and
// its sidebar belong to the search page — so submitting goes there, the
// way following a link would. Committing the query to this page's address
// instead left the record on screen under the URL of a search that had
// never run, which is no answer to pressing Enter.
if (isEntityPage) {
bibLoading = true
bibFetchingRemote = false
clearTimeout(_remoteTimer)
_remoteTimer = setTimeout(() => { bibFetchingRemote = true }, 300)
window.location.href = searchHref({ page: 1 })
return
}
searchPage = 1
history.pushState({}, '', searchHref({ page: 1 }))
}
// On initial load, resolve a ?q= identifier (DOI, ROR, ORCID, ISSN, …) directly
// instead of running a text search that would show "no results".
if (!isEntityPage && typeof window !== 'undefined') {
const initialQuery = new URLSearchParams(window.location.search).get('q') ?? ''
const parsed = parseIdentifier(initialQuery.trim())
let bounced = false
try {
bounced = parsed?.id != null && sessionStorage.getItem(NAV_KEY) === parsed.id
sessionStorage.removeItem(NAV_KEY)
} catch { /* storage blocked: no guard */ }
if (isResolvableIdentifier(parsed) && !bounced) navigateToId(parsed)
// A search, not an identifier: adopt the kind, the facet values and the
// page the link carried, so a shared result list opens as it was.
else applySearchUrl()
}
// Arrived: the id resolved, so a later ?q= for it is a fresh request.
if (isEntityPage) {
try { sessionStorage.removeItem(NAV_KEY) } catch { /* storage blocked */ }
}
async function initEntityPage(rawData) {
liveWorksCount = null
liveCiteCount = null
refPage = 1
worksPage = 1
worksWindow = 0
worksTypeFilter = null
worksYearFilter = null
refTypeFilter = null
refYearFilter = null
citeTypeFilter = null
citeYearFilter = null
citePageEntries = []
citeTotal = null
citeFacets = null
const rawArr = Array.isArray(rawData) ? rawData : null
const firstId = rawArr?.[0]?.id ?? ''
const isEntityArray = rawArr && rawArr.length > 0 && (
firstId.startsWith('https://ror.org/') || firstId.startsWith('https://orcid.org/')
|| isContainerObj(rawArr[0])
)
if (isEntityArray) {
const entityEntry = {
id: crypto.randomUUID(),
doi: firstId,
data: JSON.stringify([rawArr[0]]),
url: null,
html: '',
}
const workEntries = rawArr.slice(1, 1001).map(work => ({
id: crypto.randomUUID(),
doi: (work.id ?? '').replace(/^https?:\/\/doi\.org\//, ''),
data: JSON.stringify(work),
url: work.url ?? null,
html: '',
}))
// No bulk formatting here. A work card renders from its record, and the
// formatted citation is read by one thing only — a "citation" export —
// which formats what it needs on demand. Formatting up front cost the
// server a request per work on every page load: a thousand of them for a
// large container, about a minute of work nobody had asked for.
bibliography = [entityEntry, ...workEntries]
} else {
const cmData = JSON.stringify(rawData)
const entryId = crypto.randomUUID()
const doi = typeof rawData?.id === 'string' && rawData.id.startsWith('https://doi.org/')
? rawData.id.replace('https://doi.org/', '')
: ''
const url = rawData?.url ?? null
// Not formatted here, as for the entity arrays above: the card renders
// from the record, and only a "citation" export reads the formatted
// citation, formatting it on demand. Waiting for /api/bibliography left
// the page empty for a round trip before the card appeared — a flicker
// on every work page.
const mainEntry = { id: entryId, doi, data: cmData, url, html: '' }
bibliography = [mainEntry]
// Extract DOI lists — actual fetching is done lazily by page via $effect
allRefDois = (rawData?.references ?? [])
.map(r => typeof r?.id === 'string' && r.id.startsWith('https://doi.org/')
? r.id.replace('https://doi.org/', '') : null)
.filter(Boolean)
refCache = {}
// Group rawData.relations by type; only DOI-resolvable ids get a card.
// IsCitedBy and IsSupplementTo are aliases for citations (merged into IsReferencedBy).
// Cites and IsSupplementedBy are aliases for references (merged into allRefDois).
const CITE_ALIASES = new Set(['IsCitedBy', 'IsSupplementTo'])
const REF_ALIASES = new Set(['Cites', 'IsSupplementedBy'])
const typeGroups = {}
const extraRefDois = []
for (const r of (rawData?.relations ?? [])) {
if (!r?.type || typeof r?.id !== 'string' || !r.id.startsWith('https://doi.org/')) continue
if (r.type === 'References') continue // handled by dedicated references section
// A blog post IsPartOf its blog, which its card already names and links
// (see containerPagePath), so the blog is not listed again as a related work.
if (r.type === 'IsPartOf' && rawData?.type === 'BlogPost') continue
const doi = r.id.replace('https://doi.org/', '')
if (REF_ALIASES.has(r.type)) {
if (!extraRefDois.includes(doi)) extraRefDois.push(doi)
continue
}
const groupKey = CITE_ALIASES.has(r.type) ? 'IsReferencedBy' : r.type
if (!typeGroups[groupKey]) typeGroups[groupKey] = []
if (!typeGroups[groupKey].includes(doi)) typeGroups[groupKey].push(doi)
}
allRelationDoisByType = typeGroups
relationCacheByType = Object.fromEntries(Object.keys(typeGroups).map(t => [t, {}]))
relationPageByType = Object.fromEntries(Object.keys(typeGroups).map(t => [t, 1]))
relationFetchingByType = Object.fromEntries(Object.keys(typeGroups).map(t => [t, false]))
// Append Cites + IsSupplementedBy DOIs to the references section (dedup against existing)
if (extraRefDois.length > 0) {
const existing = new Set(allRefDois)
for (const d of extraRefDois) { if (!existing.has(d)) allRefDois = [...allRefDois, d] }
}
// Exclude references that are versions of this work (e.g. Zenodo versioned records)
const versionDois = new Set(typeGroups['IsVersionOf'] ?? [])
if (versionDois.size > 0) allRefDois = allRefDois.filter(d => !versionDois.has(d))
}
}
$effect(() => { if (isEntityPage) initEntityPage(_initData) })
// Fetch actual counts from DB for entity pages (works for org/person, citations for DOI).
// Runs once hasDb is true — avoids the race where bibliography is set before /status resolves.
$effect(() => {
if (!isEntityPage || !hasDb || bibliography.length === 0) return
const id = entityId(bibliography[0].data)
if (!id) return
fetch(`/api/count?id=${encodeURIComponent(id)}`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (d?.works != null) liveWorksCount = d.works
if (d?.citations != null) liveCiteCount = d.citations
})
.catch(() => {})
})
// Lazy fetch: load the current page of refs from SQLite, batch of PAGE_SIZE
$effect(() => {
const page = refPage
const dois = allRefDois
const pageDois = dois.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
if (pageDois.length === 0) return
void fetchDoiPage(pageDois, refCache, newEntries => { refCache = { ...refCache, ...newEntries } },
() => { refFetching = true }, () => { refFetching = false }, true)
})
// Lazy fetch: load the current page for each relation type group. Citations
// (IsReferencedBy) are not among them: the server pages those (below).
$effect(() => {
for (const [type, dois] of Object.entries(allRelationDoisByType)) {
if (type === 'IsReferencedBy') continue
const page = relationPageByType[type] ?? 1
const pageDois = dois.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
if (pageDois.length === 0) continue
const cache = relationCacheByType[type] ?? {}
void fetchDoiPage(pageDois, cache,
newEntries => { relationCacheByType = { ...relationCacheByType, [type]: { ...cache, ...newEntries } } },
() => { relationFetchingByType = { ...relationFetchingByType, [type]: true } },
() => { relationFetchingByType = { ...relationFetchingByType, [type]: false } },
VERSION_RELATION_TYPES.has(type))
}
})
// One page of a work's citations, under the filters chosen. A newer request
// supersedes an older one still in flight, so a quick run of clicks shows
// the page clicked last.
let _citeRequest = 0
$effect(() => {
const doi = citeDoi
if (!doi || !citeAny) return
const page = relationPageByType['IsReferencedBy'] ?? 1
const params = new URLSearchParams({
id: doi, limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE),
})
if (citeTypeFilter) params.set('type', citeTypeFilter)
if (citeYearFilter) params.set('year', String(citeYearFilter))
const request = ++_citeRequest
citeLoading = true
fetch(`/api/citations?${params}`)
.then(async resp => {
if (request !== _citeRequest) return
if (!resp.ok) { citePageEntries = []; citeTotal = 0; return }
const total = Number(resp.headers.get('X-Total-Count') ?? 0)
const works = await resp.json()
if (request !== _citeRequest) return
citePageEntries = Array.isArray(works) ? works.map(workEntry) : []
citeTotal = Number.isFinite(total) ? total : citePageEntries.length
})
.catch(() => {})
.finally(() => { if (request === _citeRequest) citeLoading = false })
})
// The counts of a work's whole citation list, once per work.
$effect(() => {
const doi = citeDoi
if (!doi || !citeAny) return
fetch(`/api/citations/facets?id=${encodeURIComponent(doi)}`)
.then(r => r.ok ? r.json() : null)
.then(f => { if (citeDoi === doi) citeFacets = f?.counted ? f : null })
.catch(() => {})
})
// For ORCID person / ROR org / container pages: when the server returns only the
// entity record (no inline works), its works are fetched separately after the page
// is visible. Containers are keyed by the URL path (ISSN / DOI / OpenAlex source id).
$effect(() => {
if (!isEntityPage || bibliography.length === 0) return
const id = entityId(bibliography[0].data)
const isOrcid = id?.startsWith('https://orcid.org/')
const isRor = id?.startsWith('https://ror.org/')
const isContainer = !isOrcid && !isRor && detectEntityType(bibliography[0].data) === 'Container'
let endpoint = null
let exportId = null
if (isOrcid) {
exportId = id.replace('https://orcid.org/', '')
endpoint = `/api/person/${exportId}/works`
} else if (isRor) {
exportId = id.replace('https://ror.org/', '')
endpoint = `/api/org/${exportId}/works`
} else if (isContainer) {
const path = (typeof window !== 'undefined' ? window.location.pathname : '').replace(/^\//, '')
if (path) {
exportId = path
endpoint = `/api/container/works?id=${encodeURIComponent(path)}`
}
}
// Loading a window replaces `bibliography`, which this effect reads, so it
// runs again on every page change; starting over for an endpoint already
// being served would refetch for as long as the page stayed open.
if (!endpoint || endpoint === worksEndpoint) return
worksEndpoint = endpoint
worksExportId = exportId
worksTotal = 0
worksWindow = 0
// A link into the middle of a list, filtered or not, opens where it points:
// the filters are in the URL beside the page, because they decide what the
// page is a page of.
const scope = worksScopeFromUrl()
worksTypeFilter = scope.type
worksYearFilter = scope.year
worksPage = scope.page
void loadWorksPage(endpoint, worksBlockFor(worksPage))
void loadWorksFacets(exportId)
})
// Back and forward step through what the reader did to the list — paging it
// and filtering it both. The URL is the state: whatever it names is shown.
$effect(() => {
if (!isEntityPage || typeof window === 'undefined') return
const onPopState = () => {
const scope = worksScopeFromUrl()
const sameFilter = scope.type === worksTypeFilter && scope.year === worksYearFilter
if (sameFilter) {
goToWorksPage(scope.page, false)
return
}
// The filter changed, so the list did: start it again at the page the
// URL names, without pushing an entry for a step the browser just took.
worksTypeFilter = scope.type
worksYearFilter = scope.year
worksPage = scope.page
worksWindow = 0
worksTotal = 0
if (worksEndpoint) void loadWorksPage(worksEndpoint, worksBlockFor(scope.page))
}
window.addEventListener('popstate', onPopState)
return () => window.removeEventListener('popstate', onPopState)
})
// Fetch the window of an entity's works at `offset` and show it. The server
// caps a window at WORKS_PAGE and reports the entity's full count in
// X-Total-Count, which is what the pager spans.
async function loadWorksPage(endpoint, offset) {
const sep = endpoint.includes('?') ? '&' : '?'
let url = `${endpoint}${sep}limit=${WORKS_PAGE}&offset=${offset}`
// The filters are the server's: it reads them from the relation rows, so
// the page that comes back is a page of the filtered list and the total
// beside it is the filtered total.
if (worksTypeFilter) url += `&type=${encodeURIComponent(worksTypeFilter)}`
if (worksYearFilter) url += `&year=${worksYearFilter}`
const initial = offset === 0 && bibliography.length <= 1
if (initial) personWorksLoading = true
else worksPageLoading = true
try {
const response = await fetch(url)
if (!response.ok) return
const works = await response.json()
const total = Number(response.headers.get('X-Total-Count') ?? 0)
// A filtered list can be empty, so 0 is an answer here, not a missing one.
if (Number.isFinite(total)) worksTotal = total
if (!Array.isArray(works)) return
const workEntries = works.map(work => ({
id: crypto.randomUUID(),
doi: (work.id ?? '').replace(/^https?:\/\/doi\.org\//, ''),
data: JSON.stringify(work),
url: work.url ?? null,
html: '',
}))
if (workEntries.length === 0) {
// A window past the end. The count and the pages come from the same
// query, so an offset below the count always has rows — but a link can
// name a page the list no longer reaches, having been shared when the
// entity had more works. With nothing on screen yet, open at the start
// rather than on nothing; otherwise keep the window already shown and
// put the page number back where the content is.
if (offset > 0) {
if (bibliography.length <= 1) {
worksPage = 1
void loadWorksPage(endpoint, 0)
} else {
worksPage = worksWindow / PAGE_SIZE + 1
}
// The address bar should name the page the reader is on.
if (typeof window !== 'undefined') {
window.history.replaceState({ worksPage }, '', worksPageHref(worksPage))
}
}
return
}
worksWindow = offset
bibliography = [bibliography[0], ...workEntries]
// A link can name a page past the end of a list that has since grown
// shorter. The window it asked for still has works — just not that far in
// — so the page is pulled back to the last one that does.
const lastPage = Math.max(1, Math.ceil(worksTotal / PAGE_SIZE))
if (worksPage > lastPage) {
worksPage = lastPage
if (typeof window !== 'undefined') {
window.history.replaceState({ worksPage }, '', worksPageHref(worksPage))
}
const block = worksBlockFor(worksPage)
if (block !== worksWindow) void loadWorksPage(endpoint, block)
}
} catch {
// Leave what is already shown in place.
} finally {
if (initial) personWorksLoading = false
else worksPageLoading = false
}
}
// The works page lives in the URL, so a page is a link: it can be opened in a
// new tab or shared, and the browser's back button steps through the pages a
// reader visited. Page 1 is the bare entity page, with no parameter to it.
// The page and the filters that decide what it is a page of, as the URL holds
// them. `page`, `type` and `year` are managed; everything else in the query is
// kept exactly as served — building it through URLSearchParams would rewrite
// the valueless `?noredirect` every internal link carries into `?noredirect=`.
function worksScopeHref({ page = 1, type = null, year = null } = {}) {
if (typeof window === 'undefined') return ''
const { pathname, search, hash } = window.location
const managed = ['page=', 'type=', 'year=']
const kept = search.replace(/^\?/, '').split('&')
.filter(part => part && !managed.some(m => part.startsWith(m)))
if (type) kept.push(`type=${encodeURIComponent(type)}`)
if (year) kept.push(`year=${year}`)
if (page > 1) kept.push(`page=${page}`)
return `${pathname}${kept.length > 0 ? `?${kept.join('&')}` : ''}${hash}`
}
function worksPageHref(p) {
return worksScopeHref({ page: p, type: worksTypeFilter, year: worksYearFilter })
}
function worksScopeFromUrl() {
if (typeof window === 'undefined') return { page: 1, type: null, year: null }
const params = new URLSearchParams(window.location.search)
const page = parseInt(params.get('page') ?? '', 10)
const year = parseInt(params.get('year') ?? '', 10)
return {
page: Number.isFinite(page) && page > 0 ? page : 1,
type: params.get('type') || null,
year: Number.isFinite(year) && year > 999 ? year : null,
}
}
function worksPageFromUrl() {
return worksScopeFromUrl().page
}
// The counts the filters are offered from, over the entity's whole list.
async function loadWorksFacets(id) {
worksFacets = null
if (!id) return
try {
const response = await fetch(`/api/facets?id=${encodeURIComponent(id)}`)
if (!response.ok) return
const body = await response.json()
if (body?.counted) worksFacets = body
} catch {
// No counts, so no filters offered — the list itself is unaffected.
}
}
// Apply a filter: it changes what the list is, so it starts at its first page
// and goes into the URL, which is what a reload or a shared link reads back.
function setWorksFilter({ type = worksTypeFilter, year = worksYearFilter }) {
worksTypeFilter = type || null
worksYearFilter = year || null
worksPage = 1
worksWindow = 0
worksTotal = 0
if (typeof window !== 'undefined') {
window.history.pushState(
{ worksPage: 1 },
'',
worksScopeHref({ page: 1, type: worksTypeFilter, year: worksYearFilter }),
)
}
if (worksEndpoint) void loadWorksPage(worksEndpoint, 0)
}
// The window of works holding display page `p`. A declaration, not a const:
// the effect that opens the page the URL names calls it, and is written above.
function worksBlockFor(p) {
return Math.floor(((p - 1) * PAGE_SIZE) / WORKS_PAGE) * WORKS_PAGE
}
// Show page `p` of the works list. Unfiltered, the pages run over the entity's
// whole list, so one outside the loaded window fetches the window holding it.
// `push` is false when the browser already moved us — a back or forward step,
// which must not push its own entry back onto the stack.
function goToWorksPage(p, push = true) {
worksPage = p
if (!worksEndpoint) return
if (push && typeof window !== 'undefined') {
window.history.pushState({ worksPage: p }, '', worksPageHref(p))
}
const block = worksBlockFor(p)
if (block !== worksWindow) void loadWorksPage(worksEndpoint, block)
}
async function fetchDoiPage(pageDois, cache, mergeCache, setLoading, clearLoading, fullFetch = false) {
const missing = pageDois.filter(doi => !(doi in cache))
if (missing.length === 0) return
// Merge null sentinels immediately so effect re-runs triggered by other types completing
// see these DOIs as in-flight and don't start duplicate fetches
const newEntries = Object.fromEntries(missing.map(doi => [doi, null]))
mergeCache(newEntries)
setLoading()
try {
// The page's works in one request (see fetchWorks). Not formatted: the
// cards render from the records, and an export formats what it exports.
const works = await fetchWorks(missing, !(isLocalhost || fullFetch))
missing.forEach((doi, i) => {
if (works[i]) newEntries[doi] = workEntry(works[i], doi)
})
mergeCache(newEntries)
} finally {
clearLoading()
}
}
// The commonmeta records of `dois` from POST /api/works, in order, null for
// one that resolved to nothing. One request per fifty works: a request each
// sent a page with a few hundred related works into the request limit.
const WORKS_BATCH = 50
async function fetchWorks(dois, localOnly) {
const out = []
for (let i = 0; i < dois.length; i += WORKS_BATCH) {
const ids = dois.slice(i, i + WORKS_BATCH)
let works = null
try {
const resp = await fetch(`/api/works${localOnly ? '?local_only=true' : ''}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
signal: AbortSignal.timeout(20000),
})
if (resp.ok) works = await resp.json()
} catch { /* a failed batch shows no cards, like a failed lookup did */ }
out.push(...ids.map((_, j) => (Array.isArray(works) ? works[j] : null) ?? null))
}
return out
}
// A list entry for a work record; `doi` stands in when the record has none.
function workEntry(work, doi = '') {
const id = work?.id ?? ''
return {
id: crypto.randomUUID(),
doi: id.startsWith('https://doi.org/') ? id.replace('https://doi.org/', '') : doi,
data: JSON.stringify(work),
url: work?.url ?? null,
html: '',
}
}
const entityTitle = $derived.by(() => {
if (!isEntityPage || bibliography.length === 0) return ''
try {
const d = JSON.parse(bibliography[0].data)
const obj = Array.isArray(d) ? d[0] : d
return obj?.name || obj?.title || ''
} catch { return '' }
})
// Record entity page in history once title resolves
$effect(() => {
if (entityTitle) {
const id = entityId(bibliography[0]?.data ?? '')
historySheet?.pushEntry(window.location.pathname, entityTitle, id)
}
})
// Cmd-K / Ctrl-K: focus search, then cycle the entity kind; Cmd-Y / Ctrl-Y: toggle history; Cmd-B / Ctrl-B: bookmarks; Escape: close
$effect(() => {
function onKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
if (document.activeElement === searchInputEl) {
// Already in the box, so step the kind instead of re-focusing, and
// leave the query untouched: repeated presses re-scope a search in
// progress rather than selecting the text out from under the caret.
// An unknown kind lands on index 0, which is works.
const next = (SEARCH_KINDS.indexOf(searchKind) + 1) % SEARCH_KINDS.length
searchKind = SEARCH_KINDS[next]
} else {
// First press only: focus and select, so one press never changes
// what is being searched.
searchInputEl?.focus()
searchInputEl?.select()
}
}
if ((e.metaKey || e.ctrlKey) && e.key === 'y') {
e.preventDefault()
if (historyOpen && historyTab === 'bookmarks') {
historyTab = 'history'
} else {
historyTab = 'history'
historyOpen = !historyOpen
}
}
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
e.preventDefault()
if (historyOpen && historyTab === 'bookmarks') {
historyOpen = false
} else {
historyTab = 'bookmarks'
historyOpen = true
}
}
if (e.key === 'Escape' && historyOpen) {
e.preventDefault()
historyOpen = false
}
}
window.addEventListener('keydown', onKeydown)
return () => window.removeEventListener('keydown', onKeydown)
})
// ORCID/ROR paths get redirected by handle_pid without preserving query params;
// use the prefixed API paths directly so format=commonmeta reaches the right handler
function commonmetaPath(id, path) {
if (id?.startsWith('https://ror.org/')) return '/ror/' + id.replace('https://ror.org/', '')
if (id?.startsWith('https://orcid.org/')) return '/orcid/' + id.replace('https://orcid.org/', '')
return path ?? '/' + bareBookmarkId(id)
}
async function fetchCommonmeta(path) {
const url = isLocalhost ? `${path}?format=commonmeta` : `${path}?format=commonmeta&local_only=true`
const resp = await fetch(url)
if (!resp.ok) return null
const raw = await resp.json()
const d = bookmarkRecord(raw)
return recordId(d) ? d : null
}
async function addFromHistory(entry) {
try {
const d = await fetchCommonmeta(commonmetaPath(entry.id, entry.path))
if (d) { addToSaved(recordId(d), JSON.stringify(d)); return true }
} catch (e) { console.error('addFromHistory:', e) }
return false
}
// A bookmark link (?bookmarks=…) opens the Bookmarks panel and asks before
// adding anything: the ids it carries are looked up here, not trusted as data.
let linkImport = $state(null)
if (typeof window !== 'undefined') {
const param = new URLSearchParams(window.location.search).get(BOOKMARKS_PARAM)
if (param !== null) {
decodeBookmarkIds(param).then(ids => {
if (!ids) { forgetLinkImport(); return }
linkImport = ids
historyTab = 'bookmarks'
historyOpen = true
})
}
}
function forgetLinkImport() {
linkImport = null
const url = new URL(window.location.href)
url.searchParams.delete(BOOKMARKS_PARAM)
history.replaceState(history.state, '', url.pathname + url.search + url.hash)
}
async function importFromLink() {
const ids = linkImport ?? []
const records = await Promise.all(ids.map(id =>
fetchCommonmeta(commonmetaPath(id)).catch(() => null)))
const found = records.filter(Boolean)
const { added, skipped } = importSaved(found)
forgetLinkImport()
return { added, skipped, failed: ids.length - found.length }
}
async function pickRandom() {
randomLoading = true
bibError = ''
try {
const resp = await fetch('/api/random')
if (!resp.ok) throw new Error(await resp.text().catch(() => resp.statusText))
const { id } = await resp.json()
bibDoi = id
} catch (err) {
bibError = String(err)
} finally {
randomLoading = false
}
}
async function formatItems(items, style, locale) {
const resp = await fetch('/api/bibliography', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, style, locale }),
})
if (!resp.ok) {
const text = await resp.text().catch(() => resp.statusText)
throw new Error(`${resp.status}: ${text}`)
}
return (await resp.json()).items
}
function onBibStyleChange(e) {
const val = e.target.value
if (val === '__more__') {
e.target.value = bibStyle
stylePickerOpen = true
styleSearch = ''
return
}
bibStyle = val
if (bibliography.length > 0) reformatBibliography(val, bibLocale)
}
function pickMoreStyle(style) {
bibStyle = style.value
stylePickerOpen = false
styleSearch = ''
if (bibliography.length > 0) reformatBibliography(style.value, bibLocale)
}
function onBibLocaleChange(e) {
const locale = e.target.value
bibLocale = locale
if (bibliography.length > 0) reformatBibliography(bibStyle, locale)
}
async function reformatBibliography(style, locale) {
bibLoading = true
try {
const relEntries = Object.values(relationCacheByType).flatMap(c => Object.values(c).filter(Boolean))
const allItems = [
...bibliography.map(e => ({ id: e.id, data: e.data })),
...Object.values(refCache).filter(Boolean).map(e => ({ id: e.id, data: e.data })),
...relEntries.map(e => ({ id: e.id, data: e.data })),
]
const formatted = await formatItems(allItems, style, locale)
const byId = Object.fromEntries(formatted.map(i => [i.id, i.html]))
bibliography = bibliography.map(e => ({ ...e, html: byId[e.id] ?? e.html }))
refCache = Object.fromEntries(Object.entries(refCache).map(([doi, e]) => [doi, e ? { ...e, html: byId[e.id] ?? e.html } : null]))
relationCacheByType = Object.fromEntries(
Object.entries(relationCacheByType).map(([type, cache]) => [
type, Object.fromEntries(Object.entries(cache).map(([doi, e]) => [doi, e ? { ...e, html: byId[e.id] ?? e.html } : null]))
])
)
} catch (err) {
bibError = String(err)
} finally {
bibLoading = false
}
}
function addToSaved(id, data) {
if (!id) return
if (!savedItems.some(item => item.id === id) && savedItems.length < MAX_SAVED) {
savedItems = [...savedItems, { id, data: JSON.stringify(bookmarkRecord(data)) }]
}
addedId = id
setTimeout(() => { addedId = null }, 2000)
}
// Add the records of an imported Commonmeta file, as many as fit under the
// cap. A record already bookmarked is skipped rather than overwritten.
function importSaved(records) {
let next = savedItems
for (const r of records) {
const id = recordId(r)
if (next.length >= MAX_SAVED || next.some(item => item.id === id)) continue
next = [...next, { id, data: JSON.stringify(r) }]
}
const added = next.length - savedItems.length
savedItems = next
return { added, skipped: records.length - added }
}
function moveSaved(from, to) {
const next = [...savedItems]
const [item] = next.splice(from, 1)
next.splice(to, 0, item)
savedItems = next
}
function removeFromSaved(id) {
savedItems = savedItems.filter(item => item.id !== id)
}
function deleteEntry(id) {
bibliography = bibliography.filter(e => e.id !== id)
}
function deleteBibliography() {
bibliography = bibliography.slice(0, 1)
allRefDois = []; refCache = {}
allRelationDoisByType = {}; relationCacheByType = {}; relationPageByType = {}; relationFetchingByType = {}
}
async function copyCitation(entry) {
const raw = JSON.parse(entry.data)
// Persons and orgs are stored as JSON arrays [entity, ...works]; copy only the entity.
const obj = Array.isArray(raw) ? raw[0] : raw
await navigator.clipboard.writeText(JSON.stringify(obj, null, 2))
copiedCiteId = entry.id
setTimeout(() => { copiedCiteId = null }, 2000)
}
// ── Export actions ────────────────────────────────────────────────────────
const expExtensions = { citation: 'txt', bibtex: 'bib', ris: 'ris', csl: 'json', commonmeta: 'json',
crossref: 'json', crossref_xml: 'xml', datacite: 'json', datacite_xml: 'xml',
inveniordm: 'json', schemaorg: 'json' }
async function fetchBibExport(items, exportId) {
// An entity's works are paged, so `items` holds only the pages loaded so
// far. When the server can export the entity instead, ask it for the whole
// set in one request rather than one request per loaded work. `exportId` is
// the entity's id, or { id, list: 'citations' } for a work's citations.
if (exportId) {
const target = typeof exportId === 'string' ? { id: exportId } : exportId
const citations = target.list === 'citations'
const params = new URLSearchParams({ id: target.id, format: expFormat })
if (citations) params.set('list', 'citations')
if (expFormat === 'citation') {
params.set('style', bibStyle)
params.set('locale', bibLocale)
}
// Export the list as shown: the filters go with it.
const typeFilter = citations ? citeTypeFilter : worksTypeFilter
const yearFilter = citations ? citeYearFilter : worksYearFilter
if (typeFilter) params.set('type', typeFilter)
if (yearFilter) params.set('year', String(yearFilter))
const resp = await fetch(`/api/export?${params}`)
if (!resp.ok) {
const t = await resp.text().catch(() => resp.statusText)
throw new Error(`${resp.status}: ${t}`)
}
const text = await resp.text()
if (resp.headers.get('X-Export-Truncated')) {
exportTruncated = {
exported: Number(resp.headers.get('X-Exported-Count') ?? 0),
total: Number(resp.headers.get('X-Total-Count') ?? 0),
}
}
// The citation writer renders HTML, as the bibliography endpoint does, and
// an export has always been plain text.
return expFormat === 'citation' ? stripHtml(text) : text
}
if (expFormat === 'citation') {
// Formatted on demand: entity pages no longer format their works up
// front, so this is where the work happens — for the items being
// exported, not for every work on the page.
const missing = items.filter(e => !e.html)
let byId = {}
if (missing.length > 0) {
const formatted = await formatItems(
missing.map(e => ({ id: e.id, data: e.data })),
bibStyle, bibLocale,
)
byId = Object.fromEntries(formatted.map(i => [i.id, i.html]))
}
return items.map(e => stripHtml(e.html || byId[e.id] || '')).join('\n\n')
}
// Works with no entity to export them by — a work's references, its
// related works — go to the server by DOI, all in one request: a request
// each ran an export of more than a few into the request limit.
const resp = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: items.map(e => e.doi).filter(Boolean), format: expFormat }),
})
if (!resp.ok) {
const t = await resp.text().catch(() => resp.statusText)
throw new Error(`${resp.status}: ${t}`)
}
return resp.text()
}
async function exportBibliography(items, exportId) {
if (!items.length) return
expLoading = true
expError = ''
exportTruncated = null
try {
const combined = await fetchBibExport(items, exportId)
const ext = expExtensions[expFormat] ?? 'txt'
const url = URL.createObjectURL(new Blob([combined], { type: 'text/plain' }))
Object.assign(document.createElement('a'), { href: url, download: `bibliography.${ext}` }).click()
URL.revokeObjectURL(url)
} catch (err) { expError = String(err) }
finally { expLoading = false }
}
async function copyBibExport(items, exportId) {
if (!items.length) return
expLoading = true
expError = ''
expCopied = false
exportTruncated = null
try {
const combined = await fetchBibExport(items, exportId)
await navigator.clipboard.writeText(combined)
expCopied = true
setTimeout(() => { expCopied = false }, 2000)
} catch (err) { expError = String(err) }
finally { expLoading = false }
}
</script>
<svelte:window onkeydown={e => { if (e.key === 'Escape') optionsOpen = false }} />
<div class="min-h-screen flex flex-col bg-background text-foreground antialiased">
<!-- Header -->
<header class="bg-[#f2f2f2] dark:bg-gray-800 shrink-0 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-40">
<!-- One row from md up. On a phone, two: the brand and the controls, then
the search box across the full width — squeezed into one row, the
search field had no room left at all. -->
<div class="max-w-full md:max-w-3xl lg:max-w-5xl mx-auto px-4 sm:px-6 py-2 md:py-0 md:h-14 flex flex-wrap md:flex-nowrap items-center gap-x-6 gap-y-2">
<!-- Brand -->
<div class="flex items-center gap-2 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6 text-primary shrink-0">
<path d="M12 .75a8.25 8.25 0 0 0-4.135 15.39c.686.398 1.115 1.143 1.115 1.942V18h5.25v-.008c0-.799.43-1.544 1.115-1.942A8.25 8.25 0 0 0 12 .75Z" />
<path fill-rule="evenodd" d="M9.013 19.9a.75.75 0 0 1 .877-.597 11.319 11.319 0 0 0 4.22 0 .75.75 0 1 1 .28 1.473 12.819 12.819 0 0 1-4.78 0 .75.75 0 0 1-.597-.876ZM9 22.5a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5A.75.75 0 0 1 9 22.5Z" clip-rule="evenodd" />
</svg>
<a href="/" class="text-base font-bold tracking-tight text-gray-900 dark:text-white hover:underline">Commonmeta</a>
</div>
<!-- Search form -->
<div class="relative order-last md:order-none w-full md:w-80 lg:w-[32rem] md:mx-auto min-w-0">
<form onsubmit={goToId} class="w-full">
<div class="flex items-center border border-input rounded-md bg-background overflow-hidden transition-colors focus-within:border-primary">
<!-- The caret is ours, not the browser's: a native one is drawn
against the right edge of the control, which here is the divider
the search field starts at, with no room between the two. -->
<div class="relative shrink-0 border-r border-input">
<select
bind:value={searchKind}
aria-label={_('bibliography.search_in')}
class="h-9 pl-3 pr-9 text-sm bg-transparent outline-none text-muted-foreground cursor-pointer appearance-none"
>
{#each SEARCH_KINDS as kind}
<option value={kind}>{_(SEARCH_KIND_LABELS[kind])}</option>
{/each}
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
aria-hidden="true"
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</div>
<input
type="text"
bind:value={bibDoi}
bind:this={searchInputEl}
placeholder={_('bibliography.placeholder')}
autocomplete="off"
autocorrect="off"
autocapitalize="none"
spellcheck="false"
onkeydown={onSearchKeydown}
class="flex-1 h-9 px-3 text-sm font-mono placeholder:font-sans bg-transparent outline-none min-w-0"
/>
{#if bibDoi}
<button
type="button"
aria-label="Clear"
onclick={() => { bibDoi = '' }}
class="h-9 w-8 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
{/if}
<button
type="submit"
disabled={bibLoading || !bibDoi.trim()}
aria-label={_('bibliography.go')}
class="h-9 px-3 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 shrink-0 flex items-center justify-center transition-colors"
>
{#if bibLoading}
<span class="block w-4 h-4 rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground animate-spin"></span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
{/if}
</button>
</div>
</form>
</div>
<!-- Controls -->
<div class="flex items-center gap-3 justify-end min-w-0 ml-auto md:ml-0">
<!-- From md up, the three controls; on a phone, the options menu below. -->
<button
type="button"
onclick={() => { historyOpen = true }}
aria-label="Navigation history"
class="hidden md:inline-flex text-gray-400 hover:text-gray-900 dark:text-gray-500 dark:hover:text-white transition-colors"
>
<Icon icon="fa6-solid:clock-rotate-left" width="18" height="18" />
</button>
<button
type="button"
onclick={toggleDark}
aria-label="Toggle dark mode"
class="hidden md:inline-flex text-gray-400 hover:text-gray-900 dark:text-gray-500 dark:hover:text-white transition-colors"
>
{#if isDark}
<Icon icon="fa6-solid:sun" width="18" height="18" />
{:else}
<Icon icon="fa6-solid:moon" width="18" height="18" />
{/if}
</button>
<Select bind:value={bibLocale} onchange={onBibLocaleChange}
class="hidden md:flex h-7 w-36 text-xs py-0">
{#each locales as l}
<option value={l.value}>{l.label}</option>
{/each}
</Select>
<div class="relative md:hidden">
<button
type="button"
onclick={() => { optionsOpen = !optionsOpen }}
aria-label={_('menu.options')}
aria-haspopup="menu"
aria-expanded={optionsOpen}
class="flex items-center justify-center w-9 h-9 -mr-2 rounded-md text-gray-500 hover:text-gray-900 hover:bg-black/5 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 transition-colors"
>
<Icon icon="fa6-solid:ellipsis-vertical" width="18" height="18" />
</button>
{#if optionsOpen}
<div class="fixed inset-0 z-40" role="presentation" onclick={() => { optionsOpen = false }}></div>
<div role="menu" class="absolute right-0 top-full mt-2 z-50 w-60 rounded-md border border-border bg-background shadow-lg py-1">
<button
type="button"
role="menuitem"
onclick={() => { optionsOpen = false; historyOpen = true }}
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left hover:bg-accent"
>
<Icon icon="fa6-solid:clock-rotate-left" width="16" height="16" class="text-muted-foreground" />
{_('sheet.tab_history')}
</button>
<button
type="button"
role="menuitem"
onclick={toggleDark}
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left hover:bg-accent"
>
<Icon icon={isDark ? 'fa6-solid:sun' : 'fa6-solid:moon'} width="16" height="16" class="text-muted-foreground" />
{isDark ? _('menu.light_mode') : _('menu.dark_mode')}
</button>
<div class="mt-1 border-t border-border px-4 pt-2.5 pb-3">
<label for="options-locale" class="block text-xs text-muted-foreground mb-1">{_('container.language')}</label>
<Select id="options-locale" bind:value={bibLocale} onchange={onBibLocaleChange} class="h-8 text-sm py-0">
{#each locales as l}
<option value={l.value}>{l.label}</option>
{/each}
</Select>
</div>
</div>
{/if}
</div>
</div>
</div>
</header>
<div class="h-1 w-full shrink-0" aria-live="polite">
{#if bibFetchingRemote}
<div class="fetch-progress h-full bg-primary"></div>
{/if}
</div>
{#if bibError}
<div class="max-w-full md:max-w-3xl lg:max-w-5xl w-full mx-auto px-4 sm:px-6 pt-4">
<div class="px-4 py-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive" role="alert">
{bibError}
</div>
</div>
{/if}
<!-- Main -->
<!-- An entity page fills in its lists after its first paint, so at least a
window's height: the footer then starts below the fold instead of being
pushed out of view when they arrive — a layout shift on every page. -->
<main class="flex-1 max-w-full md:max-w-3xl lg:max-w-5xl w-full mx-auto px-4 sm:px-6 space-y-12 {isEntityPage ? 'min-h-[calc(100svh-3.5rem)]' : ''}">
<!-- ── Bibliography ─────────────────────────────────────────────────── -->
<!-- The cards every list on the site is drawn with: an entity page's
works, references and citations, and the search results below. They
sit outside the lists that use them so both can reach them. -->
<!-- A related work or a reference can be a container: a blog post IsPartOf
its blog's DOI, which resolves to the blog's record. That record has
no work's title or authors, so as a work card it rendered empty; it
gets the container's card, which links to the container's page. -->
{#snippet listedCard(entry, index, hideVersionLink = false)}
{@const container = parseContainerMeta(entry.data)}
{#if container}
<li>
<ContainerCard
{entry} {container} {index} {bibLocale} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entityId(entry.data))}
oncopy={() => copyCitation(entry)}
onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
/>
</li>
{:else}
<WorkCard
{entry} meta={parseMeta(entry.data)} {index}
{bibLocale} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entityId(entry.data))}
{hideVersionLink}
oncopy={() => copyCitation(entry)}
ondelete={undefined}
onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
/>
{/if}
{/snippet}
<!-- Rows that hold a list's place while its records load, each about
as tall as the card that replaces it (the median card: 250 px on
a desktop, 415 px on a phone). A list that drew a spinner and then
grew by two thousand pixels pushed everything below it down and
out of view — the layout shift that rated a work's page poor. -->
{#snippet cardPlaceholders(n)}
<ol class="divide-y divide-border" aria-busy="true">
<!-- `_row`, not `_`: an each binding named `_` shadows the i18n
`_` inside the loop, so the `sr-only` text below called the array
item and threw `_(...) is not a function`, taking the whole list
down with it. -->
{#each Array.from({ length: n }) as _row, i (i)}
<li class="px-5 py-4 h-[26rem] sm:h-64 animate-pulse">
<div class="h-4 w-3/4 rounded bg-muted"></div>
<div class="mt-3 h-3 w-1/2 rounded bg-muted"></div>
<div class="mt-3 h-3 w-5/6 rounded bg-muted"></div>
{#if i === 0}<span class="sr-only">{_('bibliography.loading')}</span>{/if}
</li>
{/each}
</ol>
{/snippet}
{#snippet bibFooter(items, exportId)}
<div class="border-t border-border px-4 py-3 bg-muted flex items-center gap-2 flex-wrap">
<Select bind:value={expFormat} class="w-40 h-8 text-xs py-0">
{#each expFormats as f}
<option value={f.value}>{f.i18n ? _(f.i18n) : f.label}</option>
{/each}
</Select>
{#if expFormat === 'citation'}
<div class="relative flex-1 min-w-36">
<Select value={bibStyle} onchange={onBibStyleChange} class="w-full h-8 text-xs py-0">
{#if !commonStyles.some(s => s.value === bibStyle)}
<option value={bibStyle}>{allCitationStyles.find(s => s.value === bibStyle)?.label ?? bibStyle}</option>
<option disabled>──────────────────────</option>
{/if}
{#each commonStyles as s}
<option value={s.value}>{s.label}</option>
{/each}
<option disabled>──────────────────────</option>
<option value="__more__">{_('sheet.more_styles')}</option>
</Select>
{#if stylePickerOpen}
<div class="fixed inset-0 z-40" role="presentation" onclick={() => { stylePickerOpen = false; styleSearch = '' }}></div>
<div class="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg">
<div class="p-2 border-b border-border">
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={styleSearch}
placeholder={_('sheet.search_styles')}
autofocus
class="w-full text-sm px-3 py-1.5 rounded border border-input bg-background outline-none focus:ring-1 focus:ring-ring"
onkeydown={e => { if (e.key === 'Escape') { stylePickerOpen = false; styleSearch = '' } }}
/>
</div>
<ul class="max-h-72 overflow-y-auto py-1">
{#each allCitationStyles.filter(s => !styleSearch || s.label.toLowerCase().includes(styleSearch.toLowerCase())) as s}
<li>
<button
type="button"
class="w-full text-left px-3 py-1.5 text-sm hover:bg-muted {s.value === bibStyle ? 'font-semibold text-primary' : ''}"
onclick={() => pickMoreStyle(s)}
>{s.label}</button>
</li>
{/each}
{#if allCitationStyles.filter(s => !styleSearch || s.label.toLowerCase().includes(styleSearch.toLowerCase())).length === 0}
<li class="px-3 py-2 text-sm text-muted-foreground">{_('sheet.no_styles')}</li>
{/if}
</ul>
</div>
{/if}
</div>
{/if}
<Button variant="default" onclick={() => exportBibliography(items, exportId)} disabled={expLoading} class="h-8 gap-1.5 text-xs px-3">
{#if expLoading}
<span class="w-3.5 h-3.5 rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground animate-spin"></span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{/if}
{_('bibliography.export')}
</Button>
<Button variant="outline" onclick={() => copyBibExport(items, exportId)} disabled={expLoading} class="h-8 gap-1.5 text-xs px-3">
{#if expCopied}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{_('bibliography.copied')}
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
{_('bibliography.copy')}
{/if}
</Button>
{#if expError}
<p class="w-full text-xs text-destructive">{expError}</p>
{/if}
{#if exportTruncated}
<p class="w-full text-xs text-muted-foreground">
{_('bibliography.export_truncated', { values: { count: exportTruncated.exported, total: exportTruncated.total } })}
</p>
{/if}
</div>
{/snippet}
<!-- main spaces its children by three rems, which separates lists of
cards. On the homepage this section holds nothing and has no height, so
that gap became the space above the documentation — far more than a
line of text needs. Narrowed there, and left alone everywhere else,
where the section has content below it. -->
<section class={!isEntityPage && !searchShown ? 'mb-4' : ''}>
<!-- Bibliography list -->
{#if bibliography.length > 0}
<div class="mt-4 space-y-2">
{#each bibGroups() as group (group.entries[0].entry.id)}
{#if group.type === 'Work'}
{#if isEntityPage && group.entries[0].idx === 0}
{@const { entry } = group.entries[0]}
{@const meta = parseMeta(entry.data)}
<div class="border border-border rounded-md overflow-hidden">
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-lg font-semibold text-primary">{typeLabel(meta.type, bibLocale)}</h2>
</div>
<ol>
<WorkCard
{entry} meta={{ ...meta, referenceCount: allRefDois.length, relationCounts: Object.fromEntries(Object.entries(allRelationDoisByType).map(([t, dois]) => [t, t === 'IsReferencedBy' ? (citeFacets?.total ?? liveCiteCount ?? dois.length) : dois.length])) }} index={undefined} largeTitle={true}
{bibLocale} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entityId(entry.data))}
oncopy={() => copyCitation(entry)}
ondelete={undefined}
onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
/>
</ol>
</div>
{#each orderedRelTypes.filter(t => t !== 'IsReferencedBy') as relType (relType)}
{@const relDois = allRelationDoisByType[relType] ?? []}
{@const relCache = relationCacheByType[relType] ?? {}}
{@const relPage = relationPageByType[relType] ?? 1}
{@const relFetching = relationFetchingByType[relType] ?? false}
{@const relPageDois = relDois.slice((relPage - 1) * PAGE_SIZE, relPage * PAGE_SIZE)}
{@const relSlice = relPageDois.map(doi => relCache[doi]).filter(Boolean)}
{@const relHasMore = relDois.length > relPage * PAGE_SIZE}
{@const relTotalPages = Math.max(relPage + (relHasMore ? 1 : 0), Math.ceil(Object.keys(relCache).length / PAGE_SIZE), 1)}
{#if relFetching || relSlice.length > 0}
<div class="border border-border rounded-md overflow-hidden">
<div class="px-5 py-3 border-b border-border bg-muted/50 flex items-center gap-2">
<h2 class="text-base font-semibold">{humanizeRelationType(relType)}</h2>
{#if relFetching}
<svg class="animate-spin h-4 w-4 shrink-0 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{/if}
</div>
{#if relSlice.length > 0}
<ol class="divide-y divide-border">
{#each relSlice as entry, idx (entry.id)}
{@render listedCard(entry, (relPage - 1) * PAGE_SIZE + idx, relType === 'HasVersion')}
{/each}
</ol>
{:else if relFetching}
{@render cardPlaceholders(relPageDois.length)}
{/if}
{#if relTotalPages > 1}
<div class="px-5 pb-4">
<Pagination page={relPage} totalPages={relTotalPages} locale={bibLocale} onpage={p => { relationPageByType = { ...relationPageByType, [relType]: p } }} />
</div>
{/if}
{@render bibFooter(Object.values(relCache))}
</div>
{/if}
{/each}
{#if allRefDois.length > 0}
{@const WORK_YEAR = parseInt(meta.datePublished ?? '', 10) || new Date().getFullYear()}
{@const refAllCached = allRefDois.map(doi => refCache[doi]).filter(Boolean)}
{@const refTopTypes = (() => {
const counts = new Map()
for (const entry of refAllCached) {
try {
const d = JSON.parse(entry.data)
if (refYearFilter && parseInt(d?.date_published ?? '', 10) !== refYearFilter) continue
const t = d?.type ?? ''
if (t && WORK_TYPES.includes(t)) counts.set(t, (counts.get(t) ?? 0) + 1)
} catch {}
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3)
})()}
{@const refTopYears = (() => {
const counts = new Map()
for (const entry of refAllCached) {
try {
const d = JSON.parse(entry.data)
if (refTypeFilter && d?.type !== refTypeFilter) continue
const y = parseInt(d?.date_published ?? '', 10)
if (y === WORK_YEAR || y === WORK_YEAR - 1 || y === WORK_YEAR - 2) counts.set(y, (counts.get(y) ?? 0) + 1)
} catch {}
}
return [...counts.entries()].sort((a, b) => a[0] - b[0])
})()}
{@const refFiltered = (refTypeFilter || refYearFilter)
? refAllCached.filter(entry => {
try {
const d = JSON.parse(entry.data)
const typeOk = !refTypeFilter || d?.type === refTypeFilter
const yearOk = !refYearFilter || parseInt(d?.date_published ?? '', 10) === refYearFilter
return typeOk && yearOk
} catch { return false }
})
: null}
{@const refSlice = (refFiltered ?? refAllCached).slice((refPage - 1) * PAGE_SIZE, refPage * PAGE_SIZE)}
{@const refHasMore = refFiltered ? false : allRefDois.length > refPage * PAGE_SIZE}
{@const refTotalPages = refFiltered
? Math.max(1, Math.ceil(refFiltered.length / PAGE_SIZE))
: Math.max(refPage + (refHasMore ? 1 : 0), Math.ceil(Object.keys(refCache).length / PAGE_SIZE), 1)}
{#if refFetching || refAllCached.length > 0}
<div class="border border-border rounded-md overflow-hidden">
<div class="px-5 py-3 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
<h2 class="text-base font-semibold mr-auto">{_('bibliography.references_heading')}</h2>
{#if refTopTypes.length > 0}
<!-- On a phone only the years, as for an entity's works;
a type filter in force stays. -->
{#each refTopTypes as [type, count]}
<span class={refTypeFilter === type ? 'contents' : 'hidden sm:contents'}>
<Button
type="button"
variant={refTypeFilter === type ? 'default' : 'outline'}
size="sm"
onclick={() => { refTypeFilter = refTypeFilter === type ? null : type; refPage = 1 }}
>{typeLabel(type, bibLocale)}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
</span>
{/each}
{/if}
{#if refTopYears.length > 0}
<div class="sm:ml-3 flex items-center gap-1.5 sm:border-l border-border sm:pl-3">
{#each refTopYears as [year, count]}
<Button
type="button"
variant={refYearFilter === year ? 'default' : 'outline'}
size="sm"
onclick={() => { refYearFilter = refYearFilter === year ? null : year; refPage = 1 }}
>{year}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
{/each}
</div>
{/if}
</div>
{#if refFetching && refSlice.length === 0}
{@render cardPlaceholders(Math.min(PAGE_SIZE, Math.max(0, allRefDois.length - (refPage - 1) * PAGE_SIZE)))}
{:else}
<ol class="divide-y divide-border">
{#each refSlice as entry, idx (entry.id)}
{@render listedCard(entry, (refPage - 1) * PAGE_SIZE + idx)}
{/each}
</ol>
{/if}
{#if refTotalPages > 1}
<div class="px-5 pb-4">
<Pagination page={refPage} totalPages={refTotalPages} locale={bibLocale} onpage={p => refPage = p} />
</div>
{/if}
{@render bibFooter(refFiltered ?? refAllCached)}
</div>
{/if}
{/if}
<!-- A work's citations, paged and filtered by the server. The
filter counts are the whole list's and describe it
unfiltered, as an entity's works filters do. -->
{#each orderedRelTypes.filter(t => t === 'IsReferencedBy') as relType (relType)}
{@const relPage = relationPageByType[relType] ?? 1}
{@const citeTopTypes = (citeFacets?.types ?? [])
.filter(({ type }) => WORK_TYPES.includes(type))
.slice(0, 3)
.map(({ type, works }) => [type, works])}
{@const NOW_YEAR = new Date().getFullYear()}
{@const citeTopYears = (citeFacets?.years ?? [])
.filter(({ year }) => year >= NOW_YEAR - 2 && year <= NOW_YEAR)
.map(({ year, works }) => [year, works])
.sort((a, b) => a[0] - b[0])}
{@const relSlice = citePageEntries}
{@const relTotalPages = Math.max(1, Math.ceil((citeTotal ?? 0) / PAGE_SIZE))}
{#if citeLoading || (citeTotal ?? 0) > 0 || citeTypeFilter || citeYearFilter}
<div class="border border-border rounded-md overflow-hidden">
<div class="px-5 py-3 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
<h2 class="text-base font-semibold mr-auto">{humanizeRelationType(relType)}</h2>
{#if citeTopTypes.length > 0}
<!-- On a phone only the years, as for an entity's works;
a type filter in force stays. -->
{#each citeTopTypes as [type, count]}
<span class={citeTypeFilter === type ? 'contents' : 'hidden sm:contents'}>
<Button
type="button"
variant={citeTypeFilter === type ? 'default' : 'outline'}
size="sm"
onclick={() => { citeTypeFilter = citeTypeFilter === type ? null : type; relationPageByType = { ...relationPageByType, [relType]: 1 } }}
>{typeLabel(type, bibLocale)}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
</span>
{/each}
{/if}
{#if citeTopYears.length > 0}
<div class="sm:ml-3 flex items-center gap-1.5 sm:border-l border-border sm:pl-3">
{#each citeTopYears as [year, count]}
<Button
type="button"
variant={citeYearFilter === year ? 'default' : 'outline'}
size="sm"
onclick={() => { citeYearFilter = citeYearFilter === year ? null : year; relationPageByType = { ...relationPageByType, [relType]: 1 } }}
>{year}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
{/each}
</div>
{/if}
</div>
{#if citeLoading && citePageEntries.length === 0}
{@render cardPlaceholders(Math.min(PAGE_SIZE, citeTotal ?? Math.max(allRelationDoisByType['IsReferencedBy']?.length ?? 0, liveCiteCount ?? 0)))}
{:else}
<ol class="divide-y divide-border">
{#each relSlice as entry, idx (entry.id)}
{@const meta = parseMeta(entry.data)}
<WorkCard
{entry} {meta} index={idx}
{bibLocale} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entityId(entry.data))}
hideVersionLink={false}
oncopy={() => copyCitation(entry)}
ondelete={undefined}
onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
/>
{/each}
</ol>
{/if}
{#if relTotalPages > 1}
<div class="px-5 pb-4">
<Pagination page={relPage} totalPages={relTotalPages} locale={bibLocale} onpage={p => { relationPageByType = { ...relationPageByType, [relType]: p } }} />
</div>
{/if}
{@render bibFooter(citePageEntries, { id: citeDoi, list: 'citations' })}
</div>
{/if}
{/each}
{:else}
<!-- In the order the server sent them. It pages the whole list
by publication year, so sorting a page of fifty here by the
full date ordered each page against itself and none against
the next: Zenodo's first 764 pages are all one year, where
that sort rearranged every one of them and a reader stepping
between pages saw the dates jump about. -->
{@const pageWorks = group.entries}
<!-- Whatever the server sent, in its order and at its length.
Dropping the versions of other works here is what emptied
the later pages of a blog: the page had been sliced at a
position in a list that was 1,292 long, and what was left
after the drop was 649. The list that leaves them out is
the one the server pages, counts and totals. -->
{@const filteredWorks = pageWorks}
{@const isPaginatedWorks = isEntityPage && filteredWorks[0]?.idx > 0}
{@const CURRENT_YEAR = new Date().getFullYear()}
<!-- The counts are the whole list's, from /api/facets, and they
describe it unfiltered: how many works of this type the
entity has, not how many are left after the other filter.
A list the server would not count offers no filters. -->
{@const topTypes = (worksFacets?.types ?? [])
.filter(({ type }) => WORK_TYPES.includes(type))
.slice(0, 3)
.map(({ type, works }) => [type, works])}
{@const topYears = (worksFacets?.years ?? [])
.filter(({ year }) => year === CURRENT_YEAR || year === CURRENT_YEAR - 1 || year === CURRENT_YEAR - 2)
.map(({ year, works }) => [year, works])
.sort((a, b) => a[0] - b[0])}
<!-- The server returns the filtered list, so what arrives is
already what the filters selected. -->
{@const typeFilteredWorks = filteredWorks}
<!-- The pages span whatever list the server is paging — the
entity's, or what a filter selected — and the slice is taken
at the page's place within the loaded window. -->
{@const worksPageCount = !isPaginatedWorks
? 1
: Math.max(1, Math.ceil(Math.max(worksTotal, worksWindow + filteredWorks.length) / PAGE_SIZE))}
{@const worksSliceStart = (worksPage - 1) * PAGE_SIZE - worksWindow}
{@const worksSlice = isPaginatedWorks ? typeFilteredWorks.slice(worksSliceStart, worksSliceStart + PAGE_SIZE) : typeFilteredWorks}
<div class="border border-border rounded-md overflow-hidden">
{#if isPaginatedWorks}
<div class="px-5 py-3 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
<h2 class="text-base font-semibold mr-auto">{_(worksHeadingKey)}</h2>
{#if topTypes.length > 1}
<!-- On a phone only the years are offered: the type
filters took two more rows above the list. A
type filter in force stays, so it can be seen and
cleared. -->
{#each topTypes as [type, count]}
<span class={worksTypeFilter === type ? 'contents' : 'hidden sm:contents'}>
<Button
type="button"
variant={worksTypeFilter === type ? 'default' : 'outline'}
size="sm"
onclick={() => setWorksFilter({ type: worksTypeFilter === type ? null : type })}
>{typeLabel(type, bibLocale)}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
</span>
{/each}
{/if}
{#if topYears.length > 0}
<div class="sm:ml-3 flex items-center gap-1.5 sm:border-l border-border sm:pl-3">
{#each topYears as [year, count]}
<Button
type="button"
variant={worksYearFilter === year ? 'default' : 'outline'}
size="sm"
onclick={() => setWorksFilter({ year: worksYearFilter === year ? null : year })}
>{year}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
{/each}
</div>
{/if}
</div>
{/if}
<ol class="divide-y divide-border transition-opacity {worksPageLoading ? 'opacity-50' : ''}">
{#each worksSlice as { entry }, sliceIdx (entry.id)}
{@const meta = parseMeta(entry.data)}
<WorkCard
{entry} {meta} index={(worksPage - 1) * PAGE_SIZE + sliceIdx}
{bibLocale} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entityId(entry.data))}
oncopy={() => copyCitation(entry)}
ondelete={undefined}
onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
/>
{/each}
</ol>
{#if worksPageCount > 1}
<div class="px-5 pb-4">
<Pagination
page={worksPage}
totalPages={worksPageCount}
locale={bibLocale}
onpage={goToWorksPage}
href={worksPageHref}
/>
</div>
{/if}
<!-- The server exports the list as shown, filters and all. -->
{@render bibFooter(typeFilteredWorks.map(e => e.entry), worksExportId)}
</div>
{/if}
{:else if group.type === 'Organization'}
{#each group.entries as { entry, idx } (entry.id)}
{@const org = parseOrgMeta(entry.data)}
{@const worksCount = isEntityPage && idx === 0 ? Math.max(liveWorksCount ?? 0, bibliography.length - 1) : 0}
<div class="border border-border rounded-md overflow-hidden">
{#if isEntityPage && idx === 0}
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-lg font-semibold text-primary">{_('entity.organization')}</h2>
</div>
{/if}
<OrgCard {entry} {org} {bibLocale} {worksCount} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entityId(entry.data))}
oncopy={() => copyCitation(entry)}
ondelete={idx === 0 ? undefined : () => deleteEntry(entry.id)}
onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
/>
</div>
{/each}
{:else if group.type === 'Container'}
{#each group.entries as { entry, idx } (entry.id)}
{@const container = parseContainerMeta(entry.data)}
{@const worksCount = isEntityPage && idx === 0
? Math.max(worksTotal, bibliography.slice(1).filter(notAVersion).length)
: 0}
<div class="border border-border rounded-md overflow-hidden">
{#if isEntityPage && idx === 0}
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-lg font-semibold text-primary">{container?.type ? _(`container.type.${container.type}`, { default: container.type }) : _('entity.container')}</h2>
</div>
{/if}
<ContainerCard {entry} {container} {bibLocale} {worksCount} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entityId(entry.data))}
oncopy={() => copyCitation(entry)}
ondelete={idx === 0 ? undefined : () => deleteEntry(entry.id)}
onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
/>
</div>
{/each}
{:else}
{#each group.entries as { entry, idx } (entry.id)}
{@const worksCount = isEntityPage && idx === 0 ? Math.max(liveWorksCount ?? 0, bibliography.length - 1) : 0}
<div class="border border-border rounded-md overflow-hidden">
{#if isEntityPage && idx === 0}
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-lg font-semibold text-primary">{_('entity.person')}</h2>
</div>
{/if}
<PersonCard {entry} {bibLocale} {worksCount} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entityId(entry.data))}
oncopy={() => copyCitation(entry)}
ondelete={idx === 0 ? undefined : () => deleteEntry(entry.id)}
onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
/>
</div>
{/each}
{#if isEntityPage && group.entries.length > 0}
{@const personAffs = parsePersonAffiliations(group.entries[0].entry.data)}
{#if personAffs.length > 0}
<div class="border border-border rounded-md overflow-hidden">
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-base font-semibold">{_('person.affiliations')}</h2>
</div>
<div>
{#each personAffs as aff}
<div class="px-5 py-4 space-y-0.5">
<p class="text-sm font-medium inline-flex items-center gap-0.5">
{#if aff.identifier?.startsWith('https://ror.org/')}
<a href={entityPath(aff.identifier)} class="hover:underline">{aff.name}</a><img src={rorSvg} width="14" height="14" alt="ROR" class="ml-0.5 inline-block align-middle opacity-70 shrink-0" />
{:else}
{aff.name}
{/if}
</p>
{#if aff.role}
<p class="text-sm text-muted-foreground">{aff.role}</p>
{/if}
{#if aff.start_date || aff.end_date}
<div class="text-sm text-muted-foreground">
{#if aff.start_date && aff.end_date}
{formatAffDate(aff.start_date)} – {formatAffDate(aff.end_date)}
{:else if aff.start_date}
{_('person.affiliation_started')} {formatAffDate(aff.start_date)}
{:else}
{_('person.affiliation_ended')} {formatAffDate(aff.end_date)}
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{/if}
{/if}
{/each}
{#if personWorksLoading}
<div class="border border-border rounded-md overflow-hidden">
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-base font-semibold">{_(worksHeadingKey)}</h2>
</div>
<!-- A page of works is PAGE_SIZE cards, fewer for an entity
known to have fewer. -->
{@render cardPlaceholders(Math.min(PAGE_SIZE, liveWorksCount ?? PAGE_SIZE))}
</div>
{/if}
</div>
{/if}
<!-- No-results info message -->
{#if !isEntityPage && searchSettled && searchHits.length === 0 && bibliography.length === 0}
<div class="mt-4 rounded-md border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 px-5 py-4 text-sm text-blue-800 dark:text-blue-300">
{_('bibliography.no_results')} "{bibDoi}"
{#if searchFiltersActive}
<button type="button" onclick={clearSearchFilters} class="ml-1 underline hover:no-underline">
{_('search.clear_filters')}
</button>
{/if}
</div>
{/if}
</section>
<!-- ── Search results ───────────────────────────────────────────────── -->
<!-- The facets on the left, the page of results on the right. The sidebar
is hidden below lg, where it would push the results off the screen;
there the filters in force still show as chips above the list, so one
picked on a desktop can be seen and cleared on a phone. -->
{#if searchShown}
<section class="flex gap-8 items-start">
{#if searchFacetsShown}
<aside class="w-52 shrink-0 hidden lg:block sticky top-20 self-start" aria-label={_('search.filters')}>
{#each searchKindFacets as facet}
{@const counts = searchFacets[facet.counts] ?? []}
{#if counts.length > 0}
<div class="mb-5">
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">{_(facet.heading)}</p>
<ul class="space-y-0.5 text-sm">
{#each counts as count}
<!-- What the link asks for. A subject is asked for by
its code, which is what an index built since counts
and what a link carries either way; everything else
is asked for as it was counted. -->
{@const value = searchFacetValue(facet.key, count.value)}
{@const picked = isSearchFilterPicked(searchFilters, facet.key, value)}
<li>
<!-- A real link, so a filtered list can be opened in a
new tab or shared; the click is handled here and the
URL written by toggleSearchFacet. A box rather than
a plain row, because several values of one facet
can be picked at once and nothing else would say
so. -->
<a
href={searchHref({ filters: toggleSearchFilter(searchFilters, facet.key, value), page: 1 })}
onclick={e => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
e.preventDefault()
toggleSearchFacet(facet.key, value)
}}
aria-current={picked ? 'true' : undefined}
class="flex items-start gap-2 rounded px-1.5 py-1 -mx-1.5 transition-colors
{picked ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
<span
aria-hidden="true"
class="mt-[3px] shrink-0 w-3.5 h-3.5 rounded-sm border flex items-center justify-center
{picked ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40'}"
>
{#if picked}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" class="w-2.5 h-2.5">
<path d="M20 6 9 17l-5-5" />
</svg>
{/if}
</span>
<span class="flex-1 min-w-0 break-words">{searchFacetLabel(facet.key, value)}</span>
<span class="shrink-0 text-xs tabular-nums opacity-70 mt-px">{formatCount(count.count, bibLocale)}</span>
</a>
</li>
{/each}
</ul>
</div>
{/if}
{/each}
</aside>
{/if}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-3 flex-wrap mb-3">
<h2 class="text-base font-semibold">
{_(searchCountLabel, { values: { count: formatCount(searchTotal, bibLocale) } })}
</h2>
{#if searchPageCount > 1}
<!-- ml-auto: the page sits against the right edge of the list,
away from the count, and wraps under it on a narrow screen. -->
<span class="ml-auto text-sm text-muted-foreground">
{_('search.page_of', { values: { page: formatCount(searchPage, bibLocale), pages: formatCount(searchPageCount, bibLocale) } })}
</span>
{/if}
</div>
<!-- What the list is narrowed by, above the list and at every width.
It used to show below lg only, on the reasoning that the sidebar
said it already — but the sidebar's counts stay deliberately
unnarrowed, so picking a value left it looking untouched, and a
value whose count is most of the list (696 of 708 Berlin
organizations are German) changed little else. The filters now
say so themselves, and each chip clears its own. -->
{#if searchFiltersActive}
<div class="flex flex-wrap items-center gap-1.5 mb-3">
<span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{_('search.filters')}</span>
{#each searchKindFacets as facet}
<!-- One chip per value, each clearing its own: a facet may hold
several, and a chip that cleared the whole facet would take
away more than it names. -->
{#each searchFilterValues(searchFilters, facet.key) as value}
<button
type="button"
onclick={() => toggleSearchFacet(facet.key, value)}
aria-label={`${_(facet.heading)}: ${searchFacetLabel(facet.key, value)} — ${_('search.clear_filters')}`}
class="inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-primary/10 px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:border-primary/60"
>
<span class="text-muted-foreground">{_(facet.heading)}</span>
{searchFacetLabel(facet.key, value)}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="w-3 h-3 opacity-60">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
{/each}
{/each}
<button
type="button"
onclick={clearSearchFilters}
class="ml-1 text-xs text-primary hover:underline"
>{_('search.clear_filters')}</button>
</div>
{/if}
{#if searchHits.length > 0 || searchLoading}
<div class="border border-border rounded-md overflow-hidden">
{#if searchLoading && searchEntries.length === 0 && searchHits.length === 0}
{@render cardPlaceholders(Math.min(SEARCH_PAGE_SIZE, 5))}
{:else if searchKind === 'works'}
<!-- The same cards as every other list of works, numbered as a
list of references is: the ordinals run on across the pages,
so the fourth page starts at 31 rather than at 1 again.
No rule between them — the numbers already say where one
work ends and the next begins, and a list of ten rules
reads as a table of one column. -->
<ol>
{#each searchEntries as entry, i (entry.id)}
{@render listedCard(entry, (searchPage - 1) * SEARCH_PAGE_SIZE + i)}
{/each}
</ol>
{:else}
<!-- A person, an organization or a container: named and linked,
since none of them is a work and the work card would render
empty for them. -->
<ol>
<!-- Keyed by position, not by id: two container rows can
still map to the same ISSN while the store holds
duplicates, and a repeated key would throw and blank the
list. The page is replaced whole, so position is the
stable key here. -->
{#each searchHits as hit, i (i)}
<li class="flex items-start gap-3 px-5 py-4">
<!-- The ordinal in the work card's own shape, so the four
kinds of result read alike: it runs on across the
pages, and the column grows rather than clipping once
a list runs into the thousands. -->
<span class="text-xs text-muted-foreground min-w-5 shrink-0 pt-0.5 text-right tabular-nums">{formatCount((searchPage - 1) * SEARCH_PAGE_SIZE + i + 1, bibLocale)}.</span>
<div class="flex-1 min-w-0 space-y-0.5">
<p class="text-base font-semibold leading-snug">
<a href={entityPath(hit.id)} class="hover:underline">{hit.title || hit.id}</a>
</p>
<!-- Headed fields, in the organization card's own shape,
so a result and the page it leads to read alike. The
names are the acronym and ROR's aliases, capped,
because a university can carry seven and a row is a
row, not a record. -->
<!-- A container's other titles, unlabelled under its own,
as the container card has them. -->
{#if hit.entity_type === 'Container' && hit.also_known_as?.length > 0}
<p class="text-sm text-muted-foreground">{hit.also_known_as.slice(0, SEARCH_ALSO_KNOWN_AS).join(' · ')}{hit.also_known_as.length > SEARCH_ALSO_KNOWN_AS ? ' …' : ''}</p>
{/if}
<!-- The names, then the biography, then where they are —
the person card's own order. Two lists rather than one
because the biography sits between them and a `dl` may
not hold a bare paragraph; the spacing is the same
either way. -->
{#if hit.given_name || hit.family_name || (hit.also_known_as?.length > 0 && hit.entity_type !== 'Container')}
<dl class="mt-2 space-y-2 text-sm">
{#if hit.given_name}
<div>
<dt class="font-semibold text-foreground">{_('person.given_name')}</dt>
<dd class="text-muted-foreground">{hit.given_name}</dd>
</div>
{/if}
{#if hit.family_name}
<div>
<dt class="font-semibold text-foreground">{_('person.family_name')}</dt>
<dd class="text-muted-foreground">{hit.family_name}</dd>
</div>
{/if}
{#if hit.also_known_as?.length > 0 && hit.entity_type !== 'Container'}
<div>
<!-- Both cards call this the same thing; the key
differs only in which half of the file it is in. -->
<dt class="font-semibold text-foreground">{_(hit.entity_type === 'Person' ? 'person.additional_names' : 'org.additional_names')}</dt>
<dd class="text-muted-foreground">{hit.also_known_as.slice(0, SEARCH_ALSO_KNOWN_AS).join(' · ')}{hit.also_known_as.length > SEARCH_ALSO_KNOWN_AS ? ' …' : ''}</dd>
</div>
{/if}
</dl>
{/if}
<!-- A person's biography, unlabelled as the card leaves it
— it reads as prose, not as a field. Three lines in a
list where the card allows six: a row is a row. -->
{#if hit.description}
<p class="mt-2 text-sm text-muted-foreground line-clamp-3">{hit.description}</p>
{/if}
{#if hit.publisher || hit.location || hit.country || hit.types?.length > 0 || hit.identifiers?.length > 0}
<dl class="mt-2 space-y-2 text-sm">
{#if hit.publisher}
<div>
<dt class="font-semibold text-foreground">{_('container.publisher')}</dt>
<dd class="text-muted-foreground">
<!-- A ROR-identified publisher links to its page
here, as on the card; any other id is text. -->
{#if hit.publisher_id?.startsWith('https://ror.org/')}
<a href={entityPath(hit.publisher_id)} class="hover:underline">{hit.publisher}</a><img src={rorSvg} width="14" height="14" alt="ROR" class="ml-0.5 inline-block align-middle opacity-70 shrink-0" />
{:else}
{hit.publisher}
{/if}
</dd>
</div>
{/if}
{#if hit.location}
<!-- The place, then the country it is in, which
leads to the other organizations there — the
organization card's own split. ROR names the
country in the place itself ("Berlin, State of
Berlin, Germany"), so the last part is dropped
in favour of the code's own name where the row
carries the code. -->
{@const parts = hit.location.split(', ')}
{@const place = hit.country ? parts.slice(0, -1).join(', ') : hit.location}
{@const country = hit.country ? searchLocationLabel(hit.country, bibLocale) : ''}
<div>
<dt class="font-semibold text-foreground">{_('org.location')}</dt>
<dd class="text-muted-foreground">{#if place}{searchLocationLabel(place, bibLocale)}{/if}{#if place && country}{', '}{/if}{#if country}<a href={searchHrefFor({ q: SEARCH_ALL, kind: 'organizations', filters: { country: [hit.country] } })} class="hover:underline">{country}</a>{/if}</dd>
</div>
{/if}
{#if hit.country && hit.entity_type !== 'Organization'}
<div>
<dt class="font-semibold text-foreground">{_('entity.country')}</dt>
<!-- A person's country leads to everyone in it, as
their own card's does. Only a person's: a
container carries a country too, and there is
no facet to count containers by. -->
<dd class="text-muted-foreground">{#if hit.entity_type === 'Person'}<a href={searchHrefFor({ q: SEARCH_ALL, kind: 'people', filters: { country: [hit.country] } })} class="hover:underline">{searchLocationLabel(hit.country, bibLocale)}</a>{:else}{searchLocationLabel(hit.country, bibLocale)}{/if}</dd>
</div>
{/if}
{#if hit.types?.length > 0}
<div>
<dt class="font-semibold text-foreground">{_('org.types')}</dt>
<dd class="text-muted-foreground">{hit.types.map(t => _(`org.type.${t}`, { default: capitalizeWord(t) })).join(' · ')}</dd>
</div>
{/if}
<!-- What else this container is known as: every
identifier but the one the row links by. -->
{#if hit.identifiers?.length > 0}
<div>
<dt class="font-semibold text-foreground">{_('container.also_known_as')}</dt>
<dd class="text-muted-foreground space-y-0.5">
{#each hit.identifiers.slice(0, SEARCH_IDENTIFIERS) as id}
{@const link = searchIdentifierLink(id.identifier, id.identifier_type)}
<div class="leading-5">
{#if link.href}
<a href={link.href} target="_blank" rel="noreferrer" class="text-primary hover:underline wrap-anywhere max-w-full inline-flex items-center gap-1 [&>svg]:shrink-0">{#if link.doi}<Icon icon="academicons:doi" width="16" height="16" color="#fab608" class="shrink-0" />{/if}{link.label}</a>
{:else}
{link.label}
{/if}
</div>
{/each}
{#if hit.identifiers.length > SEARCH_IDENTIFIERS}
<div class="leading-5">…</div>
{/if}
</dd>
</div>
{/if}
</dl>
{/if}
<!-- How many works the entity holds, versions left out —
the number its own page shows. A blog's are blog
posts, as on the card. Drawn only when the store knows
of some: most organizations have no works edges, and
"0 works" would contradict a page that fetches them
from Crossref. -->
{#if hit.works > 0}
{@const worksKey = hit.entity_type === 'Organization'
? 'org.works'
: hit.container_type === 'Blog' ? 'container.blog_posts' : 'container.works'}
<p class="mt-2 text-sm text-muted-foreground/60">
{_(worksKey, { values: { count: hit.works } }).replace(String(hit.works), formatCount(hit.works, bibLocale))}
</p>
{/if}
<!-- The identifier in full, linked where it is described,
with its logo — the same way each card ends. -->
{#if hit.entity_type === 'Organization'}
<a href={`https://ror.org/${hit.id}`} target="_blank" rel="noreferrer"
class="text-sm text-primary hover:underline inline-flex items-center gap-1.5 pt-1 wrap-anywhere max-w-full">
<img src={rorSvg} width="14" height="14" alt="ROR" class="inline-block align-middle shrink-0" />https://ror.org/{hit.id}
</a>
{:else if hit.entity_type === 'Person'}
<a href={`https://orcid.org/${hit.id}`} target="_blank" rel="noreferrer"
class="text-sm text-primary hover:underline inline-flex items-center gap-1.5 pt-1 wrap-anywhere max-w-full">
<img src={orcidSvg} width="14" height="14" alt="ORCID" class="inline-block align-middle shrink-0" />https://orcid.org/{hit.id}
</a>
{:else if hit.entity_type !== 'Container'}
<p class="text-xs text-muted-foreground font-mono">{hit.id}</p>
{/if}
</div>
</li>
{/each}
</ol>
{/if}
</div>
{/if}
{#if searchPageCount > 1}
<Pagination
page={searchPage}
totalPages={searchPageCount}
locale={bibLocale}
href={p => searchHref({ page: p })}
onpage={p => goToSearchPage(p)}
/>
{/if}
</div>
</section>
{/if}
<!-- ── Docs (homepage only) ─────────────────────────────────────────── -->
<!-- Stood down while a search is being read: the results are what the
reader asked for, and the documentation below them was a long scroll
between the last result and the footer. -->
{#if !isEntityPage && !searchShown}
<div class="flex gap-10 items-start">
<aside class="w-48 shrink-0 hidden lg:block sticky top-20 self-start">
<nav class="text-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Contents</p>
<ul class="space-y-1.5">
<li><a href="#inspiration" class="text-muted-foreground hover:text-foreground transition-colors block">Inspiration</a></li>
<li><a href="#implementations" class="text-muted-foreground hover:text-foreground transition-colors block">Implementations</a></li>
<li><a href="#json-schema" class="text-muted-foreground hover:text-foreground transition-colors block">JSON Schema</a></li>
<li><a href="#local-pid-database" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.database.title')}</a></li>
<li><a href="#search" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.search.title')}</a></li>
<li><a href="#history-bookmarks" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.saved.title')}</a></li>
<li><a href="#keyboard-shortcuts" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.shortcuts.title')}</a></li>
<li><a href="#supported-formats" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.formats.title')}</a></li>
<li><a href="#content-negotiation" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.content_negotiation.title')}</a></li>
</ul>
</nav>
</aside>
<div class="flex-1 min-w-0">
<!-- What there is to search, in the numbers a search would return —
they come from the index, so they say what can be found rather
than what is stored. In the documentation's column rather than the
page's, so it starts where the prose below it does, and outside
the prose itself, which would size it as body text. Drawn only
once the counts arrive, so the line does not appear and then
change. -->
{#if storeCounts && storeCounts.works > 0}
<p class="mb-2 text-base font-semibold text-muted-foreground">
{_('search.store_counts', { values: {
works: formatCount(storeCounts.works, bibLocale),
people: formatCount(storeCounts.people, bibLocale),
organizations: formatCount(storeCounts.organizations, bibLocale),
containers: formatCount(storeCounts.containers, bibLocale),
} })}
</p>
{/if}
<div class="prose prose-sm prose-slate max-w-none
[&_table]:block [&_table]:overflow-x-auto
[&_h2]:text-primary [&_h2]:border-b [&_h2]:border-border [&_h2]:pb-1 [&_h2]:scroll-mt-20
[&_h3]:text-foreground
[&_a]:text-primary [&_pre]:bg-muted [&_pre]:border [&_pre]:border-border
[&_pre]:text-sm [&_pre]:text-gray-800 dark:[&_pre]:text-gray-200">
<Homepage />
</div>
</div>
</div>
{/if}
</main>
<!-- Footer -->
<footer class="py-6 text-xs text-gray-700 dark:text-gray-300 leading-5">
<div class="max-w-full md:max-w-3xl lg:max-w-5xl mx-auto px-4 sm:px-6 flex items-start justify-between">
<span class="[&_a]:hover:text-gray-900 dark:[&_a]:hover:text-gray-100 [&_a]:transition-colors">
<span class="block">{@html _('footer.copyright')}</span>
<span class="block">{@html _('footer.built_with')}</span>
</span>
<span class="flex items-center gap-3 pl-3 shrink-0">
<a href="https://docs.commonmeta.org" target="_blank" rel="noreferrer"
class="whitespace-nowrap hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
{_('footer.documentation')}
</a>
<a href="mailto:info@front-matter.de" aria-label="Email"
class="hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</a>
<a href="https://hachyderm.io/@mfenner" target="_blank" rel="noreferrer" aria-label="Mastodon"
class="hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a4 4 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522q0-1.288.66-2.046c.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764q.662.757.661 2.046z"/>
</svg>
</a>
</span>
</div>
</footer>
</div>
<HistorySheet bind:open={historyOpen} bind:tab={historyTab} bind:this={historySheet} onadd={addFromHistory} {savedItems} onremovesaved={removeFromSaved} onreordersaved={moveSaved} onclearsaved={() => { savedItems = [] }} onimportsaved={importSaved} {linkImport} onlinkimport={importFromLink} ondismisslinkimport={forgetLinkImport} savedFull={savedItems.length >= MAX_SAVED} locale={bibLocale} />
<style>
@keyframes fetch-fill {
from { width: 0%; }
to { width: 100%; }
}
.fetch-progress {
width: 0%;
animation: fetch-fill 1s ease-out forwards;
}
</style>