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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/site/src/components/ArticleNotes.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
import { Icon } from "astro-icon/components";
import { stripHtml } from "~/lib/format";
import type { ArticleNote } from "~/lib/journal-article";
import { decodeEntities } from "~/lib/sources/journal";

/*
* The take-home message and purpose of an empirical article: about the
* article rather than part of it, so they sit beside the text. A long note
* shows its first lines and opens on click.
*/

interface Props {
notes: ArticleNote[];
/** Leave out the ids in the notes' HTML, for a second copy on the same page */
copy?: boolean;
class?: string;
}

const { notes, copy = false, class: className } = Astro.props;

/** Notes longer than this many words start closed */
const OPEN_UP_TO = 70;
const PREVIEW_WORDS = 28;

const preview = (note: ArticleNote) => `${decodeEntities(stripHtml(note.html)).split(" ").slice(0, PREVIEW_WORDS).join(" ")}…`;
const html = (note: ArticleNote) => (copy ? note.html.replace(/\sid="[^"]*"/g, "") : note.html);
const text = "journal-body prose prose-sm max-w-none [--tw-prose-body:var(--color-ink)] prose-p:my-2 first:prose-p:mt-0 last:prose-p:mb-0";
const title = "font-heading text-sm font-bold tracking-wide text-journal uppercase";
---

{
notes.length > 0 && (
<div class:list={["flex flex-col gap-4", className]} data-pagefind-ignore={copy ? "all" : undefined}>
{notes.map((note) =>
note.words > OPEN_UP_TO ? (
<details class="group border-frame border-ink bg-paper p-5 shadow-hard">
<summary class="flex cursor-pointer list-none flex-col gap-2 [&::-webkit-details-marker]:hidden">
<span class="flex items-center justify-between gap-3">
<h2 class={title}>{note.title}</h2>
<Icon name="lucide:chevron-down" class="size-4 shrink-0 transition-transform group-open:rotate-180" aria-hidden="true" />
</span>
<span class="text-sm group-open:hidden" aria-hidden="true">
{preview(note)} <span class="font-semibold underline decoration-accent decoration-2 underline-offset-4">Read more</span>
</span>
</summary>
<div class:list={[text, "mt-2"]} set:html={html(note)} />
</details>
) : (
<section class="flex flex-col gap-2 border-frame border-ink bg-journal-soft p-5">
<h2 class={title}>{note.title}</h2>
<div class={text} set:html={html(note)} />
</section>
),
)}
</div>
)
}
22 changes: 20 additions & 2 deletions apps/site/src/layouts/JournalArticle.astro
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
---
import { Icon } from "astro-icon/components";
import ArticleCard from "~/components/ArticleCard.astro";
import ArticleNotes from "~/components/ArticleNotes.astro";
import Downloads from "~/components/Downloads.astro";
import JournalNav from "~/components/JournalNav.astro";
import Picture from "~/components/Picture.astro";
import SocialIcon from "~/components/SocialIcon.astro";
import { formatDate, listNames } from "~/lib/format";
import type { ArticleDetails } from "~/lib/journal-article";
Expand Down Expand Up @@ -78,8 +80,22 @@ const meta = {

<JournalNav journal={journal} />

{
// The pub's image from PubPub, as a band behind the top of the page
article.image && (
<div class="h-40 overflow-hidden border-b-frame border-ink md:h-56 lg:h-72" aria-hidden="true">
<Picture image={article.image} width={1920} eager class="size-full object-cover" />
</div>
)
}

<article class="page grid grid-cols-1 gap-x-10 py-10 lg:grid-cols-[4rem_minmax(0,48rem)_1fr]">
<header class="flex flex-col gap-4 lg:col-start-2">
<header
class:list={[
"flex flex-col gap-4 lg:col-start-2",
article.image && "relative lg:-mt-52 lg:border-frame lg:border-ink lg:bg-paper lg:p-8 lg:shadow-hard-lg",
]}
>
<div class="flex flex-wrap items-center gap-3 text-sm font-semibold">
{article.kind && <a href={journal.kindUrl(article.kind)} class="label label-journal">{article.kind}</a>}
{
Expand Down Expand Up @@ -142,6 +158,7 @@ const meta = {
)
}
<Downloads downloads={details.downloads} class="lg:hidden" />
<ArticleNotes notes={details.notes} copy class="mt-4 lg:hidden" />
</header>

<aside class="order-first mt-0 flex gap-3 lg:sticky lg:top-28 lg:col-start-1 lg:row-span-2 lg:row-start-1 lg:mt-2 lg:h-max lg:flex-col" aria-label="Share">
Expand All @@ -167,7 +184,8 @@ const meta = {

<div class="journal-body prose-site mt-10 lg:col-start-2" data-pagefind-body data-pagefind-filter={`section:${config.section}`} set:html={details.body} />

<div class="hidden lg:col-start-3 lg:row-span-3 lg:row-start-1 lg:block">
<div class="hidden flex-col gap-8 lg:col-start-3 lg:row-span-3 lg:row-start-1 lg:flex">
<ArticleNotes notes={details.notes} />
<div class="sticky top-28 flex flex-col gap-6">
{
details.headings.length > 2 && (
Expand Down
35 changes: 32 additions & 3 deletions apps/site/src/lib/journal-article.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,19 @@ export interface Connection {
article?: Article;
}

/** A section about the article rather than part of its argument, shown beside the text */
export interface ArticleNote {
/** "Take-home message", "Purpose" or "Original purpose" */
title: string;
html: string;
words: number;
}

export interface ArticleDetails {
body: string;
headings: Heading[];
/** The take-home message and (original) purpose, taken out of the body */
notes: ArticleNote[];
/** The export has no text, so the PDF is the article */
empty: boolean;
keywords: string[];
Expand Down Expand Up @@ -59,11 +69,12 @@ const IMAGE_WIDTH = 1600;
export async function getArticleDetails(article: Article, source: Journal = journal()): Promise<ArticleDetails> {
const [release, html] = await Promise.all([source.getRelease(article), source.getArticleHtml(article)]);
const assets = new Map((release?.assets ?? []).map((file) => [hash(file.$file), file]));
const { body, headings, text } = await prepareArticle(html ?? "", assets, source);
const { body, headings, text, notes } = await prepareArticle(html ?? "", assets, source, true);

return {
body,
headings,
notes,
empty: text.length < 200,
keywords: keywords(html ?? ""),
downloads: downloads(release, source.downloadUrl),
Expand Down Expand Up @@ -132,7 +143,7 @@ function keywords(html: string) {
* itself, move headings one level down under the page's h1, serve images
* from this site and point attachments at their Underlay file.
*/
async function prepareArticle(html: string, assets: Map<string, UnderlayFile>, source: Journal) {
async function prepareArticle(html: string, assets: Map<string, UnderlayFile>, source: Journal, withNotes = false) {
let body = html
.replace(/^\s*<article>|<\/article>\s*$/g, "")
.replace(/<footer class="pub-footer">[\s\S]*?<\/footer>/, "")
Expand Down Expand Up @@ -160,6 +171,21 @@ async function prepareArticle(html: string, assets: Map<string, UnderlayFile>, s
body = await replaceImages(body, assets, source.journalFileUrl);
body = await replaceDeadEmbeds(body);

const notes: ArticleNote[] = [];
if (withNotes) {
body = body.replace(/<h2[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2[\s>]|<div class="pub-notes"|$)/g, (section, title: string, content: string) => {
const text = decodeEntities(stripHtml(title)).replace(/[\s:.]+$/, "").trim();
if (!NOTE_TITLE.test(text)) return section;
const label = text.toLowerCase().replace(/^take[- ]home/, "take-home");
notes.push({
title: label[0].toUpperCase() + label.slice(1),
html: content.trim(),
words: stripHtml(content).split(/\s+/).filter(Boolean).length,
});
return "";
});
}

const ids = new Set([...body.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]));
const headings: Heading[] = [];
body = body.replace(/<(h[23])([^>]*)>([\s\S]*?)<\/\1>/g, (_, tag: string, attrs: string, content: string) => {
Expand All @@ -178,9 +204,12 @@ async function prepareArticle(html: string, assets: Map<string, UnderlayFile>, s

const main = body.replace(/<div class="pub-notes">[\s\S]*$|<h2[^>]*>\s*References\s*<\/h2>[\s\S]*$/, "");
const text = stripHtml(replaceElements(main, /<span class="katex-html"/, () => ""));
return { body, headings, text };
return { body, headings, text, notes };
}

/** Headings of the sections that become notes beside an article: its take-home message and purpose */
const NOTE_TITLE = /^(?:take[- ]home message|(?:original )?purpose)$/i;

/** Embedded pages that no longer load, such as the old interactive plots on assets.pubpub.org, become a short note */
async function replaceDeadEmbeds(body: string) {
const dead = new Set<string>();
Expand Down
Loading