diff --git a/apps/admin-x-framework/src/test/acceptance.ts b/apps/admin-x-framework/src/test/acceptance.ts index 1bff7a5ce20..97c77e61407 100644 --- a/apps/admin-x-framework/src/test/acceptance.ts +++ b/apps/admin-x-framework/src/test/acceptance.ts @@ -44,6 +44,7 @@ interface MockRequestConfig { method: string; path: string | RegExp; response: unknown; + rawResponse?: string | ArrayBuffer | Uint8Array | Buffer; responseStatus?: number; responseHeaders?: {[key: string]: string}; } @@ -208,6 +209,22 @@ export function createMockRequests(overrides: Record export async function mockApi>({page, requests, options = {}}: {page: Page, requests: Requests, options?: {useActivityPub?: boolean}}) { const lastApiRequests: {[key in keyof Requests]?: RequestRecord} = {}; + const getResponseBody = (matchingMock: MockRequestConfig) => { + if (typeof matchingMock.rawResponse === 'string' || Buffer.isBuffer(matchingMock.rawResponse)) { + return matchingMock.rawResponse; + } + + if (matchingMock.rawResponse instanceof ArrayBuffer) { + return Buffer.from(matchingMock.rawResponse); + } + + if (matchingMock.rawResponse instanceof Uint8Array) { + return Buffer.from(matchingMock.rawResponse); + } + + return typeof matchingMock.response === 'string' ? matchingMock.response : JSON.stringify(matchingMock.response); + }; + const namedRequests = Object.entries(requests).reduce( (array, [key, value]) => array.concat({name: key, ...value}), [] as Array @@ -260,7 +277,7 @@ export async function mockApi await route.fulfill({ status: matchingMock.responseStatus || 200, - body: typeof matchingMock.response === 'string' ? matchingMock.response : JSON.stringify(matchingMock.response), + body: getResponseBody(matchingMock), headers: matchingMock.responseHeaders || {} }); }); diff --git a/apps/admin-x-settings/package.json b/apps/admin-x-settings/package.json index 7ecc453f9a6..735044f359b 100644 --- a/apps/admin-x-settings/package.json +++ b/apps/admin-x-settings/package.json @@ -40,7 +40,14 @@ "preview": "vite preview" }, "dependencies": { + "@codemirror/theme-one-dark": "6.1.3", + "@codemirror/lang-css": "6.3.1", "@codemirror/lang-html": "6.4.11", + "@codemirror/lang-javascript": "6.2.4", + "@codemirror/lang-json": "6.0.2", + "@codemirror/lang-markdown": "6.3.4", + "@codemirror/search": "6.6.0", + "@codemirror/lang-yaml": "6.1.2", "@dnd-kit/sortable": "7.0.2", "@ebay/nice-modal-react": "1.2.13", "@sentry/react": "7.120.4", @@ -53,6 +60,7 @@ "@tryghost/timezone-data": "0.4.18", "@uiw/react-codemirror": "4.25.2", "clsx": "2.1.1", + "jszip": "^3.10.1", "lucide-react": "0.577.0", "mingo": "2.5.3", "react": "18.3.1", diff --git a/apps/admin-x-settings/src/components/providers/settings-router.tsx b/apps/admin-x-settings/src/components/providers/settings-router.tsx index ed226a33f99..1e8984d886d 100644 --- a/apps/admin-x-settings/src/components/providers/settings-router.tsx +++ b/apps/admin-x-settings/src/components/providers/settings-router.tsx @@ -7,6 +7,7 @@ export const modalPaths: {[key: string]: ModalName} = { 'design/change-theme': 'DesignAndThemeModal', 'design/edit': 'DesignAndThemeModal', 'theme/install': 'DesignAndThemeModal', // this is a special route, because it can install a theme directly from the Ghost Marketplace + 'theme/edit/:name': 'DesignAndThemeModal', 'navigation/edit': 'NavigationModal', 'staff/invite': 'InviteUserModal', 'staff/:slug/social-links': 'UserDetailModal', diff --git a/apps/admin-x-settings/src/components/settings/site/change-theme.tsx b/apps/admin-x-settings/src/components/settings/site/change-theme.tsx index 1f864ba5bf1..59f4f40952c 100644 --- a/apps/admin-x-settings/src/components/settings/site/change-theme.tsx +++ b/apps/admin-x-settings/src/components/settings/site/change-theme.tsx @@ -1,8 +1,9 @@ import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useState} from 'react'; import TopLevelGroup from '../../top-level-group'; -import {Button, LimitModal, SettingGroupContent, withErrorBoundary} from '@tryghost/admin-x-design-system'; -import {type Theme, useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; +import {Button, Heading, LimitModal, Menu, SettingGroupContent, withErrorBoundary} from '@tryghost/admin-x-design-system'; +import {type Theme, isDefaultOrLegacyTheme, useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; +import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useCheckThemeLimitError} from '../../../hooks/use-check-theme-limit-error'; import {useRouting} from '@tryghost/admin-x-framework/routing'; @@ -10,7 +11,7 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => { const [themeLimitError, setThemeLimitError] = useState(null); const [isCheckingLimit, setIsCheckingLimit] = useState(false); const {checkThemeLimitError} = useCheckThemeLimitError(); - const {updateRoute} = useRouting(); + const {route, updateRoute} = useRouting(); const {data: themesData} = useBrowseThemes(); const activeTheme = themesData?.themes.find((theme: Theme) => theme.active); @@ -41,21 +42,73 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => { } }; + const openThemeEditor = async () => { + if (!activeTheme) { + return; + } + + const limitError = await checkThemeLimitError(isDefaultOrLegacyTheme(activeTheme) ? '.' : activeTheme.name); + + if (limitError) { + NiceModal.show(LimitModal, { + prompt: limitError, + onOk: () => updateRoute({route: '/pro', isExternal: true}) + }); + return; + } + + updateRoute(`theme/edit/${encodeURIComponent(activeTheme.name)}?from=${encodeURIComponent(route ?? '')}`); + }; + + const downloadTheme = () => { + if (!activeTheme) { + return; + } + + const {apiRoot} = getGhostPaths(); + downloadFile(`${apiRoot}/themes/${activeTheme.name}/download`); + }; + + const themeMenuItems = [ + { + id: 'edit-code', + label: 'Edit code', + onClick: openThemeEditor + }, + { + id: 'download', + label: 'Download', + onClick: downloadTheme + } + ]; + const values = ( - + +
+ Active theme +
+
{activeTheme ? `${activeTheme.name} (v${activeTheme.package?.version || '1.0'})` : 'Loading...'}
+
+ +
+
+
+
); return ( } + customButtons={ + + ); + } + + const isExpanded = expandedDirectories.has(node.path); + const isSelected = selectedNode?.type === 'dir' && selectedNode.path === node.path; + const children = sortTreeNodes(Array.from(node.children?.values() || [])); + + return ( +
+ {node.path && ( + + )} + {(node.path === '' || isExpanded) && ( +
+ {children.map(child => renderTreeNode(child, node.path ? depth + 1 : depth))} +
+ )} +
+ ); + }; + + const selectedFileStatus = selectedFile ? changesMap.get(selectedFile.path) : null; + const reviewSummary = formatReviewSummary(reviewItems); + + return ( +
{ + if (event.target === event.currentTarget) { + closeEditor(); + } + }} + > +
+
+
+

Edit theme

+ {currentThemeName} +
+ {changes.length > 0 && ( + + )} +
+ + +
+ + {loadError && ( +
+ {loadError} +
+ )} + +
+ + +
+
+ {selectedFile ? ( + <> + {selectedFile.path} + + {getLanguageLabel(selectedFile.path)} + + {selectedFileStatus && ( + + )} +
+ {selectedFile.editable && ( + + )} + + ) : ( + No file selected + )} +
+ +
+ {!selectedNode && !isLoading && ( +
+ Select a file from the tree to start editing. +
+ )} + + {selectedNode?.type === 'dir' && ( +
+ Folder selected. Choose a file to edit, or rename or delete the folder from the file pane. +
+ )} + + {selectedFile && !selectedFile.editable && ( +
+ This file cannot be edited in the browser. +
+ )} + + {selectedFile?.editable && ( + { + setCurrentFiles(files => ({ + ...files, + [selectedFile.path]: { + ...files[selectedFile.path], + content: value + } + })); + }} + /> + )} +
+
+
+ + {isReviewOpen && ( +
setIsReviewOpen(false)}> +
event.stopPropagation()}> +
+
+

All changes

+

{reviewSummary}

+
+ +
+ +
+
+ {reviewItems.map(item => ( + + ))} + {reviewItems.length === 0 && ( +
No unsaved changes.
+ )} +
+ +
+ {selectedReviewItem ? ( + <> +
+
+
{selectedReviewItem.path}
+
+ {selectedReviewItem.editable ? 'Text file preview' : 'Binary file'} +
+
+ {selectedReviewItem.status !== 'deleted' && ( + + )} + +
+ + {!selectedReviewItem.editable ? ( +
+ Binary files are kept intact in the archive. Open or revert the change from here, but binary contents are not shown. +
+ ) : selectedReviewItem.status === 'added' ? ( +
+
After
+
{selectedReviewItem.after ?? ''}
+
+ ) : selectedReviewItem.status === 'deleted' ? ( +
+
Before
+
{selectedReviewItem.before ?? ''}
+
+ ) : ( +
+
+
Before
+
{selectedReviewItem.before ?? ''}
+
+
+
After
+
{selectedReviewItem.after ?? ''}
+
+
+ )} + + ) : ( +
+ Select a changed file to review it. +
+ )} +
+
+
+
+ )} +
+
+ ); +}; + +export default ThemeCodeEditorModal; diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts b/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts new file mode 100644 index 00000000000..8cd722ce367 --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts @@ -0,0 +1,387 @@ +import JSZip from 'jszip'; + +export type ThemeEditorFile = { + path: string; + editable: boolean; + content: string | null; + binary: Uint8Array | null; + date: Date; + unixPermissions: number | null; + dosPermissions: number | null; +}; + +export type ThemeEditorSnapshot = { + files: Record; + rootPrefix: string; +}; + +export const THEME_EDITOR_ARCHIVE_LIMITS = { + maxFiles: 1000, + maxExtractedBytes: 32 * 1024 * 1024 +} as const; + +export class ThemeArchiveExtractionError extends Error { + reason: 'invalid_archive' | 'too_many_files' | 'too_large'; + + constructor(reason: 'invalid_archive' | 'too_many_files' | 'too_large', message: string) { + super(message); + this.name = 'ThemeArchiveExtractionError'; + this.reason = reason; + } +} + +const editableExtensions = new Set([ + 'css', + 'cjs', + 'hbs', + 'handlebars', + 'htm', + 'html', + 'js', + 'json', + 'less', + 'md', + 'markdown', + 'mjs', + 'sass', + 'scss', + 'svg', + 'txt', + 'xml', + 'yaml', + 'yml' +]); + +const editableBasenames = new Set([ + '.editorconfig', + '.eslintignore', + '.eslintrc', + '.gitattributes', + '.gitignore', + '.npmignore', + '.prettierignore', + '.prettierrc', + 'CODEOWNERS', + 'LICENSE', + 'LICENCE', + 'NOTICE', + 'Procfile' +]); + +export const isEditablePath = (path: string) => { + if (editableExtensions.has(getExtension(path))) { + return true; + } + + const segments = path.split('/'); + const basename = segments.at(-1) || path; + + return editableBasenames.has(basename); +}; + +export const getExtension = (path: string) => { + const dotIndex = path.lastIndexOf('.'); + if (dotIndex === -1) { + return ''; + } + + return path.slice(dotIndex + 1).toLowerCase(); +}; + +export const normaliseRelativePath = (input: string) => { + const cleaned = input.trim().replace(/^\/+/, '').replace(/\/+/g, '/'); + + if (!cleaned) { + return null; + } + + const segments = cleaned.split('/').filter(Boolean); + + if (segments.length === 0) { + return null; + } + + if (segments.some(segment => segment === '.' || segment === '..')) { + return null; + } + + return segments.join('/'); +}; + +export const isDefaultThemeName = (themeName: string) => ['casper', 'source'].includes(themeName.toLowerCase()); + +export const detectCommonRoot = (paths: string[]) => { + if (paths.length === 0) { + return ''; + } + + const firstSlash = paths[0].indexOf('/'); + + if (firstSlash <= 0) { + return ''; + } + + const prefix = paths[0].slice(0, firstSlash + 1); + + if (paths.every(path => path.startsWith(prefix))) { + return prefix; + } + + return ''; +}; + +export const cloneThemeFiles = (files: Record) => { + return Object.fromEntries(Object.entries(files).map(([path, file]) => { + return [path, { + ...file, + date: new Date(file.date), + binary: file.binary ? new Uint8Array(file.binary) : null + }]; + })); +}; + +const invalidArchiveMessage = 'Failed to open the theme archive. Download the theme again and retry.'; + +const getThemeArchiveSizeLabel = (bytes: number) => { + const megabytes = bytes / (1024 * 1024); + + if (Number.isInteger(megabytes)) { + return `${megabytes} MB`; + } + + return `${megabytes.toFixed(1)} MB`; +}; + +const collectArchiveEntries = (zip: JSZip) => { + const entries: Array<[string, JSZip.JSZipObject]> = []; + + zip.forEach((relativePath, entry) => { + if (!entry.dir) { + entries.push([relativePath, entry]); + } + }); + + return entries; +}; + +const assertThemeArchiveLimits = (entries: Array<[string, JSZip.JSZipObject]>) => { + if (entries.length > THEME_EDITOR_ARCHIVE_LIMITS.maxFiles) { + throw new ThemeArchiveExtractionError( + 'too_many_files', + `This theme archive contains too many files for the browser editor (${entries.length}/${THEME_EDITOR_ARCHIVE_LIMITS.maxFiles}).` + ); + } +}; + +const loadThemeArchive = async (arrayBuffer: ArrayBuffer) => { + try { + return await JSZip.loadAsync(arrayBuffer); + } catch { + throw new ThemeArchiveExtractionError('invalid_archive', invalidArchiveMessage); + } +}; + +const readThemeBinaryFile = async (entry: JSZip.JSZipObject) => { + try { + return await entry.async('uint8array'); + } catch { + throw new ThemeArchiveExtractionError('invalid_archive', invalidArchiveMessage); + } +}; + +const readThemeTextFile = async (entry: JSZip.JSZipObject) => { + try { + return await entry.async('string'); + } catch { + throw new ThemeArchiveExtractionError('invalid_archive', invalidArchiveMessage); + } +}; + +const getNormalizedArchivePath = (path: string) => { + const normalizedPath = normaliseRelativePath(path); + + if (!normalizedPath || normalizedPath !== path) { + throw new ThemeArchiveExtractionError('invalid_archive', invalidArchiveMessage); + } + + return normalizedPath; +}; + +const trackExtractedBytes = (totalBytes: number, fileBytes: number) => { + const nextTotal = totalBytes + fileBytes; + + if (nextTotal > THEME_EDITOR_ARCHIVE_LIMITS.maxExtractedBytes) { + throw new ThemeArchiveExtractionError( + 'too_large', + `This theme archive is too large to open in the browser editor. Extracted files must stay under ${getThemeArchiveSizeLabel(THEME_EDITOR_ARCHIVE_LIMITS.maxExtractedBytes)}.` + ); + } + + return nextTotal; +}; + +export const extractThemeArchive = async (arrayBuffer: ArrayBuffer): Promise => { + const zip = await loadThemeArchive(arrayBuffer); + const entries = collectArchiveEntries(zip); + + assertThemeArchiveLimits(entries); + + const rootPrefix = detectCommonRoot(entries.map(([path]) => path)); + const files: Record = {}; + const textEncoder = new TextEncoder(); + let extractedBytes = 0; + + for (const [zipPath, entry] of entries) { + const displayPath = rootPrefix ? zipPath.slice(rootPrefix.length) : zipPath; + + if (!displayPath) { + continue; + } + + const normalizedPath = getNormalizedArchivePath(displayPath); + const editable = isEditablePath(normalizedPath); + + if (editable) { + try { + const content = await readThemeTextFile(entry); + extractedBytes = trackExtractedBytes(extractedBytes, textEncoder.encode(content).byteLength); + + files[normalizedPath] = { + path: normalizedPath, + editable: true, + content, + binary: null, + date: entry.date || new Date(), + unixPermissions: typeof entry.unixPermissions === 'number' ? entry.unixPermissions : null, + dosPermissions: typeof entry.dosPermissions === 'number' ? entry.dosPermissions : null + }; + continue; + } catch { + // Fall back to binary handling below. + } + } + + const binary = await readThemeBinaryFile(entry); + extractedBytes = trackExtractedBytes(extractedBytes, binary.byteLength); + + files[normalizedPath] = { + path: normalizedPath, + editable: false, + content: null, + binary, + date: entry.date || new Date(), + unixPermissions: typeof entry.unixPermissions === 'number' ? entry.unixPermissions : null, + dosPermissions: typeof entry.dosPermissions === 'number' ? entry.dosPermissions : null + }; + } + + return {files, rootPrefix}; +}; + +export const packThemeArchive = async ({files, rootPrefix}: ThemeEditorSnapshot) => { + const zip = new JSZip(); + + for (const [path, file] of Object.entries(files)) { + const zipPath = `${rootPrefix}${path}`; + const options = { + date: file.date, + createFolders: true, + unixPermissions: file.unixPermissions ?? undefined, + dosPermissions: file.dosPermissions ?? undefined + }; + + if (file.editable) { + zip.file(zipPath, file.content ?? '', options); + } else { + zip.file(zipPath, file.binary!, { + ...options, + binary: true + }); + } + } + + return zip.generateAsync({ + type: 'blob', + mimeType: 'application/zip', + compression: 'DEFLATE', + compressionOptions: {level: 6} + }); +}; + +export type ThemeChange = { + path: string; + editable: boolean; + status: 'added' | 'deleted' | 'modified'; +}; + +export const getThemeChanges = ({baseFiles, currentFiles}: { + baseFiles: Record; + currentFiles: Record; +}) => { + const allPaths = new Set([...Object.keys(baseFiles), ...Object.keys(currentFiles)]); + const changes: ThemeChange[] = []; + + for (const path of Array.from(allPaths).sort()) { + const baseFile = baseFiles[path]; + const currentFile = currentFiles[path]; + + if (!baseFile && currentFile) { + changes.push({ + path, + editable: currentFile.editable, + status: 'added' + }); + continue; + } + + if (baseFile && !currentFile) { + changes.push({ + path, + editable: baseFile.editable, + status: 'deleted' + }); + continue; + } + + if (!baseFile || !currentFile) { + continue; + } + + if (baseFile.editable && currentFile.editable && baseFile.content !== currentFile.content) { + changes.push({ + path, + editable: true, + status: 'modified' + }); + } + } + + return changes; +}; + +export const createFolderRenameMap = ({ + files, + oldPrefix, + newPrefix +}: { + files: Record; + oldPrefix: string; + newPrefix: string; +}) => { + const updates: Record = {}; + + for (const [path, file] of Object.entries(files)) { + if (!path.startsWith(oldPrefix)) { + updates[path] = file; + continue; + } + + const updatedPath = `${newPrefix}${path.slice(oldPrefix.length)}`; + updates[updatedPath] = { + ...file, + path: updatedPath + }; + } + + return updates; +}; diff --git a/apps/admin-x-settings/test/acceptance/site/theme.test.ts b/apps/admin-x-settings/test/acceptance/site/theme.test.ts index 98f5a3e9219..600bcb2bb60 100644 --- a/apps/admin-x-settings/test/acceptance/site/theme.test.ts +++ b/apps/admin-x-settings/test/acceptance/site/theme.test.ts @@ -1,5 +1,72 @@ +import JSZip from 'jszip'; +import path from 'path'; import {expect, test} from '@playwright/test'; import {expectExternalNavigate, globalDataRequests, limitRequests, mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance'; +import {readFileSync} from 'fs'; +import type {Page} from '@playwright/test'; + +const themeEditorZip = readFileSync(path.join(__dirname, '../../utils/responses/theme.zip')); + +const customThemesLimitConfig = (allowlist: string[], error: string) => ({ + ...globalDataRequests.browseConfig, + response: { + config: { + ...responseFixtures.config.config, + hostSettings: { + limits: { + customThemes: { + allowlist, + error + } + } + } + } + } +}); + +const themeDownloadRequest = (themeName: string) => ({ + method: 'GET' as const, + path: `/themes/${themeName}/download/`, + response: '', + rawResponse: themeEditorZip, + responseHeaders: {'content-type': 'application/zip'} +}); + +const createArchiveBuffer = async (build: (zip: JSZip) => void) => { + const zip = new JSZip(); + + build(zip); + + return Buffer.from(await zip.generateAsync({type: 'uint8array'})); +}; + +async function openChangeThemeModal(page: Page) { + await page.goto('/#/settings/theme'); + await page.getByTestId('theme').getByRole('button', {name: 'Change theme'}).click(); + + return page.getByTestId('theme-modal'); +} + +async function openInstalledThemeEditor(page: Page, themeName: string) { + const modal = await openChangeThemeModal(page); + await modal.getByRole('tab', {name: 'Installed'}).click(); + + const themeListItem = modal.getByTestId('theme-list-item').filter({hasText: new RegExp(themeName, 'i')}); + await themeListItem.getByRole('button', {name: 'Menu'}).click(); + await page.getByTestId('popover-content').getByRole('button', {name: 'Edit code'}).click(); + + return page.getByTestId('theme-code-editor-modal'); +} + +async function openActiveThemeEditorFromSettings(page: Page) { + await page.goto('/#/settings'); + + const themeSection = page.getByTestId('theme'); + await themeSection.getByRole('button', {name: 'Menu'}).click(); + await page.getByTestId('popover-content').getByRole('button', {name: 'Edit code'}).click(); + + return page.getByTestId('theme-code-editor-modal'); +} test.describe('Theme settings', async () => { test('Browsing and installing default themes', async ({page}) => { @@ -189,6 +256,86 @@ test.describe('Theme settings', async () => { expect(lastApiRequests.uploadTheme).toBeTruthy(); }); + test('Supports editing and saving a custom theme in browser', async ({page}) => { + const {lastApiRequests} = await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: themeDownloadRequest('edition'), + uploadTheme: { + method: 'POST', + path: '/themes/upload/', + response: { + themes: [{ + name: 'edition', + package: { + name: 'Edition', + version: '1.0.0' + }, + active: true, + templates: [] + }] + } + } + }}); + + const editorModal = await openInstalledThemeEditor(page, 'edition'); + await expect(editorModal).toBeVisible(); + await expect(editorModal).toContainText('Edit theme'); + await expect(editorModal).toContainText('edition'); + + const codeEditor = editorModal.locator('.cm-content'); + await codeEditor.click(); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.insertText('{"name":"edition","version":"1.0.0"}\n'); + + await editorModal.getByRole('button', {name: 'Save'}).click(); + await page.getByTestId('theme-editor-confirm-modal').getByRole('button', {name: 'Replace theme'}).click(); + + await expect(page.getByTestId('toast-success')).toHaveText(/Theme saved/i); + expect(lastApiRequests.downloadTheme?.url).toMatch(/\/themes\/edition\/download/); + expect(lastApiRequests.uploadTheme?.url).toMatch(/\/themes\/upload\//); + }); + + test('Saves built-in themes as a new theme name', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: themeDownloadRequest('casper'), + uploadTheme: { + method: 'POST', + path: '/themes/upload/', + response: { + themes: [{ + name: 'casper-edited', + package: { + name: 'Casper Edited', + version: '1.0.0' + }, + active: false, + templates: [] + }] + } + } + }}); + + const editorModal = await openInstalledThemeEditor(page, 'casper'); + await expect(editorModal).toBeVisible(); + + const codeEditor = editorModal.locator('.cm-content'); + await codeEditor.click(); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.insertText('{"name":"casper","version":"1.0.0"}\n'); + + await editorModal.getByRole('button', {name: 'Save'}).click(); + const inputModal = page.getByTestId('theme-editor-input-modal'); + await inputModal.getByLabel('Theme name').fill('casper-edited'); + await inputModal.getByRole('button', {name: 'Continue'}).click(); + await page.getByTestId('theme-editor-confirm-modal').getByRole('button', {name: 'Save theme'}).click(); + + await expect(page).toHaveURL(/#\/settings\/theme\/edit\/casper-edited/); + await expect(editorModal).toContainText('casper-edited'); + }); + test('Limits uploading new themes and redirect to /pro', async ({page}) => { await mockApi({page, requests: { ...globalDataRequests, @@ -396,22 +543,7 @@ test.describe('Theme settings', async () => { ...globalDataRequests, ...limitRequests, browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, - browseConfig: { - ...globalDataRequests.browseConfig, - response: { - config: { - ...responseFixtures.config.config, - hostSettings: { - limits: { - customThemes: { - allowlist: ['casper'], - error: 'Upgrade to use custom themes' - } - } - } - } - } - } + browseConfig: customThemesLimitConfig(['casper'], 'Upgrade to use custom themes') }}); // Navigate directly to the change theme route @@ -428,6 +560,117 @@ test.describe('Theme settings', async () => { await expect(page.getByTestId('theme-modal')).not.toBeVisible(); }); + test('Opens the editor from the active theme overflow menu and returns to settings on close', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: themeDownloadRequest('edition') + }}); + + const editorModal = await openActiveThemeEditorFromSettings(page); + + await expect(editorModal).toBeVisible(); + await expect(page).toHaveURL(/#\/settings\/theme\/edit\/edition/); + + await editorModal.getByRole('button', {name: 'Close'}).click(); + + await expect(page).toHaveURL(/#\/settings$/); + await expect(page.getByTestId('theme')).toBeVisible(); + }); + + test('Prevents direct access to theme editor route when editing is limited', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + ...limitRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + browseConfig: customThemesLimitConfig(['casper'], 'Upgrade to use custom themes') + }}); + + await page.goto('/#/settings/theme/edit/edition'); + + await page.waitForSelector('[data-testid="limit-modal"]', {timeout: 10000}); + + await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to use custom themes/); + await expect(page.getByTestId('theme-code-editor-modal')).not.toBeVisible(); + }); + + test('Redirects malformed theme editor routes back to theme settings', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes} + }}); + + const pageErrors: string[] = []; + page.on('pageerror', error => pageErrors.push(error.message)); + + await page.goto('/#/settings/theme/edit/%E0%A4%A'); + + await expect(page).toHaveURL(/#\/settings\/theme$/); + await expect(page.getByTestId('theme')).toBeVisible(); + await expect(page.getByTestId('theme-code-editor-modal')).not.toBeVisible(); + expect(pageErrors).toEqual([]); + }); + + test('Redirects encoded-slash theme editor routes back to theme settings', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes} + }}); + + const pageErrors: string[] = []; + page.on('pageerror', error => pageErrors.push(error.message)); + + await page.goto('/#/settings/theme/edit/%2Fedition'); + + await expect(page).toHaveURL(/#\/settings\/theme$/); + await expect(page.getByTestId('theme')).toBeVisible(); + await expect(page.getByTestId('theme-code-editor-modal')).not.toBeVisible(); + expect(pageErrors).toEqual([]); + }); + + test('Allows selecting a non-editable file and shows the browser-edit message', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: themeDownloadRequest('edition') + }}); + + const editorModal = await openInstalledThemeEditor(page, 'edition'); + + await editorModal.getByRole('button', {name: '.DS_Store'}).click(); + + await expect(editorModal).toContainText('This file cannot be edited in the browser.'); + await expect(editorModal.locator('.cm-editor')).toHaveCount(0); + }); + + test('Shows a controlled error when the downloaded theme archive exceeds editor limits', async ({page}) => { + const oversizedThemeZip = await createArchiveBuffer((zip) => { + for (let index = 0; index <= 1000; index += 1) { + zip.file(`theme/partials/file-${index}.hbs`, `{{! file ${index} }}`); + } + }); + + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: { + method: 'GET', + path: '/themes/edition/download/', + response: '', + rawResponse: oversizedThemeZip, + responseHeaders: {'content-type': 'application/zip'} + } + }}); + + const editorModal = await openInstalledThemeEditor(page, 'edition'); + + await expect(editorModal).toContainText('This theme archive contains too many files for the browser editor'); + await expect(editorModal.getByRole('button', {name: 'Save'})).toBeEnabled(); + await editorModal.getByRole('button', {name: 'Save'}).click(); + await expect(page.getByTestId('toast-info')).toHaveText(/No changes to save/i); + await expect(editorModal).toContainText('Select a file from the tree to start editing.'); + }); + test('Theme install route works without limits', async ({page}) => { await mockApi({page, requests: { ...globalDataRequests, diff --git a/apps/admin-x-settings/test/unit/utils/theme-editor-utils.test.ts b/apps/admin-x-settings/test/unit/utils/theme-editor-utils.test.ts new file mode 100644 index 00000000000..e93cd8156cd --- /dev/null +++ b/apps/admin-x-settings/test/unit/utils/theme-editor-utils.test.ts @@ -0,0 +1,303 @@ +import * as assert from 'assert/strict'; +import JSZip from 'jszip'; +import { + THEME_EDITOR_ARCHIVE_LIMITS, + ThemeArchiveExtractionError, + type ThemeEditorSnapshot, + cloneThemeFiles, + createFolderRenameMap, + extractThemeArchive, + getThemeChanges, + isEditablePath, + packThemeArchive +} from '@src/components/settings/site/theme/theme-editor-utils'; + +const createArchiveBuffer = async (build: (zip: JSZip) => void) => { + const zip = new JSZip(); + + build(zip); + + return zip.generateAsync({type: 'arraybuffer'}); +}; + +const uint8ArrayToArray = (value: Uint8Array | null) => Array.from(value ?? []); + +describe('theme-editor-utils', function () { + describe('isEditablePath', function () { + it('treats extension-based theme source files as editable', function () { + assert.equal(isEditablePath('partials/post-card.hbs'), true); + assert.equal(isEditablePath('assets/app.css'), true); + }); + + it('treats common extensionless text files as editable', function () { + assert.equal(isEditablePath('.gitignore'), true); + assert.equal(isEditablePath('LICENSE'), true); + assert.equal(isEditablePath('subdir/.editorconfig'), true); + }); + + it('keeps binary assets non-editable', function () { + assert.equal(isEditablePath('assets/logo.png'), false); + assert.equal(isEditablePath('assets/font.woff2'), false); + }); + }); + + describe('extractThemeArchive', function () { + it('detects and strips a shared root prefix while preserving file contents', async function () { + const packageJson = JSON.stringify({name: 'source-edited'}, null, 2); + const binaryLogo = new Uint8Array([137, 80, 78, 71, 0, 255, 12]); + const date = new Date('2026-05-03T12:00:00.000Z'); + const archive = await createArchiveBuffer((zip) => { + zip.file('source-edited/package.json', packageJson, { + date, + unixPermissions: 0o644 + }); + zip.file('source-edited/assets/logo.png', binaryLogo, { + binary: true, + date, + unixPermissions: 0o644 + }); + }); + + const snapshot = await extractThemeArchive(archive); + + assert.equal(snapshot.rootPrefix, 'source-edited/'); + assert.deepEqual(Object.keys(snapshot.files).sort(), ['assets/logo.png', 'package.json']); + assert.equal(snapshot.files['package.json'].editable, true); + assert.equal(snapshot.files['package.json'].content, packageJson); + assert.equal(snapshot.files['assets/logo.png'].editable, false); + assert.deepEqual(uint8ArrayToArray(snapshot.files['assets/logo.png'].binary), Array.from(binaryLogo)); + }); + + it('leaves flat archives without a synthetic root prefix', async function () { + const archive = await createArchiveBuffer((zip) => { + zip.file('index.hbs', '{{!< default}}'); + zip.file('assets/app.css', 'body { color: red; }'); + }); + + const snapshot = await extractThemeArchive(archive); + + assert.equal(snapshot.rootPrefix, ''); + assert.deepEqual(Object.keys(snapshot.files).sort(), ['assets/app.css', 'index.hbs']); + }); + + it('rejects archives with too many files before extracting them', async function () { + const archive = await createArchiveBuffer((zip) => { + for (let index = 0; index <= THEME_EDITOR_ARCHIVE_LIMITS.maxFiles; index += 1) { + zip.file(`partials/file-${index}.hbs`, `{{! file ${index} }}`); + } + }); + + await assert.rejects( + extractThemeArchive(archive), + (error: unknown) => { + assert.ok(error instanceof ThemeArchiveExtractionError); + assert.equal(error.reason, 'too_many_files'); + assert.match(error.message, /too many files/i); + + return true; + } + ); + }); + + it('rejects archives whose extracted contents exceed the browser limit', async function () { + const archive = await createArchiveBuffer((zip) => { + zip.file('assets/huge.bin', new Uint8Array(THEME_EDITOR_ARCHIVE_LIMITS.maxExtractedBytes + 1), { + binary: true, + compression: 'DEFLATE' + }); + }); + + await assert.rejects( + extractThemeArchive(archive), + (error: unknown) => { + assert.ok(error instanceof ThemeArchiveExtractionError); + assert.equal(error.reason, 'too_large'); + assert.match(error.message, /too large/i); + + return true; + } + ); + }); + + it('rejects archives with non-normalized entry paths', async function () { + const archive = await createArchiveBuffer((zip) => { + zip.file('/post-card.hbs', '{{title}}'); + }); + + await assert.rejects( + extractThemeArchive(archive), + (error: unknown) => { + assert.ok(error instanceof ThemeArchiveExtractionError); + assert.equal(error.reason, 'invalid_archive'); + assert.match(error.message, /failed to open the theme archive/i); + + return true; + } + ); + }); + }); + + describe('packThemeArchive', function () { + it('preserves root prefixes, editable text, and binary bytes across a roundtrip', async function () { + const originalArchive = await createArchiveBuffer((zip) => { + zip.file('source-edited/index.hbs', '
{{title}}
', { + date: new Date('2026-05-03T13:00:00.000Z') + }); + zip.file('source-edited/assets/logo.png', new Uint8Array([0, 1, 2, 200, 255]), { + binary: true, + date: new Date('2026-05-03T13:00:00.000Z') + }); + }); + + const extractedSnapshot = await extractThemeArchive(originalArchive); + const packedArchive = await packThemeArchive(extractedSnapshot); + const repackedBuffer = await packedArchive.arrayBuffer(); + const rawZip = await JSZip.loadAsync(repackedBuffer); + const roundTrippedSnapshot = await extractThemeArchive(repackedBuffer); + + assert.deepEqual( + Object.keys(rawZip.files).filter(path => !rawZip.files[path].dir).sort(), + ['source-edited/assets/logo.png', 'source-edited/index.hbs'] + ); + assert.equal(roundTrippedSnapshot.rootPrefix, 'source-edited/'); + assert.equal(roundTrippedSnapshot.files['index.hbs'].content, '
{{title}}
'); + assert.deepEqual( + uint8ArrayToArray(roundTrippedSnapshot.files['assets/logo.png'].binary), + [0, 1, 2, 200, 255] + ); + }); + + it('writes the current editable content into the archive instead of stale source text', async function () { + const snapshot: ThemeEditorSnapshot = { + rootPrefix: 'source-edited/', + files: { + 'index.hbs': { + path: 'index.hbs', + editable: true, + content: '
updated
', + binary: null, + date: new Date('2026-05-03T14:00:00.000Z'), + unixPermissions: null, + dosPermissions: null + } + } + }; + + const packedArchive = await packThemeArchive(snapshot); + const zip = await JSZip.loadAsync(await packedArchive.arrayBuffer()); + + assert.equal(await zip.file('source-edited/index.hbs')?.async('string'), '
updated
'); + }); + }); + + describe('getThemeChanges', function () { + it('reports sorted added, deleted, and modified text files', function () { + const date = new Date('2026-05-03T15:00:00.000Z'); + const baseFiles = { + 'assets/logo.png': { + path: 'assets/logo.png', + editable: false, + content: null, + binary: new Uint8Array([1, 2, 3]), + date, + unixPermissions: null, + dosPermissions: null + }, + 'index.hbs': { + path: 'index.hbs', + editable: true, + content: '
before
', + binary: null, + date, + unixPermissions: null, + dosPermissions: null + } + }; + const currentFiles = { + 'assets/app.css': { + path: 'assets/app.css', + editable: true, + content: 'body { color: green; }', + binary: null, + date, + unixPermissions: null, + dosPermissions: null + }, + 'index.hbs': { + path: 'index.hbs', + editable: true, + content: '
after
', + binary: null, + date, + unixPermissions: null, + dosPermissions: null + } + }; + + assert.deepEqual(getThemeChanges({baseFiles, currentFiles}), [ + {path: 'assets/app.css', editable: true, status: 'added'}, + {path: 'assets/logo.png', editable: false, status: 'deleted'}, + {path: 'index.hbs', editable: true, status: 'modified'} + ]); + }); + }); + + describe('cloneThemeFiles', function () { + it('deep-clones mutable file fields used by revert and save flows', function () { + const originalDate = new Date('2026-05-03T16:00:00.000Z'); + const originalFiles = { + 'assets/logo.png': { + path: 'assets/logo.png', + editable: false, + content: null, + binary: new Uint8Array([4, 5, 6]), + date: originalDate, + unixPermissions: null, + dosPermissions: null + } + }; + + const clonedFiles = cloneThemeFiles(originalFiles); + + clonedFiles['assets/logo.png'].binary![0] = 99; + clonedFiles['assets/logo.png'].date.setUTCFullYear(2030); + + assert.deepEqual(uint8ArrayToArray(originalFiles['assets/logo.png'].binary), [4, 5, 6]); + assert.equal(originalFiles['assets/logo.png'].date.getUTCFullYear(), 2026); + }); + }); + + describe('createFolderRenameMap', function () { + it('renames every file under a folder prefix without touching siblings', function () { + const date = new Date('2026-05-03T17:00:00.000Z'); + const renamedFiles = createFolderRenameMap({ + files: { + 'assets/app.css': { + path: 'assets/app.css', + editable: true, + content: 'body {}', + binary: null, + date, + unixPermissions: null, + dosPermissions: null + }, + 'partials/post-card.hbs': { + path: 'partials/post-card.hbs', + editable: true, + content: '{{title}}', + binary: null, + date, + unixPermissions: null, + dosPermissions: null + } + }, + oldPrefix: 'assets/', + newPrefix: 'static/' + }); + + assert.deepEqual(Object.keys(renamedFiles).sort(), ['partials/post-card.hbs', 'static/app.css']); + assert.equal(renamedFiles['static/app.css'].path, 'static/app.css'); + assert.equal(renamedFiles['partials/post-card.hbs'].path, 'partials/post-card.hbs'); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22d52b920ca..4d8b97c5582 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -579,9 +579,30 @@ importers: apps/admin-x-settings: dependencies: + '@codemirror/lang-css': + specifier: 6.3.1 + version: 6.3.1 '@codemirror/lang-html': specifier: 6.4.11 version: 6.4.11 + '@codemirror/lang-javascript': + specifier: 6.2.4 + version: 6.2.4 + '@codemirror/lang-json': + specifier: 6.0.2 + version: 6.0.2 + '@codemirror/lang-markdown': + specifier: 6.3.4 + version: 6.3.4 + '@codemirror/lang-yaml': + specifier: 6.1.2 + version: 6.1.2 + '@codemirror/search': + specifier: 6.6.0 + version: 6.6.0 + '@codemirror/theme-one-dark': + specifier: 6.1.3 + version: 6.1.3 '@dnd-kit/sortable': specifier: 7.0.2 version: 7.0.2(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) @@ -618,6 +639,9 @@ importers: clsx: specifier: 2.1.1 version: 2.1.1 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lucide-react: specifier: 0.577.0 version: 0.577.0(react@18.3.1) @@ -3642,8 +3666,17 @@ packages: '@codemirror/lang-html@6.4.11': resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} - '@codemirror/lang-javascript@6.2.5': - resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + '@codemirror/lang-javascript@6.2.4': + resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-markdown@6.3.4': + resolution: {integrity: sha512-fBm0BO03azXnTAsxhONDYHi/qWSI+uSEIpzKM7h/bkIc9fHnFp9y7KTMXKON0teNT97pFhc1a9DQTtWBYEZ7ug==} + + '@codemirror/lang-yaml@6.1.2': + resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==} '@codemirror/language@6.12.3': resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} @@ -5379,9 +5412,18 @@ packages: '@lezer/javascript@1.5.4': resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + '@lezer/lr@1.4.8': resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + '@lezer/markdown@1.6.3': + resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + '@lint-todo/utils@13.1.1': resolution: {integrity: sha512-F5z53uvRIF4dYfFfJP3a2Cqg+4P1dgJchJsFnsZE0eZp0LK8X7g2J0CsJHRgns+skpXOlM7n5vFGwkWCWj8qJg==} engines: {node: 12.* || >= 14} @@ -16038,6 +16080,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + juice@9.1.0: resolution: {integrity: sha512-odblShmPrUoHUwRuC8EmLji5bPP2MLO1GL+gt4XU3tT2ECmbSrrMjtMQaqg3wgMFP2zvUzdPZGfxc5Trk3Z+fQ==} engines: {node: '>=10.0.0'} @@ -16287,6 +16332,9 @@ packages: lie@3.1.1: resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + liftoff@3.1.0: resolution: {integrity: sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog==} engines: {node: '>= 0.8'} @@ -23988,7 +24036,7 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.1 '@codemirror/lang-css': 6.3.1 - '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-javascript': 6.2.4 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@codemirror/view': 6.40.0 @@ -23996,7 +24044,7 @@ snapshots: '@lezer/css': 1.3.3 '@lezer/html': 1.3.13 - '@codemirror/lang-javascript@6.2.5': + '@codemirror/lang-javascript@6.2.4': dependencies: '@codemirror/autocomplete': 6.20.1 '@codemirror/language': 6.12.3 @@ -24006,6 +24054,31 @@ snapshots: '@lezer/common': 1.5.1 '@lezer/javascript': 1.5.4 + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/json': 1.0.3 + + '@codemirror/lang-markdown@6.3.4': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/common': 1.5.1 + '@lezer/markdown': 1.6.3 + + '@codemirror/lang-yaml@6.1.2': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/yaml': 1.0.4 + '@codemirror/language@6.12.3': dependencies: '@codemirror/state': 6.6.0 @@ -25949,10 +26022,27 @@ snapshots: '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/lr@1.4.8': dependencies: '@lezer/common': 1.5.1 + '@lezer/markdown@1.6.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lint-todo/utils@13.1.1': dependencies: '@types/eslint': 8.56.12 @@ -40592,6 +40682,13 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + juice@9.1.0(encoding@0.1.13): dependencies: cheerio: 0.22.0 @@ -40926,6 +41023,10 @@ snapshots: dependencies: immediate: 3.0.6 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + liftoff@3.1.0: dependencies: extend: 3.0.2