Skip to content
Open
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
109 changes: 109 additions & 0 deletions app/src/components/article/article-detail-view.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { expect, waitFor } from "storybook/test";
import { highlightsQueryKey } from "@/lib/highlights/use-highlights";
import { ArticleDetailView } from "./article-detail-view";

const STORY_ITEM_ID = "story-article-view";

const SIMPLE_CONTENT = `Most improvements are small. They compound quietly: a slightly faster
build, a slightly clearer name, a test that catches the thing before it ships.

Nobody notices any one of them. Everybody notices the sum.

## Start with the annoyance

Pick the thing that irritates you every day and fix just that.`;

// Shaped like a captured magazine feature: section breaks (from the source's
// dividers) and a photo caption kept as a figcaption
const SECTIONED_CONTENT = `**The light was already going** by the time we reached the harbour. The boats had come in and the nets lay drying along the wall, and nobody seemed in a hurry to be anywhere else.

We sat at the end of the jetty and talked about nothing much until the lamps came on behind us.

* * *

**Morning came in grey.** The town was quieter than it had been the night before, and the first ferry left half empty, its wake spreading slowly across the flat water of the bay.

“It’s always like this after a storm,” the harbourmaster said. “Give it a day.”

![](/gallery/city-sunset-river.jpg)

<figcaption>PHOTO BY A PHOTOGRAPHER</figcaption>

* * *

**By the third day** the weather had turned, and the harbour filled again with the noise of engines and gulls and people calling to each other across the water.`;

// The real view fetches highlights; seed none so it never hits the network
function withQueryClient() {
const client = new QueryClient({
defaultOptions: { queries: { staleTime: Number.POSITIVE_INFINITY } },
});
client.setQueryData(highlightsQueryKey(STORY_ITEM_ID), []);
return client;
}

// The article reader: captured markdown rendered with the reader typography
const meta = {
title: "Article/ArticleDetailView",
component: ArticleDetailView,
parameters: { layout: "fullscreen" },
tags: ["autodocs"],
args: {
itemId: STORY_ITEM_ID,
content: SIMPLE_CONTENT,
originalName: "How to make everything slightly better",
enableTracking: false,
},
decorators: [
(Story) => (
<QueryClientProvider client={withQueryClient()}>
<div className="flex">
<Story />
</div>
</QueryClientProvider>
),
],
} satisfies Meta<typeof ArticleDetailView>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

// Each section after a break opens with a drop cap; the opening paragraph
// stays plain and the caption is set apart from the body text. The play test
// guards the rendered structure the reader's sibling selectors rely on
// (`hr + p`, `p:has(+ figcaption)`), which only exists if captured markdown
// still renders breaks and kept captions as siblings of the paragraphs.
export const Sectioned: Story = {
args: { content: SECTIONED_CONTENT, originalName: "After the storm" },
play: async ({ canvasElement }) => {
await waitFor(() =>
expect(canvasElement.querySelector("article figcaption")).not.toBeNull(),
);
const firstLetterFloat = (element: Element | null) => {
if (!element) throw new Error("missing paragraph");
return getComputedStyle(element, "::first-letter").float;
};

const sectionOpeners = canvasElement.querySelectorAll("article hr + p");
expect(sectionOpeners).toHaveLength(2);
for (const opener of sectionOpeners) {
expect(firstLetterFloat(opener)).toBe("left");
}
expect(firstLetterFloat(canvasElement.querySelector("article p"))).toBe(
"none",
);

const caption = canvasElement.querySelector("article figcaption");
const image = caption?.previousElementSibling?.querySelector("img");
if (!caption || !image) throw new Error("caption isn't next to its image");
const gap =
caption.getBoundingClientRect().top -
image.getBoundingClientRect().bottom;
expect(gap).toBeGreaterThanOrEqual(0);
expect(gap).toBeLessThan(16);
},
};
15 changes: 13 additions & 2 deletions app/src/components/article/article-prose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
* Typography for the article reader's body. Shared with the reader's loading
* placeholder so its skeleton lines use the same font, size, line height and
* paragraph spacing at every breakpoint.
*
* The paragraph opening each section (after a captured `<hr>` divider) gets a
* drop cap. It's our own typography rather than the publisher's — source drop
* caps are mostly CSS-only and can't be captured — and the article's first
* paragraph is left plain, since leftover page furniture can sit there.
*
* Captions follow their image as a sibling (markdown has no `<figure>`), so the
* image paragraph's bottom margin is dropped to sit the caption just under it.
*/
export const ARTICLE_PROSE_CLASS =
"prose prose-sm md:prose-base lg:prose-lg prose-neutral dark:prose-invert max-w-none prose-headings:font-serif prose-li:font-serif prose-p:font-serif";
export const ARTICLE_PROSE_CLASS = [
"prose prose-sm md:prose-base lg:prose-lg prose-neutral dark:prose-invert max-w-none prose-headings:font-serif prose-li:font-serif prose-p:font-serif",
"prose-figcaption:mt-2 prose-figcaption:font-serif prose-figcaption:leading-snug [&_p:has(+figcaption)]:mb-0 [&_p:has(+figcaption)_img]:mb-0",
Comment thread
fredrivett marked this conversation as resolved.
"[&_hr+p]:first-letter:float-left [&_hr+p]:first-letter:mt-[0.1em] [&_hr+p]:first-letter:mr-[0.08em] [&_hr+p]:first-letter:text-[3.5em] [&_hr+p]:first-letter:leading-[0.8] [&_hr+p]:first-letter:font-semibold",
].join(" ");
109 changes: 109 additions & 0 deletions app/src/lib/__fixtures__/article-sectioned-magazine.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<!doctype html>
<!--
Synthetic fixture modelled on a WordPress magazine article layout (structure
and class names as captured from a real long-form feature; prose replaced
with placeholder text). Exercises the in-body clutter, SVG section dividers
and classed captions handled by article-cleanup.ts.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>The Long Feature</title>
</head>
<body>
<header class="site-header">
<nav>
<a href="/">Home</a>
<a class="menu-item__link menu-item__link--subscribe" href="/subscribe">Subscribe</a>
</nav>
</header>
<div class="article-sponsor-desktop">
<a href="https://sponsor.example.com/">Presented by Sponsor Co</a>
</div>
<main>
<article class="article-body">
<h1>The Long Feature</h1>
<div class="share"><a href="https://x.com/share">Share on X</a></div>
<div class="podcast-listen">
<input type="checkbox" class="podcast-listen__checkbox" />
<div class="podcast-listen__label">
<label class="podcast-listen__label-toggle button">Listen on</label>
<div class="podcast-listen__label-name">Listen on:</div>
</div>
<div class="podcast-links">
<a class="podcast-links__item" href="https://podcasts.example.com/">Apple Podcasts</a>
</div>
</div>
<div class="article-body__content">
<div class="block subscribe-cta">
<div class="subscribe-cta__text">
<p><em><a href="/subscribe">Subscribe</a> to print for your office or home.</em></p>
</div>
<div class="block divider divider--style-line"><div class="divider__line"></div></div>
</div>

<p class="wp-block-paragraph">T<strong>he opening line.</strong> The first section begins here with enough words to read as a real paragraph of long-form prose, describing the scene in some detail so that the extractor treats it as the start of the article body rather than a fragment of navigation or furniture around it.</p>
<p class="wp-block-paragraph">A second paragraph continues the first section, adding more sustained prose about the people involved, the place where the interview happened, and the way the conversation unfolded over the course of a long afternoon in a quiet room overlooking the water outside.</p>
<p class="wp-block-paragraph">A third paragraph closes the first section with a quotation and a short reflection on what it might mean, keeping the prose going long enough that Readability scores this container as the main content of the page.</p>

<div class="block single-sponsor">
<a href="https://sponsor.example.com/" class="single-sponsor__image"><img src="https://example.com/sponsor-banner.jpg" alt="" /></a>
</div>

<div class="block divider divider--style-icon">
<svg width="38" height="20" viewBox="0 0 38 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 19 L19 1 L37 19" stroke="currentColor" /></svg>
</div>

<p class="dropcap wp-block-paragraph"><strong>The second section</strong> opens after an ornamental divider, picking the story up somewhere new with another long paragraph of prose that carries the reader forward through the next part of the narrative and introduces a new set of characters.</p>
<p class="wp-block-paragraph">More of the second section follows here, again written at length so the body reads as continuous prose, with details about the history of the subject and the decisions that shaped the years that followed the events described above.</p>

<div class="block read-more">
<div class="read-more__title">Read More</div>
<div class="read-more__articles">
<a class="read-more__article" href="/article/another-story/">
<img src="https://example.com/another-story.jpg" alt="" />
<h3>Another Story Entirely</h3>
<p>A short teaser for a different article that the publisher would like you to read next.</p>
<span>By Someone Else</span>
</a>
<a class="read-more__article" href="/article/a-third-story/">
<img src="https://example.com/a-third-story.jpg" alt="" />
<h3>A Third Story</h3>
<p>Another teaser line promoting a third, unrelated article from the same publisher.</p>
<span>By A Third Writer</span>
</a>
</div>
</div>

<p class="wp-block-paragraph">The second section continues after the promotional block, and this paragraph must survive cleanup intact, because it is part of the article itself and sits directly between two pieces of publisher furniture on the page.</p>

<div class="block single-image">
<div class="single-image__image"><img src="https://example.com/photo.jpg" alt="" width="2560" height="1741" /></div>
<div class="single-image__caption">
<div class="single-image__caption-short"><p>PHOTO BY A PHOTOGRAPHER</p></div>
</div>
</div>

<div class="block divider divider--style-icon">
<svg width="38" height="20" viewBox="0 0 38 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 19 L19 1 L37 19" stroke="currentColor" /></svg>
</div>

<p class="dropcap wp-block-paragraph"><strong>The third section</strong> begins after a second divider and runs to the end of the piece, with a final stretch of prose that brings the story to a close and returns to the scene described in the opening paragraph of the article.</p>
<p class="wp-block-paragraph">* * *</p>
<p class="wp-block-paragraph">A closing coda after an asterism break, written as one more long paragraph so that it reads as prose, ending the article on a quiet note that looks back over everything that came before it in the piece.</p>

<div class="block guest-subscribe-form">
<p>Get the newsletter in your inbox every week.</p>
<form><input type="email" /><button>Sign up</button></form>
</div>
</div>
</article>
<section class="recommended-items">
<h2>Recommended</h2>
<a href="/article/one/">One</a>
<a href="/article/two/">Two</a>
</section>
</main>
<footer class="site-footer"><p>© The Publisher</p></footer>
</body>
</html>
Loading
Loading