Refine the reading experience #200
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
---
|
||||
|
||||
Let inline code flow with prose without forced italics, retaining its background, padding, and rounded corners. Fenced code blocks are unchanged.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
"@luzzle/web.worker": patch
|
||||
---
|
||||
|
||||
Show clickable, three-line desktop sidenote previews with viewport-fitted native popovers. Keep scrollable bottom sheets on phones and tablets, with native keyboard controls, Escape and outside-click dismissal, and no application JavaScript.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@luzzle/web.worker": patch
|
||||
---
|
||||
|
||||
Label rendered Markdown task-list checkboxes with their task descriptions, preserving their checked and disabled states.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
"@luzzle/web.worker": patch
|
||||
---
|
||||
|
||||
Show sidenotes as native bottom sheets on phones and tablets, with whole-sheet scrolling, a subtle backdrop, and the note number as a close button. Keep visible margin notes on wide screens without application JavaScript.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
---
|
||||
|
||||
Preserve paragraph breaks in Markdown quotes, leave italics to authored emphasis, and use a thinner border with body-relative padding while retaining the background, rounded corners, and shadow.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
"@luzzle/web.worker": patch
|
||||
---
|
||||
|
||||
Restore configured responsive browser font sizing while keeping Open Graph typography fixed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
---
|
||||
|
||||
Keep fenced code and wide tables within the reading column and let readers scroll them horizontally without widening the page. Tables use their natural width, capped at the column width.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
---
|
||||
|
||||
Give rendered Markdown shared prose typography and simplify the note page's headline scale and reading-column gutters.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Untitled Words Piece
|
||||
title: "Markdown: A Typographical Exploration"
|
||||
subtitle: the subtitle
|
||||
summary: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
|
||||
@@ -8,85 +8,83 @@ summary: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
|
||||
keywords: lorem, ipsum, dolor, sit, amet, consectetur, adipiscing, elit, quisque, dictum
|
||||
date_published: 2025-11-11
|
||||
---
|
||||
# Markdown: A Typographical Exploration
|
||||
|
||||
## Introduction
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse potenti. Nullam finibus, nulla ac suscipit sollicitudin, enim felis commodo turpis, nec maximus ex enim in quam. Aenean in ultrices massa. In hac habitasse platea dictumst. Curabitur sit amet consequat odio, nec egestas velit.
|
||||
|
||||
## The Fundamentals of Emphasis
|
||||
|
||||
Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. In hac habitasse platea dictumst. Vivamus ut **elementum dolor**, sed aliquet dolor. Aliquam erat volutpat. *Nunc ut tellus in ipsum* commodo ultricies. Sed tristique, ***justo nec lobortis*** consequat, mi quam efficitur libero, atb `maximus()` erat ipsum. ~~Donec efficitur~~, lacus sed bibendum cursus, mi massa commodo sem, a commodo arcu ex sit amet quam. Integer nec mi non metus `viverra()` sodales.
|
||||
|
||||
## Citations and Visual Aides
|
||||
|
||||
Nulla facilisi. [Aenean euismod](https://example.com "aliquam quaerat voluptatem") bibendum laoreet <https://example.com>. Proin gravida dolor sit amet lacus accumsan et viverra justo commodo. Proin sodales pulvinar sic tempor. Sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nam fermentum, nulla luctus pharetra vulputate, felis tellus mollis orci, sed rhoncus pronin sapien nunc accuan.
|
||||
|
||||

|
||||
|
||||
## Structuring Data
|
||||
|
||||
Maecenas malesuada. Praesent congue erat at massa. Sed cursus turpis vitae tortor. Donec posuere vulputate arcu. Phasellus accumsan cursus velit.
|
||||
|
||||
1. Primum, vestibulum ante ipsum primis in faucibus orci luctus.
|
||||
2. Secundo, ultrices posuere cubilia Curae; Morbi lacinia molestie dui.
|
||||
3. Tertio, praesent blandit dolor. Sed non quam.
|
||||
|
||||
In vel mi sit amet augue congue elementum. Morbi in sem quis dui placerat ornare. Pellentesque odio nisi, euismod in, pharetra a, ultricies in, diam.
|
||||
* Item primarium
|
||||
* Item secundarium
|
||||
* Aliud item secundarium
|
||||
* Item aliud primarium
|
||||
* Item ultimum primarium
|
||||
|
||||
## Quoting Sources
|
||||
|
||||
Cras ultricies mi eu turpis hendrerit fringilla. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; In ac dui quis mi consectetuer lacinia. Nam pretium turpis et arcu.
|
||||
|
||||
> Duis arcu tortor, suscipit eget, imperdiet nec, imperdiet iaculis, ipsum. Sed aliquam ultrices mauris. Integer ante arcu, accumsan a, consectetuer eget, posuere ut, mauris. Praesent adipiscing. Phasellus ullamcorper ipsum rutrum nunc. Nunc nonummy metus.
|
||||
|
||||
---
|
||||
|
||||
## Managing Tasks
|
||||
|
||||
Vestibulum volutpat pretium libero. Cras id dui. Aenean ut eros et nisl sagittis vestibulum. Nullam nulla eros, ultricies sit amet, nonummy id, imperdiet feugiat, pede.
|
||||
|
||||
- [x] Complete initial research on *lorem ipsum*.
|
||||
- [ ] Draft the main body of the text.
|
||||
- [ ] Review and finalize the typographical elements.
|
||||
|
||||
## Tabulating Data
|
||||
|
||||
Morbi nec metus. Phasellus blandit leo ut odio. Maecenas ullamcorper, dui et placerat feugiat, eros pede varius nisi, condimentum viverra felis nunc et lorem. Sed magna purus, fermentum eu, tincidunt eu, varius ut, felis. Below is a summary of findings.
|
||||
|
||||
| Header 1 (left) | Header 2 (center) | Header 3 (right) |
|
||||
|:-------------|:-------------------------:|-------------------------:|
|
||||
| *Lorem* | `ipsum` | Dolor sit amet |
|
||||
| Consectetur | Adipiscing | Elit |
|
||||
| Nunc | **viverra** | Imperdiet |
|
||||
|
||||
## Technical Examples
|
||||
|
||||
In ultrices, libero non varius commodo, sem justo rutrum elit, sed pulvinar felis felis ac quam. Uta `example.js` scriptum, quod infra monstratur, hoc modo uti potest:
|
||||
|
||||
```javascript title="hello.js"
|
||||
/**
|
||||
* A simple lorem ipsum function.
|
||||
*/
|
||||
function greet(name) {
|
||||
// Return a classic greeting
|
||||
console.log(`Lorem ipsum, ${name}!`);
|
||||
}
|
||||
|
||||
greet('Mundo');
|
||||
```
|
||||
|
||||
## Asides and Annotations
|
||||
|
||||
Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc, quis gravida magna mi a libero. Fusce vulputate eleifend sapien. Vestibulum purus quam, scelerisque ut, mollis sed, nonummy id, metus.[^1] Nullam accumsan lorem in dui.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Curabitur turpis. Vestibulum facilisis, purus nec pulvinar iaculis, ligula mi congue nunc, vitae euismod ligula urna in dolor. Mauris sollicitudin fermentum libero. Praesent nonummy mi in odio. Nunc interdum lacus sit amet orci.
|
||||
|
||||
## Introduction
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse potenti. Nullam finibus, nulla ac suscipit sollicitudin, enim felis commodo turpis, nec maximus ex enim in quam. Aenean in ultrices massa. In hac habitasse platea dictumst. Curabitur sit amet consequat odio, nec egestas velit.
|
||||
|
||||
## The Fundamentals of Emphasis
|
||||
|
||||
Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. In hac habitasse platea dictumst. Vivamus ut **elementum dolor**, sed aliquet dolor. Aliquam erat volutpat. *Nunc ut tellus in ipsum* commodo ultricies. Sed tristique, ***justo nec lobortis*** consequat, mi quam efficitur libero, atb `maximus()` erat ipsum. ~~Donec efficitur~~, lacus sed bibendum cursus, mi massa commodo sem, a commodo arcu ex sit amet quam. Integer nec mi non metus `viverra()` sodales.
|
||||
|
||||
## Citations and Visual Aides
|
||||
|
||||
Nulla facilisi. [Aenean euismod](https://example.com "aliquam quaerat voluptatem") bibendum laoreet <https://example.com>. Proin gravida dolor sit amet lacus accumsan et viverra justo commodo. Proin sodales pulvinar sic tempor. Sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nam fermentum, nulla luctus pharetra vulputate, felis tellus mollis orci, sed rhoncus pronin sapien nunc accuan.
|
||||
|
||||

|
||||
|
||||
## Structuring Data
|
||||
|
||||
Maecenas malesuada. Praesent congue erat at massa. Sed cursus turpis vitae tortor. Donec posuere vulputate arcu. Phasellus accumsan cursus velit.
|
||||
|
||||
1. Primum, vestibulum ante ipsum primis in faucibus orci luctus.
|
||||
2. Secundo, ultrices posuere cubilia Curae; Morbi lacinia molestie dui.
|
||||
3. Tertio, praesent blandit dolor. Sed non quam.
|
||||
|
||||
In vel mi sit amet augue congue elementum. Morbi in sem quis dui placerat ornare. Pellentesque odio nisi, euismod in, pharetra a, ultricies in, diam.
|
||||
* Item primarium
|
||||
* Item secundarium
|
||||
* Aliud item secundarium
|
||||
* Item aliud primarium
|
||||
* Item ultimum primarium
|
||||
|
||||
## Quoting Sources
|
||||
|
||||
Cras ultricies mi eu turpis hendrerit fringilla. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; In ac dui quis mi consectetuer lacinia. Nam pretium turpis et arcu.
|
||||
|
||||
> Duis arcu tortor, suscipit eget, imperdiet nec, imperdiet iaculis, ipsum. Sed aliquam ultrices mauris. Integer ante arcu, accumsan a, consectetuer eget, posuere ut, mauris. Praesent adipiscing. Phasellus ullamcorper ipsum rutrum nunc. Nunc nonummy metus.
|
||||
|
||||
---
|
||||
|
||||
## Managing Tasks
|
||||
|
||||
Vestibulum volutpat pretium libero. Cras id dui. Aenean ut eros et nisl sagittis vestibulum. Nullam nulla eros, ultricies sit amet, nonummy id, imperdiet feugiat, pede.
|
||||
|
||||
- [x] Complete initial research on *lorem ipsum*.
|
||||
- [ ] Draft the main body of the text.
|
||||
- [ ] Review and finalize the typographical elements.
|
||||
|
||||
## Tabulating Data
|
||||
|
||||
Morbi nec metus. Phasellus blandit leo ut odio. Maecenas ullamcorper, dui et placerat feugiat, eros pede varius nisi, condimentum viverra felis nunc et lorem. Sed magna purus, fermentum eu, tincidunt eu, varius ut, felis. Below is a summary of findings.
|
||||
|
||||
| Header 1 (left) | Header 2 (center) | Header 3 (right) |
|
||||
|:-------------|:-------------------------:|-------------------------:|
|
||||
| *Lorem* | `ipsum` | Dolor sit amet |
|
||||
| Consectetur | Adipiscing | Elit |
|
||||
| Nunc | **viverra** | Imperdiet |
|
||||
|
||||
## Technical Examples
|
||||
|
||||
In ultrices, libero non varius commodo, sem justo rutrum elit, sed pulvinar felis felis ac quam. Uta `example.js` scriptum, quod infra monstratur, hoc modo uti potest:
|
||||
|
||||
```javascript title="hello.js"
|
||||
/**
|
||||
* A simple lorem ipsum function.
|
||||
*/
|
||||
function greet(name) {
|
||||
// Return a classic greeting
|
||||
console.log(`Lorem ipsum, ${name}!`);
|
||||
}
|
||||
|
||||
greet('Mundo');
|
||||
```
|
||||
|
||||
## Asides and Annotations
|
||||
|
||||
Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc, quis gravida magna mi a libero. Fusce vulputate eleifend sapien. Vestibulum purus quam, scelerisque ut, mollis sed, nonummy id, metus.[^1] Nullam accumsan lorem in dui.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Curabitur turpis. Vestibulum facilisis, purus nec pulvinar iaculis, ligula mi congue nunc, vitae euismod ligula urna in dolor. Mauris sollicitudin fermentum libero. Praesent nonummy mi in odio. Nunc interdum lacus sit amet orci.
|
||||
|
||||
[^1]: Haec est leo annotatio sive nota pedalis
|
||||
@@ -13,6 +13,20 @@ export const notebook = {
|
||||
title: 'Meteor Notebook'
|
||||
}
|
||||
|
||||
const note = `Original observation.
|
||||
|
||||
> First quoted paragraph.
|
||||
>
|
||||
> Second quoted paragraph.
|
||||
`
|
||||
const noteHtml = `<section class="markdown">
|
||||
<p>Original observation.</p>
|
||||
<blockquote>
|
||||
<p>First quoted paragraph.</p>
|
||||
<p>Second quoted paragraph.</p>
|
||||
</blockquote>
|
||||
</section>`
|
||||
|
||||
const pieces = [
|
||||
notebook,
|
||||
{
|
||||
@@ -46,7 +60,7 @@ export async function createFixture(directory: string, origin: string) {
|
||||
for (const piece of pieces) {
|
||||
await writeFile(
|
||||
path.join(archive, piece.file),
|
||||
`---\ntitle: ${piece.title}\ndate: ${dateConsumed}\n---\n\nOriginal observation.\n`
|
||||
`---\ntitle: ${piece.title}\ndate: ${dateConsumed}\n---\n\n${note}`
|
||||
)
|
||||
}
|
||||
const database = path.join(directory, 'data/luzzle.sqlite')
|
||||
@@ -58,7 +72,13 @@ export async function createFixture(directory: string, origin: string) {
|
||||
paths: { database },
|
||||
content: { text: { title: 'E2E Garden' } },
|
||||
auth: { secret: 'e2e-only-session-secret-at-least-32-characters', credentials: ADMIN },
|
||||
pieces: [{ type: 'notes', fields: { title: 'title', date_consumed: 'date' } }]
|
||||
pieces: [
|
||||
{
|
||||
type: 'notes',
|
||||
fields: { title: 'title', date_consumed: 'date' },
|
||||
templates: { page: { name: 'note' } }
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const db = createAppDb(database)
|
||||
@@ -82,13 +102,27 @@ export async function createFixture(directory: string, origin: string) {
|
||||
type: piece.type,
|
||||
file_path: piece.file,
|
||||
title: piece.title,
|
||||
note: 'Original observation.',
|
||||
note,
|
||||
json_metadata: JSON.stringify({ title: piece.title, date: dateConsumed }),
|
||||
date_added: consumedAt,
|
||||
date_consumed: consumedAt
|
||||
}))
|
||||
)
|
||||
.execute()
|
||||
await db
|
||||
.insertInto('web_pieces_assets')
|
||||
.values(
|
||||
pieces.map((piece) => ({
|
||||
piece_file_path: piece.file,
|
||||
piece_key: piece.slug,
|
||||
asset_key: piece.slug,
|
||||
transformation: 'markdown',
|
||||
mime_type: 'text/html',
|
||||
is_embedded: 1,
|
||||
content: noteHtml
|
||||
}))
|
||||
)
|
||||
.execute()
|
||||
} finally {
|
||||
await db.destroy()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from './support/test.js'
|
||||
import { notebook } from './support/fixture.js'
|
||||
|
||||
test('root font sizing responds to the viewport instead of staying fixed', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 900 })
|
||||
await page.goto(notebook.publicUrl)
|
||||
const phoneSize = await page
|
||||
.locator('html')
|
||||
.evaluate((element) => parseFloat(getComputedStyle(element).fontSize))
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
const desktopSize = await page
|
||||
.locator('html')
|
||||
.evaluate((element) => parseFloat(getComputedStyle(element).fontSize))
|
||||
expect(phoneSize).toBeLessThan(desktopSize)
|
||||
})
|
||||
|
||||
test('quoted paragraphs remain visually separated', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 900 })
|
||||
await page.goto(notebook.publicUrl)
|
||||
const quote = page.locator('main .markdown blockquote')
|
||||
const paragraphGap = await quote.evaluate((element) => {
|
||||
const [first, second] = element.querySelectorAll('p')
|
||||
return second.getBoundingClientRect().top - first.getBoundingClientRect().bottom
|
||||
})
|
||||
expect(paragraphGap).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -57,20 +57,19 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
section.hero {
|
||||
width: 85%;
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) var(--space-2-5) 0;
|
||||
padding-inline: clamp(20px, 4vw, 24px);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
section.hero {
|
||||
width: clamp(500px, 66.6666%, 800px);
|
||||
}
|
||||
section.hero {
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
section.hero h1 {
|
||||
font-size: var(--font-size-xxl, 2rem);
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
@@ -88,18 +87,10 @@
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
justify-content: space-between;
|
||||
width: 85%;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) var(--space-2-5);
|
||||
padding-block: var(--space-5);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
section.details {
|
||||
width: clamp(500px, 66.6666%, 800px);
|
||||
}
|
||||
}
|
||||
|
||||
.byline {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-on-surface-variant);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Shiki Theme CSS */
|
||||
|
||||
pre.shiki {
|
||||
pre.shiki,
|
||||
.markdown pre {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@@ -54,7 +55,77 @@ pre.shiki code span,
|
||||
}
|
||||
}
|
||||
|
||||
@position-try --sidenote-fit-viewport {
|
||||
top: min(12px, max(0px, anchor(top)));
|
||||
bottom: min(12px, max(0px, anchor(bottom)));
|
||||
align-self: anchor-center;
|
||||
}
|
||||
|
||||
@position-try --sidenote-fit-viewport-inline {
|
||||
top: min(12px, max(0px, anchor(top)));
|
||||
bottom: min(12px, max(0px, anchor(bottom)));
|
||||
left: auto;
|
||||
right: min(12px, max(0px, anchor(right)));
|
||||
align-self: anchor-center;
|
||||
}
|
||||
|
||||
@keyframes sidenote-sheet-in {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.markdown {
|
||||
line-height: 1.6;
|
||||
|
||||
:is(p, ul, ol) {
|
||||
margin-block: 1em;
|
||||
}
|
||||
|
||||
:is(h1, h2, h3, h4, h5, h6) {
|
||||
margin-block: 1.5em 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.65em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.35em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
:is(h4, h5, h6) {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-block-start: 0.25em;
|
||||
}
|
||||
|
||||
li > :is(ul, ol) {
|
||||
margin-block: 0.25em;
|
||||
}
|
||||
|
||||
> :first-child,
|
||||
blockquote > :first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
> :last-child,
|
||||
blockquote > :last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-outline);
|
||||
@@ -65,8 +136,6 @@ pre.shiki code span,
|
||||
:not(pre) > code {
|
||||
background-color: var(--color-surface-dim);
|
||||
padding: 0 var(--space-1);
|
||||
font-style: italic;
|
||||
display: inline-block;
|
||||
border-radius: 0 var(--radius-small) var(--radius-small) 0;
|
||||
}
|
||||
|
||||
@@ -114,8 +183,11 @@ pre.shiki code span,
|
||||
}
|
||||
|
||||
table {
|
||||
display: block;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
@@ -145,88 +217,116 @@ pre.shiki code span,
|
||||
ul.contains-task-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
background: var(--color-surface-container-lowest);
|
||||
border-left: 5px solid var(--color-primary);
|
||||
padding: var(--space-4);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
padding: 0.75em 1em;
|
||||
margin: var(--space-4) 0;
|
||||
font-style: italic;
|
||||
border-radius: 0 var(--radius-small) var(--radius-small) 0;
|
||||
box-shadow: 0 2px 4px var(--color-shadow);
|
||||
}
|
||||
|
||||
blockquote:before {
|
||||
color: var(--color-primary);
|
||||
content: ' ';
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sidenote__checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidenote__button {
|
||||
padding-left: 0.2rem;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.sidenote__button sup::before {
|
||||
content: var(--sidenote-callout-before-content);
|
||||
}
|
||||
|
||||
.sidenote__button sup::after {
|
||||
content: var(--sidenote-callout-after-content);
|
||||
display: none;
|
||||
padding: 0 0.2rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
min-inline-size: 1em;
|
||||
min-block-size: 1.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidenote__button sup {
|
||||
text-decoration: underline;
|
||||
color: var(--color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.sidenote__preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidenote__id {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1279px) {
|
||||
.sidenote__checkbox ~ .sidenote__content {
|
||||
.sidenote__content[popover]::backdrop {
|
||||
background: rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.sidenote__content[popover]:popover-open {
|
||||
display: block;
|
||||
position: fixed;
|
||||
position-anchor: none;
|
||||
inset: auto 0 0;
|
||||
margin: 0 auto;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 36rem;
|
||||
min-block-size: min(10rem, 30dvh);
|
||||
max-block-size: min(50dvh, 30rem);
|
||||
padding: 1rem clamp(20px, 4vw, 24px) max(1rem, env(safe-area-inset-bottom));
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
float: none;
|
||||
clear: none;
|
||||
filter: none;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
color: var(--color-on-surface);
|
||||
background: var(--color-surface-container-lowest);
|
||||
border: 1px solid var(--color-outline);
|
||||
border-block-end: 0;
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
box-shadow: 0 -4px 20px var(--color-shadow);
|
||||
|
||||
&::before,
|
||||
.sidenote__id {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidenote__checkbox:checked ~ .sidenote__content {
|
||||
position: relative;
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
margin: 0.8rem 0;
|
||||
padding: 0.8rem 1.6rem;
|
||||
background: var(--color-surface-container-lowest);
|
||||
border-left: solid var(--color-secondary) var(--space-1);
|
||||
border-radius: 0 var(--radius-small) var(--radius-small) 0;
|
||||
box-shadow: 0 2px 4px var(--color-shadow);
|
||||
.sidenote__close {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.sidenote__id::after {
|
||||
content: var(--sidenote-citation-after-content);
|
||||
.sidenote__close {
|
||||
display: none;
|
||||
min-inline-size: 1.5em;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-secondary);
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:is(.sidenote__button, .sidenote__preview, .sidenote__close):focus-visible {
|
||||
outline: 2px solid var(--color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sidenote__content[popover]:popover-open {
|
||||
animation: sidenote-sheet-in 160ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.sidenote__id::before {
|
||||
content: var(--sidenote-citation-before-content);
|
||||
}
|
||||
|
||||
.sidenote__id {
|
||||
margin-right: 1ch;
|
||||
@media screen {
|
||||
@supports selector(:popover-open) {
|
||||
.sidenote__button {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1280px) {
|
||||
.sidenote {
|
||||
cursor: default;
|
||||
.sidenote__content[popover]:popover-open {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.sidenote__content::before {
|
||||
@@ -238,17 +338,25 @@ pre.shiki code span,
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
width: 2px;
|
||||
background: var(--color-secondary);
|
||||
filter: opacity(0.3);
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidenote__content[popover]:not(:popover-open) {
|
||||
display: block;
|
||||
position: relative;
|
||||
inset: auto;
|
||||
height: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidenote__content {
|
||||
width: 25ch;
|
||||
font-size: 0.75rem;
|
||||
filter: opacity(0.7);
|
||||
line-height: 1.4;
|
||||
margin-top: -1.5ch;
|
||||
padding: 0.5rem;
|
||||
@@ -256,28 +364,150 @@ pre.shiki code span,
|
||||
clear: right;
|
||||
margin-right: -28ch;
|
||||
margin-bottom: 3ch;
|
||||
}
|
||||
|
||||
.sidenote__button:hover ~ .sidenote__content::before {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidenote__button:hover ~ .sidenote__content {
|
||||
background: var(--color-surface-container-lowest);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.sidenote__id::before {
|
||||
content: var(--sidenote-callout-before-content);
|
||||
}
|
||||
|
||||
.sidenote__id::after {
|
||||
content: var(--sidenote-callout-after-content);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
|
||||
.sidenote__id {
|
||||
margin-right: 1ch;
|
||||
}
|
||||
|
||||
@supports (position-anchor: --sidenote) and (anchor-scope: --sidenote) and
|
||||
(align-self: anchor-center) and (position-try-fallbacks: --sidenote-fit-viewport) {
|
||||
.sidenote__slot {
|
||||
display: block;
|
||||
position: relative;
|
||||
anchor-name: --sidenote;
|
||||
anchor-scope: --sidenote;
|
||||
float: right;
|
||||
clear: right;
|
||||
inline-size: 25ch;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 1.4;
|
||||
margin: -1.5ch -28ch 3ch 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sidenote__slot::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0.5rem auto 0.5rem 0;
|
||||
inline-size: 2px;
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
|
||||
.sidenote__preview {
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidenote__excerpt {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidenote__close {
|
||||
min-inline-size: 0;
|
||||
padding-inline-end: 1ch;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Keep the invoker itself visible so native Escape can restore focus. */
|
||||
.sidenote__slot:has(> :popover-open) > .sidenote__preview > .sidenote__excerpt,
|
||||
.sidenote__slot:has(> :popover-open)::before {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.sidenote__slot > .sidenote__content > :first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.sidenote__slot > .sidenote__content > :last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.sidenote__slot > .sidenote__content[popover]:not(:popover-open) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidenote__slot > .sidenote__content[popover]:popover-open {
|
||||
position: fixed;
|
||||
position-anchor: --sidenote;
|
||||
position-visibility: always;
|
||||
inset: auto;
|
||||
top: anchor(top);
|
||||
left: anchor(left);
|
||||
align-self: auto;
|
||||
inline-size: anchor-size(width);
|
||||
max-inline-size: calc(100dvw - 24px);
|
||||
block-size: auto;
|
||||
min-block-size: 0;
|
||||
max-block-size: min(50dvh, 30rem);
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
position-try-fallbacks: --sidenote-fit-viewport, --sidenote-fit-viewport-inline;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
color: var(--color-on-surface);
|
||||
background: var(--color-surface-container-lowest);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
box-shadow:
|
||||
inset 2px 0 var(--color-secondary),
|
||||
0 4px 20px var(--color-shadow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.sidenote__button {
|
||||
display: inline;
|
||||
min-inline-size: 0;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.sidenote__content[popover],
|
||||
.sidenote__content[popover]:popover-open {
|
||||
display: block;
|
||||
position: static;
|
||||
inset: auto;
|
||||
inline-size: auto;
|
||||
block-size: auto;
|
||||
max-inline-size: none;
|
||||
min-block-size: 0;
|
||||
max-block-size: none;
|
||||
margin: 0.5em 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
|
||||
.sidenote__close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidenote__id {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.sidenote__content[popover]::backdrop {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,6 @@ ${darkBlock}
|
||||
|
||||
:root {
|
||||
font-family: var(--font-sans-name), 'Adjusted Sans';
|
||||
font-size: calc(var(--font-size-root) * 1px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
@@ -85,7 +85,7 @@ afterAll(() => {
|
||||
})
|
||||
|
||||
describe('pieces/render', () => {
|
||||
test('passes configured WOFF2 bytes into the actual PNG renderer instead of the static URL', async () => {
|
||||
test('keeps configured fonts and fixed root sizing in the PNG renderer', async () => {
|
||||
const config = makeConfig()
|
||||
const fontPath = path.resolve(import.meta.dirname, '../../assets/fonts/noto-sans.woff2')
|
||||
const fontBytes = readFileSync(fontPath)
|
||||
@@ -100,7 +100,12 @@ describe('pieces/render', () => {
|
||||
|
||||
expectPng(await renderOpengraphPng(makePiece(), [], config))
|
||||
expect(vi.mocked(ImageResponse).mock.calls.at(-1)![1]).toEqual(
|
||||
expect.objectContaining({ fonts: [{ name: 'Noto Sans', data: fontBytes }] })
|
||||
expect.objectContaining({
|
||||
fonts: [{ name: 'Noto Sans', data: fontBytes }],
|
||||
stylesheets: expect.arrayContaining([
|
||||
':root { font-size: calc(var(--font-size-root) * 1px); }',
|
||||
]),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -108,7 +108,12 @@ export async function renderOpengraphPng(
|
||||
outDir?: string
|
||||
): Promise<Buffer> {
|
||||
const { staticStylesheets, fontData } = await loadStaticAssets(config)
|
||||
const stylesheets = [...staticStylesheets, generateThemeCss(config)]
|
||||
// Share images use fixed canvas typography, not responsive browser sizing.
|
||||
const stylesheets = [
|
||||
...staticStylesheets,
|
||||
generateThemeCss(config),
|
||||
':root { font-size: calc(var(--font-size-root) * 1px); }',
|
||||
]
|
||||
const markup = prepareTakumiMarkup(await renderOpengraphHtml(piece, assets, config))
|
||||
const fetchedResources = loadLocalImageResources(markup.body, getAssetsDirectory(config, outDir))
|
||||
const globals = config.theme?.globals || {}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import { unified } from 'unified'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Element } from 'hast'
|
||||
import { validateConfig } from '@luzzle/web.config'
|
||||
import { processMarkdown } from './engine.js'
|
||||
|
||||
const themes = validateConfig({}).theme.markdown.code
|
||||
const inlineDefinition =
|
||||
'First **para**graph. A [link](https://example.com), <em>in</em>line, and `code`.'
|
||||
const inlinePreview = 'First paragraph. A link, inline, and code.'
|
||||
|
||||
async function renderMarkdownTree(markdown: string) {
|
||||
const html = await processMarkdown(markdown, themes)
|
||||
return unified()
|
||||
.use(rehypeRaw)
|
||||
.runSync({
|
||||
type: 'root',
|
||||
children: [{ type: 'raw', value: html }],
|
||||
})
|
||||
}
|
||||
|
||||
function textContent(element: Element): string {
|
||||
const text: string[] = []
|
||||
visit(element, 'text', (node) => {
|
||||
text.push(node.value)
|
||||
})
|
||||
return text.join('')
|
||||
}
|
||||
|
||||
test.each([
|
||||
{
|
||||
kind: 'inline',
|
||||
definition: inlineDefinition,
|
||||
preview: inlinePreview,
|
||||
},
|
||||
{
|
||||
kind: 'block-rich',
|
||||
definition: [
|
||||
inlineDefinition,
|
||||
'',
|
||||
' Second paragraph.',
|
||||
'',
|
||||
' ```js',
|
||||
' const preview = "text";',
|
||||
' ```',
|
||||
'',
|
||||
' <p>Raw<strong>para</strong>.</p><p>Last line<br>break.</p>',
|
||||
].join('\n'),
|
||||
preview: `${inlinePreview} Second paragraph. const preview = "text"; Rawpara. Last line break.`,
|
||||
},
|
||||
])(
|
||||
'keeps repeated $kind sidenotes intact with independent controls and previews',
|
||||
async ({ definition, preview: expectedPreview }) => {
|
||||
const tree = await renderMarkdownTree(
|
||||
`First[^shared], then again[^shared].\n\n[^shared]: ${definition}`
|
||||
)
|
||||
const notes: Element[] = []
|
||||
const slots: Element[] = []
|
||||
const references: Element[] = []
|
||||
visit(tree, 'element', (node) => {
|
||||
if (node.properties.popover === 'auto') notes.push(node)
|
||||
const classes = node.properties.className
|
||||
if (!Array.isArray(classes)) return
|
||||
if (classes.includes('sidenote__slot')) slots.push(node)
|
||||
if (classes.includes('sidenote__button')) references.push(node)
|
||||
})
|
||||
|
||||
expect(notes).toHaveLength(2)
|
||||
expect(slots).toHaveLength(2)
|
||||
expect(references).toHaveLength(2)
|
||||
expect(new Set(notes.map((note) => note.properties.id)).size).toBe(2)
|
||||
for (const [index, slot] of slots.entries()) {
|
||||
const children = slot.children.filter((node): node is Element => node.type === 'element')
|
||||
expect(children).toHaveLength(2)
|
||||
const [preview, note] = children
|
||||
expect(preview.tagName).toBe('button')
|
||||
expect(preview.properties.tabIndex).toBe(-1)
|
||||
expect(note.properties.popover).toBe('auto')
|
||||
expect(preview.properties.popoverTarget).toBe(note.properties.id)
|
||||
expect(references[index].properties.popoverTarget).toBe(note.properties.id)
|
||||
expect(textContent(preview)).toBe(`shared ${expectedPreview}`)
|
||||
|
||||
const closeButtons: Element[] = []
|
||||
visit(note, 'element', (node) => {
|
||||
if (node.tagName === 'button') closeButtons.push(node)
|
||||
})
|
||||
expect(closeButtons).toHaveLength(1)
|
||||
expect(closeButtons[0].properties.popoverTarget).toBe(note.properties.id)
|
||||
expect(closeButtons[0].properties.popoverTargetAction).toBe('hide')
|
||||
expect(note.properties.tabIndex).toBe(-1)
|
||||
expect(textContent(note)).toContain('First paragraph.')
|
||||
if (definition.includes('Second paragraph.')) {
|
||||
expect(textContent(note)).toContain('Second paragraph.')
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test('labels tight, nested and loose tasks without changing their states', async () => {
|
||||
const tree = await renderMarkdownTree(
|
||||
[
|
||||
'- [x] Parent **ta**sk.',
|
||||
' - [ ] Child `task`.',
|
||||
'- [ ] Before <span hidden>secret</span><span aria-hidden="true">decoration</span>after<br>break.',
|
||||
'- [ ] Task title',
|
||||
' <p>Additional text</p>',
|
||||
'',
|
||||
'## Loose tasks',
|
||||
'',
|
||||
'- [ ] Loose *task*.',
|
||||
'',
|
||||
' More details.',
|
||||
'',
|
||||
'- [x] Another loose task.',
|
||||
].join('\n')
|
||||
)
|
||||
const checkboxes: Element[] = []
|
||||
visit(tree, 'element', (node) => {
|
||||
if (node.tagName === 'input' && node.properties.type === 'checkbox') checkboxes.push(node)
|
||||
})
|
||||
|
||||
expect(checkboxes.map((checkbox) => checkbox.properties.ariaLabel)).toEqual([
|
||||
'Parent task.',
|
||||
'Child task.',
|
||||
'Before after break.',
|
||||
'Task title Additional text',
|
||||
'Loose task.',
|
||||
'Another loose task.',
|
||||
])
|
||||
expect(checkboxes.map((checkbox) => Boolean(checkbox.properties.checked))).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
])
|
||||
expect(checkboxes.every((checkbox) => checkbox.properties.disabled === true)).toBe(true)
|
||||
})
|
||||
|
||||
test('preserves author-supplied task names while labeling unnamed raw tasks', async () => {
|
||||
const tree = await renderMarkdownTree(`
|
||||
<label for="native-task">Explicit native name</label>
|
||||
|
||||
<ul>
|
||||
<li class="task-list-item"><input type="checkbox" disabled aria-label="Explicit name">Different text</li>
|
||||
<li class="task-list-item"><input type="checkbox" disabled aria-labelledby="task-name"><span id="task-name">Referenced name</span></li>
|
||||
<li class="task-list-item"><input id="native-task" type="checkbox" disabled>Different native text</li>
|
||||
<li class="task-list-item"><input type="checkbox" disabled>Derived <em>name</em>.</li>
|
||||
</ul>
|
||||
`)
|
||||
const checkboxes: Element[] = []
|
||||
const labels: Element[] = []
|
||||
visit(tree, 'element', (node) => {
|
||||
if (node.tagName === 'input' && node.properties.type === 'checkbox') checkboxes.push(node)
|
||||
if (node.tagName === 'label') labels.push(node)
|
||||
})
|
||||
|
||||
expect(checkboxes).toHaveLength(4)
|
||||
expect(checkboxes[0].properties.ariaLabel).toBe('Explicit name')
|
||||
expect(checkboxes[1].properties.ariaLabel).toBeUndefined()
|
||||
expect(checkboxes[1].properties.ariaLabelledBy).toEqual(['task-name'])
|
||||
expect(checkboxes[2].properties.ariaLabel).toBeUndefined()
|
||||
expect(checkboxes[2].properties.id).toBe('native-task')
|
||||
expect(labels).toHaveLength(1)
|
||||
expect(labels[0].properties.htmlFor).toEqual(['native-task'])
|
||||
expect(textContent(labels[0])).toBe('Explicit native name')
|
||||
expect(checkboxes[3].properties.ariaLabel).toBe('Derived name.')
|
||||
})
|
||||
@@ -1,11 +1,18 @@
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from './remark-gfm.js'
|
||||
import { remarkSidenote, remarkSidenotesUnified, sidenoteHandler } from './remark-sidenotes.js'
|
||||
import {
|
||||
remarkSidenote,
|
||||
remarkSidenotesUnified,
|
||||
sidenoteHandler,
|
||||
rehypeSidenotes,
|
||||
rehypeSidenotePreviews,
|
||||
} from './remark-sidenotes.js'
|
||||
import remarkGemoji from 'remark-gemoji'
|
||||
import remarkDirective from 'remark-directive'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import { rehypeTaskListLabels } from './rehype-task-list-labels.js'
|
||||
import { rehypeAssetUrls, type AssetUrls } from './rehype-asset-urls.js'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
@@ -33,8 +40,11 @@ export async function processMarkdown(
|
||||
sidenote: sidenoteHandler,
|
||||
},
|
||||
})
|
||||
.use(rehypeSidenotes)
|
||||
.use(rehypePrettyCode, { theme: themes })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeTaskListLabels)
|
||||
.use(rehypeSidenotePreviews)
|
||||
.use(rehypeAssetUrls, assets)
|
||||
.use(rehypeSlug)
|
||||
.use(rehypeAutolinkHeadings)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Element, Nodes, Root } from 'hast'
|
||||
|
||||
function taskText(node: Nodes): string {
|
||||
if (node.type === 'text') return node.value
|
||||
if (node.type !== 'element') return ''
|
||||
if (
|
||||
node.properties.popover !== undefined ||
|
||||
node.properties.hidden ||
|
||||
node.properties.ariaHidden === true ||
|
||||
node.properties.ariaHidden === 'true'
|
||||
)
|
||||
return ''
|
||||
|
||||
switch (node.tagName) {
|
||||
case 'input':
|
||||
case 'button':
|
||||
case 'select':
|
||||
case 'textarea':
|
||||
case 'ul':
|
||||
case 'ol':
|
||||
case 'script':
|
||||
case 'style':
|
||||
return ''
|
||||
case 'br':
|
||||
return ' '
|
||||
case 'p':
|
||||
case 'div':
|
||||
return ` ${node.children.map(taskText).join('')} `
|
||||
case 'img':
|
||||
return typeof node.properties.alt === 'string' ? node.properties.alt : ''
|
||||
default:
|
||||
return node.children.map(taskText).join('')
|
||||
}
|
||||
}
|
||||
|
||||
export const rehypeTaskListLabels = () => {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'element', (item) => {
|
||||
const classes = item.properties.className
|
||||
if (item.tagName !== 'li' || !Array.isArray(classes) || !classes.includes('task-list-item'))
|
||||
return
|
||||
|
||||
// Prefer a direct checkbox; loose lists keep it inside a paragraph instead.
|
||||
const paragraphs = item.children.filter(
|
||||
(node): node is Element => node.type === 'element' && node.tagName === 'p'
|
||||
)
|
||||
for (const content of [item, ...paragraphs]) {
|
||||
const checkbox = content.children.find(
|
||||
(node): node is Element =>
|
||||
node.type === 'element' &&
|
||||
node.tagName === 'input' &&
|
||||
node.properties.type === 'checkbox' &&
|
||||
node.properties.disabled === true
|
||||
)
|
||||
if (!checkbox) continue
|
||||
// Generated GFM checkboxes have no IDs; leave identified author controls alone.
|
||||
if (
|
||||
checkbox.properties.id ||
|
||||
checkbox.properties.ariaLabel ||
|
||||
checkbox.properties.ariaLabelledBy
|
||||
)
|
||||
return
|
||||
|
||||
checkbox.properties.ariaLabel =
|
||||
content.children.map(taskText).join('').replace(/\s+/g, ' ').trim() || 'Task'
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { visit, SKIP } from 'unist-util-visit'
|
||||
import { h } from 'hastscript'
|
||||
import type { Handler } from 'mdast-util-to-hast'
|
||||
import type * as Hast from 'hast'
|
||||
import type { Processor, Data } from 'unified'
|
||||
import type {
|
||||
TokenizeContext,
|
||||
@@ -56,7 +57,7 @@ interface SidenoteDefinition extends Mdast.Parent {
|
||||
|
||||
interface Sidenote extends Mdast.Parent {
|
||||
type: 'sidenote'
|
||||
data?: Mdast.Data & { noteId: string }
|
||||
data: Mdast.Data & { noteId: string; referenceId: number; inline: boolean }
|
||||
children: Array<Mdast.RootContent>
|
||||
}
|
||||
|
||||
@@ -499,6 +500,7 @@ export const remarkSidenotesUnified = () => {
|
||||
return (tree: Mdast.Root) => {
|
||||
const definitions = new Map<string, Mdast.RootContent[]>()
|
||||
const nodesToRemove: SidenoteDefinition[] = []
|
||||
let referenceId = 0
|
||||
|
||||
visit(tree, 'sidenoteDefinition', (node: SidenoteDefinition) => {
|
||||
definitions.set(node.identifier, node.children)
|
||||
@@ -517,15 +519,15 @@ export const remarkSidenotesUnified = () => {
|
||||
const definitionChildren = definitions.get(id)
|
||||
if (!definitionChildren) return undefined
|
||||
|
||||
const content =
|
||||
const paragraph =
|
||||
definitionChildren.length === 1 && definitionChildren[0].type === 'paragraph'
|
||||
? definitionChildren[0].children
|
||||
: definitionChildren
|
||||
? definitionChildren[0]
|
||||
: undefined
|
||||
|
||||
parent.children.splice(index, 1, {
|
||||
type: 'sidenote',
|
||||
data: { noteId: id },
|
||||
children: content
|
||||
data: { noteId: id, referenceId: ++referenceId, inline: Boolean(paragraph) },
|
||||
children: paragraph?.children ?? definitionChildren
|
||||
} as Sidenote)
|
||||
return [SKIP, index]
|
||||
}
|
||||
@@ -535,38 +537,174 @@ export const remarkSidenotesUnified = () => {
|
||||
}
|
||||
|
||||
export const sidenoteHandler: Handler = (state, node) => {
|
||||
const data = (node as Sidenote).data
|
||||
const noteId = data?.noteId
|
||||
const type = 'sidenote'
|
||||
const checkboxId = `tufte-sn-${noteId}`
|
||||
|
||||
return h('span', { className: type }, [
|
||||
h('input', {
|
||||
'aria-label': `Show ${type}`,
|
||||
type: 'checkbox',
|
||||
id: checkboxId,
|
||||
className: 'sidenote__checkbox'
|
||||
}),
|
||||
const { noteId, referenceId, inline } = (node as Sidenote).data
|
||||
const contentId = `sidenote-${referenceId}`
|
||||
const prefix: Hast.ElementContent[] = [
|
||||
h(
|
||||
'label',
|
||||
'button',
|
||||
{
|
||||
tabIndex: 0,
|
||||
title: `${type} content`,
|
||||
'aria-describedby': `${type}-${noteId}`,
|
||||
'anchor-name': `--${type}-label-${noteId}`,
|
||||
type: 'button',
|
||||
className: 'sidenote__close',
|
||||
popovertarget: contentId,
|
||||
popovertargetaction: 'hide',
|
||||
'aria-label': `Close sidenote ${noteId}`
|
||||
},
|
||||
noteId
|
||||
),
|
||||
h('span', { className: 'sidenote__id' }, noteId),
|
||||
{ type: 'text', value: ' ' }
|
||||
]
|
||||
const content = state.all(node)
|
||||
const first = content[0]
|
||||
if (!inline && first?.type === 'element' && first.tagName === 'p') {
|
||||
first.children.unshift(...prefix)
|
||||
} else {
|
||||
content.unshift(...prefix)
|
||||
}
|
||||
|
||||
const preview = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
className: 'sidenote__preview',
|
||||
// The numbered button is the keyboard entry; avoid a second stop beneath the card.
|
||||
tabIndex: -1,
|
||||
popovertarget: contentId,
|
||||
'aria-label': `Read sidenote ${noteId}`
|
||||
},
|
||||
[
|
||||
h('span', { className: 'sidenote__excerpt', 'aria-hidden': 'true' }, [
|
||||
h('span', { className: 'sidenote__id' }, noteId),
|
||||
' '
|
||||
])
|
||||
]
|
||||
)
|
||||
const card = h(
|
||||
inline ? 'small' : 'div',
|
||||
{
|
||||
id: contentId,
|
||||
className: 'sidenote__content',
|
||||
tabIndex: -1,
|
||||
autoFocus: true,
|
||||
popover: 'auto',
|
||||
role: 'note',
|
||||
'aria-label': `Sidenote ${noteId}`
|
||||
},
|
||||
content
|
||||
)
|
||||
|
||||
return h('span', { className: 'sidenote' }, [
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
className: 'sidenote__button',
|
||||
for: checkboxId
|
||||
popovertarget: contentId,
|
||||
'aria-label': `Sidenote ${noteId}`
|
||||
},
|
||||
[h('sup', noteId)]
|
||||
),
|
||||
h(
|
||||
'small',
|
||||
{
|
||||
id: `${type}-${noteId}`,
|
||||
style: `position-anchor:--${type}-label-${noteId};`,
|
||||
className: 'sidenote__content'
|
||||
},
|
||||
[h('span', { className: 'sidenote__id' }, noteId), h('span', {}, state.all(node))]
|
||||
)
|
||||
h(inline ? 'span' : 'div', { className: 'sidenote__slot' }, [preview, card])
|
||||
])
|
||||
}
|
||||
|
||||
function hasClass(node: Hast.Nodes, className: string): node is Hast.Element {
|
||||
return (
|
||||
node.type === 'element' &&
|
||||
Array.isArray(node.properties.className) &&
|
||||
node.properties.className.includes(className)
|
||||
)
|
||||
}
|
||||
|
||||
// Hoist block-rich slots before HTML parsing repairs their nesting.
|
||||
export const rehypeSidenotes = () => {
|
||||
return (tree: Hast.Root) => {
|
||||
const parents = new Map<Hast.Nodes, Hast.Parents>()
|
||||
const slots: Hast.Element[] = []
|
||||
const flowContainers = new Set(['blockquote', 'div', 'li', 'td', 'th'])
|
||||
|
||||
visit(tree, (node, _index, parent) => {
|
||||
if (parent) parents.set(node, parent)
|
||||
if (hasClass(node, 'sidenote__slot') && node.tagName === 'div') {
|
||||
slots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
// Insert backwards so slots sharing an anchor retain reference order.
|
||||
for (const slot of slots.reverse()) {
|
||||
const parent = parents.get(slot)
|
||||
let container = parent
|
||||
let anchor: Hast.Nodes = slot
|
||||
while (container && container.type !== 'root' && !flowContainers.has(container.tagName)) {
|
||||
anchor = container
|
||||
container = parents.get(container)
|
||||
}
|
||||
if (!parent || !container || parent === container) continue
|
||||
|
||||
parent.children.splice(parent.children.indexOf(slot), 1)
|
||||
container.children.splice(container.children.indexOf(anchor) + 1, 0, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const previewBreakTags = new Set([
|
||||
'blockquote',
|
||||
'br',
|
||||
'div',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'section',
|
||||
'table',
|
||||
'td',
|
||||
'th',
|
||||
'tr',
|
||||
'ul'
|
||||
])
|
||||
|
||||
function sidenotePreviewText(node: Hast.Nodes): string {
|
||||
if (node.type === 'text') return node.value
|
||||
if (node.type !== 'element') return ''
|
||||
const classes = node.properties.className
|
||||
if (
|
||||
Array.isArray(classes) &&
|
||||
(classes.includes('sidenote__close') || classes.includes('sidenote__id'))
|
||||
)
|
||||
return ''
|
||||
if (node.tagName === 'script' || node.tagName === 'style') return ''
|
||||
|
||||
const text = node.children.map(sidenotePreviewText).join('')
|
||||
return previewBreakTags.has(node.tagName) ? ` ${text} ` : text
|
||||
}
|
||||
|
||||
export const rehypeSidenotePreviews = () => {
|
||||
return (tree: Hast.Root) => {
|
||||
visit(tree, 'element', (slot) => {
|
||||
if (!hasClass(slot, 'sidenote__slot')) return
|
||||
const preview = slot.children.find((node): node is Hast.Element =>
|
||||
hasClass(node, 'sidenote__preview')
|
||||
)
|
||||
const content = slot.children.find((node): node is Hast.Element =>
|
||||
hasClass(node, 'sidenote__content')
|
||||
)
|
||||
if (!preview || !content) return
|
||||
const excerpt = preview.children.find((node): node is Hast.Element =>
|
||||
hasClass(node, 'sidenote__excerpt')
|
||||
)
|
||||
if (!excerpt) return
|
||||
|
||||
excerpt.children.push({
|
||||
type: 'text',
|
||||
value: sidenotePreviewText(content).replace(/\s+/g, ' ').trim()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user