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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/olive-rules-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@hyperbook/markdown": minor
"hyperbook": minor
"hyperbook-studio": minor
---

Rework how bookmarks store their label, so bookmarks show what the page shows.

A bookmark label used to be baked into an `onclick` attribute of the bookmark
button and rendered with `innerHTML`. Labels are now read from the rendered
heading when a bookmark is saved, and stored as parts instead of markup:

```js
[{ text: "Getting started " }, { text: "🐧", emoji: "1f427" }];
```

The bookmark list builds its entries from those parts with the DOM, so nothing
that was stored is parsed as HTML, and an emoji that is rendered as an image
stays an image in the bookmark list. Emojis are stored by id, never by URL, so
bookmarks survive a change of the `basePath`.

This also fixes bookmarking a heading that contains a quote or a backslash.
Those characters ended up inside a JavaScript string in the `onclick`
attribute and made the button throw a syntax error.

The bookmark indicator is no longer the 🔖 emoji. Both the button on a heading
and the marker in the bookmark list are drawn from the stylesheet with a mask,
so the control looks the same on every platform, takes the color of the
heading it belongs to in light and dark mode, and does not change when the
emoji style changes. A bookmarked heading now shows a filled icon instead of only a less
transparent one.

Breaking:

- `hyperbook.ui.toggleBookmark(key, label)` no longer takes a label:
`hyperbook.ui.toggleBookmark(key)`. The label comes from the heading.
- Bookmark buttons no longer carry an inline `onclick`. They are handled by a
delegated listener and carry `data-key`, `data-label`, `aria-label` and
`aria-pressed`.
- Bookmark buttons are empty. The icon comes from `.bookmark` in the
stylesheet, so a custom style that replaced the emoji has to be updated.
- The store moves to version 6. Labels of existing bookmarks are migrated to
the new shape, and a plain label is still rendered, so nothing is lost.
10 changes: 10 additions & 0 deletions .changeset/tricky-donuts-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@hyperbook/types": patch
"hyperbook": patch
"hyperbook-studio": patch
---

Fix `mailto:` and `tel:` links in markdown. `makeUrl` only passed through URLs
containing `://` or starting with `data:`, so `[Write us](mailto:a@b.c)` was
resolved against the base path and became `href="/mailto:a@b.c"`. Any URL with
a scheme is now passed through untouched.
13 changes: 13 additions & 0 deletions .changeset/wild-pugs-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@hyperbook/markdown": patch
"@hyperbook/types": patch
"hyperbook": patch
"hyperbook-studio": patch
---

Add `elements.emoji.style`. Emojis are drawn by the reader's operating system,
so the same emoji looks different on Windows, macOS, Android and Linux. Setting
the style to `twemoji` replaces them with Twemoji images at build time, so a
hyperbook looks the same on every platform. This covers emojis in the content
as well as icons from the config, leaves code untouched, and only copies the
emojis a book actually uses into its output. The default stays `native`.
62 changes: 58 additions & 4 deletions packages/hyperbook/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
HyperbookPage,
HyperbookSection,
Navigation,
isExternalUrl,
} from "@hyperbook/types";
import lunr from "lunr";
import { process as hyperbookProcess } from "@hyperbook/markdown";
Expand Down Expand Up @@ -100,6 +101,8 @@ export type PageResultSink = (href: string, result: SinglePageResult) => void;
export interface SinglePageResult {
searchDocuments: any[];
directives: string[];
/** Twemoji file names used on this page, without the .svg extension. */
emojis: string[];
/** Absolute paths whose contents were inlined into this page. */
dependencies: string[];
}
Expand Down Expand Up @@ -134,6 +137,7 @@ export async function buildSingleBookPage(
const result = await hyperbookProcess(file.markdown.content, ctx);
const searchDocuments = [...(result.data.searchDocuments || [])];
const directives = Object.keys(result.data.directives || {});
const emojis = [...(result.data.emojis || [])];

for (const generated of (result.data.generatedFiles as any[]) || []) {
let genDir;
Expand Down Expand Up @@ -182,7 +186,12 @@ export async function buildSingleBookPage(
}
await fs.writeFile(fileOut, result.value);

return { searchDocuments, directives, dependencies: [...dependencies] };
return {
searchDocuments,
directives,
emojis,
dependencies: [...dependencies],
};
}

export async function buildSingleGlossaryPage(
Expand Down Expand Up @@ -232,6 +241,7 @@ export async function buildSingleGlossaryPage(
const result = await hyperbookProcess(file.markdown.content, ctx);
const searchDocuments = [...(result.data.searchDocuments || [])];
const directives = Object.keys(result.data.directives || {});
const emojis = [...(result.data.emojis || [])];

for (const generated of (result.data.generatedFiles as any[]) || []) {
let genDir;
Expand Down Expand Up @@ -261,7 +271,12 @@ export async function buildSingleGlossaryPage(
}
await fs.writeFile(fileOut, result.value);

return { searchDocuments, directives, dependencies: [...dependencies] };
return {
searchDocuments,
directives,
emojis,
dependencies: [...dependencies],
};
}

/**
Expand Down Expand Up @@ -456,7 +471,7 @@ export function makeBaseCtx(
version: packageJson.version,
makeUrl: (p, base, page, options = { versioned: true }) => {
if (typeof p === "string") {
if (p.includes("://") || p.startsWith("data:")) {
if (isExternalUrl(p)) {
return p;
}
if (p.endsWith(".md.hbs")) {
Expand Down Expand Up @@ -561,6 +576,7 @@ async function runBuild(
const baseCtx = makeBaseCtx(root, hyperbookJson, basePath, rootProject);

const directives = new Set<string>([]);
const emojis = new Set<string>([]);
const pagesAndSections = await hyperbook.getPagesAndSections(root);
const pageList = hyperbook.getPageList(
pagesAndSections.sections,
Expand Down Expand Up @@ -589,6 +605,9 @@ async function runBuild(
for (let directive of pageResult.directives) {
directives.add(directive);
}
for (let emoji of pageResult.emojis) {
emojis.add(emoji);
}
onPage?.(file.path.href || file.path.absolute, pageResult);

if (!process.env.CI) {
Expand Down Expand Up @@ -626,6 +645,9 @@ async function runBuild(
for (let directive of pageResult.directives) {
directives.add(directive);
}
for (let emoji of pageResult.emojis) {
emojis.add(emoji);
}
onPage?.(file.path.href || file.path.absolute, pageResult);

if (!process.env.CI) {
Expand Down Expand Up @@ -839,12 +861,44 @@ async function runBuild(
}
process.stdout.write("\n");

if (emojis.size > 0) {
const emojiPath = path.join(assetsPath, "emoji");
const emojiOut = path.join(assetsOut, "emoji");
await mkdir(emojiOut, { recursive: true });
i = 1;
for (let emoji of emojis) {
if (!process.env.CI) {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
}
process.stdout.write(
`${chalk.blue(`[${prefix}]`)} Copying emojis: [${i++}/${emojis.size}]`,
);
if (process.env.CI) {
process.stdout.write("\n");
}
try {
await cp(
path.join(emojiPath, `${emoji}.svg`),
path.join(emojiOut, `${emoji}.svg`),
);
} catch (e) {
process.stdout.write("\n");
process.stdout.write(
`${chalk.red(`[${prefix}]`)} Failed copying emoji: ${emoji}`,
);
process.stdout.write("\n");
}
}
process.stdout.write("\n");
}

const mainAssets = await fs.readdir(assetsPath);
i = 1;
for (let asset of mainAssets) {
const assetPath = path.join(assetsPath, asset);
const assetOut = path.join(assetsOut, asset);
if (!asset.startsWith("directive-")) {
if (!asset.startsWith("directive-") && asset !== "emoji") {
await cp(assetPath, assetOut, {
recursive: true,
filter: (src) => {
Expand Down
25 changes: 25 additions & 0 deletions packages/hyperbook/incremental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ export class IncrementalBuilder {

// Copy any new directive assets
await this.copyDirectiveAssets(result.directives);
await this.copyEmojiAssets(result.emojis);

const changedHref = changedFile.path.href || "/";

Expand Down Expand Up @@ -423,6 +424,7 @@ export class IncrementalBuilder {
}

await this.copyDirectiveAssets(result.directives);
await this.copyEmojiAssets(result.emojis);

const changedHref = changedFile.path.href || "/glossary";
await this.refreshSearchIndex();
Expand Down Expand Up @@ -487,6 +489,7 @@ export class IncrementalBuilder {
this.directives.add(directive);
}
await this.copyDirectiveAssets(result.directives);
await this.copyEmojiAssets(result.emojis);
return href;
}

Expand All @@ -507,6 +510,7 @@ export class IncrementalBuilder {
this.directives.add(directive);
}
await this.copyDirectiveAssets(result.directives);
await this.copyEmojiAssets(result.emojis);
return href;
}

Expand All @@ -529,6 +533,27 @@ export class IncrementalBuilder {
);
}

/**
* Emojis are copied one file at a time, so a book only ships the Twemoji
* images it actually uses.
*/
private async copyEmojiAssets(newEmojis: string[]): Promise<void> {
if (newEmojis.length === 0) return;
const emojiPath = path.join(__dirname, "assets", "emoji");
const emojiOut = path.join(this.assetsOut, "emoji");
await fs.mkdir(emojiOut, { recursive: true });
for (const emoji of newEmojis) {
try {
await cp(
path.join(emojiPath, `${emoji}.svg`),
path.join(emojiOut, `${emoji}.svg`),
);
} catch {
// Emoji has no asset
}
}
}

private async copyDirectiveAssets(newDirectives: string[]): Promise<void> {
const assetsPath = path.join(__dirname, "assets");
for (const directive of newDirectives) {
Expand Down
14 changes: 14 additions & 0 deletions packages/markdown/assets/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,25 @@ hyperbook.bootstrap = (function () {
hyperbook.store.db.bookmarks.get(key).then((bookmark) => {
if (bookmark) {
bookmarkEl.classList.add("active");
bookmarkEl.setAttribute("aria-pressed", "true");
}
});
}
}

/**
* Bookmark buttons are handled by one delegated listener, so headings that
* are added later work too and no heading has to carry its label in an
* inline script.
*/
document.addEventListener("click", (event) => {
const bookmarkEl = event.target.closest?.("button.bookmark");
const key = bookmarkEl?.getAttribute("data-key");
if (key) {
hyperbook.ui.toggleBookmark(key);
}
});

/**
* Initialize all hyperbook elements within a root.
* @param {HTMLElement} root
Expand Down
36 changes: 34 additions & 2 deletions packages/markdown/assets/content.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ figure {
max-width: 100%;
}

img.emoji {
height: 1em;
width: 1em;
min-width: 1em;
margin: 0 0.05em 0 0.1em;
vertical-align: -0.1em;
display: inline-block;
max-width: none;
}

.hyperbook-markdown figure.align-left {
display: table;
float: left;
Expand Down Expand Up @@ -321,12 +331,33 @@ figure {
align-items: center;
}

/* The bookmark icon is drawn from the stylesheet, so it looks the same on
every platform and takes the color of its heading. */
:root {
--bookmark-icon: url('data:image/svg+xml,<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"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>');
--bookmark-icon-active: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>');
}

.hyperbook-markdown .bookmark {
margin-left: 10px;
background: none;
border: none;
opacity: 0.5;
padding: 0;
/* The heading text is a link, and the button is its sibling, so inheriting
would pick up the color of the h1 rather than the color the heading
appears in. Take the same color the heading link takes. */
color: var(--color-brand);
cursor: pointer;
display: inline-block;
vertical-align: middle;
width: 1em;
height: 1em;
font-size: 1rem;
opacity: 0.5;
background-color: currentColor;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: var(--bookmark-icon);
}

.hyperbook-markdown .bookmark:hover {
Expand All @@ -335,6 +366,7 @@ figure {

.hyperbook-markdown .bookmark.active {
opacity: 1;
mask-image: var(--bookmark-icon-active);
}

.hyperbook-markdown ul.bookmarks {
Expand Down
Loading