Browse Source

Merge branch 'dev' of https://github.com/GrapesJS/grapesjs into refactor-sorter

refactor-sorter
mohamedsalem401 1 year ago
parent
commit
92302dbf97
  1. 2
      .eslintrc.js
  2. 2
      .github/workflows/quality.yml
  3. 24
      .github/workflows/test.yml
  4. 5
      .prettierignore
  5. 8
      docs/Home.md
  6. 12
      docs/modules/Assets.md
  7. 8
      package.json
  8. 122
      packages/cli/README.md
  9. 3
      packages/cli/babel.config.js
  10. 42
      packages/cli/index.html
  11. 10
      packages/cli/jest.config.ts
  12. 57
      packages/cli/package.json
  13. 6
      packages/cli/src/banner.txt
  14. 143
      packages/cli/src/build.ts
  15. 174
      packages/cli/src/cli.ts
  16. 217
      packages/cli/src/init.ts
  17. 3
      packages/cli/src/main.ts
  18. 48
      packages/cli/src/serve.ts
  19. 8
      packages/cli/src/template/.gitignore-t
  20. 7
      packages/cli/src/template/.npmignore-t
  21. 145
      packages/cli/src/template/README.md
  22. 23
      packages/cli/src/template/package.json
  23. 9
      packages/cli/src/template/src/blocks.js
  24. 12
      packages/cli/src/template/src/components.js
  25. 29
      packages/cli/src/template/src/index.js
  26. 20
      packages/cli/src/template/tsconfig.json
  27. 123
      packages/cli/src/utils.ts
  28. 129
      packages/cli/src/webpack.config.ts
  29. 359
      packages/cli/test/utils.spec.ts
  30. 21
      packages/cli/tsconfig.json
  31. 62
      packages/cli/webpack.cli.ts
  32. 10
      packages/core/package.json
  33. 3
      packages/core/src/canvas/view/CanvasView.ts
  34. 2
      packages/core/src/commands/index.ts
  35. 4
      packages/core/src/common/index.ts
  36. 2
      packages/core/src/dom_components/view/ComponentView.ts
  37. 2
      packages/core/src/pages/index.ts
  38. 10
      packages/core/src/pages/model/Pages.ts
  39. 19
      packages/core/src/undo_manager/index.ts
  40. 32
      packages/core/test/common.ts
  41. 71
      packages/core/test/specs/pages/index.ts
  42. 518
      pnpm-lock.yaml
  43. 5
      pnpm-workspace.yaml

2
.eslintrc.js

@ -52,5 +52,5 @@ module.exports = {
'max-len': ['error', { code: 300 }],
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1 }],
},
ignorePatterns: ['*/docs/api/*', 'dist'],
ignorePatterns: ['*/docs/api/*', 'dist', 'packages/cli/src/template/**/*.*', '*/locale/*', 'stats.json'],
};

2
.github/workflows/quality.yml

@ -17,7 +17,5 @@ jobs:
run: pnpm lint
- name: Format Check
run: pnpm format:check
- name: Test
run: pnpm test
- name: Docs
run: pnpm docs:api

24
.github/workflows/test.yml

@ -0,0 +1,24 @@
name: GrapesJS Tests
on:
push:
branches: [dev]
pull_request:
branches: [dev]
jobs:
test-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- name: Core Tests
run: pnpm test
working-directory: ./packages/core
test-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- name: CLI Tests
run: pnpm test
working-directory: ./packages/cli

5
.prettierignore

@ -1,3 +1,6 @@
docs/**/*.md
dist/
pnpm-lock.yaml
pnpm-lock.yaml
packages/cli/src/template/**/*.*
**/locale/**
stats.json

8
docs/Home.md

@ -172,7 +172,7 @@ You could add other commands to enable interactions with blocks. Check [Built-in
## Style Manager
Any HTML structure requires, at some point, a proper style, so to meet this need the Style Manager was added as a built-in feature in GrapesJS. Style manager is composed by sectors, which group inside different types of CSS properties. So you can add, for instance, a `Dimension` sector for `width` and `height`, and another one as `Typography` for `font-size` and `color`. So it's up to you decide how organize sectors.
Any HTML structure requires, at some point, a proper style, so to meet this need the Style Manager was added as a built-in feature in GrapesJS. Style manager is composed by sectors, which group inside different types of CSS properties. So you can add, for instance, a `Dimension` sector for `width` and `height`, and another one as `Typography` for `font-size` and `color`. So it's up to you to decide how to organize sectors.
To enable this module we rely on a built-in command `open-sm`, which shows up the Style Manager, which we gonna bind to another button in a separate panel
@ -228,7 +228,7 @@ Selecting one of the component will show up the Style Manager with default secto
[[img/default-sm.jpg]]
As we exploring different configurations inside GrapesJS we gonna overwrite all the default sectors to create some custom one
As we explore different configurations inside GrapesJS we gonna overwrite all the default sectors to create some custom one
Let's put a few sectors with use of `buildProps` which helps us building common properties
@ -356,7 +356,7 @@ styleManager : {
...
```
As you can see using `buildProps` actually will save you a lot of work. You could also mix this techniques to obtain custom properties in less time. For example, let's see how can we setup the same width but with a different value of `min`:
As you can see using `buildProps` actually will save you a lot of work. You could also mix these techniques to obtain custom properties in less time. For example, let's see how we can setup the same width but with a different value of `min`:
```js
...
@ -402,7 +402,7 @@ var editor = grapesjs.init({
...
```
Worth noting the defaut `id` parameter which adds a prefix for all keys to store. If you check the localStorage inside your DOM panel you'll see something like `{ 'gjs-components': '<div>....' ...}` in this way it prevents the risk of collisions, quite common with localStorage use in large applications.
Worth noting the default `id` parameter which adds a prefix for all keys to store. If you check the localStorage inside your DOM panel you'll see something like `{ 'gjs-components': '<div>....' ...}` in this way it prevents the risk of collisions, quite common with localStorage use in large applications.
Storing data locally it's easy and fast but useless in some common cases. In the next example we'll see how to setup a remote storage, which is not far from the previous one

12
docs/modules/Assets.md

@ -78,7 +78,7 @@ editor.runCommand('open-assets');
```
Worth nothing that by doing this you can't do much with assets (if you double click on them nothing happens) and this is because you've not indicated any target. Try just to select an image in your canvas and run this in console (you should first make the editor globally available `window.editor = editor;` in your script)
Worth noting that by doing this you can't do much with assets (if you double click on them nothing happens) and this is because you've not indicated any target. Try just to select an image in your canvas and run this in console (you should first make the editor globally available `window.editor = editor;` in your script)
```js
editor.runCommand('open-assets', {
@ -225,7 +225,7 @@ am.getAll().length; // <- 3
am.getAllVisible().length; // <- 3
```
Ok, now let's show only assets form the first category
Ok, now let's show only assets from the first category
```js
const assets = am.getAll();
@ -325,7 +325,7 @@ Here an example of using custom Asset Manager with a Vue component.
The example above is the right way if you need to replace the default UI, but as you might notice we append the mounted element to the container `props.container.appendChild(this.$el);`.
This is required as the Asset Manager, by default, is placed in the [Modal](/modules/Modal.html).
How to approach the case when your Asset Manager is a completely independent/external module (eg. should be showed in its own custom modal)? Not a problem, you can bind the Asset Manager state via `assetManager.custom.open`.
How to approach the case when your Asset Manager is a completely independent/external module (eg. should be shown in its own custom modal)? Not a problem, you can bind the Asset Manager state via `assetManager.custom.open`.
```js
const editor = grapesjs.init({
@ -339,7 +339,7 @@ const editor = grapesjs.init({
// Init and open your external Asset Manager
// ...
// IMPORTANT:
// When the external library is closed you have to comunicate
// When the external library is closed you have to communicate
// this state back to the editor, otherwise GrapesJS will think
// the Asset Manager is still open.
// example: myAssetManager.on('close', () => props.close())
@ -357,10 +357,10 @@ It's important to declare also the `close` function, the editor should be able t
<!--
### Define new Asset type
Generally speaking, images aren't the only asset you'll use, it could be a `video`, `svg-icon`, or any other kind of `document`. Each type of asset is applied in our templates/pages differently. If you need to change the image of the Component all you need is another `url` in `src` attribute. However In case of a `svg-icon`, its not the same, you might want to replace the element with a new `<svg>` content. Besides this you also have to deal with the presentation/preview of the asset inside the panel/modal. For example, showing a thumbnail for big images or the possibility to preview videos.
Generally speaking, images aren't the only asset you'll use, it could be a `video`, `svg-icon`, or any other kind of `document`. Each type of asset is applied in our templates/pages differently. If you need to change the image of the Component all you need is another `url` in `src` attribute. However In case of a `svg-icon`, it's not the same, you might want to replace the element with a new `<svg>` content. Besides this you also have to deal with the presentation/preview of the asset inside the panel/modal. For example, showing a thumbnail for big images or the possibility to preview videos.
Defining a new asset it means we have to push on top of the 'Stack of Types' a new layer. This stack is iterated over by the editor at any addition of the asset and tries to associate the correct type.
Defining a new asset means we have to push on top of the 'Stack of Types' a new layer. This stack is iterated over by the editor at any addition of the asset and tries to associate the correct type.
```js
am.add('https://.../image.png');

8
package.json

@ -4,21 +4,23 @@
"packageManager": "pnpm@9.10.0",
"scripts": {
"start": "pnpm --filter grapesjs start",
"test": "pnpm --filter grapesjs test",
"test": "pnpm -r run test",
"docs": "pnpm --filter @grapesjs/docs docs",
"docs:api": "pnpm --filter @grapesjs/docs docs:api",
"lint": "eslint .",
"build": "pnpm --filter \"!@grapesjs/docs\" build",
"ts:check": "pnpm --filter grapesjs ts:check",
"clean": "find . -type d \\( -name \"node_modules\" -o -name \"build\" -o -name \"dist\" \\) -exec rm -rf {} + && rm ./pnpm-lock.yaml",
"format": "prettier . --write",
"format:check": "prettier . --check"
"format": "prettier . --write --ignore-path .prettierignore",
"format:check": "prettier . --check --ignore-path .prettierignore"
},
"devDependencies": {
"@babel/cli": "7.24.8",
"@babel/core": "7.25.2",
"@babel/preset-env": "7.25.4",
"@babel/preset-typescript": "7.24.7",
"@babel/runtime": "7.25.6",
"babel-loader": "9.1.3",
"@jest/globals": "29.7.0",
"@types/jest": "29.5.12",
"@types/node": "22.4.1",

122
packages/cli/README.md

@ -0,0 +1,122 @@
# GrapesJS CLI
[![npm](https://img.shields.io/npm/v/grapesjs-cli.svg)](https://www.npmjs.com/package/grapesjs-cli)
![grapesjs-cli](https://user-images.githubusercontent.com/11614725/67523496-0ed41300-f6af-11e9-9850-7175355f2946.jpg)
A simple CLI library for helping in GrapesJS plugin development.
The goal of this package is to avoid the hassle of setting up all the dependencies and configurations for the plugin development by centralizing and speeding up the necessary steps during the process.
- Fast project scaffolding
- No need to touch Babel and Webpack configurations
## Plugin from 0 to 100
Create a production-ready plugin in a few simple steps.
- Create a folder for your plugin and init some preliminary steps
```sh
mkdir grapesjs-my-plugin
cd grapesjs-my-plugin
npm init -y
git init
```
- Install the package
```sh
npm i -D grapesjs-cli
```
- Init your plugin project by following few steps
```sh
npx grapesjs-cli init
```
You can also skip all the questions with `-y` option or pass all the answers via options (to see all available options run `npx grapesjs-cli init --help`)
```sh
npx grapesjs-cli init -y --user=YOUR-GITHUB-USERNAME
```
- The command will scaffold the `src` directory and a bunch of other files inside your project. The `src/index.js` will be the entry point of your plugin. Before starting developing your plugin run the development server and open the printed URL (eg. the default is http://localhost:8080)
```sh
npx grapesjs-cli serve
```
If you need a custom port use the `-p` option
```sh
npx grapesjs-cli serve -p 8081
```
Under the hood we use `webpack-dev-server` and you can pass its option via CLI in this way
```sh
npx grapesjs-cli serve --devServer='{"https": true}'
```
- Once the development is finished you can build your plugin and generate the minified file ready for production
```sh
npx grapesjs-cli build
```
- Before publishing your package remember to complete your README.md file with all the available options, components, blocks and so on.
For a better user engagement create a simple live demo by using services like [JSFiddle](https://jsfiddle.net) [CodeSandbox](https://codesandbox.io) [CodePen](https://codepen.io) and link it in your README. To help you in this process we'll print all the necessary HTML/CSS/JS in your README, so it will be just a matter of copy-pasting on some of those services.
## Customization
### Customize webpack config
If you need to customize the webpack configuration, you can create `webpack.config.js` file in the root dir of your project and export a function, which should return the new configuration object. Check the example below.
```js
// YOUR-PROJECT-DIR/webpack.config.js
// config is the default configuration
export default ({ config }) => {
// This is how you can distinguish the `build` command from the `serve`
const isBuild = config.mode === 'production';
return {
...config,
module: {
rules: [
{
/* extra rule */
},
...config.module.rules,
],
},
};
};
```
## Generic CLI usage
Show all available commands
```sh
grapesjs-cli
```
Show available options for a command
```sh
grapesjs-cli COMMAND --help
```
Run the command
```sh
grapesjs-cli COMMAND --OPT1 --OPT2=VALUE
```
## License
MIT

3
packages/cli/babel.config.js

@ -0,0 +1,3 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
};

42
packages/cli/index.html

@ -0,0 +1,42 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title><%= title %></title>
<link
href="<%= pathGjsCss ? pathGjsCss : 'https://unpkg.com/grapesjs@' + gjsVersion + '/dist/css/grapes.min.css' %>"
rel="stylesheet"
/>
<script src="<%= pathGjs ? pathGjs : 'https://unpkg.com/grapesjs@' + gjsVersion %>"></script>
<style>
body,
html {
height: 100%;
margin: 0;
}
</style>
</head>
<body>
<div id="gjs" style="height: 0px; overflow: hidden">
<div style="margin: 100px 100px 25px; padding: 25px; font: caption">
This is a demo content generated from <b>GrapesJS CLI</b>. For the development, you should create a _index.html
template file (might be a copy of this one) and on the next server start the new file will be served, and it
will be ignored by git.
</div>
</div>
<script type="text/javascript">
window.onload = () => {
window.editor = grapesjs.init({
height: '100%',
container: '#gjs',
showOffsets: 1,
noticeOnUnload: 0,
storageManager: false,
fromElement: true,
plugins: ['<%= name %>'],
});
};
</script>
</body>
</html>

10
packages/cli/jest.config.ts

@ -0,0 +1,10 @@
import type { Config } from 'jest';
const config: Config = {
testEnvironment: 'node',
verbose: true,
modulePaths: ['<rootDir>/src'],
testMatch: ['<rootDir>/test/**/*.(t|j)s'],
};
export default config;

57
packages/cli/package.json

@ -0,0 +1,57 @@
{
"name": "grapesjs-cli",
"version": "4.1.3",
"description": "GrapesJS CLI tool for the plugin development",
"bin": {
"grapesjs-cli": "dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "BUILD_MODE=production webpack --config ./webpack.cli.ts",
"build:watch": "webpack --config ./webpack.cli.ts --watch",
"lint": "eslint src",
"patch": "npm version patch -m 'Bump v%s'",
"test": "jest"
},
"repository": {
"type": "git",
"url": "https://github.com/GrapesJS/grapesjs.git"
},
"keywords": [
"grapesjs",
"plugin",
"dev",
"cli"
],
"author": "Artur Arseniev",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/core": "7.25.2",
"@babel/plugin-transform-runtime": "7.25.4",
"@babel/preset-env": "7.25.4",
"@babel/runtime": "7.25.6",
"babel-loader": "9.1.3",
"chalk": "^4.1.2",
"core-js": "3.38.1",
"dts-bundle-generator": "^8.0.1",
"html-webpack-plugin": "5.6.0",
"inquirer": "^8.2.5",
"listr": "^0.14.3",
"lodash.template": "^4.5.0",
"rimraf": "^4.1.2",
"spdx-license-list": "^6.6.0",
"terser-webpack-plugin": "^5.3.10",
"webpack": "5.94.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.1.0",
"yargs": "^17.6.2"
},
"devDependencies": {
"@types/webpack-node-externals": "^3.0.0",
"copy-webpack-plugin": "^11.0.0",
"fork-ts-checker-webpack-plugin": "^8.0.0",
"webpack-node-externals": "^3.0.0"
}
}

6
packages/cli/src/banner.txt

@ -0,0 +1,6 @@
  ______ _______ ________ ____
  / ____/________ _____ ___ _____ / / ___/ / ____/ / / _/
 / / __/ ___/ __ `/ __ \/ _ \/ ___/_ / /\__ \______/ / / / / /
/ /_/ / / / /_/ / /_/ / __(__ ) /_/ /___/ /_____/ /___/ /____/ /
\____/_/ \__,_/ .___/\___/____/\____//____/ \____/_____/___/
  /_/

143
packages/cli/src/build.ts

@ -0,0 +1,143 @@
import {
printRow,
printError,
buildWebpackArgs,
normalizeJsonOpt,
copyRecursiveSync,
rootResolve,
babelConfig,
log,
writeFile,
} from './utils';
import { generateDtsBundle } from 'dts-bundle-generator';
import webpack from 'webpack';
import fs from 'fs';
import webpackConfig from './webpack.config';
import { exec } from 'child_process';
import chalk from 'chalk';
import rimraf from 'rimraf';
import { transformFileSync } from '@babel/core';
interface BuildOptions {
verbose?: boolean;
patch?: boolean;
statsOutput?: string;
localePath?: string;
dts?: 'include' | 'skip' | 'only';
}
/**
* Build locale files
* @param {Object} opts
*/
export const buildLocale = async (opts: BuildOptions = {}) => {
const { localePath } = opts;
if (!fs.existsSync(rootResolve(localePath))) return;
printRow('Start building locale files...', { lineDown: 0 });
await rimraf('locale');
const localDst = rootResolve('locale');
copyRecursiveSync(rootResolve(localePath), localDst);
// Create locale/index.js file
let result = '';
fs.readdirSync(localDst).forEach((file) => {
const name = file.split('.')[0];
result += `export { default as ${name} } from './${name}'\n`;
});
fs.writeFileSync(`${localDst}/index.js`, result);
// Compile files
const babelOpts = { ...babelConfig(buildWebpackArgs(opts) as any) };
fs.readdirSync(localDst).forEach((file) => {
const filePath = `${localDst}/${file}`;
const compiled = transformFileSync(filePath, babelOpts).code;
fs.writeFileSync(filePath, compiled);
});
printRow('Locale files building completed successfully!');
};
/**
* Build TS declaration file
* @param {Object} opts
*/
export const buildDeclaration = async (opts: BuildOptions = {}) => {
const filePath = rootResolve('src/index.ts');
if (!fs.existsSync(filePath)) return;
printRow('Start building TS declaration file...', { lineDown: 0 });
const entry = { filePath, output: { noBanner: true } };
const bundleOptions = { preferredConfigPath: rootResolve('tsconfig.json') };
const result = generateDtsBundle([entry], bundleOptions)[0];
await writeFile(rootResolve('dist/index.d.ts'), result);
printRow('TS declaration file building completed successfully!');
};
/**
* Build the library files
* @param {Object} opts
*/
export default (opts: BuildOptions = {}) => {
printRow('Start building the library...');
const isVerb = opts.verbose;
const { dts } = opts;
isVerb && log(chalk.yellow('Build config:\n'), opts, '\n');
const buildWebpack = () => {
const buildConf = {
...webpackConfig({
production: 1,
args: buildWebpackArgs(opts),
cmdOpts: opts,
}),
...normalizeJsonOpt(opts, 'config'),
};
if (dts === 'only') {
return buildDeclaration(opts);
}
webpack(buildConf, async (err, stats) => {
const errors = err || (stats ? stats.hasErrors() : false);
const statConf = {
hash: false,
colors: true,
builtAt: false,
entrypoints: false,
modules: false,
...normalizeJsonOpt(opts, 'stats'),
};
if (stats) {
opts.statsOutput && fs.writeFileSync(rootResolve(opts.statsOutput), JSON.stringify(stats.toJson()));
isVerb && log(chalk.yellow('Stats config:\n'), statConf, '\n');
const result = stats.toString(statConf);
log(result, '\n');
}
await buildLocale(opts);
if (dts !== 'skip') {
await buildDeclaration(opts);
}
if (errors) {
printError(`Error during building`);
console.error(err);
} else {
printRow('Building completed successfully!');
}
});
};
if (opts.patch) {
isVerb && log(chalk.yellow('Patch the version'), '\n');
exec('npm version --no-git-tag-version patch', buildWebpack);
} else {
buildWebpack();
}
};

174
packages/cli/src/cli.ts

@ -0,0 +1,174 @@
import yargs from 'yargs';
import fs from 'fs';
import path from 'path';
import { serve, build, init } from './main';
import chalk from 'chalk';
import { printError } from './utils';
import { version } from '../package.json';
yargs.usage(chalk.green.bold(fs.readFileSync(path.resolve(__dirname, './banner.txt'), 'utf8') + `\nv${version}`));
const webpackOptions = (yargs) => {
yargs
.positional('config', {
describe: 'webpack configuration options',
type: 'string',
default: '{}',
})
.positional('babel', {
describe: 'Babel configuration object',
type: 'string',
default: '{}',
})
.positional('targets', {
describe: 'Browser targets in browserslist query',
type: 'string',
default: '> 0.25%, not dead',
})
.positional('entry', {
describe: 'Library entry point',
type: 'string',
default: 'src/index',
})
.positional('output', {
describe: 'Build destination directory',
type: 'string',
default: 'dist',
});
};
export const createCommands = (yargs) => {
return yargs
.command(
['serve [port]', 'server'],
'Start the server',
(yargs) => {
yargs
.positional('devServer', {
describe: 'webpack-dev-server options',
type: 'string',
default: '{}',
})
.positional('host', {
alias: 'h',
describe: 'Host to bind on',
type: 'string',
default: 'localhost',
})
.positional('port', {
alias: 'p',
describe: 'Port to bind on',
type: 'number',
default: 8080,
})
.positional('htmlWebpack', {
describe: 'html-webpack-plugin options',
type: 'string',
default: '{}',
});
webpackOptions(yargs);
},
(argv) => serve(argv),
)
.command(
'build',
'Build the source',
(yargs) => {
yargs
.positional('stats', {
describe: 'Options for webpack Stats instance',
type: 'string',
default: '{}',
})
.positional('statsOutput', {
describe: 'Specify the path where to output webpack stats file (eg. "stats.json")',
type: 'string',
default: '',
})
.positional('patch', {
describe: 'Increase automatically the patch version',
type: 'boolean',
default: true,
})
.positional('localePath', {
describe: 'Path to the directory containing locale files',
type: 'string',
default: 'src/locale',
})
.positional('dts', {
describe: 'Generate typescript dts file ("include", "skip", "only")',
type: 'string',
default: 'include',
});
webpackOptions(yargs);
},
(argv) => build(argv),
)
.command(
'init',
'Init GrapesJS plugin project',
(yargs) => {
yargs
.positional('yes', {
alias: 'y',
describe: 'All default answers',
type: 'boolean',
default: false,
})
.positional('name', {
describe: 'Name of the project',
type: 'string',
})
.positional('rName', {
describe: 'Repository name',
type: 'string',
})
.positional('user', {
describe: 'Repository username',
type: 'string',
})
.positional('components', {
describe: 'Indicate to include custom component types API',
type: 'boolean',
})
.positional('blocks', {
describe: 'Indicate to include blocks API',
type: 'boolean',
})
.positional('i18n', {
describe: 'Indicate to include the support for i18n',
type: 'boolean',
})
.positional('license', {
describe: 'License of the project',
type: 'string',
});
},
(argv) => init(argv),
)
.options({
verbose: {
alias: 'v',
description: 'Run with verbose logging',
type: 'boolean', // boolean | number | string
default: false,
},
})
.recommendCommands()
.strict();
};
export const argsToOpts = async () => {
return await createCommands(yargs).parse();
};
export const run = async (opts = {}) => {
try {
let options = await argsToOpts();
if (!options._.length) yargs.showHelp();
} catch (error) {
printError((error.stack || error).toString());
}
};
run();

217
packages/cli/src/init.ts

@ -0,0 +1,217 @@
import inquirer from 'inquirer';
import { printRow, isUndefined, log, ensureDir } from './utils';
import Listr from 'listr';
import path from 'path';
import fs from 'fs';
import spdxLicenseList from 'spdx-license-list/full';
import template from 'lodash.template';
import { version } from '../package.json';
interface InitOptions {
license?: string;
name?: string;
components?: boolean;
blocks?: boolean;
i18n?: boolean;
verbose?: boolean;
rName?: string;
user?: string;
yes?: boolean;
}
const tmpPath = './template';
const rootPath = process.cwd();
const getName = (str: string) =>
str
.replace(/\_/g, '-')
.split('-')
.filter((i) => i)
.map((i) => i[0].toUpperCase() + i.slice(1))
.join(' ');
const getTemplateFileContent = (pth: string) => {
const pt = path.resolve(__dirname, `${tmpPath}/${pth}`);
return fs.readFileSync(pt, 'utf8');
};
const resolveRoot = (pth: string) => {
return path.resolve(rootPath, pth);
};
const resolveLocal = (pth: string) => {
return path.resolve(__dirname, `${tmpPath}/${pth}`);
};
const createSourceFiles = async (opts: InitOptions = {}) => {
const rdmSrc = getTemplateFileContent('README.md');
const rdmDst = resolveRoot('README.md');
const indxSrc = getTemplateFileContent('src/index.js');
const indxDst = resolveRoot('src/index.js');
const indexCnt = getTemplateFileContent('_index.html');
const indexDst = resolveRoot('_index.html');
const license = spdxLicenseList[opts.license];
const licenseTxt =
license &&
(license.licenseText || '')
.replace('<year>', `${new Date().getFullYear()}-current`)
.replace('<copyright holders>', opts.name);
ensureDir(indxDst);
// write src/_index.html
fs.writeFileSync(indxDst, template(indxSrc)(opts).trim());
// write _index.html
fs.writeFileSync(indexDst, template(indexCnt)(opts));
// Write README.md
fs.writeFileSync(rdmDst, template(rdmSrc)(opts));
// write LICENSE
licenseTxt && fs.writeFileSync(resolveRoot('LICENSE'), licenseTxt);
// Copy files
fs.copyFileSync(resolveLocal('.gitignore-t'), resolveRoot('.gitignore'));
fs.copyFileSync(resolveLocal('.npmignore-t'), resolveRoot('.npmignore'));
fs.copyFileSync(resolveLocal('tsconfig.json'), resolveRoot('tsconfig.json'));
};
const createFileComponents = (opts: InitOptions = {}) => {
const filepath = 'src/components.js';
const cmpSrc = resolveLocal(filepath);
const cmpDst = resolveRoot(filepath);
opts.components && fs.copyFileSync(cmpSrc, cmpDst);
};
const createFileBlocks = (opts: InitOptions = {}) => {
const filepath = 'src/blocks.js';
const blkSrc = resolveLocal(filepath);
const blkDst = resolveRoot(filepath);
opts.blocks && fs.copyFileSync(blkSrc, blkDst);
};
const createI18n = (opts = {}) => {
const enPath = 'src/locale/en.js';
const tmpEn = getTemplateFileContent(enPath);
const dstEn = resolveRoot(enPath);
ensureDir(dstEn);
fs.writeFileSync(dstEn, template(tmpEn)(opts));
};
const createPackage = (opts = {}) => {
const filepath = 'package.json';
const cnt = getTemplateFileContent(filepath);
const dst = resolveRoot(filepath);
fs.writeFileSync(
dst,
template(cnt)({
...opts,
version,
}),
);
};
const checkBoolean = (value) => (value && value !== 'false' ? true : false);
export const initPlugin = async (opts: InitOptions = {}) => {
printRow('Start project creation...');
opts.components = checkBoolean(opts.components);
opts.blocks = checkBoolean(opts.blocks);
opts.i18n = checkBoolean(opts.i18n);
const tasks = new Listr([
{
title: 'Creating initial source files',
task: () => createSourceFiles(opts),
},
{
title: 'Creating custom Component Type file',
task: () => createFileComponents(opts),
enabled: () => opts.components,
},
{
title: 'Creating Blocks file',
task: () => createFileBlocks(opts),
enabled: () => opts.blocks,
},
{
title: 'Creating i18n structure',
task: () => createI18n(opts),
enabled: () => opts.i18n,
},
{
title: 'Update package.json',
task: () => createPackage(opts),
},
]);
await tasks.run();
};
export default async (opts: InitOptions = {}) => {
const rootDir = path.basename(process.cwd());
const questions = [];
const { verbose, name, rName, user, yes, components, blocks, i18n, license } = opts;
let results = {
name: name || getName(rootDir),
rName: rName || rootDir,
user: user || 'YOUR-USERNAME',
components: isUndefined(components) ? true : components,
blocks: isUndefined(blocks) ? true : blocks,
i18n: isUndefined(i18n) ? true : i18n,
license: license || 'MIT',
};
printRow(`Init the project${verbose ? ' (verbose)' : ''}...`);
if (!yes) {
!name &&
questions.push({
name: 'name',
message: 'Name of the project',
default: results.name,
});
!rName &&
questions.push({
name: 'rName',
message: 'Repository name (used also as the plugin name)',
default: results.rName,
});
!user &&
questions.push({
name: 'user',
message: 'Repository username (eg. on GitHub/Bitbucket)',
default: results.user,
});
isUndefined(components) &&
questions.push({
type: 'boolean',
name: 'components',
message: 'Will you need to add custom Component Types?',
default: results.components,
});
isUndefined(blocks) &&
questions.push({
type: 'boolean',
name: 'blocks',
message: 'Will you need to add Blocks?',
default: results.blocks,
});
isUndefined(i18n) &&
questions.push({
type: 'boolean',
name: 'i18n',
message: 'Do you want to setup i18n structure in this plugin?',
default: results.i18n,
});
!license &&
questions.push({
name: 'license',
message: 'License of the project',
default: results.license,
});
}
const answers = await inquirer.prompt(questions);
results = {
...results,
...answers,
};
verbose && log({ results, opts });
await initPlugin(results);
printRow('Project created! Happy coding');
};

3
packages/cli/src/main.ts

@ -0,0 +1,3 @@
export { default as init } from './init';
export { default as build } from './build';
export { default as serve } from './serve';

48
packages/cli/src/serve.ts

@ -0,0 +1,48 @@
import { printRow, buildWebpackArgs, log, normalizeJsonOpt } from './utils';
import webpack from 'webpack';
import webpackDevServer from 'webpack-dev-server';
import webpackConfig from './webpack.config';
import chalk from 'chalk';
interface ServeOptions {
host?: string;
port?: number;
verbose?: boolean;
}
/**
* Start up the development server
* @param {Object} opts
*/
export default (opts: ServeOptions = {}) => {
printRow('Start the development server...');
const { host, port } = opts;
const isVerb = opts.verbose;
const resultWebpackConf = {
...webpackConfig({ args: buildWebpackArgs(opts), cmdOpts: opts }),
...normalizeJsonOpt(opts, 'webpack'),
};
const devServerConf = {
...resultWebpackConf.devServer,
open: true,
...normalizeJsonOpt(opts, 'devServer'),
};
if (host !== 'localhost') {
devServerConf.host = host;
}
if (port !== 8080) {
devServerConf.port = port;
}
if (isVerb) {
log(chalk.yellow('Server config:\n'), opts, '\n');
log(chalk.yellow('DevServer config:\n'), devServerConf, '\n');
}
const compiler = webpack(resultWebpackConf);
const server = new webpackDevServer(devServerConf, compiler);
server.start();
};

8
packages/cli/src/template/.gitignore-t

@ -0,0 +1,8 @@
.DS_Store
private/
/locale
node_modules/
*.log
_index.html
dist/
stats.json

7
packages/cli/src/template/.npmignore-t

@ -0,0 +1,7 @@
.*
*.log
*.html
**/tsconfig.json
**/webpack.config.js
node_modules
src

145
packages/cli/src/template/README.md

@ -0,0 +1,145 @@
# <%= name %>
## Live Demo
> **Show a live example of your plugin**
To make your plugin more engaging, create a simple live demo using online tools like [JSFiddle](https://jsfiddle.net), [CodeSandbox](https://codesandbox.io), or [CodePen](https://codepen.io). Include the demo link in your README. Adding a screenshot or GIF of the demo is a bonus.
Below, you'll find the necessary HTML, CSS, and JavaScript. Copy and paste this code into one of the tools mentioned. Once you're done, delete this section and update the link at the top with your demo.
### HTML
```html
<link href="https://unpkg.com/grapesjs/dist/css/grapes.min.css" rel="stylesheet" />
<script src="https://unpkg.com/grapesjs"></script>
<script src="https://unpkg.com/<%= rName %>"></script>
<div id="gjs"></div>
```
### JS
```js
const editor = grapesjs.init({
container: '#gjs',
height: '100%',
fromElement: true,
storageManager: false,
plugins: ['<%= rName %>'],
});
```
### CSS
```css
body,
html {
margin: 0;
height: 100%;
}
```
## Summary
- Plugin name: `<%= rName %>`
- Components
- `component-id-1`
- `component-id-2`
- ...
- Blocks
- `block-id-1`
- `block-id-2`
- ...
## Options
| Option | Description | Default |
| --------- | ------------------ | --------------- |
| `option1` | Description option | `default value` |
## Download
- CDN
- `https://unpkg.com/<%= rName %>`
- NPM
- `npm i <%= rName %>`
- GIT
- `git clone https://github.com/<%= user %>/<%= rName %>.git`
## Usage
Directly in the browser
```html
<link href="https://unpkg.com/grapesjs/dist/css/grapes.min.css" rel="stylesheet" />
<script src="https://unpkg.com/grapesjs"></script>
<script src="path/to/<%= rName %>.min.js"></script>
<div id="gjs"></div>
<script type="text/javascript">
var editor = grapesjs.init({
container: '#gjs',
// ...
plugins: ['<%= rName %>'],
pluginsOpts: {
'<%= rName %>': {
/* options */
},
},
});
</script>
```
Modern javascript
```js
import grapesjs from 'grapesjs';
import plugin from '<%= rName %>';
import 'grapesjs/dist/css/grapes.min.css';
const editor = grapesjs.init({
container : '#gjs',
// ...
plugins: [plugin],
pluginsOpts: {
[plugin]: { /* options */ }
}
// or
plugins: [
editor => plugin(editor, { /* options */ }),
],
});
```
## Development
Clone the repository
```sh
$ git clone https://github.com/<%= user %>/<%= rName %>.git
$ cd <%= rName %>
```
Install dependencies
```sh
npm i
```
Start the dev server
```sh
npm start
```
Build the source
```sh
npm run build
```
## License
MIT

23
packages/cli/src/template/package.json

@ -0,0 +1,23 @@
{
"name": "<%= rName %>",
"version": "1.0.0",
"description": "<%= name %>",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/<%= user %>/<%= rName %>.git"
},
"scripts": {
"start": "grapesjs-cli serve",
"build": "grapesjs-cli build",
"bump": "npm version patch -m 'Bump v%s'"
},
"keywords": [
"grapesjs",
"plugin"
],
"devDependencies": {
"grapesjs-cli": "^<%= version %>"
},
"license": "<%= license %>"
}

9
packages/cli/src/template/src/blocks.js

@ -0,0 +1,9 @@
export default (editor, opts = {}) => {
const bm = editor.BlockManager;
bm.add('MY-BLOCK', {
label: 'My block',
content: { type: 'MY-COMPONENT' },
// media: '<svg>...</svg>',
});
};

12
packages/cli/src/template/src/components.js

@ -0,0 +1,12 @@
export default (editor, opts = {}) => {
const domc = editor.DomComponents;
domc.addType('MY-COMPONENT', {
model: {
defaults: {
// Default props
},
},
view: {},
});
};

29
packages/cli/src/template/src/index.js

@ -0,0 +1,29 @@
<% if(components){ %>import loadComponents from './components';<% } %>
<% if(blocks){ %>import loadBlocks from './blocks';<% } %>
<% if(i18n){ %>import en from './locale/en';<% } %>
export default (editor, opts = {}) => {
const options = { ...{
<% if(i18n){ %>i18n: {},<% } %>
// default options
}, ...opts };
<% if(components){ %>// Add components
loadComponents(editor, options);<% } %>
<% if(blocks){ %>// Add blocks
loadBlocks(editor, options);<% } %>
<% if(i18n){ %>// Load i18n files
editor.I18n && editor.I18n.addMessages({
en,
...options.i18n,
});<% } %>
// TODO Remove
editor.on('load', () =>
editor.addComponents(
`<div style="margin:100px; padding:25px;">
Content loaded from the plugin
</div>`,
{ at: 0 }
))
};

20
packages/cli/src/template/tsconfig.json

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"sourceMap": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false
},
"include": ["src"]
}

123
packages/cli/src/utils.ts

@ -0,0 +1,123 @@
import chalk from 'chalk';
import path from 'path';
import fs from 'fs';
import fsp from 'fs/promises';
export const isString = (val: any): val is string => typeof val === 'string';
export const isUndefined = (value: any) => typeof value === 'undefined';
export const isFunction = (value: any): value is Function => typeof value === 'function';
export const isObject = (val: any) => val !== null && !Array.isArray(val) && typeof val === 'object';
export const printRow = (str: string, { color = 'green', lineDown = 1 } = {}) => {
console.log('');
console.log(chalk[color].bold(str));
lineDown && console.log('');
};
export const printError = (str: string) => {
printRow(str, { color: 'red' });
};
export const log = (...args: any[]) => console.log.apply(this, args);
export const ensureDir = (filePath: string) => {
const dirname = path.dirname(filePath);
if (fs.existsSync(dirname)) return true;
fs.mkdirSync(dirname);
return ensureDir(dirname);
};
/**
* Normalize JSON options
* @param opts Options
* @param key Options name to normalize
* @returns {Object}
*/
export const normalizeJsonOpt = (opts: Record<string, any>, key: string) => {
let devServerOpt = opts[key] || {};
if (isString(devServerOpt)) {
try {
devServerOpt = JSON.parse(devServerOpt);
} catch (e) {
printError(`Error while parsing "${key}" option`);
printError(e);
devServerOpt = {};
}
}
return devServerOpt;
};
export const buildWebpackArgs = (opts: Record<string, any>) => {
return {
...opts,
babel: normalizeJsonOpt(opts, 'babel'),
htmlWebpack: normalizeJsonOpt(opts, 'htmlWebpack'),
};
};
export const copyRecursiveSync = (src: string, dest: string) => {
const exists = fs.existsSync(src);
const isDir = exists && fs.statSync(src).isDirectory();
if (isDir) {
fs.mkdirSync(dest);
fs.readdirSync(src).forEach((file) => {
copyRecursiveSync(path.join(src, file), path.join(dest, file));
});
} else if (exists) {
fs.copyFileSync(src, dest);
}
};
export const isPathExists = async (path: string) => {
try {
await fsp.access(path);
return true;
} catch {
return false;
}
};
export const writeFile = async (filePath: string, data: string) => {
try {
const dirname = path.dirname(filePath);
const exist = await isPathExists(dirname);
if (!exist) {
await fsp.mkdir(dirname, { recursive: true });
}
await fsp.writeFile(filePath, data, 'utf8');
} catch (err) {
throw new Error(err);
}
};
export const rootResolve = (val: string) => path.resolve(process.cwd(), val);
export const originalRequire = () => {
// @ts-ignore need this to use the original 'require.resolve' as it's replaced by webpack
return __non_webpack_require__;
};
export const resolve = (value: string) => {
return originalRequire().resolve(value);
};
export const babelConfig = (opts: { targets?: string } = {}) => ({
presets: [
[
resolve('@babel/preset-env'),
{
targets: opts.targets,
// useBuiltIns: 'usage', // this makes the build much bigger
// corejs: 3,
},
],
],
plugins: [resolve('@babel/plugin-transform-runtime')],
});

129
packages/cli/src/webpack.config.ts

@ -0,0 +1,129 @@
import { babelConfig, rootResolve, isFunction, isObject, log, resolve, originalRequire } from './utils';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import chalk from 'chalk';
import path from 'path';
import fs from 'fs';
import webpack from 'webpack';
const dirCwd = process.cwd();
let plugins = [];
export default (opts: Record<string, any> = {}): any => {
const pkgPath = path.join(dirCwd, 'package.json');
const rawPackageJson = fs.readFileSync(pkgPath) as unknown as string;
const pkg = JSON.parse(rawPackageJson);
const { args, cmdOpts = {} } = opts;
const { htmlWebpack = {} } = args;
const name = pkg.name;
const isProd = opts.production;
const banner = `/*! ${name} - ${pkg.version} */`;
if (!isProd) {
const fname = 'index.html';
const index = `${dirCwd}/${fname}`;
const indexDev = `${dirCwd}/_${fname}`;
let template = path.resolve(__dirname, `./../${fname}`);
if (fs.existsSync(indexDev)) {
template = indexDev;
} else if (fs.existsSync(index)) {
template = index;
}
plugins.push(
new HtmlWebpackPlugin({
inject: 'head',
template,
...htmlWebpack,
templateParameters: {
name,
title: name,
gjsVersion: 'latest',
pathGjs: '',
pathGjsCss: '',
...(htmlWebpack.templateParameters || {}),
},
}),
);
}
const outPath = path.resolve(dirCwd, args.output);
const modulesPaths = ['node_modules', path.join(__dirname, '../node_modules')];
let config = {
entry: path.resolve(dirCwd, args.entry),
mode: isProd ? 'production' : 'development',
devtool: isProd ? 'source-map' : 'eval',
optimization: {
minimizer: [
new TerserPlugin({
extractComments: false,
terserOptions: {
compress: {
evaluate: false, // Avoid breaking gjs scripts
},
output: {
comments: false,
quote_style: 3, // Preserve original quotes
preamble: banner, // banner here instead of BannerPlugin
},
},
}),
],
},
output: {
path: outPath,
filename: 'index.js',
library: name,
libraryTarget: 'umd',
globalObject: `typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : this)`,
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: resolve('ts-loader'),
exclude: /node_modules/,
options: {
context: rootResolve(''),
configFile: rootResolve('tsconfig.json'),
},
},
{
test: /\.js$/,
loader: resolve('babel-loader'),
include: /src/,
options: {
...babelConfig(args),
cacheDirectory: true,
...args.babel,
},
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
modules: modulesPaths,
},
plugins,
};
// Try to load local webpack config
const localWebpackPath = rootResolve('webpack.config.js');
let localWebpackConf: any;
if (fs.existsSync(localWebpackPath)) {
const customWebpack = originalRequire()(localWebpackPath);
localWebpackConf = customWebpack.default || customWebpack;
}
if (isFunction(localWebpackConf)) {
const fnRes = localWebpackConf({ config, webpack, pkg });
config = isObject(fnRes) ? fnRes : config;
}
cmdOpts.verbose && log(chalk.yellow('Webpack config:\n'), config, '\n');
return config;
};

359
packages/cli/test/utils.spec.ts

@ -0,0 +1,359 @@
import {
isFunction,
isObject,
isString,
isUndefined,
printRow,
printError,
log,
ensureDir,
normalizeJsonOpt,
buildWebpackArgs,
copyRecursiveSync,
babelConfig,
originalRequire,
resolve,
rootResolve,
} from '../src/utils';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import * as process from 'process';
const typeTestValues = {
undefinedValue: undefined,
nullValue: null,
stringValue: 'hello',
emptyObject: {},
nonEmptyObject: { key: 'value' },
emptyArray: [],
functionValue: () => {},
numberValue: 42,
booleanValue: true,
dateValue: new Date(),
};
function runTypeCheck(typeCheckFunction: (value: any) => boolean) {
const keysWithPassingTypeChecks = Object.keys(typeTestValues).filter((key) => {
const value = typeTestValues[key];
return typeCheckFunction(value);
});
return keysWithPassingTypeChecks;
}
jest.mock('fs');
jest.mock('fs/promises');
describe('utils', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('isString', () => {
it('should correctly identify strings', () => {
const result = runTypeCheck(isString);
expect(result).toEqual(['stringValue']);
});
});
describe('isUndefined', () => {
it('should correctly identify undefined values', () => {
const result = runTypeCheck(isUndefined);
expect(result).toEqual(['undefinedValue']);
});
});
describe('isFunction', () => {
it('should correctly identify functions', () => {
const result = runTypeCheck(isFunction);
expect(result).toEqual(['functionValue']);
});
});
describe('isObject', () => {
it('should correctly identify objects', () => {
const result = runTypeCheck(isObject);
expect(result).toEqual(['emptyObject', 'nonEmptyObject', 'dateValue']);
});
});
describe('printRow', () => {
// TODO: We should refactor the function to make lineDown a boolean not a number
it('should console.log the given string with the specified color and line breaks', () => {
const str = 'Test string';
const color = 'blue';
const lineDown = 1;
console.log = jest.fn() as jest.Mock;
printRow(str, { color, lineDown });
expect(console.log).toHaveBeenCalledTimes(3); // 1 for empty line, 1 for colored string, 1 for line break
expect((console.log as jest.Mock).mock.calls[1][0]).toEqual(chalk[color].bold(str));
});
it('should not add a line break if lineDown is false', () => {
const str = 'Test string';
const color = 'green';
const lineDown = 0;
console.log = jest.fn();
printRow(str, { color, lineDown });
expect(console.log).toHaveBeenCalledTimes(2); // 1 for empty line, 1 for colored string
});
});
describe('printError', () => {
it('should print the given string in red', () => {
const str = 'Error message';
(console.log as jest.Mock).mockImplementation(() => {});
printError(str);
expect(console.log).toHaveBeenCalledTimes(3); // 1 for empty line, 1 for red string, 1 for line break
expect((console.log as jest.Mock).mock.calls[1][0]).toEqual(chalk.red.bold(str));
});
});
describe('log', () => {
it('should call console.log with the given arguments', () => {
const arg1 = 'Argument 1';
const arg2 = 'Argument 2';
console.log = jest.fn();
log(arg1, arg2);
expect(console.log).toHaveBeenCalledWith(arg1, arg2);
});
});
describe('ensureDir', () => {
it('should return true when the directory already exists', () => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
const result = ensureDir('/path/to/file.txt');
expect(result).toBe(true);
expect(fs.existsSync).toHaveBeenCalledWith('/path/to');
expect(fs.mkdirSync).not.toHaveBeenCalled();
});
it('should create the directory when it does not exist', () => {
(fs.existsSync as jest.Mock).mockReturnValueOnce(false).mockReturnValueOnce(true);
const result = ensureDir('/path/to/file.txt');
expect(result).toBe(true);
expect(fs.existsSync).toHaveBeenCalledWith('/path/to');
expect(fs.mkdirSync).toHaveBeenCalledWith('/path/to');
});
it('should create parent directories recursively when they do not exist', () => {
(fs.existsSync as jest.Mock)
.mockReturnValueOnce(false) // Check /path/to (does not exist)
.mockReturnValueOnce(false) // Check /path (does not exist)
.mockReturnValueOnce(true); // Check / (root, exists)
const result = ensureDir('/path/to/file.txt');
expect(result).toBe(true);
expect(fs.existsSync).toHaveBeenCalledTimes(3); // /path/to, /path, /
expect(fs.mkdirSync).toHaveBeenCalledTimes(2); // /path, /path/to
});
});
describe('normalizeJsonOpt', () => {
it('should return the object if the option is already an object', () => {
const opts = { babel: { presets: ['@babel/preset-env'] } };
const result = normalizeJsonOpt(opts, 'babel');
expect(result).toEqual(opts.babel);
});
it('should parse and return the object if the option is a valid JSON string', () => {
const opts = { babel: '{"presets":["@babel/preset-env"]}' };
const result = normalizeJsonOpt(opts, 'babel');
expect(result).toEqual({ presets: ['@babel/preset-env'] });
});
it('should return an empty object if the option is an invalid JSON string', () => {
const opts = { babel: '{"presets":["@babel/preset-env"]' }; // Invalid JSON
const result = normalizeJsonOpt(opts, 'babel');
expect(result).toEqual({});
});
it('should return an empty object if the option is not provided', () => {
const opts = {};
const result = normalizeJsonOpt(opts, 'babel');
expect(result).toEqual({});
});
});
describe('buildWebpackArgs', () => {
it('should return the options with normalized JSON options for babel and htmlWebpack', () => {
const opts = {
babel: '{"presets":["@babel/preset-env"]}',
htmlWebpack: '{"template":"./src/index.html"}',
otherOption: 'someValue',
};
const result = buildWebpackArgs(opts);
expect(result).toEqual({
babel: { presets: ['@babel/preset-env'] },
htmlWebpack: { template: './src/index.html' },
otherOption: 'someValue',
});
});
it('should return empty objects for babel and htmlWebpack if they are invalid JSON strings', () => {
const opts = {
babel: '{"presets":["@babel/preset-env"]', // Invalid JSON
htmlWebpack: '{"template":"./src/index.html', // Invalid JSON
};
const result = buildWebpackArgs(opts);
expect(result).toEqual({
babel: {},
htmlWebpack: {},
});
});
it('should return the original objects if babel and htmlWebpack are already objects', () => {
const opts = {
babel: { presets: ['@babel/preset-env'] },
htmlWebpack: { template: './src/index.html' },
};
const result = buildWebpackArgs(opts);
expect(result).toEqual({
babel: opts.babel,
htmlWebpack: opts.htmlWebpack,
});
});
it('should handle missing babel and htmlWebpack keys gracefully', () => {
const opts = { otherOption: 'someValue' };
const result = buildWebpackArgs(opts);
expect(result).toEqual({
babel: {},
htmlWebpack: {},
otherOption: 'someValue',
});
});
});
describe('copyRecursiveSync', () => {
// TODO: Maybe this test case is a bit complex and we should think of an easier solution
it('should copy a directory and its contents recursively', () => {
/**
* First call: Mock as a directory with two files
* Subsequent calls: Mock as a file
*/
const existsSyncMock = (fs.existsSync as jest.Mock).mockReturnValue(true);
const statSyncMock = (fs.statSync as jest.Mock)
.mockReturnValueOnce({ isDirectory: () => true })
.mockReturnValue({ isDirectory: () => false });
const readdirSyncMock = (fs.readdirSync as jest.Mock)
.mockReturnValueOnce(['file1.txt', 'file2.txt'])
.mockReturnValue([]);
const copyFileSyncMock = (fs.copyFileSync as jest.Mock).mockImplementation(() => {});
copyRecursiveSync('/src', '/dest');
expect(existsSyncMock).toHaveBeenCalledWith('/src');
expect(statSyncMock).toHaveBeenCalledWith('/src');
expect(fs.mkdirSync).toHaveBeenCalledWith('/dest');
expect(readdirSyncMock).toHaveBeenCalledWith('/src');
expect(copyFileSyncMock).toHaveBeenCalledWith(
path.normalize('/src/file1.txt'),
path.normalize('/dest/file1.txt'),
);
expect(copyFileSyncMock).toHaveBeenCalledWith(
path.normalize('/src/file2.txt'),
path.normalize('/dest/file2.txt'),
);
});
it('should copy a file when source is a file', () => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false });
copyRecursiveSync('/src/file.txt', '/dest/file.txt');
expect(fs.existsSync).toHaveBeenCalledWith('/src/file.txt');
expect(fs.statSync).toHaveBeenCalledWith('/src/file.txt');
expect(fs.copyFileSync).toHaveBeenCalledWith('/src/file.txt', '/dest/file.txt');
});
// Maybe we can change the behavior to throw an error if the `src` doesn't exist
it('should do nothing when source does not exist', () => {
(fs.existsSync as jest.Mock).mockReturnValue(false);
copyRecursiveSync('/src/file.txt', '/dest/file.txt');
expect(fs.existsSync).toHaveBeenCalledWith('/src/file.txt');
expect(fs.statSync).not.toHaveBeenCalled();
expect(fs.mkdirSync).not.toHaveBeenCalled();
expect(fs.copyFileSync).not.toHaveBeenCalled();
});
});
describe('rootResolve', () => {
it('should resolve a relative path to an absolute path', () => {
const result = rootResolve('src/index.js');
expect(result).toBe(path.join(process.cwd(), 'src/index.js'));
});
});
describe('originalRequire', () => {
it('should return the original require.resolve function', () => {
const originalRequireMock = jest.fn();
global.__non_webpack_require__ = originalRequireMock;
const result = originalRequire();
expect(result).toBe(originalRequireMock);
});
});
describe('resolve', () => {
it('should resolve a module path using the original require.resolve', () => {
const originalRequireMock = {
resolve: jest.fn().mockReturnValue('resolved/path'),
};
global.__non_webpack_require__ = originalRequireMock;
const result = resolve('my-module');
expect(result).toBe('resolved/path');
expect(originalRequireMock.resolve).toHaveBeenCalledWith('my-module');
});
});
describe('babelConfig', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should return a Babel configuration object with specified presets and plugins', () => {
const result = babelConfig();
expect(result).toEqual({
presets: [[resolve('@babel/preset-env'), { targets: undefined }]],
plugins: [resolve('@babel/plugin-transform-runtime')],
});
});
it('should include the specified targets in the Babel configuration', () => {
const result = babelConfig({ targets: 'node 14' });
expect(result).toEqual({
presets: [[resolve('@babel/preset-env'), { targets: 'node 14' }]],
plugins: [resolve('@babel/plugin-transform-runtime')],
});
});
});
});

21
packages/cli/tsconfig.json

@ -0,0 +1,21 @@
{
"compilerOptions": {
"allowJs": true,
"noImplicitThis": true,
"moduleResolution": "node",
"noUnusedLocals": true,
"allowUnreachableCode": false,
"module": "commonjs",
"target": "es2016",
"outDir": "dist",
"esModuleInterop": true,
"declaration": true,
"noImplicitReturns": false,
"noImplicitAny": false,
"strictNullChecks": false,
"resolveJsonModule": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": ["src/cli.ts"]
}

62
packages/cli/webpack.cli.ts

@ -0,0 +1,62 @@
import webpack, { type Configuration } from 'webpack';
import NodeExternals from 'webpack-node-externals';
import CopyPlugin from 'copy-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import { resolve } from 'path';
const MODE = process.env.BUILD_MODE === 'production' ? 'production' : 'development';
const config: Configuration = {
context: process.cwd(),
mode: MODE,
entry: './src/cli.ts',
output: {
filename: 'cli.js',
path: resolve(__dirname, 'dist'),
},
target: 'node',
stats: {
preset: 'minimal',
warnings: false,
},
module: {
rules: [
{
test: /\.(jsx?|tsx?)$/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
presets: ['@babel/preset-typescript'],
assumptions: {
setPublicClassFields: false,
},
},
},
exclude: [/node_modules/],
},
],
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.d.ts'],
},
plugins: [
new ForkTsCheckerWebpackPlugin(),
new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true }),
new CopyPlugin({
patterns: [
{ from: 'src/banner.txt', to: 'banner.txt' },
{
from: 'src/template',
to: 'template',
// Terser skip this file for minimization
info: { minimized: true },
},
],
}),
],
externalsPresets: { node: true },
externals: [NodeExternals()],
};
export default config;

10
packages/core/package.json

@ -41,7 +41,7 @@
},
"devDependencies": {
"@types/markdown-it": "14.1.2",
"grapesjs-cli": "4.1.3",
"grapesjs-cli": "workspace:^",
"jest-environment-jsdom": "29.7.0",
"jsdom": "24.1.1",
"npm-run-all": "4.1.5",
@ -67,13 +67,13 @@
"scripts": {
"build": "npm run build-all",
"build-all": "run-s build:*",
"build:js": "grapesjs-cli build --patch=false --targets=\"> 1%, ie 11, safari 8, not dead\" --statsOutput=\"stats.json\" --localePath=\"src/i18n/locale\"",
"build:mjs": "BUILD_MODULE=true grapesjs-cli build --dts='skip' --patch=false --targets=\"> 1%, ie 11, safari 8, not dead\"",
"build:js": "node node_modules/grapesjs-cli/dist/cli.js build --patch=false --targets=\"> 1%, ie 11, safari 8, not dead\" --statsOutput=\"stats.json\" --localePath=\"src/i18n/locale\"",
"build:mjs": "BUILD_MODULE=true node node_modules/grapesjs-cli/dist/cli.js build --dts='skip' --patch=false --targets=\"> 1%, ie 11, safari 8, not dead\"",
"build:css": "sass src/styles/scss/main.scss dist/css/grapes.min.css --no-source-map --style=compressed --load-path=node_modules",
"ts:build": "grapesjs-cli build --dts='only' --patch=false",
"ts:build": "node node_modules/grapesjs-cli/dist/cli.js build --dts='only' --patch=false",
"ts:check": "tsc --noEmit --esModuleInterop dist/index.d.ts",
"start": "run-p start:*",
"start:js": "grapesjs-cli serve",
"start:js": "node node_modules/grapesjs-cli/dist/cli.js serve",
"start:css": "npm run build:css -- --watch",
"test": "jest --forceExit",
"test:dev": "jest --watch"

3
packages/core/src/canvas/view/CanvasView.ts

@ -589,7 +589,8 @@ export default class CanvasView extends ModuleView<Canvas> {
setTimeout(function() {
var item = document.getElementById('${id}');
if (!item) return;
(${scriptFnStr}.bind(item))(${scriptProps})
var script = (${scriptFnStr}).bind(item);
script(${scriptProps});
}, 1);`;
// #873
// Adding setTimeout will make js components work on init of the editor

2
packages/core/src/commands/index.ts

@ -139,7 +139,7 @@ export default class CommandsModule extends Module<CommandsConfig & { pStylePref
const em = ed.getModel();
const { event } = opts;
const trg = opts.target as Component | undefined;
const trgs = trg ? [trg] : [...ed.getSelectedAll()];
const trgs = Array.isArray(trg) ? trg : trg ? [trg] : [...ed.getSelectedAll()];
const targets = trgs.map((trg) => trg.delegate?.move?.(trg) || trg).filter(Boolean);
const target = targets[targets.length - 1] as Component | undefined;
const nativeDrag = event?.type === 'dragstart';

4
packages/core/src/common/index.ts

@ -14,9 +14,11 @@ export type DisableOptions = { fromMove?: boolean };
export type LocaleOptions = { locale?: boolean };
export type UndoOptions = { fromUndo?: boolean };
export type WithHTMLParserOptions = { parserOptions?: HTMLParserOptions };
export type RemoveOptions = Backbone.Silenceable;
export type RemoveOptions = Backbone.Silenceable & UndoOptions;
export type EventHandler = Backbone.EventHandler;

2
packages/core/src/dom_components/view/ComponentView.ts

@ -164,7 +164,7 @@ TComp> {
event.stopPropagation();
event.preventDefault();
this.em.Commands.run('tlb-move', {
target: this.model,
target: [...this.em.getSelectedAll()],
event,
});
}

2
packages/core/src/pages/index.ts

@ -75,7 +75,7 @@ export default class PageManager extends ItemManagerModule<PageManagerConfig, Pa
constructor(em: EditorModel) {
super(em, 'PageManager', new Pages([], em), PagesEvents);
bindAll(this, '_onPageChange');
const model = new ModuleModel({ _undo: true } as any);
const model = new ModuleModel(this, { _undo: true });
this.model = model;
this.pages.on('reset', (coll) => coll.at(0) && this.select(coll.at(0)));
this.pages.on('all', this.__onChange, this);

10
packages/core/src/pages/model/Pages.ts

@ -1,4 +1,4 @@
import { Collection } from '../../common';
import { Collection, RemoveOptions } from '../../common';
import EditorModel from '../../editor/model/Editor';
import Page from './Page';
@ -14,11 +14,13 @@ export default class Pages extends Collection<Page> {
};
}
onReset(m: Page, opts?: { previousModels?: Pages }) {
opts?.previousModels?.map((p) => this.onRemove(p));
onReset(m: Page, opts?: RemoveOptions & { previousModels?: Pages }) {
opts?.previousModels?.map((p) => this.onRemove(p, this, opts));
}
onRemove(removed?: Page) {
onRemove(removed?: Page, _p?: this, opts: RemoveOptions = {}) {
// Avoid removing frames if triggered from undo #6142
if (opts.fromUndo) return;
removed?.onRemove();
}
}

19
packages/core/src/undo_manager/index.ts

@ -40,6 +40,8 @@ const hasSkip = (opts: any) => opts.avoidStore || opts.noUndo;
const getChanged = (obj: any) => Object.keys(obj.changedAttributes());
const changedMap = new WeakMap();
export default class UndoManagerModule extends Module<UndoManagerConfig & { name?: string; _disable?: boolean }> {
beforeCache?: any;
um: any;
@ -69,22 +71,29 @@ export default class UndoManagerModule extends Module<UndoManagerConfig & { name
return false;
},
on(object: any, v: any, opts: any) {
!this.beforeCache && (this.beforeCache = object.previousAttributes());
let before = changedMap.get(object);
if (!before) {
before = object.previousAttributes();
changedMap.set(object, before);
}
const opt = opts || v || {};
opt.noUndo &&
if (opt.noUndo) {
setTimeout(() => {
this.beforeCache = null;
changedMap.delete(object);
});
}
if (hasSkip(opt)) {
return;
} else {
const after = object.toJSON({ fromUndo });
const result = {
object,
before: this.beforeCache,
before,
after,
};
this.beforeCache = null;
changedMap.delete(object);
// Skip undo in case of empty changes
if (isEmpty(after)) return;

32
packages/core/test/common.ts

@ -1,17 +1,22 @@
import CanvasEvents from '../src/canvas/types';
import Editor from '../src/editor';
import { EditorConfig } from '../src/editor/config/config';
import EditorModel from '../src/editor/model/Editor';
// DocEl + Head + Wrapper
export const DEFAULT_CMPS = 3;
export function setupTestEditor() {
export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial<EditorConfig> }) {
document.body.innerHTML = '<div id="fixtures"></div> <div id="canvas-wrp"></div> <div id="editor"></div>';
const editor = new Editor({
mediaCondition: 'max-width',
el: document.body.querySelector('#editor') as HTMLElement,
avoidInlineStyle: true,
...opts?.config,
});
const em = editor.getModel();
const dsm = em.DataSources;
document.body.innerHTML = '<div id="fixtures"></div>';
const { Pages, Components } = em;
const { Pages, Components, Canvas } = em;
Pages.onLoad();
const cmpRoot = Components.getWrapper()!;
const View = Components.getType('wrapper')!.view;
@ -22,9 +27,30 @@ export function setupTestEditor() {
wrapperEl.render();
const fixtures = document.body.querySelector('#fixtures')!;
fixtures.appendChild(wrapperEl.el);
const canvasWrapEl = document.body.querySelector('#canvas-wrp')!;
/**
* When trying to render the canvas, seems like jest gets stuck in a loop of iframe.onload (FrameView.ts)
* and all subsequent tests containing setTimeout are not executed.
*/
if (opts?.withCanvas) {
Canvas.postLoad();
canvasWrapEl.appendChild(Canvas.render());
editor.on(CanvasEvents.frameLoad, ({ el }) => {
// this seems to fix the issue of the loop
el.onload = null;
});
// Enable undo manager
editor.Pages.postLoad();
}
return { editor, em, dsm, cmpRoot, fixtures: fixtures as HTMLElement };
}
export function waitEditorEvent(em: Editor | EditorModel, event: string) {
return new Promise((resolve) => em.once(event, resolve));
}
export function flattenHTML(html: string) {
return html.replace(/>\s+|\s+</g, (m) => m.trim());
}

71
packages/core/test/specs/pages/index.ts

@ -1,8 +1,9 @@
import CanvasEvents from '../../../src/canvas/types';
import { ComponentDefinition } from '../../../src/dom_components/model/types';
import Editor from '../../../src/editor';
import EditorModel from '../../../src/editor/model/Editor';
import { PageProperties } from '../../../src/pages/model/Page';
import { DEFAULT_CMPS } from '../../common';
import { DEFAULT_CMPS, setupTestEditor, waitEditorEvent } from '../../common';
describe('Pages', () => {
let editor: Editor;
@ -281,3 +282,71 @@ describe('Managing pages', () => {
expect(rule2.getStyle()).toEqual({ color: 'blue' });
});
});
describe('Pages in canvas', () => {
let editor: Editor;
let canvas: Editor['Canvas'];
let em: EditorModel;
let fxt: HTMLElement;
let pm: Editor['Pages'];
const clsPageEl = 'cmp';
const selPageEl = `.${clsPageEl}`;
const getPageContent = () => canvas.getBody().querySelector(selPageEl)?.innerHTML;
beforeEach(async () => {
const testEditor = setupTestEditor({
withCanvas: true,
config: {
pageManager: {
pages: [
{
id: 'page-1',
component: `<div class="${clsPageEl}">Page 1</div>`,
},
],
},
},
});
editor = testEditor.editor;
canvas = editor.Canvas;
em = testEditor.em;
fxt = testEditor.fixtures;
pm = editor.Pages;
await waitEditorEvent(em, 'change:readyCanvas');
});
afterEach(() => {
editor.destroy();
});
test('Pages are rendering properly with undo/redo', async () => {
const mainPage = pm.getMain();
expect(mainPage).toBe(pm.getSelected());
const page = pm.add(
{
id: 'page-2',
component: `<div class="${clsPageEl}">Page 2</div>`,
},
{ select: true },
)!;
// Check the second page is selected and rendered properly
expect(page).toBe(pm.getSelected());
await waitEditorEvent(em, CanvasEvents.frameLoadBody);
expect(getPageContent()).toEqual('Page 2');
// Undo and check the main page is rendered properly
em.UndoManager.undo();
expect(mainPage).toBe(pm.getSelected());
await waitEditorEvent(em, CanvasEvents.frameLoadBody);
expect(getPageContent()).toBe('Page 1');
// Redo and check the second page is rendered properly again
em.UndoManager.redo();
expect(page).toBe(pm.getSelected());
await waitEditorEvent(em, CanvasEvents.frameLoadBody);
expect(getPageContent()).toEqual('Page 2');
});
});

518
pnpm-lock.yaml

File diff suppressed because it is too large

5
pnpm-workspace.yaml

@ -1,3 +1,4 @@
packages:
- 'packages/**'
- 'docs/**'
- 'packages/cli'
- 'packages/core'
- 'docs/'

Loading…
Cancel
Save