diff --git a/AGENTS.md b/AGENTS.md index 849532580..cfae4350f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ plugins. | Layer | Technology | |---------------|----------------------------------------------------------------| | Backend | PHP 7.4+, WordPress APIs, PSR-4 via Composer | -| Frontend | TypeScript, React 18, `@wordpress/components`, CodeMirror 5 | +| Frontend | TypeScript, React 18, `@wordpress/components`, CodeMirror 6 | | Styling | SCSS (PostCSS, logical properties via `stylelint-use-logical`) | | Build | Webpack 5, Babel, `ts-loader`, `sass-loader` | | PHP linting | PHPCS + WPCS (`npm run lint:php`) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d4e8faa..cad999183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [4.1.0] (UPCOMING) + +### Added +* CSS and JavaScript snippets are checked for syntax errors as you type. +* Autocompletion for PHP in the code editor, covering keywords, the snippet's own variables and functions, and WordPress and PHP functions with their parameters, a summary and a link to their documentation. +* Browser globals such as `document` and `window` are suggested while typing JavaScript. +* Templates for common WordPress code, such as hooking into an action or filter, registering a shortcode and enqueueing a stylesheet, offered in PHP autocompletion. +* Indentation Guides editor setting, on by default, showing a vertical guide for each level of indentation. +* Highlight Trailing Spaces editor setting, off by default, marking spaces and tabs left at the end of a line. + +### Changed +* The code editor now uses CodeMirror 6, bundled with the plugin rather than loaded from WordPress. Existing editor settings and themes carry over. +* The editor's find and replace panel is now a single panel at the top of the editor, and its labels can be translated. +* The keyboard shortcut list in the editor now reflects CodeMirror 6 shortcuts. Shift+Tab outdents the current line or selection. +* With Indent With Tabs enabled, each indentation level is one tab. +* The Sublime Text keymap now covers the most-used Sublime Text shortcuts, such as selecting, deleting, duplicating and moving lines, and selecting the next occurrence. Less common ones, such as joining lines and selecting between brackets, are no longer available. +* CSS snippets no longer show CSSLint style warnings; only syntax errors are reported. +* Code previews on the snippets list load the code editor only when a preview is opened. +* Custom editor themes added through the `code_snippets_codemirror_atts` filter keep their syntax colours, but rules for the editor background, gutters, cursor, selection and active line must use CodeMirror 6 class names such as `.cm-editor`, `.cm-gutters` and `.cm-activeLine`. +* The `code_snippets_codemirror_atts` filter only applies the editor settings' own options; options for the WordPress code editor, such as `mode`, `gutters` and `extraKeys`, are ignored. Plugin screens no longer create `wp.codeEditor` instances or `.CodeMirror` elements. + ## [4.0.0] (UPCOMING) ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4949dde96..d30547e0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ npm run build This will run the basic Webpack configuration, which will: -1. Copies required vendor files from the `node_modules/` directory, such as CodeMirror and PrismJS theme files. +1. Converts the CodeMirror 5 theme stylesheets in `node_modules/` into code editor themes. 2. Transforms the SCSS source files into browser-ready minified CSS code, including right-to-left files where appropriate. 3. Transforms the TypeScript source files into browser-ready minified JavaScript code, after checking with a linter. diff --git a/config/webpack/modules/codemirror-keymaps.d.ts b/config/webpack/modules/codemirror-keymaps.d.ts deleted file mode 100644 index 9f5e587c1..000000000 --- a/config/webpack/modules/codemirror-keymaps.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module 'codemirror/src/input/keymap' { - import type { KeyMap } from 'codemirror' - - export const getKeyMap: (keyMap: string) => KeyMap -} diff --git a/config/webpack/modules/replit-codemirror.d.ts b/config/webpack/modules/replit-codemirror.d.ts new file mode 100644 index 000000000..3b50a5b4a --- /dev/null +++ b/config/webpack/modules/replit-codemirror.d.ts @@ -0,0 +1,26 @@ +// These packages' export maps have no types condition, so their declarations +// are not found when they are resolved as CommonJS. +declare module '@replit/codemirror-emacs' { + import type { Extension } from '@codemirror/state' + + export const emacs: () => Extension +} + +declare module '@replit/codemirror-indentation-markers' { + import type { Extension } from '@codemirror/state' + + export interface IndentationMarkerConfiguration { + hideFirstIndent?: boolean + markerType?: 'fullScope' | 'codeOnly' + thickness?: number + activeThickness?: number + colors?: { + light?: string + dark?: string + activeLight?: string + activeDark?: string + } + } + + export const indentationMarkers: (config?: IndentationMarkerConfiguration) => Extension +} diff --git a/config/webpack/postcss-codemirror-theme.ts b/config/webpack/postcss-codemirror-theme.ts new file mode 100644 index 000000000..cf057229d --- /dev/null +++ b/config/webpack/postcss-codemirror-theme.ts @@ -0,0 +1,97 @@ +import type { PluginCreator, Rule } from 'postcss' + +/** + * Selectors for CodeMirror 5 editor structure, and their CodeMirror 6 + * equivalents. Token classes such as `.cm-keyword` need no mapping, as the + * editor's highlight style emits them unchanged. + */ +const SELECTOR_MAP: readonly (readonly [RegExp, string])[] = [ + [/\.CodeMirror-activeline-background(?![\w-])/g, '.cm-activeLine'], + [/\.CodeMirror-activeline-gutter(?![\w-])/g, '.cm-activeLineGutter'], + [/\.CodeMirror-gutters(?![\w-])/g, '.cm-gutters'], + [/\.CodeMirror-gutter-elt(?![\w-])/g, '.cm-gutterElement'], + [/\.CodeMirror-gutter(?![\w-])/g, '.cm-gutter'], + [/\.CodeMirror-linenumber(?![\w-])/g, '.cm-lineNumbers .cm-gutterElement'], + [/\.CodeMirror-guttermarker-subtle(?![\w-])/g, '.cm-foldGutter .cm-gutterElement'], + [/\.CodeMirror-foldmarker(?![\w-])/g, '.cm-foldPlaceholder'], + [/\.CodeMirror-cursor(?![\w-])/g, '.cm-cursor'], + [/\.CodeMirror-selected(?![\w-])/g, '.cm-selectionBackground'], + [/\.CodeMirror-matchingbracket(?![\w-])/g, '.cm-matchingBracket'], + [/\.CodeMirror-nonmatchingbracket(?![\w-])/g, '.cm-nonmatchingBracket'], + [/\.CodeMirror-focused(?![\w-])/g, '.cm-focused'], + [/\.CodeMirror-line(?![\w-])/g, '.cm-line'], + [/\.CodeMirror-(?:code|lines)(?![\w-])/g, '.cm-content'], + [/\.CodeMirror-scroll(?![\w-])/g, '.cm-scroller'], + [/\.CodeMirror(?![\w-])/g, '.cm-editor'], + [/\.cm-searching(?![\w-])/g, '.cm-searchMatch'], + [/\.cm-matchhighlight(?![\w-])/g, '.cm-selectionMatch'] +] + +/** + * Selectors with no CodeMirror 6 counterpart: native selection is hidden in + * favour of drawn selection, and the rest style addons that are not loaded. + * Fold markers were the subtle gutter markers; plain ones marked breakpoints + * and the like, which the editor does not have. + */ +const UNSUPPORTED_ADDON_CLASSES = [ + 'guttermarker(?![\\w-])', 'simplescroll', 'overlayscroll', 'hint', 'ruler', 'widget', 'secondarycursor', 'overwrite', 'cursors', + 'matchingtag', 'selectedtext', 'foldgutter', 'gutter-text', 'activeline(?![\\w-])' +] + +const UNSUPPORTED_SELECTOR = new RegExp(`::?-?(?:moz-)?selection|\\.CodeMirror-(?:${UNSUPPORTED_ADDON_CLASSES.join('|')})`) + +const STRUCTURAL_CLASSES = [ + 'editor', 'gutters?', 'gutterElement', 'lineNumbers', 'foldGutter', 'foldPlaceholder', 'cursor', + 'selectionBackground', 'matchingBracket', 'nonmatchingBracket', 'focused', 'line', 'content', 'scroller', + 'activeLine', 'activeLineGutter', 'searchMatch', 'selectionMatch' +] + +const STRUCTURAL_SELECTOR = new RegExp(`\\.cm-(?:${STRUCTURAL_CLASSES.join('|')})(?![\\w-])`) + +const convertSelector = (selector: string): string | null => { + if (UNSUPPORTED_SELECTOR.test(selector)) { + return null + } + + const converted = SELECTOR_MAP.reduce((result, [pattern, replacement]) => + result.replace(pattern, replacement), selector) + + return converted.includes('.CodeMirror') ? null : converted +} + +/** + * Convert a CodeMirror 5 theme stylesheet to style a CodeMirror 6 editor. + * + * Declarations on editor structure are made important: CodeMirror 6 injects + * its base theme after the page's stylesheets, so it would otherwise win any + * rule of equal specificity. + */ +const postcssCodeMirrorTheme: PluginCreator = () => ({ + postcssPlugin: 'postcss-codemirror-theme', + Rule(rule: Rule) { + if ('atrule' === rule.parent?.type && 'keyframes' === (<{ name?: string }> rule.parent).name) { + return + } + + const selectors = rule.selectors + .map(convertSelector) + .filter((selector): selector is string => null !== selector) + + if (0 === selectors.length) { + rule.remove() + return + } + + rule.selectors = selectors + + if (selectors.some(selector => STRUCTURAL_SELECTOR.test(selector))) { + rule.walkDecls(declaration => { + declaration.important = true + }) + } + } +}) + +postcssCodeMirrorTheme.postcss = true + +export default postcssCodeMirrorTheme diff --git a/config/webpack/webpack-css.ts b/config/webpack/webpack-css.ts index 1e4f94e54..7f4ad39ac 100644 --- a/config/webpack/webpack-css.ts +++ b/config/webpack/webpack-css.ts @@ -6,6 +6,7 @@ import rgbaCompat from 'postcss-hexrgba' import MiniCssExtractPlugin from 'mini-css-extract-plugin' import RemoveEmptyScriptsPlugin from 'webpack-remove-empty-scripts' import { glob } from 'glob' +import codeMirrorTheme from './postcss-codemirror-theme' import hslCompat from './postcss-hsl-legacy' import type { Configuration, EntryObject } from 'webpack' import type { Config as PostCssConfig } from 'postcss-load-config' @@ -21,6 +22,8 @@ const postcssOptions: PostCssConfig = { ] } +const EDITOR_THEME_FILES = 'node_modules/codemirror/theme/*.css' + const entriesFromFiles = (patterns: string | string[], entry: (filename: string) => string): EntryObject => Object.fromEntries( glob.sync(patterns) @@ -34,9 +37,10 @@ export const cssWebpackConfig: Configuration = { filename => `${path.parse(filename).name}-css` ), ...entriesFromFiles( - 'node_modules/codemirror/theme/*.css', + EDITOR_THEME_FILES, filename => `codemirror-theme-${path.parse(filename).name}` - ) + ), + 'editor-themes-css': glob.sync(EDITOR_THEME_FILES).sort().map(filename => `./${filename}`) }, module: { rules: [ @@ -85,7 +89,7 @@ export const cssWebpackConfig: Configuration = { loader: 'postcss-loader', options: { postcssOptions: { - plugins: [cssnano()] + plugins: [codeMirrorTheme(), cssnano()] } } } diff --git a/config/webpack/webpack-js.ts b/config/webpack/webpack-js.ts index 62981acea..3c00ed347 100644 --- a/config/webpack/webpack-js.ts +++ b/config/webpack/webpack-js.ts @@ -40,6 +40,7 @@ export const jsWebpackConfig: Configuration = { output: { path: join(resolve(__dirname), '..', '..', DEST_DIR), filename: '[name].js', + chunkFilename: 'chunks/[name].[contenthash:8].js', clean: true }, externalsType: 'window', @@ -50,7 +51,6 @@ export const jsWebpackConfig: Configuration = { 'react/jsx-runtime': 'ReactJSXRuntime', 'jquery': 'jQuery', 'tinymce': 'tinymce', - 'codemirror': ['wp', 'CodeMirror'], ...Object.fromEntries( Object.keys(dependencies) .filter(name => name.startsWith('@wordpress/')) @@ -69,6 +69,11 @@ export const jsWebpackConfig: Configuration = { { test: /\.[jt]sx?$/, exclude: /node_modules/, + // Dynamic imports are resolved as ECMAScript modules by TypeScript, which + // requires them to name a file extension. + resolve: { + extensionAlias: { '.js': ['.ts', '.tsx', '.js'] } + }, use: { loader: 'babel-loader', options: babelConfig diff --git a/package-lock.json b/package-lock.json index 9f98a02e3..ade46298f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,31 @@ "version": "4.0.0-beta.1", "license": "GPL-2.0-or-later", "dependencies": { - "@codemirror/fold": "^0.19.4", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.0", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/language": "^6.12.4", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.4", + "@codemirror/view": "^6.43.11", + "@lezer/common": "^1.5.2", + "@lezer/highlight": "^1.2.3", + "@replit/codemirror-emacs": "^6.1.0", + "@replit/codemirror-indentation-markers": "^6.5.3", + "@replit/codemirror-vim": "^6.4.0", "@wordpress/components": "^29.3.0", "@wordpress/date": "^5.43.0", "@wordpress/dom-ready": "^4.17.0", "@wordpress/element": "^6.28.0", + "@wordpress/hooks": "^4.55.0", "@wordpress/i18n": "^5.17.0", "@wordpress/url": "^4.20.0", "axios": "^1.13.5", "classnames": "^2.5.1", - "codemirror": "^5.29", "php-parser": "^3.2.2", "prismjs": "^1.29.0", "react": "^18.3.1", @@ -34,7 +49,6 @@ "@stylistic/stylelint-plugin": "^3.1.2", "@tsconfig/node18": "^18.2.4", "@types/archiver": "^6.0.3", - "@types/codemirror": "^5.60.15", "@types/jquery": "^3.5.32", "@types/node": "^22.13.1", "@types/prismjs": "^1.26.5", @@ -50,6 +64,7 @@ "autoprefixer": "^10.4.20", "babel-loader": "^9.2.1", "babel-plugin-prismjs": "^2.1.0", + "codemirror": "^5.65.21", "css-loader": "^7.1.2", "cssnano": "^7.0.6", "eslint": "^9.20.1", @@ -1769,63 +1784,142 @@ "node": ">=6.9.0" } }, - "node_modules/@codemirror/fold": { - "version": "0.19.4", + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", "license": "MIT", "dependencies": { - "@codemirror/gutter": "^0.19.0", - "@codemirror/language": "^0.19.0", - "@codemirror/rangeset": "^0.19.0", - "@codemirror/state": "^0.19.0", - "@codemirror/view": "^0.19.22" + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" } }, - "node_modules/@codemirror/gutter": { - "version": "0.19.9", + "node_modules/@codemirror/commands": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz", + "integrity": "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==", "license": "MIT", "dependencies": { - "@codemirror/rangeset": "^0.19.0", - "@codemirror/state": "^0.19.0", - "@codemirror/view": "^0.19.23" + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.12", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz", + "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" } }, "node_modules/@codemirror/language": { - "version": "0.19.10", + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "license": "MIT", "dependencies": { - "@codemirror/state": "^0.19.0", - "@codemirror/text": "^0.19.0", - "@codemirror/view": "^0.19.0", - "@lezer/common": "^0.15.5", - "@lezer/lr": "^0.15.0" + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" } }, - "node_modules/@codemirror/rangeset": { - "version": "0.19.9", + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", "license": "MIT", "dependencies": { - "@codemirror/state": "^0.19.0" + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" } }, - "node_modules/@codemirror/state": { - "version": "0.19.9", + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", "license": "MIT", "dependencies": { - "@codemirror/text": "^0.19.0" + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" } }, - "node_modules/@codemirror/text": { - "version": "0.19.6", - "license": "MIT" + "node_modules/@codemirror/state": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.4.tgz", + "integrity": "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } }, "node_modules/@codemirror/view": { - "version": "0.19.48", + "version": "6.43.11", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.11.tgz", + "integrity": "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg==", "license": "MIT", "dependencies": { - "@codemirror/rangeset": "^0.19.5", - "@codemirror/state": "^0.19.3", - "@codemirror/text": "^0.19.0", - "style-mod": "^4.0.0", + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, @@ -2532,16 +2626,79 @@ "license": "MIT" }, "node_modules/@lezer/common": { - "version": "0.15.12", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", "license": "MIT" }, + "node_modules/@lezer/css": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.6.tgz", + "integrity": "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/lr": { - "version": "0.15.8", + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", "license": "MIT", "dependencies": { - "@lezer/common": "^0.15.0" + "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/php": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.6.tgz", + "integrity": "sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2926,6 +3083,52 @@ "node": ">=18" } }, + "node_modules/@replit/codemirror-emacs": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.1.0.tgz", + "integrity": "sha512-74DITnht6Cs6sHg02PQ169IKb1XgtyhI9sLD0JeOFco6Ds18PT+dkD8+DgXBDokne9UIFKsBbKPnpFRAz60/Lw==", + "license": "MIT", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.2", + "@codemirror/commands": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.1", + "@codemirror/view": "^6.3.0" + } + }, + "node_modules/@replit/codemirror-indentation-markers": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@replit/codemirror-indentation-markers/-/codemirror-indentation-markers-6.5.3.tgz", + "integrity": "sha512-hL5Sfvw3C1vgg7GolLe/uxX5T3tmgOA3ZzqlMv47zjU1ON51pzNWiVbS22oh6crYhtVhv8b3gdXwoYp++2ilHw==", + "license": "MIT", + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/@replit/codemirror-vim": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.4.0.tgz", + "integrity": "sha512-t9UMDNhkmeAkl0uRbiJVotv97bGD6mf4GyJJoEbrjUqa/Pov0s9eL+4AaI0Xto3OGri17i9qwIpT0JbVlVXt8A==", + "license": "MIT", + "dependencies": { + "@replit/codemirror-vim-core": "^0.1.0" + }, + "peerDependencies": { + "@codemirror/commands": "6.x.x", + "@codemirror/language": "6.x.x", + "@codemirror/search": "6.x.x", + "@codemirror/state": "6.x.x", + "@codemirror/view": "6.x.x" + } + }, + "node_modules/@replit/codemirror-vim-core": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vim-core/-/codemirror-vim-core-0.1.0.tgz", + "integrity": "sha512-1i6EBKpcNfDKvTmTh6N6g9lL6udD5t+uFNh4JCqozRnVlvUGOps7h/QzS2ne4zcvPUjvApKpmcP7Grc3fNbZiQ==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "dev": true, @@ -3122,14 +3325,6 @@ "@types/responselike": "^1.0.0" } }, - "node_modules/@types/codemirror": { - "version": "5.60.15", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/tern": "*" - } - }, "node_modules/@types/eslint": { "version": "8.56.11", "dev": true, @@ -3287,14 +3482,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tern": { - "version": "0.23.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, "node_modules/@types/tinymce": { "version": "4.6.9", "dev": true, @@ -4496,9 +4683,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.43.0.tgz", - "integrity": "sha512-BY7GPjEwhOlgkavVak40E3RtA8Z9ehydqTZckRoesMRjXYfxKSzr1C1FT4wAPS5uXM1pNlWivfofMaJjVNQu5w==", + "version": "4.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.55.0.tgz", + "integrity": "sha512-oI5NkudaBOewGR/qxPKx7us4h/D+Iokr1UPJHdh3pSnwhUgXFS1ptY7O98Hl6HpUZMyTwdP60fZJ5hlgSyivVA==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -6062,7 +6249,10 @@ } }, "node_modules/codemirror": { - "version": "5.65.15", + "version": "5.65.21", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.21.tgz", + "integrity": "sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==", + "dev": true, "license": "MIT" }, "node_modules/color-convert": { @@ -6290,6 +6480,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "dev": true, @@ -12600,7 +12796,9 @@ } }, "node_modules/style-mod": { - "version": "4.1.0", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, "node_modules/style-search": { @@ -13742,6 +13940,8 @@ }, "node_modules/w3c-keyname": { "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, "node_modules/watchpack": { diff --git a/package.json b/package.json index 4d395c85b..6d4cc1740 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "lint:php:fix": "src/vendor/bin/phpcbf --standard=phpcs.xml src/php tests", "lint:readme": "ts-node scripts/linters/lint-readme.ts", "lint:changelog": "ts-node scripts/linters/lint-changelog.ts", + "generate:function-index": "php scripts/generate-function-index.php .wp-core > src/js/editor/data/function-index.json", "version": "ts-node scripts/version.ts", "version-dev": "npm version --git-tag-version=false --preid=dev", "version-alpha": "npm version --git-tag-version=false --preid=alpha", @@ -49,16 +50,31 @@ "url": "https://github.com/codesnippetspro/code-snippets/issues" }, "dependencies": { - "@codemirror/fold": "^0.19.4", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.0", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/language": "^6.12.4", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.4", + "@codemirror/view": "^6.43.11", + "@lezer/common": "^1.5.2", + "@lezer/highlight": "^1.2.3", + "@replit/codemirror-emacs": "^6.1.0", + "@replit/codemirror-indentation-markers": "^6.5.3", + "@replit/codemirror-vim": "^6.4.0", "@wordpress/components": "^29.3.0", "@wordpress/date": "^5.43.0", "@wordpress/dom-ready": "^4.17.0", "@wordpress/element": "^6.28.0", + "@wordpress/hooks": "^4.55.0", "@wordpress/i18n": "^5.17.0", "@wordpress/url": "^4.20.0", "axios": "^1.13.5", "classnames": "^2.5.1", - "codemirror": "^5.29", "php-parser": "^3.2.2", "prismjs": "^1.29.0", "react": "^18.3.1", @@ -74,7 +90,6 @@ "@stylistic/stylelint-plugin": "^3.1.2", "@tsconfig/node18": "^18.2.4", "@types/archiver": "^6.0.3", - "@types/codemirror": "^5.60.15", "@types/jquery": "^3.5.32", "@types/node": "^22.13.1", "@types/prismjs": "^1.26.5", @@ -90,6 +105,7 @@ "autoprefixer": "^10.4.20", "babel-loader": "^9.2.1", "babel-plugin-prismjs": "^2.1.0", + "codemirror": "^5.65.21", "css-loader": "^7.1.2", "cssnano": "^7.0.6", "eslint": "^9.20.1", diff --git a/scripts/generate-function-index.php b/scripts/generate-function-index.php new file mode 100644 index 000000000..48da27622 --- /dev/null +++ b/scripts/generate-function-index.php @@ -0,0 +1,291 @@ + > src/js/editor/data/function-index.json + * + * WordPress functions are read from the source of the given installation, skipping those documented as private or + * deprecated. PHP functions are read by reflection from the running PHP, limited to extensions that are always or + * almost always present, so the output does not depend on which optional extensions the machine happens to have. + * + * @package Code_Snippets + */ + +// phpcs:disable WordPress.WP.AlternativeFunctions -- Runs outside WordPress. + +const MAX_SUMMARY_LENGTH = 160; + +const PHP_EXTENSIONS = [ 'Core', 'standard', 'date', 'pcre', 'json', 'ctype', 'filter', 'hash', 'SPL', 'random', 'mbstring' ]; + +/** + * Paths that hold bundled libraries, polyfills of PHP functions, stubs, deprecated functions or block render + * callbacks rather than the WordPress API, relative to the installation root. + */ +const EXCLUDED_PATHS = [ + 'wp-includes/ID3/', + 'wp-includes/IXR/', + 'wp-includes/PHPMailer/', + 'wp-includes/Requests/', + 'wp-includes/SimplePie/', + 'wp-includes/Text/', + 'wp-includes/blocks/', + 'wp-includes/compat.php', + 'wp-includes/compat-utf8.php', + 'wp-includes/php-compat/', + 'wp-includes/spl-autoload-compat.php', + 'wp-includes/pomo/', + 'wp-includes/sodium_compat/', + 'wp-includes/deprecated.php', + 'wp-includes/ms-deprecated.php', + 'wp-includes/pluggable-deprecated.php', + 'wp-admin/includes/deprecated.php', + 'wp-admin/includes/ms-deprecated.php', + 'wp-admin/includes/noop.php', +]; + +/** + * Collapse a run of tokens into source text with single spaces. + * + * @param array $tokens Tokens. + * + * @return string + */ +function tokens_to_text( array $tokens ): string { + $text = ''; + + foreach ( $tokens as $token ) { + if ( is_array( $token ) && in_array( $token[0], [ T_COMMENT, T_DOC_COMMENT ], true ) ) { + continue; + } + + $text .= is_array( $token ) ? $token[1] : $token; + } + + return trim( preg_replace( '/\s+/', ' ', $text ) ); +} + +/** + * Read the summary and relevant tags from a docblock. + * + * @param string $docblock Docblock comment. + * + * @return array{summary: string, private: bool, deprecated: bool} + */ +function parse_docblock( string $docblock ): array { + $lines = preg_split( '/\R/', preg_replace( '#^/\*\*|\*/$#', '', $docblock ) ); + $summary = []; + + foreach ( $lines as $line ) { + $line = trim( preg_replace( '/^\s*\*\s?/', '', $line ) ); + + if ( '' === $line || '@' === $line[0] ) { + if ( $summary ) { + break; + } + + continue; + } + + $summary[] = $line; + } + + $summary = implode( ' ', $summary ); + + if ( strlen( $summary ) > MAX_SUMMARY_LENGTH ) { + $summary = rtrim( substr( $summary, 0, MAX_SUMMARY_LENGTH - 1 ) ) . '…'; + } + + return [ + 'summary' => $summary, + 'private' => (bool) preg_match( '/@access\s+private/', $docblock ), + 'deprecated' => (bool) preg_match( '/@deprecated\b/', $docblock ), + ]; +} + +/** + * Find the functions declared at the top level of a file, outside any class or function body. + * + * @param string $filename PHP file. + * + * @return array Name, parameter list and summary of each public function. + */ +function find_functions( string $filename ): array { + $tokens = token_get_all( file_get_contents( $filename ) ); + $count = count( $tokens ); + $functions = []; + $blocks = []; + $next_block = 'other'; + $docblock = ''; + + for ( $i = 0; $i < $count; $i++ ) { + $token = $tokens[ $i ]; + $type = is_array( $token ) ? $token[0] : $token; + + if ( T_DOC_COMMENT === $type ) { + $docblock = $token[1]; + continue; + } + + if ( in_array( $type, [ T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM ], true ) ) { + $previous = $tokens[ $i - 1 ] ?? null; + $is_class_constant = is_array( $previous ) && T_DOUBLE_COLON === $previous[0]; + + if ( ! $is_class_constant ) { + $next_block = 'class'; + } + } elseif ( T_FUNCTION === $type ) { + $j = $i + 1; + while ( $j < $count && is_array( $tokens[ $j ] ) && T_WHITESPACE === $tokens[ $j ][0] ) { + ++$j; + } + + $is_named = is_array( $tokens[ $j ] ) && T_STRING === $tokens[ $j ][0]; + + if ( $is_named && ! in_array( 'class', $blocks, true ) && ! in_array( 'function', $blocks, true ) ) { + $name = $tokens[ $j ][1]; + + while ( '(' !== $tokens[ $j ] ) { + ++$j; + } + + $depth = 0; + $start = $j; + + do { + if ( '(' === $tokens[ $j ] ) { + ++$depth; + } elseif ( ')' === $tokens[ $j ] ) { + --$depth; + } + + ++$j; + } while ( $depth > 0 ); + + $doc = parse_docblock( $docblock ); + + if ( ! $doc['private'] && ! $doc['deprecated'] ) { + $params = tokens_to_text( array_slice( $tokens, $start + 1, $j - $start - 2 ) ); + $functions[] = [ $name, '' === $params ? '()' : "( $params )", $doc['summary'] ]; + } + } + + $next_block = 'function'; + } elseif ( '{' === $type || T_CURLY_OPEN === $type || T_DOLLAR_OPEN_CURLY_BRACES === $type ) { + $blocks[] = '{' === $type ? $next_block : 'other'; + $next_block = 'other'; + } elseif ( '}' === $type ) { + array_pop( $blocks ); + } elseif ( ';' === $type && 'function' === $next_block ) { + $next_block = 'other'; + } + + if ( T_WHITESPACE !== $type && T_FUNCTION !== $type && T_STRING !== $type && T_ATTRIBUTE !== $type ) { + $docblock = T_DOC_COMMENT === $type ? $docblock : ''; + } + } + + return $functions; +} + +/** + * Describe a PHP function's parameters. + * + * @param ReflectionFunction $reflection Function. + * + * @return string + */ +function describe_parameters( ReflectionFunction $reflection ): string { + $params = array_map( + function ( ReflectionParameter $param ) { + $text = $param->hasType() ? $param->getType() . ' ' : ''; + $text .= ( $param->isPassedByReference() ? '&' : '' ) . ( $param->isVariadic() ? '...' : '' ) . '$' . $param->getName(); + + if ( $param->isOptional() && ! $param->isVariadic() ) { + $text .= $param->isDefaultValueAvailable() && ! $param->isDefaultValueConstant() + ? ' = ' . preg_replace( '/\s+/', ' ', var_export( $param->getDefaultValue(), true ) ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- Formats a default value as PHP source. + : ' = …'; + } + + return $text; + }, + $reflection->getParameters() + ); + + return $params ? '( ' . implode( ', ', $params ) . ' )' : '()'; +} + +$wordpress_path = rtrim( $argv[1] ?? '', '/' ) . '/'; + +if ( ! is_file( $wordpress_path . 'wp-includes/version.php' ) ) { + fwrite( STDERR, "Usage: php scripts/generate-function-index.php \n" ); + exit( 1 ); +} + +/** + * Read the version of a WordPress installation. + * + * @param string $wordpress_path Installation root, with a trailing slash. + * + * @return string + */ +function read_wordpress_version( string $wordpress_path ): string { + $wp_version = ''; + require $wordpress_path . 'wp-includes/version.php'; + return $wp_version; +} + +$files = []; + +foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $wordpress_path, FilesystemIterator::SKIP_DOTS ) ) as $file ) { + $files[] = $file->getPathname(); +} + +// Where a function is declared more than once, the first declaration wins, so read files in a stable order. +sort( $files ); + +$wordpress_functions = []; + +foreach ( $files as $filename ) { + $relative = substr( $filename, strlen( $wordpress_path ) ); + $is_api_file = 0 === strpos( $relative, 'wp-includes/' ) || 0 === strpos( $relative, 'wp-admin/includes/' ); + + if ( '.php' !== substr( $filename, -4 ) || ! $is_api_file ) { + continue; + } + + foreach ( EXCLUDED_PATHS as $excluded ) { + if ( 0 === strpos( $relative, $excluded ) ) { + continue 2; + } + } + + foreach ( find_functions( $filename ) as $function ) { + $wordpress_functions[ $function[0] ] = $wordpress_functions[ $function[0] ] ?? $function; + } +} + +ksort( $wordpress_functions ); + +$php_functions = []; + +foreach ( get_defined_functions()['internal'] as $name ) { + $function = new ReflectionFunction( $name ); + + if ( in_array( $function->getExtensionName(), PHP_EXTENSIONS, true ) && ! $function->isDeprecated() && ! isset( $wordpress_functions[ $name ] ) ) { + $php_functions[ $name ] = [ $name, describe_parameters( $function ) ]; + } +} + +ksort( $php_functions ); + +echo json_encode( + [ + 'wordpress' => read_wordpress_version( $wordpress_path ), + 'php' => PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, + 'functions' => [ + 'wordpress' => array_values( $wordpress_functions ), + 'php' => array_values( $php_functions ), + ], + ], + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE +), "\n"; diff --git a/src/css/common/_codemirror.scss b/src/css/common/_codemirror.scss index 2944798a1..87529fe8d 100644 --- a/src/css/common/_codemirror.scss +++ b/src/css/common/_codemirror.scss @@ -1,113 +1,119 @@ -.CodeMirror, .snippet-condition-editor-container { - border: 1px solid #c3c4c7; +// CodeMirror injects its base theme after the page's stylesheets, so rules +// here are qualified with the plugin's own editor class to take precedence. +.cs-code-editor.cm-editor { + border: 1px solid var(--cs-color-border); border-radius: 5px; - block-size: auto !important; background-color: #fff; -} - -.CodeMirror-code { - outline: none; -} + color: #000; -.CodeMirror-focused .cm-matchhighlight { - color: white !important; - outline: 1px solid green; -} + // Without wrapping, a long line would otherwise widen the editor, and the + // page layout around it, to fit; containment keeps the scrolling inside. + contain: inline-size; -/* Add a bit of extra space above the editor */ -.CodeMirror-sizer { - &::before, &::after { - display: block; - color: #bbb; + &.cm-focused { + outline: 2px solid var(--cs-color-accent); + outline-offset: -1px; } - &::before { - content: ''; - padding-block-end: 5px; + .cm-scroller { + font-family: monospace; + line-height: 1.5; } -} -/* Fix cursor color with rubyblue theme (see https://goo.gl/3HDgRm */ -.cm-s-rubyblue .CodeMirror-cursor { - border-inline-start: 1px solid white !important; -} - -[class*='CodeMirror-lint-marker'], [class*='CodeMirror-lint-message'], .CodeMirror-lint-marker-multiple { - background-image: none; -} - -.CodeMirror-lint-marker-error, .CodeMirror-lint-marker-warning { - cursor: help; -} - -.CodeMirror-lint-marker-multiple { - position: absolute; - inset-block-start: 0; -} - -[class*='CodeMirror-lint-marker']::before { - font: normal 18px/1 dashicons; - position: relative; - inset-block-start: -2px; -} - -[class*='CodeMirror-lint-message']::before { - font: normal 16px/1 dashicons; - position: absolute; - inset-inline-start: 16px; -} + .cm-gutters { + border-start-start-radius: 5px; + border-end-start-radius: 5px; + } -.CodeMirror-lint-message-error, -.CodeMirror-lint-message-warning { - box-shadow: 0 1px 1px 0 rgb(0 0 0 / 10%); - margin-block: 5px 2px; - padding-block: 3px; - margin-inline: 0; - padding-inline: 28px 12px; -} + .cm-lineNumbers .cm-gutterElement { + color: #666; + } -.CodeMirror-lint-message-warning { - background-color: #fff8e5; - border-inline-start: 4px solid #ffb900; -} + // Themes that colour their fold markers override this, which is the colour + // CodeMirror 5 gave them by default. Many dark themes set the gutter text to + // the gutter background, relying on it. + .cm-foldGutter .cm-gutterElement { + color: #999; + } -.CodeMirror-lint-marker-warning::before, .CodeMirror-lint-message-warning::before { - content: '\f534'; - color: #f6a306; -} + .cm-matchingBracket { + background: rgb(255 150 0 / 30%); + color: inherit; + } -.CodeMirror-lint-message-error { - background-color: #fbeaea; - border-inline-start: 4px solid #dc3232; -} + .cm-diagnostic-error { + border-inline-start-color: var(--cs-color-error); + } -.CodeMirror-lint-marker-error::before, .CodeMirror-lint-message-error::before { - content: '\f153'; - color: #dc3232; -} + .cm-diagnostic-warning { + border-inline-start-color: var(--cs-color-warning); + } -.CodeMirror-lint-tooltip { - background: none; - border: none; - border-radius: 0; - direction: ltr; -} + .cm-panels { + border-color: var(--cs-color-border); + } -.CodeMirror .CodeMirror-matchingbracket { - background: rgb(255 150 0 / 30%); - color: inherit; -} + .cm-foldPlaceholder { + font-weight: bold; + } -.CodeMirror .CodeMirror-linenumber { - color: #666; + .cs-completion-info { + display: flex; + flex-direction: column; + gap: 6px; + max-inline-size: 420px; + white-space: normal; + + code { + padding: 0; + background: none; + font-size: inherit; + overflow-wrap: anywhere; + } + + p { + margin: 0; + } + } } -.CodeMirror .codemirror-colorview { - border: 0; +.snippet-condition-editor-container { + border: 1px solid #c3c4c7; + border-radius: 5px; + block-size: auto !important; + background-color: #fff; } -.CodeMirror-foldmarker { - color: inherit; - margin-inline: 0.25em; - font-weight: bold; +// Token colours for the default theme. Other themes are CodeMirror 5 theme +// stylesheets, converted at build time, which style these same classes. +.cm-s-default { + .cm-header { color: blue; } + .cm-quote { color: #090; } + .cm-keyword { color: #708; } + .cm-atom { color: #219; } + .cm-number { color: #164; } + .cm-def { color: #00f; } + .cm-variable-2 { color: #05a; } + .cm-variable-3, .cm-type { color: #085; } + .cm-comment { color: #a50; } + .cm-string { color: #a11; } + .cm-string-2 { color: #f50; } + .cm-meta { color: #555; } + .cm-qualifier { color: #555; } + .cm-builtin { color: #30a; } + .cm-bracket { color: #997; } + .cm-tag { color: #170; } + .cm-attribute { color: #00c; } + .cm-hr { color: #999; } + .cm-link { color: #00c; } + .cm-error { color: #f00; } +} + +.cm-editor { + .cm-negative { color: #d44; } + .cm-positive { color: #292; } + .cm-header, .cm-strong { font-weight: bold; } + .cm-em { font-style: italic; } + .cm-link { text-decoration: underline; } + .cm-strikethrough { text-decoration: line-through; } } diff --git a/src/css/common/_modal.scss b/src/css/common/_modal.scss index 38cb83ba2..0252d3266 100644 --- a/src/css/common/_modal.scss +++ b/src/css/common/_modal.scss @@ -74,7 +74,7 @@ margin-inline-end: 8px; } - // Header and footer stay pinned; the CodeMirror editor is the only + // Header and footer stay pinned; the code editor is the only // scroll region, scrolling long code both vertically and horizontally. .components-modal__content { display: flex; @@ -99,7 +99,7 @@ flex: 1; min-block-size: 0; - .CodeMirror { + .cm-editor { flex: 1; min-block-size: 0; block-size: 100%; diff --git a/src/css/edit/_editor.scss b/src/css/edit/_editor.scss index 2fd9e9a2b..d92cec09c 100644 --- a/src/css/edit/_editor.scss +++ b/src/css/edit/_editor.scss @@ -1,26 +1,26 @@ -.CodeMirror, .snippet-form textarea { inline-size: 100%; font-family: monospace; } -.CodeMirror-sizer { - min-block-size: 300px !important; - box-sizing: border-box; - padding-block-end: 1.5em !important; +.snippet-editor .cm-content, +.snippet-editor .cm-gutter { + min-block-size: 300px; +} - &::after { - position: absolute; - inset-block-end: 0; +// The markup a snippet is wrapped in when it runs, shown around the code. +.snippet-editor .cm-editor { + &::before, &::after { + display: block; + padding-block: 4px; + padding-inline: 8px; + color: #bbb; + font-family: monospace; } - .snippet-form.php-snippet & { - padding-block-end: 0 !important; - - &::before { - content: ' { - const { codeEditorInstance } = useSnippetForm() + const { editorView } = useSnippetForm() const directionId = useId() return ( @@ -12,9 +13,11 @@ export const RTLControl: React.FC = () => { {__('Code Direction', 'code-snippets')} - { + if (editorView) { + setEditorDirection(editorView, 'rtl' === event.target.value ? 'rtl' : 'ltr') + } + }}> diff --git a/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx b/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx index a006df03f..998d605b7 100644 --- a/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx +++ b/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx @@ -3,10 +3,11 @@ import React, { useCallback, useMemo, useState } from 'react' import { createContextHook } from '../../../utils/bootstrap' import { isLicensed } from '../../../utils/screen' import { isProSnippet } from '../../../utils/snippets/snippets' +import { replaceEditorContent } from '../../../editor/snippetEditor' +import type { EditorView } from '@codemirror/view' import type { Dispatch, PropsWithChildren, SetStateAction } from 'react' import type { ScreenNotice } from '../../../types/ScreenNotice' import type { Snippet } from '../../../types/Snippet' -import type { CodeEditorInstance } from '../../../types/vendor/WordPressCodeEditor' export interface SnippetFormContext { snippet: Snippet @@ -19,9 +20,9 @@ export interface SnippetFormContext { setIsWorking: Dispatch> currentNotice: ScreenNotice | undefined setCurrentNotice: Dispatch> - codeEditorInstance: CodeEditorInstance | undefined + editorView: EditorView | undefined handleRequestError: (error: unknown, message?: string) => void - setCodeEditorInstance: Dispatch> + setEditorView: Dispatch> } const [Context, useSnippetForm] = createContextHook('useSnippetForm') @@ -56,7 +57,7 @@ export const WithSnippetFormContext: React.FC = ({ const [savedSnippet, setSavedSnippet] = useState(initialValue) const [isWorking, setIsWorking] = useState(false) const [currentNotice, setCurrentNotice] = useState() - const [codeEditorInstance, setCodeEditorInstance] = useState() + const [editorView, setEditorView] = useState() const isReadOnly = useMemo( () => snippet.locked || !isLicensed() && isProSnippet({ scope: snippet.scope }), @@ -80,11 +81,11 @@ export const WithSnippetFormContext: React.FC = ({ const updateSnippet: Dispatch> = useCallback((value: SetStateAction) => { setSnippet(previous => { const updated = 'object' === typeof value ? value : value(previous) - codeEditorInstance?.codemirror.setValue(updated.code) + replaceEditorContent(editorView, updated.code) window.tinymce?.activeEditor.setContent(updated.desc) return updated }) - }, [codeEditorInstance?.codemirror]) + }, [editorView]) const value: SnippetFormContext = { snippet, @@ -97,9 +98,9 @@ export const WithSnippetFormContext: React.FC = ({ updateSnippet, currentNotice, setCurrentNotice, - codeEditorInstance, + editorView, handleRequestError, - setCodeEditorInstance + setEditorView } return {children} diff --git a/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx b/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx index 32a67b904..bd73de355 100644 --- a/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useId, useRef } from 'react' import { __, sprintf } from '@wordpress/i18n' +import { getEditorPhrases } from '../../../../editor/phrases' +import { createSnippetEditor, replaceEditorContent, setEditorSnippetType } from '../../../../editor/snippetEditor' import { useSubmitSnippet } from '../../../../hooks/useSubmitSnippet' import { handleUnknownError } from '../../../../utils/errors' -import { isMacOS } from '../../../../utils/screen' import { getSnippetType } from '../../../../utils/snippets/snippets' import { stripWrapperTags } from '../../../../utils/snippets/tags' import { useSnippetForm } from '../WithSnippetFormContext' @@ -10,129 +11,154 @@ import { Button } from '../../../common/Button' import { ExpandIcon } from '../../../common/icons/ExpandIcon' import { MinimiseIcon } from '../../../common/icons/MinimiseIcon' import { CodeEditorShortcuts } from './CodeEditorShortcuts' -import type { Dispatch, RefObject, SetStateAction } from 'react' -import type { ScreenNotice } from '../../../../types/ScreenNotice' -import type { Snippet } from '../../../../types/Snippet' +import type { ViewUpdate } from '@codemirror/view' +import type { Dispatch, SetStateAction } from 'react' +import type { SnippetCodeType, SnippetType } from '../../../../types/Snippet' -interface EditorTextareaProps { - textareaRef: RefObject +const toCodeType = (type: SnippetType): SnippetCodeType => + 'cond' === type ? 'php' : type + +interface EditorAreaProps { snippetCodeId: string } -const EditorTextarea: React.FC = ({ textareaRef, snippetCodeId }) => { - const descriptionId = useId() +const PlainTextEditor: React.FC = ({ snippetCodeId }) => { const { snippet, setSnippet } = useSnippetForm() return ( -
-

- {__('In the editing area, the Tab key enters a tab character. To exit the code editor, press the Escape key and then the Tab key.', 'code-snippets')} -

-