Compare commits

..

2 Commits

Author SHA1 Message Date
Peter White 384cd63729 changeset 2026-10-14 10:18:25 +02:00
Peter White 0898723168 Expose preview segment claims to expressions 2026-10-14 10:18:25 +02:00
22 changed files with 198 additions and 162 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix Mermaid block diagrams falling back to a code block on published sites.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix long Mermaid labels being clipped instead of wrapping at some browser zoom levels.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Show expression values in site previews that use a visitor segment.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Stop page group breadcrumbs from linking to a URL that redirects to the group's first page.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix 404 on Markdown pages (View as Markdown) in site previews and when a theme is forced with `?theme=`.
-15
View File
@@ -1,15 +0,0 @@
# Slack PR status
[GitbookIO/slack-pr-status](https://github.com/GitbookIO/slack-pr-status) adds review-status reactions to PR links posted in [#engineering](https://gitbook.slack.com/archives/C01NXGWJELS). It runs from `.github/workflows/slack-pr-status.yml` in `GitbookIO/gitbook`, `GitbookIO/gitbook-x` and `GitbookIO/integrations`. Emojis and statuses are configured in that workflow; see the action's README for the options.
Owner: Peter White.
| Configuration | Value |
| --- | --- |
| Slack app | **PR Status** (`A0C2AGLAD0T`), created from `.github/slack-pr-status-app-manifest.json` |
| Bot token | Repository Actions secret `SLAPR_SLACK_BOT_TOKEN` |
| GitHub token | The workflow's `GITHUB_TOKEN`, read-only |
Posts that link PRs from more than one repository are skipped, because each workflow's token can only read its own repository. PRs from forks get no reactions, because their workflows don't receive secrets.
To disable it in one repository, run `gh workflow disable slack-pr-status.yml --repo GitbookIO/<repository>`. To stop it everywhere, revoke the PR Status app's token.
+88
View File
@@ -0,0 +1,88 @@
# Slack PR reactions
[DataDog/slapr](https://github.com/DataDog/slapr) updates reactions on existing PR-link messages in [#engineering](https://gitbook.slack.com/archives/C01NXGWJELS). It runs in GitHub Actions. It does not post messages, approve or merge PRs, or change branch protection.
## Rollout and ownership
Owner: Peter White.
The rollout targets `GitbookIO/gitbook` (GBO), `GitbookIO/gitbook-x` (GBX), and `GitbookIO/integrations`. Each repository contains `.github/workflows/slapr.yml` and this runbook.
| Configuration | Value |
| --- | --- |
| Channel | `#engineering`, `C01NXGWJELS` |
| App manifest | `.github/slapr-app-manifest.json` |
| Slack app ID | `A0C2AGLAD0T` |
| Bot user ID | `U0C2AGP16R1`, repository variable `SLAPR_BOT_USER_ID` |
| Bot token | Repository Actions secret `SLAPR_SLACK_BOT_TOKEN` |
| Action | `DataDog/slapr@1c074e1dfd433f91a679d5113f7f8b956b65d973` |
| Approval threshold | Stock default, one approval |
| Events | `pull_request_review: submitted`, `pull_request: closed` |
## Install and configure
1. In [Slack app management](https://api.slack.com/apps), create the **PR Status** app from `.github/slapr-app-manifest.json` in the GitBook workspace. Obtain workspace approval if required, then install it. The bot requests Slapr's documented `channels:history`, `reactions:read`, and `reactions:write` scopes. `#engineering` is public, so `groups:history` is unnecessary. No review map is configured, so `channels:read` is unnecessary.
2. Invite **PR Status** only to `#engineering`. Keep it dedicated to Slapr: stock Slapr removes obsolete reactions belonging to this bot on a matched message. It leaves other users' reactions alone.
3. Record the app ID and bot user ID above. The bot profile's member ID or Slack `auth.test` response's `user_id` is the required bot user ID, not the app ID or bot ID.
4. Store the bot OAuth token as `SLAPR_SLACK_BOT_TOKEN` in each of the three repositories' Actions secrets. Use the GitHub secret UI or `gh secret set SLAPR_SLACK_BOT_TOKEN --repo GitbookIO/<repository>` and its hidden prompt. Never paste the token into a command argument, chat, workflow, or committed file. Alternatively, an organization secret must use selected-repository visibility limited to these three repositories.
5. Set Actions variable `SLAPR_BOT_USER_ID` in each repository to that bot's user ID. The token and user ID must belong to the same installation. Use `gh variable set SLAPR_BOT_USER_ID --repo GitbookIO/<repository> --body '<user-id>'` or the GitHub UI.
6. Merge the workflows through normal review, then run the live checks below. Repository maintainers handle reviews and merges; Slapr has only read access to GitHub contents and pull requests.
GBX runs this workflow on `blacksmith-2vcpu-ubuntu-2404`. GBO and integrations use `ubuntu-latest`, matching their existing workflows.
The workflow hardcodes the engineering channel and does not check out PR code. It uses the stock event triggers and leaves the approval threshold unset. There are no listeners, schedules, review maps, custom discovery, or additional services.
## Reactions
Slapr's status selection stays unchanged. Missing custom emoji names are mapped through the action's supported inputs to existing equivalents. Workspace lookup on 2026-09-17 confirmed `pr-approved` and `merged`; the other mappings are standard Slack emojis.
| Stock status | Reaction |
| --- | --- |
| Review started | `:eyes:` |
| Partially approved | `:next_track_button:` |
| Approved | `:pr-approved:` |
| Changes requested | `:construction:` |
| Merged | `:merged:` |
| Closed without merging | `:no_entry_sign:` |
| Comment-only review | `:speech_balloon:` |
The partially-approved reaction is configured but cannot occur with the default threshold of one approval. It becomes relevant only if that threshold is raised.
On approval, expect review-started and approved reactions. On merge, stock Slapr removes review-started and adds merged; it can retain approved alongside merged. Approval and merging have distinct reactions. Other stock review-state behavior is accepted.
## Live acceptance test
For each repository after credentials are configured and the workflow is on the default branch:
1. Open a fresh same-repository test PR through normal review. Post its full `https://github.com/GitbookIO/<repository>/pull/<number>` link as a new top-level message in `#engineering` before the review event.
2. Have another contributor approve it. Open the **Slack PR reactions** Actions run and verify that the matched message has the bot's `pr-approved` reaction.
3. Have an authorized maintainer merge it. Verify the closed-event run succeeds and the same message has the bot's `merged` reaction. No new Slack status message should appear.
4. Record the PR URL, Slack message permalink, approval/merge workflow run URLs, date, and result below. A green workflow alone does not prove the message was found or reacted to.
| Repository | PR and Slack message | Approval run/result | Merge run/result |
| --- | --- | --- | --- |
| GitbookIO/gitbook | Pending | Not tested | Not tested |
| GitbookIO/gitbook-x | Pending | Not tested | Not tested |
| GitbookIO/integrations | Pending | Not tested | Not tested |
Only the first matching message in Slapr's recent-history response is updated. Older messages, thread replies, duplicate posts, fork PRs, and links posted after the event are outside this rollout's acceptance criteria. Slapr does not paginate history or reconcile missed events.
## Troubleshoot and disable
- No workflow run: confirm the workflow is merged, Actions permits the pinned action, and the event was a submitted review or PR closure. These are the only configured triggers.
- `No message found requesting review`: confirm the full PR link was posted as a recent top-level message in `#engineering` before the event. This is a successful no-op in stock Slapr.
- `not_in_channel` or `channel_not_found`: check the app installation, channel ID, and bot membership in `#engineering`.
- `missing_scope`: compare the installed bot scopes with the manifest and reinstall after changes.
- `invalid_auth` or `account_inactive`: replace the repository secrets with the current bot token. If the bot identity changed, update `SLAPR_BOT_USER_ID` too.
- `invalid_name`: check the emoji mappings still exist. If a custom emoji was removed, map that input to another existing emoji, keeping approval and merged distinct.
- Old reactions remain or removal fails: confirm `SLAPR_BOT_USER_ID` is the member ID for the token's bot.
- GitHub permission errors: keep `contents: read` and `pull-requests: read`; do not introduce a PAT, write permissions, or `pull_request_target` to cover forks. Fork and Dependabot secret restrictions are accepted limitations.
- Rate limits or transient API failures: inspect the Actions log and retry the failed run after the limit resets. There is no scheduled reconciliation.
Disable one repository with `gh workflow disable slapr.yml --repo GitbookIO/<repository>`, or disable **Slack PR reactions** in its Actions UI. Repeat for all three to stop the integration. Revoke the Slack app token to stop its access immediately, remove its repository secrets, and remove the bot from `#engineering` when retiring it. Existing reactions remain.
## Pin review
On 2026-09-17, the pinned action definition, Dockerfile, entry point, dependency declarations, GitHub reads, Slack history lookup and reaction writes were inspected. Without `review-map`, the action targets only the supplied channel. It reads PR/review state and adds/removes reactions; it does not post Slack messages or write to GitHub.
The action SHA pins Slapr's source. Its upstream Dockerfile still uses the mutable `python:3.14` image and installs dependencies, including `pyyaml>=5.0`, at build time. This rollout keeps stock Slapr unchanged. Review upstream source and dependency changes before updating the SHA in all three repositories.
-33
View File
@@ -1,33 +0,0 @@
name: Slack PR status
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
types: [closed, reopened]
permissions:
contents: read
pull-requests: read
jobs:
slack-pr-status:
# Secrets aren't available to workflows triggered from forks.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: GitbookIO/slack-pr-status@main
with:
slack-token: ${{ secrets.SLAPR_SLACK_BOT_TOKEN }}
config: |
channels: [C01NXGWJELS]
approvalsRequired: 1
multiPr: aggregate
emojis:
commented: false
partiallyApproved: next_track_button
approved: pr-approved
changesRequested: construction
merged: merged
closed: no_entry_sign
partiallyReviewed: hourglass_flowing_sand
+30
View File
@@ -0,0 +1,30 @@
name: Slack PR reactions
on:
pull_request_review:
types: [submitted]
pull_request:
types: [closed]
permissions:
contents: read
pull-requests: read
jobs:
run_slapr:
runs-on: ubuntu-latest
steps:
- name: Update PR reactions in engineering
uses: DataDog/slapr@1c074e1dfd433f91a679d5113f7f8b956b65d973
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
slack-api-token: ${{ secrets.SLAPR_SLACK_BOT_TOKEN }}
slack-channel-id: C01NXGWJELS
bot-user-id: ${{ vars.SLAPR_BOT_USER_ID }}
emoji-review-started: eyes
emoji-partially-approved: next_track_button
emoji-approved: pr-approved
emoji-changes-requested: construction
emoji-merged: merged
emoji-closed: no_entry_sign
emoji-commented: speech_balloon
-1
View File
@@ -343,7 +343,6 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.3.8": "patches/next@16.3.8.patch",
"mermaid@11.14.0": "patches/mermaid@11.14.0.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
+1 -2
View File
@@ -67,7 +67,6 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.3.8": "patches/next@16.3.8.patch",
"mermaid@11.14.0": "patches/mermaid@11.14.0.patch"
"next@16.3.8": "patches/next@16.3.8.patch"
}
}
-24
View File
@@ -1125,30 +1125,6 @@ const testCases: TestsCase[] = [
).toBeVisible();
},
},
{
name: 'Markdown page with theme cookie',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
searchParams.set('theme', 'dark');
return `url/${getGitBookPreviewURL(`${data.site}/blocks?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
// The app opens previews with `?theme=`, which the middleware persists in a cookie.
const markdownURL = new URL(page.url());
markdownURL.pathname = `${markdownURL.pathname.replace(/\/$/, '')}.md`;
markdownURL.search = '';
const response = await page.request.get(markdownURL.toString());
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('text/markdown');
},
},
{
name: 'Redirect to app for authentication when missing token',
url: async () => {
@@ -1,6 +1,5 @@
import { build } from 'bun';
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, rm } from 'node:fs/promises';
import { mkdir, rename, rm } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -13,20 +12,14 @@ const temporaryDir = join(outputDir, '.build');
const require = createRequire(import.meta.url);
const mermaidPackage = require('mermaid/package.json') as { version: string };
const zenumlPackage = require('@mermaid-js/mermaid-zenuml/package.json') as { version: string };
const mermaidPatch = await readFile(
join(scriptDir, `../../../patches/mermaid@${mermaidPackage.version}.patch`)
).catch(() => null);
const mermaidPatchSuffix = mermaidPatch
? `-patch.${createHash('sha256').update(mermaidPatch).digest('hex').slice(0, 8)}`
: '';
// The runtime URL is served as immutable, it has to change whenever the bundled versions or patch change.
// The runtime URL is served as immutable, it has to change whenever the bundled versions change.
if (
MERMAID_RUNTIME_PATH !==
`mermaid/mermaid@${mermaidPackage.version}${mermaidPatchSuffix}-zenuml@${zenumlPackage.version}.mjs`
`mermaid/mermaid@${mermaidPackage.version}-zenuml@${zenumlPackage.version}.mjs`
) {
throw new Error(
'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions and the mermaid patch'
'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions'
);
}
@@ -1 +1 @@
export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.14.0-patch.caefc3e9-zenuml@0.2.2.mjs';
export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.14.0-zenuml@0.2.2.mjs';
@@ -222,14 +222,9 @@ export async function PageHeader(props: {
))}
{ancestors.map((breadcrumb, index) => {
const parentPages = ancestors[index - 1]?.pages ?? revision.pages;
// A group's own URL redirects to its first document, so link straight to it.
const target =
breadcrumb.type === 'group'
? (resolveFirstDocument([breadcrumb], [])?.page ?? breadcrumb)
: breadcrumb;
const href = linker.toPathForPage({
pages: revision.pages,
page: target,
page: breadcrumb,
});
return (
<li key={breadcrumb.id} className="inline">
@@ -488,16 +483,15 @@ function getPageSiblings(
if (page.hidden && page.id !== activeId) {
continue;
}
// A group's own URL redirects to its first document, so link straight to it. A group whose
// subtree has no document resolves to no page, so don't offer it (its path would 404).
const target = page.type === 'group' ? resolveFirstDocument([page], [])?.page : page;
if (!target) {
// A group whose subtree has no document resolves to no page — the sidebar renders it as a
// non-clickable label, so don't offer it as a navigable sibling (its path would 404).
if (page.type === 'group' && page.id !== activeId && !resolveFirstDocument([page], [])) {
continue;
}
result.push({
id: page.id,
title: page.linkTitle || page.title,
href: context.linker.toPathForPage({ pages: context.revision.pages, page: target }),
href: context.linker.toPathForPage({ pages: context.revision.pages, page }),
emoji: page.emoji,
icon: page.icon,
isActive: page.id === activeId,
+2 -1
View File
@@ -627,11 +627,12 @@ describe('resolveContentRef for direct space links', () => {
{ label: 'API guides' },
{ label: 'Authentification' },
{ label: 'Référence', href: targetSiteSpace.urls.published },
{ label: 'Getting started', icon: null, href: result?.href },
{ label: 'Getting started', icon: null, href: expect.any(String) },
]);
expect(result?.ancestors?.[0]?.href).toBeUndefined();
expect(result?.ancestors?.[1]?.href).toBeUndefined();
expect(result?.ancestors?.[2]?.href).toBeUndefined();
expect(result?.ancestors?.[4]?.href).toBeTruthy();
});
it('uses the first document as the target for a page-group reference', async () => {
+2 -9
View File
@@ -23,7 +23,7 @@ import {
findGitPageURLTarget,
findPageForGitPageURLTarget,
} from './gitPageURL';
import { resolveFirstDocument, resolvePageId } from './pages';
import { resolvePageId } from './pages';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
@@ -276,14 +276,7 @@ export async function resolveContentRef(
style={iconStyle}
/>
) : null,
// A group's own URL redirects to its first document, so link straight to it.
href: linker.toPathForPage({
page:
ancestor.type === 'group'
? (resolveFirstDocument([ancestor], [])?.page ?? ancestor)
: ancestor,
pages: revision.pages,
}),
href: linker.toPathForPage({ page: ancestor, pages: revision.pages }),
})) ?? [];
if (!page) {
return null;
+26
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test';
import type { JwtPayload } from 'jwt-decode';
import { NextRequest } from 'next/server';
import {
type ResponseCookies,
@@ -14,6 +15,7 @@ import {
isRevalidationRequest,
normalizeVisitorURL,
parseHostnameList,
serveVisitorClaimsDataRequest,
shouldSendVisitorCountry,
} from './visitors';
@@ -691,3 +693,27 @@ describe('parseHostnameList', () => {
);
});
});
describe('serveVisitorClaimsDataRequest', () => {
it('should expose the claims of the preview API token', async () => {
const siteRequestURL = new URL(
'https://sites.gitbook.com/preview/site_foo/~gitbook/visitor'
);
const claims = { apiUrl: 'https://client-123.example.com', isBeta: true };
const previewAPIToken = [
btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })),
btoa(JSON.stringify({ kind: 'site', site: 'site_foo', claims })),
'signature',
].join('.');
const response = serveVisitorClaimsDataRequest(
new NextRequest(siteRequestURL),
siteRequestURL,
previewAPIToken
);
expect(await response.json()).toEqual({
visitor: { claims: { unsigned: {}, ...claims } },
});
});
});
+16 -2
View File
@@ -3,7 +3,7 @@ import { type JwtPayload, jwtDecode } from 'jwt-decode';
import { type NextRequest, NextResponse } from 'next/server';
import hash from 'object-hash';
import type { SiteVisitorPayload } from '@gitbook/api';
import type { SiteAPIToken, SiteVisitorPayload } from '@gitbook/api';
import {
MAX_CHUNKED_COOKIE_LENGTH,
@@ -571,7 +571,21 @@ export function getVisitorAuthCookieMaxAge(decoded: JwtPayload): number {
/**
* Handler for the /~gitbook/visitor middleware route to expose visitor data.
*/
export function serveVisitorClaimsDataRequest(request: NextRequest, siteRequestURL: URL) {
export function serveVisitorClaimsDataRequest(
request: NextRequest,
siteRequestURL: URL,
previewAPIToken?: string
) {
// A site preview carries the visitor segment claims in its API token, not in a visitor token.
if (previewAPIToken) {
try {
const { claims } = jwtDecode<SiteAPIToken>(previewAPIToken);
return NextResponse.json({ visitor: { claims: { unsigned: {}, ...claims } } });
} catch (error) {
console.warn('Error decoding preview API token', error);
}
}
const { visitorToken, unsignedClaims } = getVisitorData({
cookies: request.cookies.getAll(),
headers: request.headers,
+18 -6
View File
@@ -210,7 +210,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// Handler that returns visitor data for the app to consume.
if (siteRequestURL.pathname.endsWith('/~gitbook/visitor')) {
return serveVisitorClaimsDataRequest(request, siteRequestURL);
const previewAPIToken = isPreviewRequest(siteRequestURL)
? getAPITokenFromCookies(
request.cookies.getAll(),
getAPITokenCookieName(getPreviewScopePath(siteRequestURL))
)
: undefined;
return serveVisitorClaimsDataRequest(request, siteRequestURL, previewAPIToken);
}
//
@@ -496,9 +502,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
themeMode === CustomizationThemeMode.Dark
? CustomizationDefaultThemeMode.Dark
: CustomizationDefaultThemeMode.Light;
} else if (routeTypeFromPathname !== 'static') {
// Markdown, llms.txt, RSS and sitemap routes only exist under `sites/static`, so
// forcing them dynamic 404s. They don't render a theme anyway. RND-12749.
} else {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Theme, themeMode);
}
@@ -627,7 +631,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
request.headers.set('x-gitbook-disable-tracking', 'true');
return serveWithQueryAPIToken({
// We scope the API token to the site ID.
scopePath: ['preview', getPreviewRequestIdentifier(siteRequestURL)].join('/'),
scopePath: getPreviewScopePath(siteRequestURL),
// We keep the original request URL when using `url` mode
requestURL: mode === 'url' ? requestURL : siteRequestURL,
requestCookies: request.cookies,
@@ -679,6 +683,14 @@ function serveErrorResponse(error: Error) {
throw error;
}
function getPreviewScopePath(siteRequestURL: URL) {
return ['preview', getPreviewRequestIdentifier(siteRequestURL)].join('/');
}
function getAPITokenCookieName(scopePath: string) {
return getPathScopedCookieName('gitbook-api-token', scopePath);
}
/**
* Serve a response with an API token obtained from the query params.
*/
@@ -691,7 +703,7 @@ async function serveWithQueryAPIToken(input: {
const { scopePath, requestURL, requestCookies, serve } = input;
// We store the API token in a cookie that is scoped to the specific route
// to avoid errors when multiple previews are opened in different tabs.
const cookieName = getPathScopedCookieName('gitbook-api-token', scopePath);
const cookieName = getAPITokenCookieName(scopePath);
// Extract a potential GitBook API token passed in the request
// If found, we redirect to the same URL but with the token in the cookie
-26
View File
@@ -1,26 +0,0 @@
diff --git a/dist/chunks/mermaid.core/blockDiagram-DXYQGD6D.mjs b/dist/chunks/mermaid.core/blockDiagram-DXYQGD6D.mjs
index f37722f2f65d3c49904ec704998a9a0ce6f01d21..0b97ddef583d120235530222990f08db3de83e5d 100644
--- a/dist/chunks/mermaid.core/blockDiagram-DXYQGD6D.mjs
+++ b/dist/chunks/mermaid.core/blockDiagram-DXYQGD6D.mjs
@@ -3658,7 +3658,7 @@ async function calculateBlockSize(elem, block, db2) {
const nodeEl = await insertNode(elem, node, { config: config2 });
const boundingBox = nodeEl.node().getBBox();
const obj = db2.getBlock(node.id);
- obj.size = { width: boundingBox.width, height: boundingBox.height, x: 0, y: 0, node: nodeEl };
+ obj.size = { width: boundingBox.width, height: boundingBox.height, x: 0, y: 0 };
db2.setBlock(obj);
nodeEl.remove();
}
diff --git a/dist/chunks/mermaid.core/chunk-U2HBQHQK.mjs b/dist/chunks/mermaid.core/chunk-U2HBQHQK.mjs
index 6f5ff605824cec370f0ff1077fe3005823fa59f5..1468e9b37b3c1ef3449774b816202e3143174476 100644
--- a/dist/chunks/mermaid.core/chunk-U2HBQHQK.mjs
+++ b/dist/chunks/mermaid.core/chunk-U2HBQHQK.mjs
@@ -308,7 +308,7 @@ async function addHtmlSpan(element, node, width, classes, addBackground = false,
div.attr("class", "labelBkg");
}
let bbox = div.node().getBoundingClientRect();
- if (bbox.width === width) {
+ if (Math.round(bbox.width) === width) {
div.style("display", "table");
div.style("white-space", "break-spaces");
div.style("width", width + "px");