Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder 74387bb047 test(#522): make the internal-link subset invariant a real cross-package drift guard
Reviewer follow-up on the #522 internal-link import fix. The fix itself is
confirmed correct; these three changes harden its load-bearing subset invariant
and fix a stale doc.

Finding 1 (drift guard): the in-package test only compared isInternalPagePath
against a HAND-COPIED server regex, so a later narrowing of the real server
INTERNAL_LINK_REGEX would leave the test green while the client silently became
WIDER than the server. Add apps/server/.../export/internal-link-parity.spec.ts:
the top layer already depends on @docmost/prosemirror-markdown and exports
isInternalPagePath, so this spec imports the LIVE server INTERNAL_LINK_REGEX AND
the LIVE isInternalPagePath and asserts subset over an accept-corpus — a server
narrowing now reddens CI. The in-package manual copy is demoted from its
"drift guard" role (comment updated to say so; it stays as local documentation).

Finding 2 (charset non-vacuity): the REJECT list was purely structural, so the
mutation widening the slug charset [a-zA-Z0-9-] -> [a-zA-Z0-9-.] survived the
whole suite. Add REJECT cases whose ONLY defect is a forbidden slug character
(abc.def / abc_def / abc%20 / abc~x); assert both isInternalPagePath and the
server regex reject them. The mutation now reddens the new reject test.

Finding 3 (stale JSDoc): canonicalize.ts KNOWN_DEFAULTS module blurb claimed
every entry was read from docmost-schema and import-materialized. The new
link.internal default breaks both claims (source is editor-ext/src/lib/link.ts;
external links leave internal absent/null, never false). Add a bullet documenting
the one editor-sourced, non-materialized default (false ≡ absent/null).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:06:59 +03:00
agent_coder 6ec2981743 fix(prosemirror-markdown): помечать внутренние ссылки на страницы как internal при импорте markdown
При импорте markdown ссылка вида `[t](/s/<space>/p/<slug>)` сохранялась как
внешняя (link-mark получал `internal:null`, `target:"_blank"`,
`rel:"noopener noreferrer nofollow"`): открывалась новой вкладкой, без
hover-превью и без участия в backlinks. Единственный путь к нативной внутренней
ссылке был ручной JSON-патч.

Чиню в общем конвертере пакета, через который проходят ВСЕ markdown-пути (MCP
updatePageMarkdown/importPageMarkdown, patch/insertNode, тела комментариев,
серверный REST create/update, single/zip-импорт, ai-chat, git-sync pull,
вставка markdown в редактор) — все они получают исправление разом.

- Новый модуль `internal-links.ts`: чистый `isInternalPagePath(href)` —
  якорный `^/s/<space>/p/<slug>/?$`, СТРОГОЕ подмножество серверного
  `INTERNAL_LINK_REGEX` (apps/server/.../export/utils.ts), поэтому всё
  помеченное гарантированно бэклинкуется и переписывается при экспорте.
  Fail-toward-external: любая неоднозначность (scheme/host, `#`, `?`,
  бесспейсовый `/p/<slug>`, лишний сегмент, не-строка) остаётся внешней.
- `markInternalLinks(doc)`: пост-обход готового ProseMirror-документа,
  помечает КАЖДУЮ text-ноду, покрытую внутренней ссылкой (включая случай
  вложенных bold/italic внутри ссылки) → `{internal:true, target:null,
  rel:null}`. Чистая и идемпотентная.
- Разводка в `markdownToProseMirrorSync` после stripEmptyParagraphs.
- §11 (обязательная сопутствующая правка): `internal:false` добавлен в
  `KNOWN_DEFAULTS.link` канонизатора — редакторные внешние ссылки хранят
  `internal:false`, теперь он канонизируется как absent/null/external, а
  load-bearing `internal:true` (не дефолт) переживает канонизацию.

Тесты: accept/reject-пиннинг матчера (edge: trailing slash, `#`, `?`,
`/p/x` без спейса, `https://h/p/x`, `/api/...`, uppercase, empty-space),
subset-инвариант против серверного регэкспа, мультинодовая ссылка со вложенными
марками, идемпотентность, полный конвертер (internal помечен / external нетронут /
бэклинк-извлекаемость), комментарий-тело через общий путь, canonicalize §11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:06:58 +03:00
32 changed files with 672 additions and 1505 deletions
-1
View File
@@ -463,7 +463,6 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
## Conventions
-31
View File
@@ -142,18 +142,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
snapshots switched from a fixed interval to a trailing idle-flush with a
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
changes (e.g. a person's edits followed by the AI agent). (#370)
- **Open tabs pick up a new deploy on their own.** After the server is
redeployed while a tab is left open for hours, the tab now learns the new
build version over the existing WebSocket (announced per-connect, so a natural
reconnect delivers it) and shows a "A new version is available" banner with an
Update button. To avoid dropping a half-written comment or form, the tab is
not reloaded when you merely switch away from it; instead it auto-reloads at
the next safe point — the next in-app navigation (or immediately if you click
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
reload happens per 5-minute window, shared with the existing chunk-load
recovery, so a permanent version skew degrades to the banner rather than a
reload loop while a second deploy in the same tab still recovers. When the
build carries no version info the feature stays inert. (#481)
- **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline
@@ -404,25 +392,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`@docmost/token-estimate` package) so they can never diverge. Deferred-tool
activation is also cached in the chat metadata to avoid re-resolving it each
turn. (#490)
- **Cyrillic (and any non-ASCII) draw.io labels no longer turn into mojibake
when a diagram is opened in the draw.io editor.** Agent-created diagrams
(`drawioCreate`) and Confluence-imported diagrams stored their model in the
SVG's `content=` attribute as base64; the draw.io editor decodes that via
Latin-1 `atob` (no UTF-8 step), so every non-ASCII char (e.g. `Старт-бит`,
`ё`, ``) split into garbage and the editor's autosave then persisted the
corrupted model, breaking the page preview too. Both write paths
(`buildDrawioSvg`, the import service's `createDrawioSvg`) now write `content=`
as XML-entity-escaped mxfile XML — draw.io's own native form, decoded by the
DOM as UTF-8 — so labels open intact. The decoder reads both the new
entity-encoded form and the old base64 form, so existing diagrams still open.
*Healing pre-fix diagrams:* only a diagram that still holds its original
(correct-UTF-8) base64 — i.e. one not yet opened/autosaved in the draw.io
editor — can be repaired in place by `drawioGet` → `drawioUpdate` with the
same XML (rewrites the attachment in the new form); no migration script is
needed. A diagram that was already opened in the editor persisted the
mojibake at rest, so `drawioGet` reads the already-corrupted text and
`drawioUpdate` faithfully rewrites it — that text is lost and is not
recoverable by a rewrite. (#507)
- **A chat with one malformed message part no longer 500s on every turn, and a
failed send no longer duplicates the user's message.** Incoming client parts
are now whitelisted to `text` (a forged tool-result part can no longer reach
@@ -1,5 +1,4 @@
{
"A new version is available": "A new version is available",
"Account": "Account",
"Active": "Active",
"Add": "Add",
@@ -1,5 +1,4 @@
{
"A new version is available": "Доступна новая версия",
"Account": "Аккаунт",
"Active": "Активный",
"Add": "Добавить",
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { isChunkLoadError } from "./chunk-load-error-boundary";
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
// The detector decides whether a caught render error is a stale-deploy chunk-404
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
@@ -35,3 +35,31 @@ describe("isChunkLoadError", () => {
expect(isChunkLoadError(err)).toBe(false);
});
});
// The window gate replaces the old one-shot flag: it must permit recovery across
// several deploys in one tab (each > window apart) while still stopping an infinite
// reload loop when a lazy chunk is permanently broken (a second failure < window).
describe("shouldAutoReload", () => {
const WINDOW = 5 * 60 * 1000;
const NOW = 1_000_000_000_000;
it("allows a reload when we have never auto-reloaded", () => {
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
});
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
});
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
});
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
});
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
});
});
@@ -1,11 +1,26 @@
import { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Button, Center, Stack, Text } from "@mantine/core";
import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
} from "@/lib/reload-guard";
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
const RELOAD_AT_KEY = "chunk-reload-at";
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
// by a single reload, so anything inside the window is treated as a reload loop
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
// window. Anything inside the window is suppressed to break an infinite reload loop.
export function shouldAutoReload(
now: number,
lastReloadAt: number | null,
windowMs: number,
): boolean {
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
return now - lastReloadAt > windowMs;
}
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
@@ -24,26 +39,24 @@ export function isChunkLoadError(error: unknown): boolean {
);
}
// Exported for tests: the reactive chunk-load reload decision, so the shared
// window budget (invariant: ≤1 auto-reload per window across this path AND the
// proactive version-coherence path) can be exercised against the real guard.
export function handleError(error: unknown) {
function handleError(error: unknown) {
if (!isChunkLoadError(error)) return;
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
// the new chunk manifest. Auto-reload at most once per window via the SHARED
// window-based reload guard (see @/lib/reload-guard — the same budget the
// proactive version-coherence path consumes, so a mismatch that arrives on
// both paths reloads at most once per window across BOTH). This recovers
// across multiple deploys in a single tab's lifetime, yet a permanently-broken
// lazy chunk (which would loop) is stopped after the first reload and falls
// through to the manual recovery UI below. If the shared budget is already
// spent this window, or the stamp write fails (storage unavailable), we return
// without reloading rather than risk a loop.
if (hasAutoReloaded()) return;
if (!markAutoReloaded()) return;
// Trace before the reload clears the console (same diagnostic breadcrumb the
// proactive version-coherence path writes, tagged with this path).
recordReloadBreadcrumb({ path: "chunk-boundary" });
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
// recovers across multiple deploys in a single tab's lifetime, yet a
// permanently-broken lazy chunk (which would loop) is stopped after the first
// reload and falls through to the manual recovery UI below.
try {
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
const now = Date.now();
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
} catch {
// sessionStorage unavailable (private mode / disabled): skip the automatic
// reload rather than risk an unguarded loop; the fallback UI still recovers.
return;
}
window.location.reload();
}
@@ -1,195 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, act, cleanup } from "@testing-library/react";
import { MemoryRouter, useNavigate } from "react-router-dom";
// Mocks for the dirty shell's side-effecting collaborators.
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
vi.mock("@/lib/reload-guard", () => ({
hasAutoReloaded: vi.fn(() => false),
markAutoReloaded: vi.fn(() => true),
recordReloadBreadcrumb: vi.fn(),
takeReloadBreadcrumb: vi.fn(() => null),
}));
import { notifications } from "@mantine/notifications";
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
__resetGuardedReloadForTests,
} from "./guarded-reload";
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
typeof vi.fn
>;
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
typeof vi.fn
>;
let reload: ReturnType<typeof vi.fn>;
let visibility: DocumentVisibilityState;
// Test harness mounted inside a router: it installs the navigation hook and
// exposes `navigate` so a test can drive an in-app router navigation.
let doNavigate: (to: string) => void;
function Harness() {
useVersionReloadOnNavigation();
const navigate = useNavigate();
doNavigate = navigate;
return null;
}
function mountHarness() {
render(
<MemoryRouter initialEntries={["/start"]}>
<Harness />
</MemoryRouter>,
);
}
function navigateTo(path: string) {
act(() => {
doNavigate(path);
});
}
beforeEach(() => {
__resetGuardedReloadForTests();
vi.clearAllMocks();
mockHasAutoReloaded.mockReturnValue(false);
mockMarkAutoReloaded.mockReturnValue(true);
vi.stubGlobal("APP_VERSION", "test-A");
reload = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { reload },
});
visibility = "visible";
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => visibility,
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("triggerGuardedReload (variant C)", () => {
it("noop when versions match: no banner, no reload", () => {
triggerGuardedReload("test-A");
expect(show).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
});
it("noop when the server version is empty (fail-safe)", () => {
triggerGuardedReload("");
triggerGuardedReload(undefined);
expect(show).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
});
it("real mismatch shows the banner but does NOT reload immediately", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(show).toHaveBeenCalledTimes(1);
expect(show.mock.calls[0][0]).toMatchObject({
id: "app-version-reload",
autoClose: false,
withCloseButton: true,
});
expect(reload).not.toHaveBeenCalled();
});
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
// A second navigation must NOT reload again (one-shot was consumed).
navigateTo("/again");
expect(reload).toHaveBeenCalledTimes(1);
});
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
// Another app-version mismatch arrives (reconnect): must not re-arm.
triggerGuardedReload("test-C");
navigateTo("/again");
expect(reload).toHaveBeenCalledTimes(1);
});
it("does NOT reload merely from the tab going to the background", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
visibility = "hidden";
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(reload).not.toHaveBeenCalled();
});
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
visibility = "hidden";
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
expect(show).toHaveBeenCalledTimes(1);
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
});
it("the banner's Update button reloads immediately", () => {
triggerGuardedReload("test-B");
const message = show.mock.calls[0][0].message as {
props: { onClick: () => void };
};
message.props.onClick();
expect(reload).toHaveBeenCalledTimes(1);
});
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
mockHasAutoReloaded.mockReturnValue(true);
mountHarness();
triggerGuardedReload("test-B");
expect(show).toHaveBeenCalledTimes(1);
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
});
it("does NOT reload when the flag write fails; falls back to the banner", () => {
mockMarkAutoReloaded.mockReturnValue(false);
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
// performAutoReload falls back to showing the banner (initial + fallback).
expect(show).toHaveBeenCalled();
});
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
triggerGuardedReload("test-B");
triggerGuardedReload("test-B");
triggerGuardedReload("test-C");
expect(show).toHaveBeenCalledTimes(1);
});
});
@@ -1,188 +0,0 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import i18n from "@/i18n.ts";
import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
} from "@/lib/reload-guard";
import { decideVersionAction } from "@/features/user/version-coherence";
// Dirty shell around the pure `decideVersionAction`: it reads globals
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
// Mantine notification, and arms the router-navigation reload hook. Kept
// separate from the pure module so the decision stays unit-testable without a
// DOM.
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
// single banner instead of stacking a new one each time.
const BANNER_ID = "app-version-reload";
// Module-level idempotency for the current tab-load: once a mismatch has been
// handled we don't re-arm the navigation reload or re-show the banner on
// subsequent app-version emits.
let handled = false;
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
// the background (that would silently drop a half-written comment/form). Instead
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
// the user is already leaving the current page, so an in-app navigation would
// discard that unsaved component-state anyway and the reload adds no extra loss.
let pendingNavReload = false;
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
// the (already-visible) banner.
let lastServerVersion = "";
let lastClientVersion = "";
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
// where Vite's `define` did not run) — an unknown client version makes the
// pure decision no-op (fail-safe).
function readClientVersion(): string {
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
}
// Perform the actual reload — but only after the shared one-shot flag is
// persisted. If the write fails (storage unavailable) we must NOT reload
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
// to the manual banner so the user can still recover.
function performAutoReload(): void {
if (!markAutoReloaded()) {
showReloadBanner();
return;
}
// Trace right before the reload (which clears the console): a persistent
// breadcrumb + a log line so the auto-reload is observable in a field report.
recordReloadBreadcrumb({
path: "proactive",
serverVersion: lastServerVersion,
clientVersion: lastClientVersion,
});
console.warn(
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
);
window.location.reload();
}
function showReloadBanner(): void {
notifications.show({
id: BANNER_ID,
title: i18n.t("A new version is available"),
message: (
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
{i18n.t("Update")}
</Button>
),
autoClose: false,
withCloseButton: true,
});
}
/**
* Handle a server `app-version` announcement: compare it to this bundle's
* version and, on a real mismatch, show the banner and arm a guarded reload for
* the next in-app navigation (variant C).
*
* - real mismatch (window budget available) → banner + arm navigation reload.
* The banner's "Update" button reloads immediately (same shared window guard).
* The tab is NOT reloaded on visibility change.
* - auto-reload already used this window / storage error → banner only (no arm),
* so there is at most one automatic reload per RELOAD_WINDOW_MS window (loop
* safety).
* - in sync / unknown version → noop (fail-safe).
*/
export function triggerGuardedReload(
rawServerVersion: string | undefined | null,
): void {
const serverVersion = (rawServerVersion ?? "").trim();
const clientVersion = readClientVersion();
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
// reloading (banner only).
const autoReloadUsed = hasAutoReloaded();
const action = decideVersionAction({
serverVersion,
clientVersion,
autoReloadUsed,
});
if (action === "noop") return;
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
// emits (reconnects) once we've already acted.
if (handled) return;
handled = true;
lastServerVersion = serverVersion;
lastClientVersion = clientVersion;
if (action === "banner") {
// Entered banner-only (permanent skew, node oscillation, or the window's
// auto-reload budget already spent). Log for diagnosability; show the banner.
console.warn(
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
"auto-reload budget already spent this window — showing manual banner",
);
showReloadBanner();
return;
}
// action === "reload" (variant C): show the banner and defer the auto-reload
// to the next in-app navigation instead of reloading now / on visibility.
showReloadBanner();
pendingNavReload = true;
}
/**
* Consume the armed one-shot navigation reload, if any. Called by
* `useVersionReloadOnNavigation` on each in-app router navigation.
*/
export function consumeNavigationReload(): void {
if (!pendingNavReload) return;
pendingNavReload = false;
performAutoReload();
}
/**
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
* NEXT in-app router navigation after a version mismatch. Skips the initial
* render so it only reacts to real navigations, not the first location.
*/
export function useVersionReloadOnNavigation(): void {
const location = useLocation();
const firstRender = useRef(true);
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
consumeNavigationReload();
}, [location.key]);
}
/**
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
* load — the reload cleared the console, so this makes a "tab reloaded itself"
* report diagnosable. Call once on app startup.
*/
export function surfacePreviousReloadBreadcrumb(): void {
const crumb = takeReloadBreadcrumb();
if (!crumb) return;
console.info(
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
`at=${new Date(crumb.at).toISOString()}`,
);
}
// Test-only: reset module-level latches between cases.
export function __resetGuardedReloadForTests(): void {
handled = false;
pendingNavReload = false;
lastServerVersion = "";
lastClientVersion = "";
}
@@ -1,171 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, act, cleanup } from "@testing-library/react";
import { MemoryRouter, useNavigate } from "react-router-dom";
// Integration test for the SHARED, window-based auto-reload budget (invariant a):
// the reactive chunk-load boundary and the proactive version-coherence path both
// route through the REAL @/lib/reload-guard, so at most one automatic reload
// happens per RELOAD_WINDOW_MS across BOTH paths combined. Only the two paths'
// side-effecting collaborators are mocked — the reload guard is intentionally
// REAL so this exercises the actual shared sessionStorage budget.
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
import { handleError } from "@/components/chunk-load-error-boundary";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
__resetGuardedReloadForTests,
} from "./guarded-reload";
import { RELOAD_WINDOW_MS } from "@/lib/reload-guard";
const CHUNK_ERROR = { name: "ChunkLoadError", message: "boom" };
const T0 = 1_000_000_000_000;
let reload: ReturnType<typeof vi.fn>;
let nowMock: ReturnType<typeof vi.spyOn>;
// Harness mounted inside a router: installs the navigation hook and exposes
// `navigate` so a test can drive an in-app router navigation (the point where the
// proactive path fires its armed reload).
let doNavigate: (to: string) => void;
function Harness() {
useVersionReloadOnNavigation();
doNavigate = useNavigate();
return null;
}
function mountHarness() {
render(
<MemoryRouter initialEntries={["/start"]}>
<Harness />
</MemoryRouter>,
);
}
function navigateTo(path: string) {
act(() => {
doNavigate(path);
});
}
function setNow(t: number) {
nowMock.mockReturnValue(t);
}
beforeEach(() => {
sessionStorage.clear();
__resetGuardedReloadForTests();
vi.clearAllMocks();
nowMock = vi.spyOn(Date, "now").mockReturnValue(T0);
vi.stubGlobal("APP_VERSION", "test-A");
reload = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { reload },
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
sessionStorage.clear();
});
describe("shared window-based reload budget (invariant a)", () => {
it("a chunk-load reload spends the budget: a version-coherence mismatch within the window shows the banner but does NOT reload", () => {
// Path 1 (reactive): a stale-chunk 404 auto-reloads once and stamps the window.
handleError(CHUNK_ERROR);
expect(reload).toHaveBeenCalledTimes(1);
reload.mockClear();
// Path 2 (proactive), 1 min later — still inside the window. The shared budget
// is spent, so the version mismatch degrades to the banner and never reloads.
setNow(T0 + 60_000);
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
});
it("a version-coherence reload spends the SAME budget: a chunk-load error within the window does NOT reload", () => {
// Path 2 (proactive) first: real mismatch → arm → fire on navigation.
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
reload.mockClear();
// Path 1 (reactive), 2 min later — inside the window. Budget already spent by
// the proactive path, so the stale-chunk error must NOT trigger a second reload.
setNow(T0 + 2 * 60_000);
handleError(CHUNK_ERROR);
expect(reload).not.toHaveBeenCalled();
});
it("recovers after the window: a reload strictly older than the window is allowed again (second deploy)", () => {
// First auto-reload (reactive) stamps the window.
handleError(CHUNK_ERROR);
expect(reload).toHaveBeenCalledTimes(1);
reload.mockClear();
// A second deploy arrives after the window has fully elapsed → the proactive
// path is allowed to reload again (window, not a permanent one-shot).
__resetGuardedReloadForTests();
setNow(T0 + RELOAD_WINDOW_MS + 1);
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
});
it("reactive path fails closed when storage READS but cannot WRITE (quota / Safari private): no unguarded reload", () => {
// getItem→null makes hasAutoReloaded() report the budget as available, so
// handleError passes the first guard and reaches `if (!markAutoReloaded())
// return;`. setItem throws → the stamp cannot stick, so markAutoReloaded()
// returns false and that guard MUST bail — otherwise the reactive path would
// reload on every stale-chunk error with no persisted budget (an unguarded
// loop). This is the asymmetric gap: the proactive path's equivalent is
// covered by guarded-reload.test.tsx "does NOT reload when the flag write
// fails".
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("quota exceeded");
},
removeItem: () => {},
clear: () => {},
});
try {
handleError(CHUNK_ERROR);
expect(reload).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it("sessionStorage unavailable: neither path performs an unguarded reload", () => {
// The real guard fails toward NOT reloading when storage throws.
vi.stubGlobal("sessionStorage", {
getItem: () => {
throw new Error("storage disabled");
},
setItem: () => {
throw new Error("storage disabled");
},
removeItem: () => {},
clear: () => {},
});
try {
handleError(CHUNK_ERROR);
expect(reload).not.toHaveBeenCalled();
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -13,12 +13,6 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { queryClient } from "@/main.tsx";
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
surfacePreviousReloadBreadcrumb,
} from "@/features/user/guarded-reload.tsx";
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
@@ -28,16 +22,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
// fetch collab token on load
const { data: collab } = useCollabToken();
// version-coherence: fire the armed one-shot reload on the next in-app
// navigation (variant C — a safe point, not on tab backgrounding).
useVersionReloadOnNavigation();
// Surface any breadcrumb left by an auto-reload in the previous page load
// (the reload cleared the console) so a field report stays diagnosable.
useEffect(() => {
surfacePreviousReloadBreadcrumb();
}, []);
useEffect(() => {
if (isLoading || isError) {
return;
@@ -63,16 +47,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
handleConnect();
});
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
// connects: the server emits `app-version` immediately in handleConnection,
// so a listener attached after connect would miss it on a fast localhost
// connect. On a version mismatch the client shows a banner and defers the
// auto-reload to the next in-app navigation (variant C — avoids reloading a
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
triggerGuardedReload(payload?.version);
});
return () => {
console.log("ws disconnected");
newSocket.disconnect();
@@ -1,64 +0,0 @@
import { describe, it, expect } from "vitest";
import { decideVersionAction } from "./version-coherence";
describe("decideVersionAction", () => {
it("noop when the server version is empty (fail-safe)", () => {
expect(
decideVersionAction({
serverVersion: "",
clientVersion: "v1",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("noop when the client version is empty (fail-safe)", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("noop when versions are equal (in sync)", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "v1",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("reload on a real mismatch the first time this session", () => {
expect(
decideVersionAction({
serverVersion: "test-B",
clientVersion: "test-A",
autoReloadUsed: false,
}),
).toBe("reload");
});
it("banner on a mismatch once the session auto-reload is spent", () => {
expect(
decideVersionAction({
serverVersion: "test-B",
clientVersion: "test-A",
autoReloadUsed: true,
}),
).toBe("banner");
});
it("equal versions stay noop even if auto-reload was already used", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "v1",
autoReloadUsed: true,
}),
).toBe("noop");
});
});
@@ -1,32 +0,0 @@
// Payload of the per-connect `app-version` socket.io event announced by the
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
// `operation`), so it never touches use-query-subscription.
export type AppVersionSocketPayload = { version: string };
/**
* Pure decision for the version-coherence guard.
*
* All inputs are injected (no globals, no side effects) so it is unit-testable
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
*
* - `autoReloadUsed` = an automatic reload has already happened within the
* current ~5-min window, so we must not auto-reload again (loop safety,
* shared window budget with the reactive chunk-load boundary).
*
* Returns:
* - "noop" — do nothing (unknown version on either side, or already in sync).
* - "banner" — show the manual "update available" banner only (no auto-reload).
* - "reload" — real first-time mismatch: eligible for a guarded auto-reload.
*/
export function decideVersionAction(args: {
serverVersion: string;
clientVersion: string;
autoReloadUsed: boolean;
}): "reload" | "banner" | "noop" {
const { serverVersion, clientVersion, autoReloadUsed } = args;
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
if (serverVersion === clientVersion) return "noop"; // in sync
if (autoReloadUsed) return "banner"; // one auto-reload per RELOAD_WINDOW_MS window already spent
return "reload"; // real mismatch, window budget available
}
-145
View File
@@ -1,145 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
hasAutoReloaded,
markAutoReloaded,
shouldAutoReload,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
RELOAD_WINDOW_MS,
} from "./reload-guard";
// The shared budget is a single sessionStorage timestamp keyed here; both the
// reactive chunk-load boundary and the proactive version-coherence path read and
// stamp it, so at most one auto-reload happens per RELOAD_WINDOW_MS across BOTH.
const RELOAD_AT_KEY = "chunk-reload-at";
const NOW = 1_000_000_000_000;
describe("reload-guard", () => {
beforeEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
it("hasAutoReloaded is false before any reload, true within the window after mark", () => {
expect(hasAutoReloaded(NOW)).toBe(false);
expect(markAutoReloaded(NOW)).toBe(true);
// Same key both paths share; stores the reload timestamp, not a flag.
expect(sessionStorage.getItem(RELOAD_AT_KEY)).toBe(String(NOW));
// Inside the window → budget spent → true (fall through to manual UI).
expect(hasAutoReloaded(NOW)).toBe(true);
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS)).toBe(true);
});
it("hasAutoReloaded is false again once the window has elapsed (a later deploy recovers)", () => {
markAutoReloaded(NOW);
// Strictly older than the window → a new deploy's mismatch may reload again.
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS + 1)).toBe(false);
});
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => {
throw new Error("storage disabled");
},
setItem: () => {
throw new Error("storage disabled");
},
});
try {
expect(hasAutoReloaded()).toBe(true);
} finally {
vi.unstubAllGlobals();
}
});
it("hasAutoReloaded treats an unparseable stored timestamp as never-reloaded", () => {
sessionStorage.setItem(RELOAD_AT_KEY, "not-a-number");
expect(hasAutoReloaded(NOW)).toBe(false);
});
it("markAutoReloaded returns false when writing storage throws", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("storage disabled");
},
});
try {
expect(markAutoReloaded()).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
it("records and then takes a breadcrumb once (cleared on read)", () => {
recordReloadBreadcrumb({
path: "proactive",
serverVersion: "test-B",
clientVersion: "test-A",
});
const crumb = takeReloadBreadcrumb();
expect(crumb).toMatchObject({
path: "proactive",
serverVersion: "test-B",
clientVersion: "test-A",
});
expect(typeof crumb?.at).toBe("number");
// Cleared on read → a second take returns null.
expect(takeReloadBreadcrumb()).toBeNull();
});
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
expect(takeReloadBreadcrumb()).toBeNull();
});
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("storage disabled");
},
removeItem: () => {},
});
try {
expect(() =>
recordReloadBreadcrumb({ path: "chunk-boundary" }),
).not.toThrow();
} finally {
vi.unstubAllGlobals();
}
});
});
// The pure window gate replaces the old one-shot flag: it must permit recovery
// across several deploys in one tab (each > window apart) while still stopping an
// infinite reload loop when a lazy chunk is permanently broken (a second failure
// < window). Moved here from the chunk-load boundary now that it is the shared
// guard both paths route through.
describe("shouldAutoReload", () => {
const WINDOW = RELOAD_WINDOW_MS;
it("allows a reload when we have never auto-reloaded", () => {
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
});
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
});
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
});
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
});
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
});
});
-121
View File
@@ -1,121 +0,0 @@
// Shared, window-based auto-reload budget.
//
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
// they share ONE window-scoped reload budget: at most a single automatic
// reload per RELOAD_WINDOW_MS across BOTH paths. A window (rather than a
// permanent one-shot flag) lets a SECOND deploy in the same tab's lifetime
// recover too, while a permanent skew, node oscillation, or a genuinely-missing
// chunk still degrades to a manual banner/UI after the first reload instead of
// looping. When sessionStorage is unavailable every mismatch degrades to the
// manual UI — no unguarded reload.
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload
// (shared by both paths).
const RELOAD_AT_KEY = "chunk-reload-at";
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
// by a single reload, so anything inside the window is treated as a reload loop
// (permanently-broken chunk / permanent skew) and falls through to the manual UI.
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
/**
* Pure window decision, unit-tested in isolation: auto-reload only if we have
* never auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older
* than the window. Anything inside the window is suppressed to break an infinite
* reload loop.
*/
export function shouldAutoReload(
now: number,
lastReloadAt: number | null,
windowMs: number,
): boolean {
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
return now - lastReloadAt > windowMs;
}
/**
* Has an automatic reload already happened within the current window (so the
* shared budget is spent right now)? Both paths check this before reloading; a
* `true` return means fall through to the manual banner/UI instead of reloading.
*
* A storage read error (private mode / disabled) is reported as `true` so the
* caller fails toward NOT reloading — an unguarded loop is worse than a stale
* tab the user can reload manually. Note a window (not a permanent flag): once
* the window elapses a later deploy's mismatch is allowed to reload again.
*/
export function hasAutoReloaded(now: number = Date.now()): boolean {
try {
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
return !shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS);
} catch {
return true;
}
}
/**
* Stamp the shared window as consumed now — record that an automatic reload is
* being performed within the current RELOAD_WINDOW_MS window.
*
* Returns whether the write succeeded. A `false` return (storage unavailable)
* means the caller MUST NOT reload — otherwise the stamp would never stick and
* the reload could loop.
*/
export function markAutoReloaded(now: number = Date.now()): boolean {
try {
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
return true;
} catch {
return false;
}
}
// Diagnostic breadcrumb for an automatic reload. Written right before
// window.location.reload() (which clears the console) and read back on the next
// page load, so a "the tab reloaded itself / it's looping" field report is
// diagnosable: which path fired (proactive version-coherence vs the reactive
// chunk-load boundary) and which version pair triggered it. sessionStorage
// survives a same-tab reload, unlike the console.
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
export type ReloadBreadcrumb = {
path: "proactive" | "chunk-boundary";
serverVersion?: string;
clientVersion?: string;
at: number;
};
/**
* Persist a best-effort breadcrumb just before an automatic reload. Failures
* (storage unavailable) are swallowed — this is diagnostics only and must never
* block or alter the reload decision.
*/
export function recordReloadBreadcrumb(
entry: Omit<ReloadBreadcrumb, "at">,
): void {
try {
sessionStorage.setItem(
RELOAD_BREADCRUMB_KEY,
JSON.stringify({ ...entry, at: Date.now() }),
);
} catch {
// best-effort diagnostics only
}
}
/**
* Read and clear the breadcrumb left by an auto-reload in the previous page
* load. Cleared on read so it surfaces exactly once per reload.
*/
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
try {
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
if (!raw) return null;
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
return JSON.parse(raw) as ReloadBreadcrumb;
} catch {
return null;
}
}
+2 -29
View File
@@ -1,8 +1,7 @@
import { defineConfig, loadEnv, type Plugin } from "vite";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2";
import * as path from "path";
import * as fs from "node:fs";
import { execSync } from "node:child_process";
const envPath = path.resolve(process.cwd(), "..", "..");
@@ -25,32 +24,7 @@ function resolveAppVersion(cwd: string): string {
}
}
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
// the exact same build id the bundle was compiled with. The value is the SAME
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
// global are identical by construction — the single source of truth (no
// runtime-env second copy that could drift and cause a false version mismatch).
function versionJsonPlugin(version: string): Plugin {
let outDir = "dist";
return {
name: "emit-version-json",
apply: "build",
configResolved(config) {
outDir = config.build.outDir;
},
writeBundle() {
const root = path.resolve(process.cwd(), outDir);
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(
path.join(root, "version.json"),
JSON.stringify({ version }),
);
},
};
}
export default defineConfig(({ mode }) => {
const appVersion = resolveAppVersion(envPath);
const {
APP_URL,
FILE_UPLOAD_SIZE_LIMIT,
@@ -78,11 +52,10 @@ export default defineConfig(({ mode }) => {
POSTHOG_HOST,
POSTHOG_KEY,
},
APP_VERSION: JSON.stringify(appVersion),
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
},
plugins: [
react(),
versionJsonPlugin(appVersion),
// Emit .br and .gz next to every built asset so the server can serve the
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
compression({
@@ -1,52 +0,0 @@
import { join } from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { readClientBuildVersion } from './client-version';
describe('readClientBuildVersion', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
const writeVersionJson = (content: string) =>
fs.writeFileSync(join(dir, 'version.json'), content);
it('returns the version from a valid version.json', () => {
writeVersionJson(JSON.stringify({ version: 'test-A' }));
expect(readClientBuildVersion(dir)).toBe('test-A');
});
it('trims surrounding whitespace in the version', () => {
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
});
it('returns "" when version.json is missing', () => {
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" on malformed JSON', () => {
writeVersionJson('{ not json');
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the version field is absent', () => {
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the version field is not a string', () => {
writeVersionJson(JSON.stringify({ version: 123 }));
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the path does not exist at all', () => {
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
});
});
@@ -1,36 +0,0 @@
import { join } from 'path';
import * as fs from 'node:fs';
/**
* Resolve the absolute path to the built client bundle directory
* (`apps/client/dist`) shipped into the runtime image.
*
* The `../` depth is anchored on THIS module's compiled location
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
* MUST share this single helper rather than duplicating the depth a copy in a
* module at a different depth would silently resolve to the wrong directory.
*/
export function resolveClientDistPath(): string {
return join(__dirname, '..', '..', '..', '..', 'client/dist');
}
/**
* Read the build version the client bundle was compiled with, from
* `<clientDistPath>/version.json` (written by the Vite build the single
* source of truth shared by the baked-in `APP_VERSION` global and this file).
*
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
* version) yields `''`. The caller treats an empty version as "unknown" and
* the whole version-coherence feature stays silently inert existing deploys
* without the file keep working unchanged.
*/
export function readClientBuildVersion(clientDistPath: string): string {
try {
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
const version = (JSON.parse(raw) as { version?: unknown }).version;
return typeof version === 'string' ? version.trim() : '';
} catch {
return '';
}
}
-1
View File
@@ -3,4 +3,3 @@ export * from './nanoid.utils';
export * from './file.helper';
export * from './constants';
export * from './security-headers';
export * from './client-version';
@@ -189,11 +189,10 @@ export function stepBudgetWarning(stepNumber: number): string {
//
// `system` is the in-scope system prompt; we CONCATENATE so the original
// persona/context is preserved — a bare `system` override would REPLACE the
// whole system prompt for the step. `activatedTools` is a closure Set grown by
// loadTools and owned by the streaming loop; the caller seeds it from and
// persists it to the chat's metadata across turns (#490), but this function only
// READS the Set it is handed, so it stays a pure function of its arguments (not
// module-global).
// whole system prompt for the step. `activatedTools` is PER-TURN mutable state
// owned by the streaming loop (a closure Set grown by loadTools); it is passed
// in (not module-global, not persisted) so this stays a pure function of its
// arguments.
//
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
@@ -1411,11 +1410,10 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
const baseTools = { ...external.tools, ...docmostTools };
// Deferred tool loading state (#332), scoped to THIS streaming loop:
// - `activatedTools` is a fresh closure Set per streamText call (not
// module-global), SEEDED from the chat's persisted metadata.activatedTools
// (#490, just below) so activation carries across turns. loadTools.execute
// adds to it; prepareAgentStep reads it to widen `activeTools` on the NEXT
// step; turn end persists it back.
// - `activatedTools` is per-TURN mutable state — a fresh closure Set created
// per streamText call, NOT module-global and NOT persisted, so a new turn
// starts cold. loadTools.execute adds to it; prepareAgentStep reads it to
// widen `activeTools` on the NEXT step.
// - `validDeferredNames` = every tool that is NOT core (the in-app deferred
// tools + ALL external MCP tools), computed from the ACTUAL toolset so an
// external tool is loadable by its namespaced name. loadTools rejects any
@@ -0,0 +1,70 @@
import { INTERNAL_LINK_REGEX } from './utils';
import { isInternalPagePath } from '@docmost/prosemirror-markdown';
/**
* Cross-package DRIFT GUARD for the internal-link subset invariant (#522).
*
* The client-side `isInternalPagePath`
* (`packages/prosemirror-markdown/src/lib/internal-links.ts`) promotes a markdown
* link to `internal: true` on import. Every link it marks internal MUST be one
* the server would backlink and export-rewrite i.e. the client matcher MUST be
* a STRICT SUBSET of the server's canonical `INTERNAL_LINK_REGEX`
* (`./utils.ts`). If the client ever accepts a path the server rejects, that link
* is stored internal but silently dropped from the backlink graph and broken on
* export the exact bug #522 fixed.
*
* This spec is the load-bearing guard: it imports the LIVE server regex AND the
* LIVE `isInternalPagePath` (no hand-copied regex on either side). A narrowing of
* EITHER most dangerously the server regex reddens here. The in-package
* accept/reject test documents the client's behaviour but cannot see the server
* regex; this top-layer spec is what makes the subset relation mechanical
* (AGENTS.md rule #7: a CI test that fails on drift of the source of truth).
*/
describe('internal-link subset parity (client isInternalPagePath ⊆ server INTERNAL_LINK_REGEX)', () => {
// Every path the CLIENT accepts. Kept deliberately broad across the risky
// dimensions — the slug charset (digits, hyphens, mixed case, leading/trailing
// hyphen), the space charset, and the optional trailing slash — so a narrowing
// of the server regex on any of them reddens the subset assertion below.
const CLIENT_ACCEPTS = [
'/s/eng/p/abc123',
'/s/eng/p/abc123/', // trailing slash
'/s/My-Space/p/A-b-C-9', // hyphens + mixed case in both segments
'/s/x/p/z', // shortest
'/s/eng/p/my-page-abc123', // slug with hyphens (extractPageSlugId shape)
'/s/eng/p/-lead', // leading hyphen in slug
'/s/eng/p/trail-', // trailing hyphen in slug
'/s/eng/p/0123456789', // all-digit slug
'/s/a.b/p/abc', // dot in the SPACE segment (space charset is [^/]+)
'/s/space with space/p/abc', // space char in the SPACE segment
];
it('every corpus path is actually client-accepted (guards the corpus itself)', () => {
// If a path here stopped being client-accepted the subset test would pass
// vacuously; assert acceptance up front so the corpus stays meaningful. The
// filter-to-empty form names the offending paths on failure.
const notAccepted = CLIENT_ACCEPTS.filter((h) => !isInternalPagePath(h));
expect(notAccepted).toEqual([]);
});
it('every client-accepted path also matches the LIVE server regex (subset)', () => {
// The mechanical drift guard: narrow the server INTERNAL_LINK_REGEX and at
// least one hyphen/charset/structure case appears here.
const notInServer = CLIENT_ACCEPTS.filter((h) => !INTERNAL_LINK_REGEX.test(h));
expect(notInServer).toEqual([]);
});
it('the client rejects forbidden-slug-char paths the server also rejects', () => {
// Documents the subset BOUNDARY: correct shape, forbidden slug char. The
// client and the LIVE server regex must agree on rejection.
const forbidden = [
'/s/eng/p/abc.def',
'/s/eng/p/abc_def',
'/s/eng/p/abc%20',
'/s/eng/p/abc~x',
];
const clientAccepts = forbidden.filter((h) => isInternalPagePath(h));
const serverAccepts = forbidden.filter((h) => INTERNAL_LINK_REGEX.test(h));
expect(clientAccepts).toEqual([]);
expect(serverAccepts).toEqual([]);
});
});
@@ -1,131 +0,0 @@
// p-limit and @sindresorhus/slugify are ESM-only and not in jest's transform
// allowlist; both are irrelevant to createDrawioSvg (a pure fs + string method),
// so they are mocked out to keep the module graph loadable under ts-jest.
jest.mock('p-limit', () => ({
__esModule: true,
default: () => (fn: () => unknown) => fn(),
}));
jest.mock('@sindresorhus/slugify', () => ({
__esModule: true,
default: (input: string) => String(input),
}));
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ImportAttachmentService } from './import-attachment.service';
/**
* Unit test for ImportAttachmentService.createDrawioSvg (issue #507).
*
* The Confluence import wraps a `.drawio` file into a `.drawio.svg` attachment.
* The `content=` payload MUST be the mxfile XML entity-escaped (draw.io's native
* form), NOT base64 draw.io's editor decodes a base64 content= via Latin-1
* atob, mangling every non-ASCII char into mojibake. createDrawioSvg touches no
* injected dependency, so the service is built with placeholder deps.
*/
describe('ImportAttachmentService.createDrawioSvg (#507)', () => {
const service = new ImportAttachmentService(
{} as any,
{} as any,
{} as any,
);
const call = (p: string): Promise<Buffer> =>
(service as any).createDrawioSvg(p);
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'drawio-507-'));
});
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
const writeDrawio = async (name: string, xml: string): Promise<string> => {
const p = path.join(tmpDir, name);
await fs.writeFile(p, xml, 'utf-8');
return p;
};
it('writes content= as entity-encoded XML, not base64', async () => {
const drawio =
'<mxfile host="Confluence"><diagram name="Схема — ёж">' +
'<mxGraphModel><root><mxCell id="0"/>' +
'<mxCell id="2" value="Старт-бит" vertex="1" parent="0"/>' +
'</root></mxGraphModel></diagram></mxfile>';
const p = await writeDrawio('cyrillic.drawio', drawio);
const svg = (await call(p)).toString('utf-8');
const content = /content="([^"]*)"/.exec(svg)?.[1];
expect(content).toBeDefined();
// Entity-encoded XML form, starting with &lt;mxfile — never a base64 blob.
expect(content).toMatch(/^&lt;mxfile/);
expect(content).toContain('&lt;');
// Non-ASCII survives as raw UTF-8, with no Latin-1 mojibake.
expect(content).toContain('Старт-бит');
expect(content).toContain('Схема — ёж');
expect(content).not.toContain('Ð');
// Decoding the attribute (un-escaping) yields the original drawio file.
const decoded = content!
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(decoded).toBe(drawio);
});
it('encodes literal tab/newline/CR as numeric char-refs, not literal control chars (#507 F1)', async () => {
// A literal tab/newline/CR inside the mxfile XML would be collapsed to a
// single space by XML attribute-value normalization when the draw.io editor
// reads content=, silently flattening multi-line labels and tab-bearing
// values. They must be emitted as numeric char-refs instead.
const drawio =
'<mxfile><diagram name="p">' +
'<mxGraphModel><root><mxCell id="0"/>' +
'<mxCell id="2" value="col1\tcol2" style="html=1;\nshadow=0" vertex="1" parent="0"/>' +
'<mxCell id="3" value="Line1\nLine2\rLine3" vertex="1" parent="0"/>' +
'</root></mxGraphModel></diagram></mxfile>';
const p = await writeDrawio('ctrl.drawio', drawio);
const svg = (await call(p)).toString('utf-8');
const content = /content="([^"]*)"/.exec(svg)?.[1];
expect(content).toBeDefined();
// No literal control chars survive in the attribute value.
expect(content).not.toMatch(/[\t\n\r]/);
// They round-trip as numeric char-refs.
expect(content).toContain('&#x9;');
expect(content).toContain('&#xa;');
expect(content).toContain('&#xd;');
// Decoding (char-refs back to literal, entities back) recovers the file.
const decoded = content!
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x9;/gi, '\t')
.replace(/&#xa;/gi, '\n')
.replace(/&#xd;/gi, '\r')
.replace(/&amp;/g, '&');
expect(decoded).toBe(drawio);
});
it('escapes XML metacharacters in the drawio payload', async () => {
const drawio = '<mxfile><diagram name="a &amp; b">"q" &lt;x&gt;</diagram></mxfile>';
const p = await writeDrawio('meta.drawio', drawio);
const svg = (await call(p)).toString('utf-8');
const content = /content="([^"]*)"/.exec(svg)?.[1];
expect(content).toBeDefined();
// The attribute value must contain no bare `<`, `>` or `"` that would break
// out of the content="..." attribute or the SVG element.
expect(content).not.toMatch(/[<>"]/);
const decoded = content!
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(decoded).toBe(drawio);
});
});
@@ -8,7 +8,6 @@ import { createReadStream } from 'node:fs';
import { promises as fs } from 'fs';
import { Readable } from 'stream';
import { getMimeType, sanitizeFileName } from '../../../common/helpers';
import { htmlEscape } from '../../../common/helpers/html-escaper';
import { v7 } from 'uuid';
import { FileTask } from '@docmost/db/types/entity.types';
import { getAttachmentFolderPath } from '../../../core/attachment/attachment.utils';
@@ -850,12 +849,7 @@ export class ImportAttachmentService {
): Promise<Buffer> {
try {
const drawioContent = await fs.readFile(drawioPath, 'utf-8');
// Write the mxfile XML XML-entity-escaped (draw.io's native content= form),
// NOT base64. draw.io's editor decodes a base64 content= via Latin-1 atob
// (no UTF-8 step), turning every non-ASCII char (Cyrillic, ё, —) into
// mojibake; the entity-encoded form is decoded by the DOM as UTF-8 and
// opens intact. Docmost's own decoder reads both forms.
const drawioEscaped = this.xmlEscapeContent(drawioContent);
const drawioBase64 = Buffer.from(drawioContent).toString('base64');
let imageElement = '';
// If we have a PNG, include it in the SVG
@@ -881,7 +875,7 @@ export class ImportAttachmentService {
width="600"
height="400"
viewBox="0 0 600 400"
content="${drawioEscaped}">${imageElement}</svg>`;
content="${drawioBase64}">${imageElement}</svg>`;
return Buffer.from(svgContent, 'utf-8');
} catch (error) {
@@ -890,24 +884,6 @@ export class ImportAttachmentService {
}
}
/**
* Escape a string so it is safe as the value of a double-quoted XML attribute
* (the `content=` payload of a `.drawio.svg`). The shared `htmlEscape` covers
* `& < > " '` (a strict superset of what this attribute needs; the extra `'`
* escape is harmless in a `"`-delimited value). On top of that, the numeric
* char-refs for tab/newline/CR are required: a literal tab/newline/CR inside
* an attribute value is collapsed to a single space by XML attribute-value
* normalization on DOM read (both our decoder and the real draw.io editor),
* silently flattening multi-line labels and tab-bearing values. Char-refs
* survive that normalization (#507).
*/
private xmlEscapeContent(s: string): string {
return htmlEscape(s)
.replace(/\t/g, '&#x9;')
.replace(/\n/g, '&#xa;')
.replace(/\r/g, '&#xd;');
}
private async uploadWithRetry(opts: {
abs: string;
storageFilePath: string;
@@ -4,7 +4,6 @@ import { join } from 'path';
import * as fs from 'node:fs';
import fastifyStatic from '@fastify/static';
import { EnvironmentService } from '../environment/environment.service';
import { resolveClientDistPath } from '../../common/helpers/client-version';
/**
* Resolve the response headers for a statically served client asset.
@@ -57,7 +56,14 @@ export class StaticModule implements OnModuleInit {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const app = httpAdapter.getInstance();
const clientDistPath = resolveClientDistPath();
const clientDistPath = join(
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
const indexFilePath = join(clientDistPath, 'index.html');
+2 -37
View File
@@ -9,14 +9,10 @@ import {
import { Server, Socket } from 'socket.io';
import { TokenService } from '../core/auth/services/token.service';
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { OnModuleDestroy } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { WsService } from './ws.service';
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
import {
readClientBuildVersion,
resolveClientDistPath,
} from '../common/helpers/client-version';
import * as cookie from 'cookie';
@WebSocketGateway({
@@ -24,40 +20,17 @@ import * as cookie from 'cookie';
transports: ['websocket'],
})
export class WsGateway
implements
OnGatewayConnection,
OnGatewayInit,
OnModuleInit,
OnModuleDestroy
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
{
@WebSocketServer()
server: Server;
private readonly logger = new Logger(WsGateway.name);
// The build version of the client bundle shipped in this image, read once at
// startup from client/dist/version.json (single source of truth, same value
// baked into the client's APP_VERSION). Empty string => version.json missing
// or empty => the proactive version-coherence reload feature stays inert.
private appVersion = '';
constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
private wsService: WsService,
) {}
onModuleInit(): void {
this.appVersion = readClientBuildVersion(resolveClientDistPath());
if (this.appVersion) {
this.logger.log(`app-version reload: ACTIVE (v=${this.appVersion})`);
} else {
this.logger.log(
'app-version reload: DISABLED (version.json missing/empty)',
);
}
}
afterInit(server: Server): void {
this.wsService.setServer(server);
}
@@ -82,14 +55,6 @@ export class WsGateway
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
client.join([userRoom, workspaceRoom, ...spaceRooms]);
// Announce this container's client build version to the freshly
// authenticated socket. On a redeploy the client reconnects to the new
// container and receives the new version here, letting it guard-reload
// before it hits a stale lazy chunk. Per-connect only (no broadcast):
// natural reconnect covers both single-container and cluster without a
// thundering-herd fleet reload.
client.emit('app-version', { version: this.appVersion });
} catch (err) {
client.emit('Unauthorized');
client.disconnect();
@@ -322,21 +322,20 @@ describe('AiChatService.stream [integration]', () => {
});
/**
* #332 + #490 deferred tool loading, the ON path. Turn 1 starts COLD (CORE +
* loadTools only) and activates a deferred tool via loadTools; that activation
* is PERSISTED into the chat's metadata.activatedTools (#490) so the NEXT turn
* SEEDS from it and the tool is active from the fresh turn's FIRST step the
* model never re-runs loadTools to re-activate the same tool. The unit tests
* only exercise pure prepareAgentStep with hand-fed Sets; this pins the real
* wiring end-to-end (loadTools.execute -> activatedTools -> persist -> next-turn
* seed -> prepareStep -> per-step activeTools) against the real streamText loop.
* We drive a MockLanguageModelV3 whose step 1 calls loadTools(['createPage'])
* and assert, via the model's recorded per-step CallOptions.tools (the AI SDK
* filters the provider tool list by activeTools), that the deferred tool becomes
* active on the SAME turn's next step AND, seeded from metadata, on the next
* turn's first step.
* #332 deferred tool loading, the ON path. The riskiest property is that the
* per-turn `activatedTools` Set is created FRESH inside each stream() call, so a
* tool a previous turn activated via loadTools is NOT still active when the next
* turn starts the new turn begins "cold" (CORE + loadTools only). The unit
* tests only exercise pure prepareAgentStep with hand-fed Sets; this pins the
* real wiring end-to-end (loadTools.execute -> activatedTools -> prepareStep ->
* per-step activeTools) against the real streamText loop, and proves there is no
* cross-turn leak. We drive a MockLanguageModelV3 whose step 1 calls
* loadTools(['createPage']) and assert, via the model's recorded per-step
* CallOptions.tools (the AI SDK filters the provider tool list by activeTools),
* that the deferred tool becomes active on the SAME turn's next step but NOT on a
* fresh turn's first step.
*/
describe('deferred tool loading ON — cross-turn activation persistence (#332 + #490)', () => {
describe('deferred tool loading ON — per-turn activation, no leak (#332)', () => {
// A stub deferred (non-core) tool the agent can activate. Its execute is never
// called — the model only needs to SEE it become active — but it must be a
// valid AI-SDK tool so the SDK includes it in a step's tool list once active.
@@ -452,7 +451,7 @@ describe('AiChatService.stream [integration]', () => {
} as any);
}
it('activates a deferred tool for the SAME turn, and a NEW turn SEEDS it from persisted chat metadata (#490)', async () => {
it('activates a deferred tool for the SAME turn, and a NEW turn starts cold (no leak)', async () => {
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
// --- Turn 1: loadTools(createPage) on step 1, then answer on step 2. ---
@@ -475,7 +474,7 @@ describe('AiChatService.stream [integration]', () => {
// Step 2 of the SAME turn sees the just-activated deferred tool.
expect(step2Tools).toContain('createPage');
// --- Turn 2 on the SAME chat: seeds the persisted activation (#490). ---
// --- Turn 2 on the SAME chat: must start cold again. ---
const model2 = new MockLanguageModelV3({
doStream: async () => ({ stream: successStream() }),
} as any);
@@ -486,10 +485,9 @@ describe('AiChatService.stream [integration]', () => {
const nextTurnFirstStep = toolNames(model2.doStreamCalls[0]);
expect(nextTurnFirstStep).toContain('loadTools');
// #490: activation PERSISTS across turns — turn 1 wrote createPage into the
// chat's metadata.activatedTools, so the next turn seeds from it and the
// deferred tool is active from the FIRST step (no need to re-run loadTools).
expect(nextTurnFirstStep).toContain('createPage');
// The activated set is per-turn: the prior turn's createPage did NOT leak,
// so the fresh turn's first step sees it deferred again.
expect(nextTurnFirstStep).not.toContain('createPage');
});
});
});
+9 -36
View File
@@ -178,11 +178,10 @@ function sliceModel(xml: string): string | null {
// --- decode chain ----------------------------------------------------------
/**
* Read the `content=` attribute out of a `.drawio.svg` string. Docmost writes
* the mxfile XML entity-encoded there (buildDrawioSvg / createDrawioSvg), which
* is also how draw.io's own SVG export stores it; older attachments stored a
* base64 payload instead. The DOM decodes entities for us, so the caller only
* has to distinguish "starts with '<'" (raw XML) from base64.
* Read the `content=` attribute out of a `.drawio.svg` string. Docmost stores a
* base64 payload there (createDrawioSvg); draw.io's own SVG export may store the
* XML entity-encoded instead. The DOM decodes entities for us, so the caller
* only has to distinguish "starts with '<'" (raw XML) from base64.
*/
export function extractContentAttr(svg: string): string {
const { doc, error } = parseXml(svg);
@@ -196,23 +195,12 @@ export function extractContentAttr(svg: string): string {
// value itself never contains a double-quote (base64 / entity-encoded XML).
const m = /content="([^"]*)"/.exec(svg);
if (m) {
// Decode the handful of XML entities a raw regex would leave encoded. The
// numeric char-refs for tab/newline/CR MUST be decoded here too: the DOM
// path above turns them back into the literal control chars, so this
// regex fallback has to agree or the two decode paths diverge (#507).
// `&amp;` is decoded last so an escaped `&amp;#x9;` reads back as the
// literal text `&#x9;`, not a tab.
// Decode the handful of XML entities a raw regex would leave encoded.
return m[1]
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x9;/gi, "\t")
.replace(/&#9;/g, "\t")
.replace(/&#xa;/gi, "\n")
.replace(/&#10;/g, "\n")
.replace(/&#xd;/gi, "\r")
.replace(/&#13;/g, "\r")
.replace(/&amp;/g, "&");
}
throw new Error("drawio: SVG has no content= attribute to decode");
@@ -319,16 +307,9 @@ export function encodeDrawioFile(modelXml: string, title = "Page-1"): string {
/**
* Build the `diagram.drawio.svg` attachment. Mirrors the import service's
* createDrawioSvg contract exactly:
* <svg xmlns= xmlns:xlink= content="${xmlEscape(drawioFile)}">${inner}</svg>
* <svg xmlns= xmlns:xlink= content="${base64(drawioFile)}">${inner}</svg>
* plus width/height/viewBox from the diagram bounding box and the schematic
* preview as the visible children (`inner`).
*
* The `content=` value is the mxfile XML XML-entity-escaped (draw.io's own
* native form), NOT base64. draw.io's editor decodes a base64 content= via
* Latin-1 atob (no UTF-8 step), turning every non-ASCII char (e.g. Cyrillic,
* ё, ) into mojibake; the entity-encoded form is decoded by the DOM as UTF-8
* and opens intact. Our decoder (decodeDrawioSvg) reads both forms, so old
* base64 attachments still round-trip.
*/
export function buildDrawioSvg(
modelXml: string,
@@ -337,14 +318,14 @@ export function buildDrawioSvg(
title = "Page-1",
): string {
const file = encodeDrawioFile(modelXml, title);
const content = xmlEscape(file);
const base64 = Buffer.from(file, "utf-8").toString("base64");
const w = Math.max(1, Math.round(bbox.width));
const h = Math.max(1, Math.round(bbox.height));
return (
`<svg xmlns="http://www.w3.org/2000/svg" ` +
`xmlns:xlink="http://www.w3.org/1999/xlink" ` +
`width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ` +
`content="${content}">${inner}</svg>`
`content="${base64}">${inner}</svg>`
);
}
@@ -353,15 +334,7 @@ function xmlEscape(s: string): string {
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
// A literal tab/newline/CR inside an attribute value is collapsed to a
// single space by XML attribute-value normalization on DOM read (both jsdom
// here and the real draw.io editor), silently flattening multi-line labels
// and tab-bearing values. Numeric char-refs survive that normalization, so
// emit them the way draw.io's own native export does (#507).
.replace(/\t/g, "&#x9;")
.replace(/\n/g, "&#xa;")
.replace(/\r/g, "&#xd;");
.replace(/"/g, "&quot;");
}
// --- normalization + hash --------------------------------------------------
+2 -123
View File
@@ -51,14 +51,6 @@ const hasRule = (issues, rule, cellId) =>
(i) => i.rule === rule && (cellId === undefined || i.cellId === cellId),
);
// Reverse the attribute-value XML escaping used in the `content=` payload.
const unescapeAttr = (s) =>
s
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&");
// --- style parsing ---------------------------------------------------------
test("parseStyle: base stylename + key=value pairs", () => {
@@ -343,20 +335,16 @@ test("encode/build: a title with < > \" & round-trips without corrupting the SVG
// The outer content="..." attribute must not be broken by the title: the raw
// title metacharacters never appear literally in the SVG markup (they are
// entity-escaped inside content=, doubly so where the file XML already escaped
// them inside name="...").
// base64-encoded inside content=, and escaped inside the file XML).
const contentMatch = /content="([^"]*)"/.exec(svg);
assert.ok(contentMatch, "SVG has a single well-formed content= attribute");
// The diagram model still decodes losslessly despite the exotic title.
assert.equal(decodeDrawioSvg(svg), model);
// content= is now entity-encoded XML (draw.io's native form), never base64.
assert.match(contentMatch[1], /^&lt;mxfile/);
// The file XML is well-formed: the title lives in name="..." as escaped
// entities, so unescaping recovers the original title byte-for-byte.
const fileXml = unescapeAttr(contentMatch[1]);
const fileXml = Buffer.from(contentMatch[1], "base64").toString("utf-8");
const nameMatch = /<diagram id="[^"]*" name="([^"]*)">/.exec(fileXml);
assert.ok(nameMatch, "the diagram name attribute is intact and quote-safe");
const decodedTitle = nameMatch[1]
@@ -370,112 +358,3 @@ test("encode/build: a title with < > \" & round-trips without corrupting the SVG
const file = encodeDrawioFile(model, title);
assert.match(file, /name="A &lt; B &gt; C &quot; D &amp; E">/);
});
// --- #507: content= is entity-encoded XML, never base64 --------------------
// A model whose cell values carry Cyrillic, ё and an em dash — exactly the
// characters draw.io's Latin-1 atob mangles when content= is base64.
const CYRILLIC_MODEL =
"<mxGraphModel><root>" +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="Старт-бит — ёж" style="rounded=1;html=1;" vertex="1" parent="1">' +
'<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
test("#507: buildDrawioSvg writes content= as entity-encoded XML (not base64)", () => {
const model = normalizeXml(CYRILLIC_MODEL);
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, "Диаграмма");
const contentMatch = /content="([^"]*)"/.exec(svg);
assert.ok(contentMatch, "SVG has a single well-formed content= attribute");
const content = contentMatch[1];
// The content= value is the entity-encoded mxfile XML — starts with `&lt;mxfile`.
assert.match(content, /^&lt;mxfile/, "content= is entity-encoded mxfile XML");
// It must NOT be a base64 blob: base64 has no XML entities and no literal `<`.
assert.ok(content.includes("&lt;"), "content= carries XML entities, not base64");
// Cyrillic / ё / — survive verbatim in the attribute (raw UTF-8, not atob-mangled).
assert.ok(content.includes("Старт-бит — ёж"), "non-ASCII value is raw UTF-8 in content=");
assert.ok(content.includes("Диаграмма"), "non-ASCII title is raw UTF-8 in content=");
// The mojibake that base64+atob would have produced must be absent.
assert.ok(!content.includes("Ð"), "no Latin-1 mojibake in content=");
});
test("#507: Cyrillic model round-trips byte-stable through buildDrawioSvg -> decodeDrawioSvg", () => {
const model = normalizeXml(CYRILLIC_MODEL);
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, "Заголовок — ё");
assert.equal(decodeDrawioSvg(svg), model);
});
test("#507 back-compat: an OLD base64-form .drawio.svg still decodes losslessly", () => {
const model = normalizeXml(CYRILLIC_MODEL);
// Reproduce the pre-fix write path: encodeDrawioFile -> base64 in content=.
const file = encodeDrawioFile(model, "Старая диаграмма");
const base64 = Buffer.from(file, "utf-8").toString("base64");
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" content="${base64}"><g/></svg>`;
// No XML entities, purely base64 alphabet — this is the legacy form.
assert.ok(!base64.includes("<") && !base64.includes("&"));
assert.equal(decodeDrawioSvg(svg), model);
});
test("#507 negative: non-ASCII in a cell value AND in the title round-trip clean", () => {
const model = normalizeXml(CYRILLIC_MODEL);
const title = "Тест — ёмкость № 5";
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, title);
// Model recovered byte-for-byte.
assert.equal(decodeDrawioSvg(svg), model);
// Title lands in the <diagram name="..."> of the decoded file XML, intact.
const content = /content="([^"]*)"/.exec(svg)[1];
const fileXml = unescapeAttr(content);
const nameMatch = /<diagram id="[^"]*" name="([^"]*)">/.exec(fileXml);
assert.ok(nameMatch, "diagram name attribute present");
assert.equal(unescapeAttr(nameMatch[1]), title);
});
// --- #507 F1: literal tab/newline/CR in an attribute value survive the
// content= round-trip. The whole mxfile XML lives in one content="..." attr;
// XML attribute-value normalization collapses a LITERAL tab/newline/CR to a
// single space on DOM read, so the escape must emit numeric char-refs instead.
const CTRL_MODEL =
"<mxGraphModel><root>" +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="col1\tcol2" style="html=1;\nshadow=0" vertex="1" parent="1">' +
'<mxGeometry x="10" y="10" width="120" height="60" as="geometry"/></mxCell>' +
'<mxCell id="3" value="Line1\nLine2\rLine3" vertex="1" parent="1">' +
'<mxGeometry x="10" y="100" width="120" height="60" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
test("#507 F1: literal tab/newline/CR in a value round-trip byte-stable (DOM decode)", () => {
const svg = buildDrawioSvg(CTRL_MODEL, "<g/>", { width: 200, height: 200 });
const content = /content="([^"]*)"/.exec(svg)[1];
// The tab/newline/CR must be emitted as numeric char-refs, never as literal
// control chars (which DOM attribute-value normalization would eat).
assert.ok(
!/[\t\n\r]/.test(content),
"no literal tab/newline/CR survive in the content= attribute",
);
assert.ok(
content.includes("&#x9;") &&
content.includes("&#xa;") &&
content.includes("&#xd;"),
"tab/newline/CR are emitted as numeric char-refs",
);
// Full DOM decode recovers the model byte-for-byte, control chars intact.
assert.equal(decodeDrawioSvg(svg), CTRL_MODEL);
});
test("#507 F1: regex fallback decodes tab/newline/CR char-refs (agrees with DOM path)", () => {
const svg = buildDrawioSvg(CTRL_MODEL, "<g/>", { width: 200, height: 200 });
const content = /content="([^"]*)"/.exec(svg)[1];
// A bare `&` makes the SVG wrapper malformed, forcing extractContentAttr onto
// its regex fallback branch. That branch must decode the tab/newline/CR
// char-refs exactly like the DOM path, or the two decoders diverge.
const malformedSvg = `<svg content="${content}">&</svg>`;
assert.equal(decodeDrawioSvg(malformedSvg), CTRL_MODEL);
// The well-formed (DOM) path yields the identical result.
assert.equal(decodeDrawioSvg(svg), CTRL_MODEL);
});
@@ -25,13 +25,22 @@
* drop any attr whose value equals its known schema default. A non-default
* value (e.g. `orderedList.start: 5`) is NOT a default, so it is KEPT.
*
* Every entry below was read from `packages/docmost-client/src/lib/
* Every entry below with the single documented exception of the `link.internal`
* marker (see its own bullet) was read from `packages/docmost-client/src/lib/
* docmost-schema.ts` (the line refs are the exact `default:` declarations) and
* confirmed to be materialized by an exportimportexport round-trip:
* - mark `link` target / rel DocmostAttributes + StarterKit link.
* StarterKit's link extension defaults `target: "_blank"` and
* `rel: "noopener noreferrer nofollow"`; both materialize on import
* (empirically confirmed) even when the source had only `href`.
* - mark `link` internal (#522) the ONE editor-sourced, NOT-import-
* materialized default here. Its source is `editor-ext/src/lib/link.ts`
* (default `internal: false`), not docmost-schema, and import does NOT
* materialize it (an imported external link leaves `internal` absent/null,
* never `false`). It is listed so that the editor's stored `internal:false`
* normalizes to the same "external" canon as absent/null (`false ≡ absent`),
* keeping a stored external link canonically equal to its re-import. The
* load-bearing `internal:true` is NON-default and therefore KEPT.
* - mark `comment` resolved docmost-schema.ts L213-214 (`default: false`).
* - node `orderedList` start provided by StarterKit's orderedList
* (`default: 1`); materializes on import (empirically confirmed).
@@ -56,6 +65,14 @@ const KNOWN_DEFAULTS: Record<string, Record<string, unknown>> = {
link: {
target: "_blank",
rel: "noopener noreferrer nofollow",
// Editor-authored EXTERNAL links store `internal: false` (editor-ext link
// default `packages/editor-ext/src/lib/link.ts`), while an imported external
// link leaves `internal` absent/null. Both mean "external", so `internal:
// false` must normalize away exactly like `null`/absent — otherwise a stored
// `internal:false` link diverges from its re-import under
// `docsCanonicallyEqual` (false !== null). The internal marker `internal:true`
// is NON-default, so it is KEPT and survives canonicalization (#522 §11).
internal: false,
},
comment: {
resolved: false,
@@ -38,6 +38,10 @@ export {
normalizeForeignMarkdown,
normalizeAgentMarkdown,
} from "./foreign-markdown.js";
// Pure primitive: detect the unambiguously-internal wiki-page link path
// (`/s/<space>/p/<slug>`) and promote such link marks to their native internal
// form during import (#522). A strict subset of the server's INTERNAL_LINK_REGEX.
export { isInternalPagePath, markInternalLinks } from "./internal-links.js";
// The Docmost tiptap schema mirror. Exposed so consumers (and the sync
// engine's schema-validity regression tests) can build the exact ProseMirror
@@ -0,0 +1,112 @@
/**
* Detect and mark unambiguously-internal wiki-page links during markdown import.
*
* WHY THIS EXISTS
* ---------------
* The markdown converter (`markdownToProseMirrorSync`) materializes every link
* with StarterKit's external defaults `internal: null`, `target: "_blank"`,
* `rel: "noopener noreferrer nofollow"`. A markdown link that points at an
* internal wiki page written in its host-less, root-relative form
* (`[text](/s/<space>/p/<slugId>)`) is therefore stored as EXTERNAL: it opens in
* a new tab, gets no hover-preview, and is invisible to the backlink graph
* (`extractInternalLinkSlugIds` only counts links whose mark carries
* `internal: true`). This module supplies the pure primitive that a post-walk in
* the converter uses to promote such links to their native internal form.
*
* WHERE THE CANON LIVES (a strict subset, on purpose)
* ---------------------------------------------------
* The authoritative definition of "what is an internal link path" is the
* server's `INTERNAL_LINK_REGEX`
* (`apps/server/src/integrations/export/utils.ts`):
* /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/
* This package is a lower layer than the server app and cannot import it, so the
* matcher below is a DOCUMENTED, STRICT SUBSET of that regex: it accepts only the
* space-qualified, root-relative page path `/s/<space>/p/<slug>` (with optional
* trailing slash). Because it is a subset, every link we mark internal here is
* guaranteed to also satisfy the server regex, hence guaranteed backlink-able and
* export-rewritable. A unit test pins the exact accept/reject set as the guard
* against drift.
*
* FAIL-TOWARD-EXTERNAL
* --------------------
* We mark a link internal ONLY on an unambiguous anchored match. Any ambiguity
* (a scheme/host, `#anchor`, `?query`, a space-less `/p/<slug>`, a relative
* `p/<slug>`, a non-string href) leaves the link external. A false-external is a
* soft degradation (a new tab); a false-internal would produce broken SPA
* navigation, so "external" is the conservative default.
*
* Precedent for the target internal shape: the file importer already promotes
* internal anchors (`apps/server/src/integrations/import/utils/import-formatter.ts`
* `$a.attr('data-internal','true')`) and editor-ext reads it
* (`packages/editor-ext/src/lib/link.ts`).
*/
/**
* Matches ONLY the space-qualified, root-relative internal page path
* `/s/<space>/p/<slug>` (with optional trailing slash). A STRICT SUBSET of the
* server's `INTERNAL_LINK_REGEX`:
* - `<space>` = `[^/]+` (any non-slash segment, as in the server's group 4)
* - `<slug>` = `[a-zA-Z0-9-]+` (as in the server's group 5 / `extractPageSlugId`)
* Anchored on both ends so a scheme/host, a trailing `#anchor`/`?query`, or any
* extra path segment fails to match.
*/
const INTERNAL_PAGE_PATH = /^\/s\/[^/]+\/p\/[a-zA-Z0-9-]+\/?$/;
/**
* True iff `href` is the unambiguous, space-qualified, root-relative internal
* page path. Pure and side-effect-free. Non-string input returns false.
*/
export function isInternalPagePath(href: unknown): boolean {
return typeof href === "string" && INTERNAL_PAGE_PATH.test(href);
}
/**
* The native internal-link attribute overrides applied to a link mark whose href
* is an internal page path. Mirrors the manual JSON-patch form
* (`{internal:true, target:null, rel:null}`) and the editor-ext/file-importer
* precedent: internal links carry no `target`/`rel` (same-tab SPA navigation).
*/
const INTERNAL_LINK_ATTRS = { internal: true, target: null, rel: null } as const;
/**
* In-place post-walk of a finished ProseMirror doc that promotes every
* unambiguously-internal link mark to its native internal form.
*
* A link that spans several text nodes (e.g. `[**bold** word](/s/x/p/abc)`)
* stores an equivalent link mark on EACH covered text node and a covered node
* may carry nested marks (bold/italic) alongside the link. We therefore walk the
* entire tree and rewrite EVERY `link` mark whose href passes
* `isInternalPagePath`, so no covered segment is left external.
*
* Pure of external effects and IDEMPOTENT: re-running on an already-marked doc
* leaves it unchanged (an internal-path href always maps to the same attrs).
* External links are never touched their `target:_blank`/`rel:noopener…` stay.
*
* The doc is mutated in place (the converter owns the freshly-built doc and
* returns it directly); the same node reference is returned for convenience.
*/
export function markInternalLinks<T>(node: T): T {
walk(node);
return node;
}
function walk(node: any): void {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) {
for (const child of node) walk(child);
return;
}
if (Array.isArray(node.marks)) {
for (const mark of node.marks) {
if (
mark &&
mark.type === "link" &&
mark.attrs &&
isInternalPagePath(mark.attrs.href)
) {
mark.attrs = { ...mark.attrs, ...INTERNAL_LINK_ATTRS };
}
}
}
if (Array.isArray(node.content)) walk(node.content);
}
@@ -12,6 +12,7 @@ import { parseHtmlDocument, generateJsonWith } from "./dom-parser.js";
import type { TokenizerExtension, RendererExtension } from "marked";
import { docmostExtensions } from "./docmost-schema.js";
import { parseAttachedComment } from "./attached-comment.js";
import { markInternalLinks } from "./internal-links.js";
import { splitFootnoteParagraphs } from "./footnote.js";
import {
decodeInlineMathLatex,
@@ -1094,7 +1095,12 @@ export function markdownToProseMirrorSync(markdownContent: string): any {
const withFootnotes = assembleFootnotes(withAttrs);
const bridged = bridgeTaskLists(withFootnotes);
const doc = generateJsonWith(bridged, docmostExtensions);
return stripEmptyParagraphs(doc);
// Promote unambiguously-internal wiki-page links (`[t](/s/<space>/p/<slug>)`)
// to their native internal form (`internal:true, target:null, rel:null`) so
// they get same-tab SPA navigation, hover-preview, and backlink participation.
// Every markdown import path funnels through here, so all of them are fixed at
// once; external links are left untouched (#522).
return markInternalLinks(stripEmptyParagraphs(doc));
}
/**
@@ -0,0 +1,345 @@
import { describe, expect, it } from 'vitest';
import {
isInternalPagePath,
markInternalLinks,
} from '../src/lib/internal-links.js';
import { markdownToProseMirrorSync } from '../src/lib/markdown-to-prosemirror.js';
import {
canonicalizeContent,
docsCanonicallyEqual,
} from '../src/lib/canonicalize.js';
// A LOCAL, illustrative copy of the server's INTERNAL_LINK_REGEX
// (apps/server/src/integrations/export/utils.ts) used only to document the
// subset boundary WITHIN this package (this layer cannot import the server).
// It is NOT the cross-package drift guard: a hand copy can silently go stale if
// the server regex is later narrowed. The real, mechanical drift guard lives in
// the top layer, `apps/server/src/integrations/export/internal-link-parity.spec.ts`,
// which imports BOTH the LIVE server `INTERNAL_LINK_REGEX` and the LIVE
// `isInternalPagePath` and reddens if the client ever ceases to be a subset.
const SERVER_INTERNAL_LINK_REGEX =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
// Walk a doc collecting every link mark (across all text nodes).
const linkMarks = (doc: any): any[] => {
const out: any[] = [];
const walk = (n: any): void => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) return n.forEach(walk);
if (Array.isArray(n.marks))
for (const m of n.marks) if (m?.type === 'link') out.push(m);
if (Array.isArray(n.content)) walk(n.content);
};
walk(doc);
return out;
};
describe('isInternalPagePath', () => {
const ACCEPT = [
'/s/eng/p/abc123',
'/s/eng/p/abc123/', // trailing slash
'/s/My-Space/p/A-b-C-9', // hyphens + mixed case in both segments
'/s/x/p/z',
];
const REJECT = [
'https://example.com/p/x', // scheme + host
'http://host/s/eng/p/abc', // scheme + host, even on the internal shape
'//host/s/eng/p/abc', // protocol-relative host
'/p/abc123', // space-less form (server does NOT backlink it)
'p/abc123', // relative
's/eng/p/abc123', // missing leading slash
'/s/eng/p/abc#section', // anchor
'/s/eng/p/abc?q=1', // query
'/s/eng/p/abc/extra', // extra segment
'/s//p/abc', // empty space segment
'/s/eng/p/', // empty slug
'/s/eng/p', // no slug at all
'/api/pages/abc', // other internal route
'/s/eng/x/abc', // wrong middle segment
'/s/a/b/p/abc', // space contains slash -> extra segment
'',
];
// Structurally VALID `/s/<space>/p/<slug>` shapes whose ONLY defect is a
// forbidden character in the slug segment. These pin the slug charset
// `[a-zA-Z0-9-]` itself (not just the surrounding structure): drop them and a
// mutation widening the charset (e.g. adding `.`) survives the suite. Each is
// ALSO rejected by the server regex — documenting that the subset boundary
// holds precisely on the charset, not just on structure.
const REJECT_SLUG_CHARSET = [
'/s/eng/p/abc.def', // dot
'/s/eng/p/abc_def', // underscore
'/s/eng/p/abc%20', // percent-encoded space
'/s/eng/p/abc~x', // tilde
];
it('accepts the space-qualified root-relative page path (+trailing slash)', () => {
for (const href of ACCEPT)
expect(isInternalPagePath(href), href).toBe(true);
});
it('rejects external URLs, ambiguous, and non-page forms', () => {
for (const href of REJECT)
expect(isInternalPagePath(href), href).toBe(false);
});
it('rejects a correctly-shaped path with a forbidden slug character', () => {
// Pins the slug charset itself: a mutation widening `[a-zA-Z0-9-]` reddens.
for (const href of REJECT_SLUG_CHARSET)
expect(isInternalPagePath(href), href).toBe(false);
});
it('the forbidden-slug-char paths are rejected by the server regex too', () => {
// The subset boundary holds on the charset, not just the structure: none of
// these match the server regex, so the client must not accept them either.
for (const href of REJECT_SLUG_CHARSET)
expect(SERVER_INTERNAL_LINK_REGEX.test(href), href).toBe(false);
});
it('rejects non-string input (fail-toward-external)', () => {
for (const v of [null, undefined, 123, {}, [], true])
expect(isInternalPagePath(v as unknown)).toBe(false);
});
it('is a STRICT SUBSET of the server INTERNAL_LINK_REGEX', () => {
// Every accepted href must also satisfy the server regex (so it is
// guaranteed backlink-able / export-rewritable).
for (const href of ACCEPT)
expect(SERVER_INTERNAL_LINK_REGEX.test(href), href).toBe(true);
});
});
describe('markInternalLinks', () => {
const linkNode = (text: string, href: string, extraMarks: any[] = []) => ({
type: 'text',
text,
marks: [
{
type: 'link',
attrs: {
href,
internal: null,
title: null,
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
},
},
...extraMarks,
],
});
it('marks an internal-path link internal:true, target:null, rel:null', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [linkNode('hi', '/s/eng/p/abc123')] },
],
};
markInternalLinks(doc);
const m = linkMarks(doc)[0];
expect(m.attrs.internal).toBe(true);
expect(m.attrs.target).toBeNull();
expect(m.attrs.rel).toBeNull();
expect(m.attrs.href).toBe('/s/eng/p/abc123'); // href untouched
});
it('leaves external links untouched', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [linkNode('ext', 'https://example.com/p/x')],
},
],
};
markInternalLinks(doc);
const m = linkMarks(doc)[0];
expect(m.attrs.internal).toBeNull();
expect(m.attrs.target).toBe('_blank');
expect(m.attrs.rel).toBe('noopener noreferrer nofollow');
});
it('marks EVERY text node a multi-node link spans (incl. nested bold/italic)', () => {
// A link `[**bold** plain *ital*](/s/x/p/abc)` stores the link mark on each
// covered text node; some also carry bold/italic. All must become internal.
const bold = { type: 'bold' };
const italic = { type: 'italic' };
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
linkNode('bold', '/s/x/p/abc', [bold]),
linkNode(' plain ', '/s/x/p/abc'),
linkNode('ital', '/s/x/p/abc', [italic]),
],
},
],
};
markInternalLinks(doc);
const marks = linkMarks(doc);
expect(marks).toHaveLength(3);
for (const m of marks) {
expect(m.attrs.internal).toBe(true);
expect(m.attrs.target).toBeNull();
expect(m.attrs.rel).toBeNull();
}
// Nested bold/italic marks are preserved alongside the promoted link.
const nested = doc.content[0].content;
expect(nested[0].marks.some((m: any) => m.type === 'bold')).toBe(true);
expect(nested[2].marks.some((m: any) => m.type === 'italic')).toBe(true);
});
it('is idempotent', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [linkNode('hi', '/s/eng/p/abc')] },
],
};
markInternalLinks(doc);
const once = JSON.stringify(doc);
markInternalLinks(doc);
expect(JSON.stringify(doc)).toBe(once);
});
});
describe('markdown import (full converter) — #522 acceptance', () => {
it('promotes an internal link and leaves an external one external', () => {
const doc = markdownToProseMirrorSync(
'[t](/s/eng/p/abc123) and [ext](https://example.com/p/x)',
);
const marks = linkMarks(doc);
const internal = marks.find((m) => m.attrs.href === '/s/eng/p/abc123');
const external = marks.find(
(m) => m.attrs.href === 'https://example.com/p/x',
);
expect(internal.attrs).toMatchObject({
internal: true,
target: null,
rel: null,
});
expect(external.attrs).toMatchObject({
internal: null,
target: '_blank',
rel: 'noopener noreferrer nofollow',
});
});
it('does NOT promote the space-less /p/<slug> form (documented limit)', () => {
const doc = markdownToProseMirrorSync('[t](/p/abc123)');
const m = linkMarks(doc)[0];
expect(m.attrs.internal).toBeNull();
expect(m.attrs.target).toBe('_blank');
});
it('the promoted link is backlink-extractable (matches server regex + true)', () => {
// Mirrors extractInternalLinkSlugIds: internal flag AND server-regex match.
const doc = markdownToProseMirrorSync('[t](/s/eng/p/my-page-abc123)');
const m = linkMarks(doc)[0];
expect(m.attrs.internal).toBe(true);
const match = m.attrs.href.match(SERVER_INTERNAL_LINK_REGEX);
expect(match).not.toBeNull();
// group 5 is the slug segment the server feeds to extractPageSlugId.
expect(match![5]).toBe('my-page-abc123');
});
it('comment-body markdown gets the same treatment (shared converter path)', () => {
// Comment bodies go through the same markdownToProseMirrorSync; a spot-check
// that the shared path (not a comment-only branch) does the promotion.
const doc = markdownToProseMirrorSync('see [here](/s/team/p/xyz789)');
const m = linkMarks(doc).find((x) => x.attrs.href === '/s/team/p/xyz789');
expect(m.attrs.internal).toBe(true);
});
});
describe('canonicalize — internal:false ≡ external (#522 §11)', () => {
it('drops editor-authored internal:false so external stays canonically equal', () => {
// Editor stores external links with internal:false; import leaves it absent.
const stored = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'x',
marks: [
{
type: 'link',
attrs: {
href: 'https://example.com',
internal: false,
target: '_blank',
rel: 'noopener noreferrer nofollow',
},
},
],
},
],
},
],
};
const reimport = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'x',
marks: [
{
type: 'link',
attrs: {
href: 'https://example.com',
internal: null, // import default
target: '_blank',
rel: 'noopener noreferrer nofollow',
},
},
],
},
],
},
],
};
expect(docsCanonicallyEqual(stored, reimport)).toBe(true);
// internal:false / target / rel all drop as defaults; only the non-default
// href survives.
const canon = canonicalizeContent(stored);
const mark = canon.content[0].content[0].marks[0];
expect(mark.attrs).toEqual({ href: 'https://example.com' });
});
it('keeps internal:true through canonicalization (non-default, load-bearing)', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'x',
marks: [
{
type: 'link',
attrs: { href: '/s/x/p/abc', internal: true },
},
],
},
],
},
],
};
const canon = canonicalizeContent(doc);
const mark = canon.content[0].content[0].marks[0];
expect(mark.attrs.internal).toBe(true);
expect(mark.attrs.href).toBe('/s/x/p/abc');
});
});