mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 19:47:16 +09:00
fix: Preserve MDX edits and restore article undo
This commit is contained in:
1 parent
a2a567551c
commit
6359f979a3
20 files changed
+805
-39
No files matched your search
@@ -156,7 +156,13 @@ const MdxContentMenu = ({ assetId }: { assetId?: string }) => {
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (open) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem disabled={!assetId} onSelect={() => setOpen(true)}>
|
||||
Open MDX file
|
||||
</DropdownMenuItem>
|
||||
@@ -1295,6 +1301,7 @@ export const NavigatorTree = () => {
|
||||
};
|
||||
|
||||
export const __testing__ = {
|
||||
MdxContentMenu,
|
||||
getMdxContentSource,
|
||||
commitNavigatorDrop,
|
||||
getFocusSelectionSkipCountAfterPointerDown,
|
||||
|
||||
@@ -139,7 +139,7 @@ export const useBindableControl = ({
|
||||
});
|
||||
};
|
||||
return {
|
||||
fieldError,
|
||||
fieldError: fieldError ?? externalRoot?.persistenceError,
|
||||
expression,
|
||||
bound,
|
||||
scope,
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import { createAssetContentSession } from "@webstudio-is/content-engine/asset-content-session";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import { $assets } from "~/shared/sync/data-stores";
|
||||
import { $authPermit } from "~/shared/nano-states";
|
||||
import {
|
||||
createAssetContentBridge,
|
||||
__testing__,
|
||||
} from "~/shared/asset-content-bridge.client";
|
||||
import { TextFileEditor } from "./text-file-editor";
|
||||
import { __testing__ as navigatorTesting } from "../navigator/navigator-tree";
|
||||
|
||||
const { initBridge, clearBridge } = __testing__;
|
||||
const { MdxContentMenu } = navigatorTesting;
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
test.each(["repair", "discard", "navigator"])(
|
||||
"keeps invalid MDX drafts open until %s without replacing the saved article",
|
||||
async (action) => {
|
||||
const asset: Asset = {
|
||||
id: "validation-article",
|
||||
projectId: "validation-project",
|
||||
name: "article.mdx",
|
||||
format: "mdx",
|
||||
type: "file",
|
||||
size: 9,
|
||||
meta: {},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const writes: string[] = [];
|
||||
const session = createAssetContentSession({
|
||||
repository: {
|
||||
readContent: async () => ({
|
||||
asset,
|
||||
data: (async function* () {
|
||||
yield new TextEncoder().encode("# Article");
|
||||
})(),
|
||||
}),
|
||||
updateContent: async ({ data }) => {
|
||||
writes.push(await new Response(data).text());
|
||||
return asset;
|
||||
},
|
||||
},
|
||||
authorize: () => true,
|
||||
debounceMilliseconds: 0,
|
||||
});
|
||||
initBridge(
|
||||
createAssetContentBridge({
|
||||
origin: window.location.origin,
|
||||
request: fetch,
|
||||
authorize: () => true,
|
||||
requireReload: vi.fn(),
|
||||
getContentSession: () => session,
|
||||
})
|
||||
);
|
||||
$assets.set(new Map([[asset.id, asset]]));
|
||||
const previousPermit = $authPermit.get();
|
||||
$authPermit.set("own");
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const onOpenChange = vi.fn();
|
||||
try {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
{action === "navigator" ? (
|
||||
<MdxContentMenu assetId={asset.id} />
|
||||
) : (
|
||||
<TextFileEditor assetId={asset.id} onOpenChange={onOpenChange} />
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
if (action === "navigator") {
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
document.querySelector('[aria-label="MDX content settings"]')!
|
||||
)
|
||||
);
|
||||
await act(async () =>
|
||||
userEvent.click(document.querySelector('[role="menuitem"]')!)
|
||||
);
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
document.querySelector('[aria-label="Markdown source"]')
|
||||
).not.toBeNull()
|
||||
);
|
||||
const editor = document.querySelector<HTMLElement>(
|
||||
'[aria-label="Markdown source"]'
|
||||
)!;
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(editor));
|
||||
if (action === "navigator") {
|
||||
return;
|
||||
}
|
||||
await act(async () => userEvent.fill(editor, "<YouTube url={ />"));
|
||||
const close = document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Close"]'
|
||||
)!;
|
||||
await act(async () => userEvent.click(close));
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector('[role="alert"]')).not.toBeNull()
|
||||
);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(session.get(asset.id)?.source).toBe("# Article");
|
||||
expect(writes).toEqual([]);
|
||||
if (action === "repair") {
|
||||
await act(async () => userEvent.fill(editor, "# Repaired article"));
|
||||
await act(async () => userEvent.click(close));
|
||||
} else {
|
||||
const discard = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Discard changes"
|
||||
)!;
|
||||
await act(async () => userEvent.click(discard));
|
||||
}
|
||||
await vi.waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
||||
await act(async () => session.flush(asset.id));
|
||||
expect(session.get(asset.id)?.source).toBe(
|
||||
action === "repair" ? "# Repaired article" : "# Article"
|
||||
);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
clearBridge();
|
||||
session.dispose();
|
||||
$authPermit.set(previousPermit);
|
||||
$assets.set(new Map());
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import { getCollectionEntrySourceIssues } from "@webstudio-is/content-engine";
|
||||
import { validateTextAssetSource } from "@webstudio-is/content-engine/mdx";
|
||||
import { useContentCollections } from "~/builder/shared/assets/content-collections";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
@@ -599,6 +600,7 @@ export const MarkdownEditor = ({
|
||||
asset,
|
||||
ariaLabel = "Markdown source",
|
||||
defaultPreviewOpen = true,
|
||||
autoFocus = false,
|
||||
value,
|
||||
readOnly,
|
||||
languageExtensions = [],
|
||||
@@ -608,6 +610,7 @@ export const MarkdownEditor = ({
|
||||
asset: Asset;
|
||||
ariaLabel?: string;
|
||||
defaultPreviewOpen?: boolean;
|
||||
autoFocus?: boolean;
|
||||
value: string;
|
||||
readOnly: boolean;
|
||||
languageExtensions?: Extension[];
|
||||
@@ -642,6 +645,7 @@ export const MarkdownEditor = ({
|
||||
assetContainers={assetContainers}
|
||||
>
|
||||
<CodeEditor
|
||||
autoFocus={autoFocus}
|
||||
ariaLabel={ariaLabel}
|
||||
editorApiRef={editorApiRef}
|
||||
value={value}
|
||||
@@ -687,6 +691,7 @@ export const TextFileEditor = ({
|
||||
const pendingMdxSavesRef = useRef(0);
|
||||
const saveQueueRef = useRef(Promise.resolve());
|
||||
const reportedConflictRef = useRef<string>();
|
||||
const saveAttemptRef = useRef(0);
|
||||
const mdxSession =
|
||||
asset !== undefined &&
|
||||
isMdxFileAsset(asset) &&
|
||||
@@ -859,26 +864,45 @@ export const TextFileEditor = ({
|
||||
})
|
||||
: undefined;
|
||||
return () => {
|
||||
saveAttemptRef.current += 1;
|
||||
controller.abort();
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [assetId]);
|
||||
|
||||
const save = (content: string) => {
|
||||
const save = async (content: string) => {
|
||||
const attempt = ++saveAttemptRef.current;
|
||||
if (canEdit === false) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const currentAsset = currentAssetRef.current;
|
||||
if (currentAsset === undefined) {
|
||||
toast.error("Unable to save: asset not found");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeTextFileContent(currentAsset, content);
|
||||
if ("error" in normalized) {
|
||||
toast.error(normalized.error);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const normalizedContent = normalized.content;
|
||||
if (isMdxFileAsset(currentAsset)) {
|
||||
const { diagnostics } = await validateTextAssetSource({
|
||||
source: normalizedContent,
|
||||
format: "mdx",
|
||||
});
|
||||
if (attempt !== saveAttemptRef.current) {
|
||||
return false;
|
||||
}
|
||||
const error = diagnostics.find(({ severity }) => severity === "error");
|
||||
if (error !== undefined) {
|
||||
setPersistenceFeedback({ kind: "invalid", message: error.message });
|
||||
return false;
|
||||
}
|
||||
setPersistenceFeedback((feedback) =>
|
||||
feedback?.kind === "invalid" ? undefined : feedback
|
||||
);
|
||||
}
|
||||
if (normalizedContent !== content) {
|
||||
setState({ status: "loaded", content: normalizedContent });
|
||||
}
|
||||
@@ -890,7 +914,7 @@ export const TextFileEditor = ({
|
||||
currentAsset.projectId === undefined
|
||||
) {
|
||||
toast.error("Unable to save: MDX content is not loaded");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
pendingMdxSavesRef.current += 1;
|
||||
void replaceExternalContentAssetSource({
|
||||
@@ -916,7 +940,7 @@ export const TextFileEditor = ({
|
||||
.finally(() => {
|
||||
pendingMdxSavesRef.current -= 1;
|
||||
});
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
saveQueueRef.current = saveQueueRef.current.then(async () => {
|
||||
const requestedContent = requestedContentRef.current;
|
||||
@@ -945,6 +969,7 @@ export const TextFileEditor = ({
|
||||
toast.error(error instanceof Error ? error.message : "Unable to save");
|
||||
}
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const title = asset === undefined ? "Text file" : formatAssetName(asset);
|
||||
@@ -952,10 +977,12 @@ export const TextFileEditor = ({
|
||||
let editor: ReactNode;
|
||||
if (state.status === "loaded" && asset !== undefined) {
|
||||
const onChange = (content: string) => {
|
||||
saveAttemptRef.current += 1;
|
||||
setState({ status: "loaded", content });
|
||||
};
|
||||
editor = isMarkdown ? (
|
||||
<MarkdownEditor
|
||||
autoFocus
|
||||
asset={asset}
|
||||
value={state.content}
|
||||
readOnly={canEdit === false}
|
||||
@@ -965,6 +992,7 @@ export const TextFileEditor = ({
|
||||
/>
|
||||
) : (
|
||||
<CodeEditor
|
||||
autoFocus
|
||||
value={state.content}
|
||||
languageExtensions={languageExtensions}
|
||||
size="full"
|
||||
@@ -986,7 +1014,12 @@ export const TextFileEditor = ({
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (open === false && state.status === "loaded") {
|
||||
save(state.content);
|
||||
void save(state.content).then((saved) => {
|
||||
if (saved) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
onOpenChange(open);
|
||||
}}
|
||||
@@ -1022,9 +1055,20 @@ export const TextFileEditor = ({
|
||||
align="center"
|
||||
css={{ padding: rawTheme.spacing[5] }}
|
||||
>
|
||||
<Text role="alert" color="destructive" variant="tiny">
|
||||
<Text role="alert" color="destructive">
|
||||
{persistenceFeedback.message}
|
||||
</Text>
|
||||
{persistenceFeedback.kind === "invalid" && (
|
||||
<Button
|
||||
color="ghost"
|
||||
onClick={() => {
|
||||
saveAttemptRef.current += 1;
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
)}
|
||||
{persistenceFeedback.kind === "failed" &&
|
||||
mdxSession !== undefined && (
|
||||
<Button
|
||||
|
||||
@@ -39,7 +39,7 @@ import type { AssetContentSessionState } from "@webstudio-is/content-engine/asse
|
||||
import { formatContentBlockDiagnostic } from "~/shared/content-block-diagnostics";
|
||||
|
||||
export type MdxPersistenceFeedback = Readonly<{
|
||||
kind: "failed" | "conflicting";
|
||||
kind: "failed" | "conflicting" | "invalid";
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -36,8 +36,10 @@ import { $instances } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
externalContentInstanceNameMessage,
|
||||
getExternalContentRoots,
|
||||
findExternalContentRootEntryBySelector,
|
||||
isExternalContentInstance,
|
||||
} from "~/shared/external-content-mutations";
|
||||
import { traverseExternalContentHistory } from "~/shared/external-content-roots";
|
||||
|
||||
// Declare command for type safety
|
||||
declare module "~/shared/pubsub" {
|
||||
@@ -176,6 +178,35 @@ const guardDesignOrContentModeCommand = ({
|
||||
const hasMultiInstanceSelection = () =>
|
||||
$allSelectedInstanceSelectors.get().length > 1;
|
||||
|
||||
const traverseSelectedContentHistory = (direction: "undo" | "redo") => {
|
||||
const selector = $selectedInstancePath.get()?.[0]?.instanceSelector;
|
||||
if (selector === undefined) {
|
||||
return false;
|
||||
}
|
||||
const roots = getExternalContentRoots();
|
||||
const root = findExternalContentRootEntryBySelector(roots, selector)?.[1];
|
||||
if (
|
||||
root?.projectId === undefined ||
|
||||
root.assetId === undefined ||
|
||||
($isContentMode.get() === false &&
|
||||
isExternalContentInstance(roots, selector[0]) === false &&
|
||||
root.contentInstanceId !== selector[0])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
void traverseExternalContentHistory({
|
||||
projectId: root.projectId,
|
||||
direction,
|
||||
}).catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to restore the article edit."
|
||||
);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const copyPageActionTarget = () => {
|
||||
if ($isDesignMode.get() === false) {
|
||||
return false;
|
||||
@@ -1157,7 +1188,9 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
|
||||
defaultHotkeys: ["meta+z", "ctrl+z"],
|
||||
disableOnInputLikeControls: true,
|
||||
handler: () => {
|
||||
serverSyncStore.undo();
|
||||
if (traverseSelectedContentHistory("undo") === false) {
|
||||
serverSyncStore.undo();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1168,7 +1201,9 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
|
||||
defaultHotkeys: ["meta+shift+z", "ctrl+shift+z"],
|
||||
disableOnInputLikeControls: true,
|
||||
handler: () => {
|
||||
serverSyncStore.redo();
|
||||
if (traverseSelectedContentHistory("redo") === false) {
|
||||
serverSyncStore.redo();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -387,6 +387,7 @@ describe("getMenuPermissions", () => {
|
||||
});
|
||||
|
||||
expect(permissions).toMatchObject({
|
||||
canMove: true,
|
||||
canCopy: true,
|
||||
canPaste: true,
|
||||
canCut: false,
|
||||
@@ -422,8 +423,8 @@ describe("getMenuPermissions", () => {
|
||||
isContentMode: true,
|
||||
isDesignMode: false,
|
||||
instances,
|
||||
}).canDelete
|
||||
).toBe(false);
|
||||
})
|
||||
).toMatchObject({ canDelete: false, canMove: false });
|
||||
});
|
||||
|
||||
test("content mode keeps multi-copy available but disables single-instance and mutation actions", () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "~/shared/nano-states";
|
||||
import {
|
||||
getInstancePath,
|
||||
canMoveInstanceInContentMode,
|
||||
type InstancePath,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { canDeleteInstanceInContentMode } from "@webstudio-is/project-build/runtime";
|
||||
@@ -94,12 +95,22 @@ const getMenuPermissions = ({
|
||||
});
|
||||
|
||||
if (isContentMode) {
|
||||
const instanceSelector = instancePath?.[0]?.instanceSelector;
|
||||
const parentSelector = instancePath?.[1]?.instanceSelector;
|
||||
return {
|
||||
canCopy: hasActionableSelection,
|
||||
canPaste: hasActionableSelection,
|
||||
canCut: false,
|
||||
canDuplicate: false,
|
||||
canMove: false,
|
||||
canMove:
|
||||
canUseSingleSelectionActions &&
|
||||
instanceSelector !== undefined &&
|
||||
parentSelector !== undefined &&
|
||||
canMoveInstanceInContentMode({
|
||||
instanceSelector,
|
||||
parentSelector,
|
||||
instances,
|
||||
}),
|
||||
canHide: false,
|
||||
canRename: false,
|
||||
canWrap: false,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { createAssetContentSession } from "@webstudio-is/content-engine/asset-content-session";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import {
|
||||
createAssetContentBridge,
|
||||
__testing__,
|
||||
} from "./asset-content-bridge.client";
|
||||
import { $externalContentHistory } from "./external-content-history";
|
||||
import { $externalContentRoots } from "./external-content-mutations";
|
||||
import {
|
||||
disposeExternalContentProject,
|
||||
flushExternalContentAsset,
|
||||
traverseExternalContentHistory,
|
||||
updateExternalContentAssetSource,
|
||||
} from "./external-content-roots";
|
||||
|
||||
const { initBridge, clearBridge } = __testing__;
|
||||
|
||||
test("undo and redo save source changes in order across article and author files", async () => {
|
||||
const projectId = "history-project";
|
||||
const stored = new Map([
|
||||
["article", "# Original"],
|
||||
["author", "---\nname: Original\n---"],
|
||||
]);
|
||||
const describeAsset = (assetId: string): Asset => ({
|
||||
id: assetId,
|
||||
projectId,
|
||||
name: `${assetId}.mdx`,
|
||||
format: "mdx",
|
||||
type: "file",
|
||||
size: new TextEncoder().encode(stored.get(assetId)!).byteLength,
|
||||
meta: {},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const session = createAssetContentSession({
|
||||
repository: {
|
||||
readContent: async ({ assetId }) => ({
|
||||
asset: describeAsset(assetId),
|
||||
data: (async function* () {
|
||||
yield new TextEncoder().encode(stored.get(assetId)!);
|
||||
})(),
|
||||
}),
|
||||
updateContent: async ({ assetId, data }) => {
|
||||
stored.set(assetId, await new Response(data).text());
|
||||
return describeAsset(assetId);
|
||||
},
|
||||
},
|
||||
authorize: () => true,
|
||||
debounceMilliseconds: 0,
|
||||
});
|
||||
initBridge(
|
||||
createAssetContentBridge({
|
||||
origin: window.location.origin,
|
||||
request: fetch,
|
||||
authorize: () => true,
|
||||
requireReload: vi.fn(),
|
||||
getContentSession: () => session,
|
||||
})
|
||||
);
|
||||
try {
|
||||
await session.open("article");
|
||||
await session.open("author");
|
||||
await updateExternalContentAssetSource({
|
||||
projectId,
|
||||
assetId: "article",
|
||||
update: () => "# Changed",
|
||||
});
|
||||
$externalContentRoots.set(
|
||||
new Map([
|
||||
[
|
||||
"pending-root",
|
||||
{
|
||||
projectId,
|
||||
assetId: "author",
|
||||
blockInstanceId: "block",
|
||||
instanceIds: new Set(),
|
||||
mutationRevision: 1,
|
||||
savedMutationRevision: 0,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
let traversed = false;
|
||||
const pendingUndo = traverseExternalContentHistory({
|
||||
projectId,
|
||||
direction: "undo",
|
||||
}).then(() => {
|
||||
traversed = true;
|
||||
});
|
||||
// Let the queued command run while the canvas is still serializing its edit.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(traversed).toBe(false);
|
||||
await updateExternalContentAssetSource({
|
||||
projectId,
|
||||
assetId: "author",
|
||||
update: () => "---\nname: Changed\n---",
|
||||
});
|
||||
$externalContentRoots.set(new Map());
|
||||
await pendingUndo;
|
||||
expect(session.get("author")?.source).toBe("---\nname: Original\n---");
|
||||
expect(session.get("article")?.source).toBe("# Changed");
|
||||
await traverseExternalContentHistory({ projectId, direction: "undo" });
|
||||
expect(session.get("article")?.source).toBe("# Original");
|
||||
await Promise.all([
|
||||
traverseExternalContentHistory({ projectId, direction: "redo" }),
|
||||
traverseExternalContentHistory({ projectId, direction: "redo" }),
|
||||
]);
|
||||
expect(session.get("article")?.source).toBe("# Changed");
|
||||
expect(session.get("author")?.source).toBe("---\nname: Changed\n---");
|
||||
await flushExternalContentAsset({ projectId, assetId: "article" });
|
||||
await flushExternalContentAsset({ projectId, assetId: "author" });
|
||||
expect(stored.get("article")).toBe("# Changed");
|
||||
expect(stored.get("author")).toBe("---\nname: Changed\n---");
|
||||
await traverseExternalContentHistory({ projectId, direction: "undo" });
|
||||
await updateExternalContentAssetSource({
|
||||
projectId,
|
||||
assetId: "author",
|
||||
update: () => "---\nname: New edit\n---",
|
||||
});
|
||||
await traverseExternalContentHistory({ projectId, direction: "redo" });
|
||||
expect(session.get("author")?.source).toBe("---\nname: New edit\n---");
|
||||
|
||||
// A source replaced outside this history must never be overwritten by undo.
|
||||
session.save("author", "---\nname: Concurrent edit\n---");
|
||||
await expect(
|
||||
traverseExternalContentHistory({ projectId, direction: "undo" })
|
||||
).rejects.toThrow("changed since this edit");
|
||||
expect(session.get("author")?.source).toBe(
|
||||
"---\nname: Concurrent edit\n---"
|
||||
);
|
||||
} finally {
|
||||
$externalContentRoots.set(new Map());
|
||||
await updateExternalContentAssetSource({
|
||||
projectId,
|
||||
assetId: "author",
|
||||
update: (source) => source,
|
||||
});
|
||||
await disposeExternalContentProject({ projectId, session });
|
||||
expect($externalContentHistory.get().has(projectId)).toBe(false);
|
||||
session.dispose();
|
||||
clearBridge();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { atom } from "nanostores";
|
||||
|
||||
type ContentHistory = {
|
||||
edits: readonly {
|
||||
id: string;
|
||||
assetId: string;
|
||||
before: string;
|
||||
after: string;
|
||||
}[];
|
||||
index: number;
|
||||
};
|
||||
|
||||
// Source history is separate from the transient instance patches used to render
|
||||
// MDX. Replaying those patches would also undo loading and rematerialization.
|
||||
export const $externalContentHistory = atom<
|
||||
ReadonlyMap<string, ContentHistory>
|
||||
>(new Map());
|
||||
|
||||
export const recordExternalContentHistory = ({
|
||||
projectId,
|
||||
assetId,
|
||||
before,
|
||||
after,
|
||||
}: {
|
||||
projectId: string;
|
||||
assetId: string;
|
||||
before: string;
|
||||
after: string;
|
||||
}) => {
|
||||
if (before === after) {
|
||||
return;
|
||||
}
|
||||
const current = $externalContentHistory.get();
|
||||
const history = current.get(projectId);
|
||||
const edits = [
|
||||
...(history?.edits.slice(0, history.index) ?? []),
|
||||
{ id: crypto.randomUUID(), assetId, before, after },
|
||||
].slice(-20);
|
||||
$externalContentHistory.set(
|
||||
new Map(current).set(projectId, {
|
||||
edits,
|
||||
index: edits.length,
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -45,12 +45,16 @@ export type ExternalContentRoot = {
|
||||
/** Latest template ownership observed in the project stores. */
|
||||
templateOwnership?: ExternalContentOwnership;
|
||||
mutationRevision: number;
|
||||
/** Last mutation serialized into the shared source session. */
|
||||
savedMutationRevision?: number;
|
||||
/** Cross-realm signal that the mounted content must resolve templates again. */
|
||||
templateMutationRevision?: number;
|
||||
projectId?: string;
|
||||
assetId?: string;
|
||||
/** User-visible failure from the latest template rematerialization attempt. */
|
||||
templateMaterializationError?: string;
|
||||
/** Save preparation failure shared with controls in the Builder frame. */
|
||||
persistenceError?: string;
|
||||
/** Unsaved clones that retain the exact template selected by the user. */
|
||||
insertedTemplates?: ReadonlyMap<Instance["id"], MdxTemplateInsertion>;
|
||||
identity?: ContentBlockExternalContentIdentity;
|
||||
@@ -256,12 +260,14 @@ export const registerExternalContentRoot = (
|
||||
const registeredRoot = {
|
||||
...root,
|
||||
mutationRevision: current.get(key)?.mutationRevision ?? 0,
|
||||
savedMutationRevision: current.get(key)?.savedMutationRevision ?? 0,
|
||||
templateMutationRevision:
|
||||
current.get(key)?.templateMutationRevision ??
|
||||
root.templateMutationRevision ??
|
||||
0,
|
||||
templateMaterializationError:
|
||||
current.get(key)?.templateMaterializationError,
|
||||
persistenceError: current.get(key)?.persistenceError,
|
||||
insertedTemplates:
|
||||
current.get(key)?.insertedTemplates ?? root.insertedTemplates,
|
||||
};
|
||||
|
||||
@@ -2711,6 +2711,12 @@ test("materializes into normal instances and saves their synchronous mutations o
|
||||
).rejects.toThrow(
|
||||
"The MDX content source must be structurally valid before canvas edits can be saved."
|
||||
);
|
||||
expect(
|
||||
findExternalContentRoot(getExternalContentRoots(), "block", '["block"]')
|
||||
?.persistenceError
|
||||
).toBe(
|
||||
"The MDX content source must be structurally valid before canvas edits can be saved."
|
||||
);
|
||||
|
||||
session.save(asset.id, "# Repaired");
|
||||
await session.flush(asset.id);
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
* Manages the live lifecycle between connected Asset documents and canvas
|
||||
* instances: materialization, persistence, template refresh, and cleanup.
|
||||
*/
|
||||
import {
|
||||
$externalContentHistory,
|
||||
recordExternalContentHistory,
|
||||
} from "./external-content-history";
|
||||
import {
|
||||
createCanonicalAssetPath,
|
||||
createMarkdownFrontmatterDiagnostics,
|
||||
@@ -116,6 +120,7 @@ type RootEntry = {
|
||||
|
||||
type AssetQueue = {
|
||||
projectId: string;
|
||||
assetId: string;
|
||||
pendingSnapshots: number;
|
||||
serialization: Promise<void>;
|
||||
failedUpdate?: AssetUpdate;
|
||||
@@ -134,7 +139,7 @@ type PreparedAssetUpdate =
|
||||
| string
|
||||
| Readonly<{
|
||||
source: string;
|
||||
document: MdxDocument;
|
||||
document?: MdxDocument;
|
||||
afterSave?: () => void;
|
||||
}>
|
||||
| undefined;
|
||||
@@ -235,6 +240,7 @@ const getAssetQueue = (projectId: string, assetId: string) => {
|
||||
if (queue === undefined) {
|
||||
queue = {
|
||||
projectId,
|
||||
assetId,
|
||||
pendingSnapshots: 0,
|
||||
serialization: Promise.resolve(),
|
||||
listeners: new Set(),
|
||||
@@ -259,6 +265,23 @@ const parseAssetSource = (
|
||||
};
|
||||
|
||||
const publishAssetQueueError = (queue: AssetQueue) => {
|
||||
const current = getExternalContentRoots();
|
||||
const next = new Map(current);
|
||||
const persistenceError = queue.error?.message;
|
||||
let changed = false;
|
||||
for (const [key, root] of current) {
|
||||
if (
|
||||
root.projectId === queue.projectId &&
|
||||
root.assetId === queue.assetId &&
|
||||
root.persistenceError !== persistenceError
|
||||
) {
|
||||
next.set(key, { ...root, persistenceError });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
$externalContentRoots.set(next);
|
||||
}
|
||||
for (const listener of queue.listeners) {
|
||||
listener(queue.error);
|
||||
}
|
||||
@@ -1021,11 +1044,13 @@ const enqueueAssetUpdate = ({
|
||||
assetId,
|
||||
update,
|
||||
preservedRootKey,
|
||||
recordHistory = true,
|
||||
}: {
|
||||
projectId: string;
|
||||
assetId: string;
|
||||
update: AssetUpdate;
|
||||
preservedRootKey?: string;
|
||||
recordHistory?: boolean;
|
||||
}) => {
|
||||
const queue = getAssetQueue(projectId, assetId);
|
||||
const session = getSession(projectId);
|
||||
@@ -1049,10 +1074,18 @@ const enqueueAssetUpdate = ({
|
||||
}
|
||||
const source = typeof prepared === "string" ? prepared : prepared.source;
|
||||
queue.latestDocument =
|
||||
typeof prepared === "string"
|
||||
typeof prepared === "string" || prepared.document === undefined
|
||||
? undefined
|
||||
: { source, document: prepared.document };
|
||||
session.save(assetId, source);
|
||||
if (recordHistory) {
|
||||
recordExternalContentHistory({
|
||||
projectId,
|
||||
assetId,
|
||||
before: state.source,
|
||||
after: source,
|
||||
});
|
||||
}
|
||||
if (typeof prepared !== "string") {
|
||||
prepared.afterSave?.();
|
||||
}
|
||||
@@ -1099,6 +1132,112 @@ const enqueueAssetUpdate = ({
|
||||
return tracked;
|
||||
};
|
||||
|
||||
const applyExternalContentHistory = async ({
|
||||
projectId,
|
||||
direction,
|
||||
}: {
|
||||
projectId: string;
|
||||
direction: "undo" | "redo";
|
||||
}) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const finish = (error?: Error) => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
if (error === undefined) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
const check = () => {
|
||||
const pending = Array.from(getExternalContentRoots().values()).filter(
|
||||
(root) =>
|
||||
root.projectId === projectId &&
|
||||
root.mutationRevision > (root.savedMutationRevision ?? 0)
|
||||
);
|
||||
const failed = pending.find(
|
||||
(root) => root.persistenceError !== undefined
|
||||
);
|
||||
if (failed !== undefined) {
|
||||
finish(new Error(failed.persistenceError));
|
||||
} else if (pending.length === 0) {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
new Error(
|
||||
"Wait for the article changes to finish saving, then try again."
|
||||
)
|
||||
),
|
||||
10000
|
||||
);
|
||||
const unsubscribe = $externalContentRoots.listen(check);
|
||||
check();
|
||||
});
|
||||
const history = $externalContentHistory.get().get(projectId);
|
||||
if (history === undefined) {
|
||||
return;
|
||||
}
|
||||
const editIndex = direction === "undo" ? history.index - 1 : history.index;
|
||||
const edit = history.edits[editIndex];
|
||||
if (edit === undefined) {
|
||||
return;
|
||||
}
|
||||
const { assetId } = edit;
|
||||
await getSession(projectId).open(assetId);
|
||||
return enqueueAssetUpdate({
|
||||
projectId,
|
||||
assetId,
|
||||
recordHistory: false,
|
||||
update: (state) => {
|
||||
const current = $externalContentHistory.get().get(projectId);
|
||||
if (
|
||||
current?.index !== history.index ||
|
||||
current.edits[editIndex]?.id !== edit.id ||
|
||||
(direction === "undo" ? edit.after : edit.before) !== state.source
|
||||
) {
|
||||
throw new MdxAuthoredContentConflictError(
|
||||
"The article changed since this edit. Reload it before continuing."
|
||||
);
|
||||
}
|
||||
return {
|
||||
source: direction === "undo" ? edit.before : edit.after,
|
||||
afterSave: () => {
|
||||
$externalContentHistory.set(
|
||||
new Map($externalContentHistory.get()).set(projectId, {
|
||||
...history,
|
||||
index: history.index + (direction === "undo" ? -1 : 1),
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const contentHistoryTraversals = new Map<string, Promise<void>>();
|
||||
|
||||
export const traverseExternalContentHistory = (
|
||||
input: Parameters<typeof applyExternalContentHistory>[0]
|
||||
) => {
|
||||
const pending =
|
||||
contentHistoryTraversals.get(input.projectId) ?? Promise.resolve();
|
||||
const next = pending
|
||||
.catch(() => {})
|
||||
.then(() => applyExternalContentHistory(input));
|
||||
contentHistoryTraversals.set(input.projectId, next);
|
||||
void next
|
||||
.finally(() => {
|
||||
if (contentHistoryTraversals.get(input.projectId) === next) {
|
||||
contentHistoryTraversals.delete(input.projectId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return next;
|
||||
};
|
||||
|
||||
subscribeExternalContentMutations((rootKeys) => {
|
||||
for (const key of rootKeys) {
|
||||
const entry = roots.get(key);
|
||||
@@ -1116,6 +1255,8 @@ subscribeExternalContentMutations((rootKeys) => {
|
||||
});
|
||||
const insertedTemplates = getInsertedTemplates(entry, fragment);
|
||||
const authoredRoot = entry.root;
|
||||
const mutationRevision =
|
||||
getExternalContentRoots().get(key)?.mutationRevision ?? 0;
|
||||
const saveRevision = ++entry.saveRevision;
|
||||
entry.installedFragment = fragment;
|
||||
registerMutationRoot(entry, entry.installedFragment);
|
||||
@@ -1212,7 +1353,23 @@ subscribeExternalContentMutations((rootKeys) => {
|
||||
}),
|
||||
};
|
||||
},
|
||||
}).catch(() => {});
|
||||
})
|
||||
.then(() => {
|
||||
const current = getExternalContentRoots();
|
||||
const root = current.get(key);
|
||||
if (root !== undefined && roots.get(key) === entry) {
|
||||
$externalContentRoots.set(
|
||||
new Map(current).set(key, {
|
||||
...root,
|
||||
savedMutationRevision: Math.max(
|
||||
root.savedMutationRevision ?? 0,
|
||||
mutationRevision
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1848,8 +2005,20 @@ export const subscribeExternalContentAsset = ({
|
||||
const session = getSession(projectId);
|
||||
const queue = getAssetQueue(projectId, assetId);
|
||||
const publish = (state: AssetContentSessionState) => {
|
||||
const persistenceError = Array.from(
|
||||
getExternalContentRoots().values()
|
||||
).find(
|
||||
(root) =>
|
||||
root.projectId === projectId &&
|
||||
root.assetId === assetId &&
|
||||
root.persistenceError !== undefined
|
||||
)?.persistenceError;
|
||||
const error =
|
||||
queue.error ?? getTemplateMaterializationError(projectId, assetId);
|
||||
queue.error ??
|
||||
(persistenceError === undefined
|
||||
? undefined
|
||||
: new Error(persistenceError)) ??
|
||||
getTemplateMaterializationError(projectId, assetId);
|
||||
listener(
|
||||
error === undefined ? state : { ...state, status: "failed", error }
|
||||
);
|
||||
@@ -2041,6 +2210,9 @@ export const disposeExternalContentProject = async ({
|
||||
}
|
||||
}
|
||||
subscribedSessions.delete(session);
|
||||
const history = new Map($externalContentHistory.get());
|
||||
history.delete(projectId);
|
||||
$externalContentHistory.set(history);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
type InstanceSelector,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { $externalContentRoots } from "../external-content-mutations";
|
||||
import { $externalContentHistory } from "../external-content-history";
|
||||
|
||||
enableMapSet();
|
||||
// safari structuredClone fix
|
||||
@@ -407,6 +408,7 @@ export const createObjectPool = () => {
|
||||
new NanostoresSyncObject("canvasScrollbarWidth", $canvasScrollbarSize),
|
||||
new NanostoresSyncObject("systemDataByPage", $systemDataByPage),
|
||||
new NanostoresSyncObject("externalContentRoots", $externalContentRoots),
|
||||
new NanostoresSyncObject("externalContentHistory", $externalContentHistory),
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@ Select the connected filename to choose another file. Select **Open** to edit th
|
||||
|
||||
Edit the connected body with the usual Content mode controls. You can change text and supported properties, insert templates, reorder content, and delete content. Canvas changes appear immediately and then save to the MDX file.
|
||||
|
||||
While editing connected content, **Undo** and **Redo** restore source edits, including edits to referenced author files, without undoing the loading of the Content Block. This history keeps the last 20 source edits in the current project session. Reloading clears the history. If the file has changed outside that history, Webstudio does not overwrite it.
|
||||
|
||||
Component properties in Content mode are limited to authored content, such as links, media sources and alternative text, form labels and placeholders, code, and date values. Layout, dimensions, visual themes, form wiring, and interaction settings remain available only in Design mode.
|
||||
|
||||
If the file changes after a canvas edit starts but before it is saved, reload the Content Block before continuing. Webstudio keeps the local canvas state until reload and does not silently merge or overwrite either version.
|
||||
@@ -164,6 +166,8 @@ In the MDX editor, type `<` to autocomplete templates connected to the file and
|
||||
|
||||
Missing template references are marked as errors at their JSX source range. Hover the underline to read the explanation. For dynamic sources, the editor also uses currently rendered Content Blocks to find the file's templates.
|
||||
|
||||
Malformed MDX stays as an unsaved draft in the file editor. Fix the reported error before closing, or choose **Discard changes** to keep the last saved version. The saved article remains available while you repair the draft.
|
||||
|
||||
The Navigator shows an unresolved reference as **Missing template: Name**. Delete that item to remove its JSX, including its children, from the MDX file. Leaving the item in place preserves its source until the template is available.
|
||||
|
||||
When two component libraries export the same name, the core component keeps the plain identifier and the namespaced component gets a stable library prefix, such as `Checkbox` and `RadixCheckbox`. Component discovery reports the exact JSX identifier to use.
|
||||
|
||||
@@ -115,13 +115,13 @@ const containsDocumentReference = (value: JsonValue): boolean => {
|
||||
};
|
||||
|
||||
const isFieldAffectedByDocumentReference = (
|
||||
document: AssetFileDocument,
|
||||
document: ContentDatabaseDocument,
|
||||
field: AssetQueryFieldPath
|
||||
) => {
|
||||
if (field[0] !== "properties") {
|
||||
return false;
|
||||
}
|
||||
let value: JsonValue = document.properties;
|
||||
let value: JsonValue = document.properties ?? {};
|
||||
for (const segment of field.slice(1)) {
|
||||
if (typeof getJsonReferenceMarkerValue(value) === "string") {
|
||||
return true;
|
||||
@@ -151,7 +151,7 @@ const evaluateWhere = ({
|
||||
where,
|
||||
available,
|
||||
}: {
|
||||
document: AssetFileDocument;
|
||||
document: ContentDatabaseDocument;
|
||||
where: ContentCompilationWhere;
|
||||
available: "base" | "all";
|
||||
}): boolean | undefined => {
|
||||
@@ -280,7 +280,7 @@ export const selectContentHydrationCandidates = ({
|
||||
documents,
|
||||
plan,
|
||||
}: {
|
||||
documents: readonly AssetFileDocument[];
|
||||
documents: readonly ContentDatabaseDocument[];
|
||||
plan: ContentCompilationPlan;
|
||||
}) => {
|
||||
const selected = new Set<string>();
|
||||
|
||||
@@ -416,10 +416,26 @@ describe("Content Block MDX compilation", () => {
|
||||
}),
|
||||
});
|
||||
const artifact = {
|
||||
documentGraph: {
|
||||
nodes: [
|
||||
{ id: "post", revision: "r1", contentRef: "post" },
|
||||
{ id: "author", revision: "r1", contentRef: "author" },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
sourceId: "post",
|
||||
referenceId: "#frontmatter/author",
|
||||
reference: {
|
||||
documentId: "author",
|
||||
revision: "r1",
|
||||
representation: { type: "document" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
documents: [
|
||||
{
|
||||
_id: "post",
|
||||
_type: "asset.file",
|
||||
name: "post.json",
|
||||
path: "post.json",
|
||||
key: "post",
|
||||
@@ -430,7 +446,6 @@ describe("Content Block MDX compilation", () => {
|
||||
},
|
||||
{
|
||||
_id: "private-post",
|
||||
_type: "asset.file",
|
||||
name: "private.json",
|
||||
path: "private.json",
|
||||
key: "private",
|
||||
@@ -441,7 +456,6 @@ describe("Content Block MDX compilation", () => {
|
||||
},
|
||||
{
|
||||
_id: "second-post",
|
||||
_type: "asset.file",
|
||||
name: "second.json",
|
||||
path: "second.json",
|
||||
key: "second",
|
||||
@@ -472,6 +486,18 @@ describe("Content Block MDX compilation", () => {
|
||||
expect(plan?.queries.map(({ id }) => id)).not.toContain(
|
||||
"__content-block-mdx__:second.mdx"
|
||||
);
|
||||
|
||||
// Returning an unrelated author is safe; using that unresolved reference
|
||||
// as the MDX source is still rejected, including inside a Collection.
|
||||
build.props[0].value =
|
||||
kind === "detail"
|
||||
? `${resourceVariable}.data.properties.author.mdx`
|
||||
: `${itemVariable}.properties.author.mdx`;
|
||||
expect(() =>
|
||||
resolvePublishedMdxAssetCandidates({ build, artifact })
|
||||
).toThrow(
|
||||
"Dynamic MDX source candidates through resolved document references"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
createDocumentGraph,
|
||||
createLiteralContentCompilationQuery,
|
||||
getDocumentGraphQueryRootIds,
|
||||
isDocumentGraphFieldAffected,
|
||||
type DocumentGraph,
|
||||
hasDynamicContentCompilationValues,
|
||||
type AssetQueryInput,
|
||||
type ContentCompilationPlan,
|
||||
@@ -12,7 +14,7 @@ import {
|
||||
defaultAssetResourceOutputSelection,
|
||||
getAssetQueryFieldValue,
|
||||
selectContentHydrationCandidates,
|
||||
type AssetFileDocument,
|
||||
type ContentDatabaseDocument,
|
||||
} from "@webstudio-is/content-engine";
|
||||
import {
|
||||
blockComponent,
|
||||
@@ -344,7 +346,8 @@ export const resolvePublishedMdxDependencyClosure = async ({
|
||||
|
||||
const candidateDocumentKey = Symbol("candidate-document");
|
||||
type CandidateDocument = Readonly<{
|
||||
[candidateDocumentKey]: AssetFileDocument;
|
||||
[candidateDocumentKey]: ContentDatabaseDocument;
|
||||
graph?: DocumentGraph;
|
||||
}>;
|
||||
|
||||
const isCandidateDocument = (value: unknown): value is CandidateDocument =>
|
||||
@@ -355,6 +358,19 @@ const getValueAtPath = (
|
||||
path: readonly string[]
|
||||
): unknown => {
|
||||
if (isCandidateDocument(value)) {
|
||||
if (
|
||||
path[0] === "properties" &&
|
||||
value.graph !== undefined &&
|
||||
isDocumentGraphFieldAffected({
|
||||
graph: value.graph,
|
||||
sourceId: value[candidateDocumentKey]._id,
|
||||
field: ["properties", ...path.slice(1)],
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
"Dynamic MDX source candidates through resolved document references are not supported safely for publication"
|
||||
);
|
||||
}
|
||||
return path.length === 0
|
||||
? value[candidateDocumentKey]._id
|
||||
: getAssetQueryFieldValue(value[candidateDocumentKey], [...path]);
|
||||
@@ -408,9 +424,9 @@ export const resolvePublishedMdxAssetCandidates = ({
|
||||
artifact?.documentGraph === undefined
|
||||
? undefined
|
||||
: createDocumentGraph(artifact.documentGraph);
|
||||
const documents = (artifact?.documents ?? []).filter(
|
||||
(document): document is AssetFileDocument => document._type === "asset.file"
|
||||
);
|
||||
// Compiled documents are projected file records; metadata such as _type
|
||||
// can be omitted by the resource's output selection.
|
||||
const documents = artifact?.documents ?? [];
|
||||
|
||||
const evaluateDataSource = (
|
||||
dataSourceId: string,
|
||||
@@ -448,8 +464,16 @@ export const resolvePublishedMdxAssetCandidates = ({
|
||||
}
|
||||
if (documentGraph !== undefined) {
|
||||
if (
|
||||
getDocumentGraphQueryRootIds({ graph: documentGraph, query }).length >
|
||||
0
|
||||
getDocumentGraphQueryRootIds({
|
||||
graph: documentGraph,
|
||||
// Only filtering and sorting affect candidate membership here.
|
||||
// Check the field used by src when it is read below, not every
|
||||
// unrelated reference included in the resource output.
|
||||
query: {
|
||||
...query,
|
||||
output: { mode: "fields", fields: [], includeMetadata: false },
|
||||
},
|
||||
}).length > 0
|
||||
) {
|
||||
throw new Error(
|
||||
"Dynamic MDX source candidates through resolved document references are not supported safely for publication"
|
||||
@@ -475,8 +499,12 @@ export const resolvePublishedMdxAssetCandidates = ({
|
||||
fieldPath.length === 0
|
||||
? ({
|
||||
[candidateDocumentKey]: document,
|
||||
graph: documentGraph,
|
||||
} satisfies CandidateDocument)
|
||||
: getAssetQueryFieldValue(document, [...fieldPath])
|
||||
: getValueAtPath(
|
||||
{ [candidateDocumentKey]: document, graph: documentGraph },
|
||||
fieldPath
|
||||
)
|
||||
);
|
||||
}
|
||||
const collection = instances.get(dataSource.scopeInstanceId ?? "");
|
||||
|
||||
@@ -1870,10 +1870,6 @@ const assertSupportedNamespaces = ({
|
||||
nextKeys.length !== nextKeySet.size ||
|
||||
Array.from(originalByKey.keys()).some(
|
||||
(key) => !nextKeySet.has(key) && !removableKeys.has(key)
|
||||
) ||
|
||||
!equal(
|
||||
nextKeys,
|
||||
Array.from(originalByKey.keys()).filter((key) => nextKeySet.has(key))
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
elementComponent,
|
||||
type WebstudioData,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { componentMetas } from "@webstudio-is/sdk-components-registry/metas";
|
||||
import { materializeMdxSource } from "./mdx-source";
|
||||
import { serializeMdxAuthoredContent } from "./mdx-authored-content";
|
||||
|
||||
test("saves content when extracting template records changes their map order", async () => {
|
||||
const data: Omit<WebstudioData, "pages"> = {
|
||||
instances: new Map([
|
||||
[
|
||||
"block",
|
||||
{
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "templates" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"templates",
|
||||
{
|
||||
type: "instance",
|
||||
id: "templates",
|
||||
component: blockTemplateComponent,
|
||||
children: [
|
||||
{ type: "id", value: "heading" },
|
||||
{ type: "id", value: "paragraph" },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"heading",
|
||||
{
|
||||
type: "instance",
|
||||
id: "heading",
|
||||
component: elementComponent,
|
||||
tag: "h2",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
"paragraph",
|
||||
{
|
||||
type: "instance",
|
||||
id: "paragraph",
|
||||
component: elementComponent,
|
||||
tag: "p",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map(),
|
||||
dataSources: new Map(),
|
||||
resources: new Map(),
|
||||
assets: new Map(),
|
||||
breakpoints: new Map(),
|
||||
styles: new Map(),
|
||||
styleSources: new Map([
|
||||
["heading-style", { type: "local", id: "heading-style" }],
|
||||
["paragraph-style", { type: "local", id: "paragraph-style" }],
|
||||
]),
|
||||
styleSourceSelections: new Map([
|
||||
["heading", { instanceId: "heading", values: ["heading-style"] }],
|
||||
["paragraph", { instanceId: "paragraph", values: ["paragraph-style"] }],
|
||||
]),
|
||||
};
|
||||
const { root } = await materializeMdxSource({
|
||||
source: "## Heading\n\nParagraph",
|
||||
identity: {
|
||||
blockInstanceId: "block",
|
||||
assetId: "article",
|
||||
revision: "one",
|
||||
contentRef: "article.mdx",
|
||||
format: "mdx",
|
||||
renderScope: "page",
|
||||
},
|
||||
data,
|
||||
metas: componentMetas,
|
||||
projectId: "project",
|
||||
});
|
||||
const fragment = structuredClone(root.fragment);
|
||||
expect(fragment.styleSourceSelections).toHaveLength(2);
|
||||
fragment.styleSourceSelections.reverse();
|
||||
fragment.styleSources.reverse();
|
||||
fragment.instances.find(({ tag }) => tag === "h2")!.children = [
|
||||
{ type: "text", value: "Updated heading" },
|
||||
];
|
||||
const source = await serializeMdxAuthoredContent({ root, fragment });
|
||||
expect(source).toContain("Updated heading");
|
||||
expect(source).toContain("Paragraph");
|
||||
expect(source).not.toContain("heading-style");
|
||||
|
||||
// Changing selections themselves must still be rejected, not silently lost.
|
||||
fragment.styleSourceSelections[0].values.push("another-style");
|
||||
await expect(serializeMdxAuthoredContent({ root, fragment })).rejects.toThrow(
|
||||
"Changes to styleSourceSelections"
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user