diff --git a/README.md b/README.md
index fcb56be..332ca37 100644
--- a/README.md
+++ b/README.md
@@ -64,14 +64,15 @@ Open in your browser: `http://localhost:4210/`
## Sample plugins
-| Plugin | Description | PORT | Start command | Manifest URL |
-| --------------------- | ----------------------------------------------------------- | ---- | ------------------------------- | ------------------------------------------ |
-| poc-state-plugin | Sandbox plugin to test new plugins api functionality | 4301 | npm run start:plugin:poc-state | http://localhost:4301/assets/manifest.json |
-| contrast-plugin | Sample plugin that gives you color contrast information | 4302 | npm run start:plugin:contrast | http://localhost:4302/manifest.json |
-| icons-plugin | Tool to add icons from [Feather](https://feathericons.com/) | 4303 | npm run start:plugin:icons | http://localhost:4303/assets/manifest.json |
-| lorem-ipsum-plugin | Generate Lorem ipsum text | 4304 | npm run start:plugin:loremipsum | http://localhost:4304/assets/manifest.json |
-| create-palette-plugin | Creates a board with all the palette colors | 4305 | npm run start:plugin:palette | http://localhost:4305/assets/manifest.json |
-| table-plugin | Create or import table | 4306 | npm run start:table-plugin | http://localhost:4306/assets/manifest.json | | -- |
+| Plugin | Description | PORT | Start command | Manifest URL |
+| --------------------- | ----------------------------------------------------------- | ---- | --------------------------------- | ------------------------------------------ |
+| poc-state-plugin | Sandbox plugin to test new plugins api functionality | 4301 | npm run start:plugin:poc-state | http://localhost:4301/assets/manifest.json |
+| contrast-plugin | Sample plugin that gives you color contrast information | 4302 | npm run start:plugin:contrast | http://localhost:4302/manifest.json |
+| icons-plugin | Tool to add icons from [Feather](https://feathericons.com/) | 4303 | npm run start:plugin:icons | http://localhost:4303/assets/manifest.json |
+| lorem-ipsum-plugin | Generate Lorem ipsum text | 4304 | npm run start:plugin:loremipsum | http://localhost:4304/assets/manifest.json |
+| create-palette-plugin | Creates a board with all the palette colors | 4305 | npm run start:plugin:palette | http://localhost:4305/assets/manifest.json |
+| table-plugin | Create or import table | 4306 | npm run start:table-plugin | http://localhost:4306/assets/manifest.json |
+| rename-layers-plugin | Rename layers in bulk | 4307 | npm run start:plugin:renamelayers | http://localhost:4307/assets/manifest.json |
## Web Apps
diff --git a/apps/rename-layers-plugin/eslint.config.js b/apps/rename-layers-plugin/eslint.config.js
new file mode 100644
index 0000000..29c2176
--- /dev/null
+++ b/apps/rename-layers-plugin/eslint.config.js
@@ -0,0 +1,43 @@
+import baseConfig from '../../eslint.config.js';
+import { compat } from '../../eslint.base.config.js';
+
+export default [
+ ...baseConfig,
+ ...compat
+ .config({
+ extends: [
+ 'plugin:@nx/angular',
+ 'plugin:@angular-eslint/template/process-inline-templates',
+ ],
+ })
+ .map((config) => ({
+ ...config,
+ files: ['**/*.ts'],
+ rules: {
+ '@angular-eslint/directive-selector': [
+ 'error',
+ {
+ type: 'attribute',
+ prefix: 'app',
+ style: 'camelCase',
+ },
+ ],
+ '@angular-eslint/component-selector': [
+ 'error',
+ {
+ type: 'element',
+ prefix: 'app',
+ style: 'kebab-case',
+ },
+ ],
+ },
+ })),
+ ...compat
+ .config({ extends: ['plugin:@nx/angular-template'] })
+ .map((config) => ({
+ ...config,
+ files: ['**/*.html'],
+ rules: {},
+ })),
+ { ignores: ['**/assets/*.js'] },
+];
diff --git a/apps/rename-layers-plugin/project.json b/apps/rename-layers-plugin/project.json
new file mode 100644
index 0000000..3bb5af9
--- /dev/null
+++ b/apps/rename-layers-plugin/project.json
@@ -0,0 +1,92 @@
+{
+ "name": "rename-layers-plugin",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "application",
+ "prefix": "app",
+ "sourceRoot": "apps/rename-layers-plugin/src",
+ "tags": [],
+ "targets": {
+ "build": {
+ "executor": "@angular-devkit/build-angular:application",
+ "outputs": ["{options.outputPath}"],
+ "options": {
+ "outputPath": "dist/apps/rename-layers-plugin",
+ "index": "apps/rename-layers-plugin/src/index.html",
+ "browser": "apps/rename-layers-plugin/src/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "apps/rename-layers-plugin/tsconfig.app.json",
+ "assets": [
+ "apps/rename-layers-plugin/src/favicon.ico",
+ "apps/rename-layers-plugin/src/assets"
+ ],
+ "styles": [
+ "libs/plugins-styles/src/lib/styles.css",
+ "apps/rename-layers-plugin/src/styles.css"
+ ],
+ "optimization": {
+ "scripts": true,
+ "styles": true,
+ "fonts": false
+ },
+ "scripts": []
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kb",
+ "maximumError": "1mb"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "2kb",
+ "maximumError": "4kb"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production",
+ "dependsOn": ["buildPlugin"]
+ },
+ "serve": {
+ "executor": "@angular-devkit/build-angular:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "rename-layers-plugin:build:production"
+ },
+ "development": {
+ "buildTarget": "rename-layers-plugin:build:development",
+ "host": "0.0.0.0",
+ "port": 4307
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "extract-i18n": {
+ "executor": "@angular-devkit/build-angular:extract-i18n",
+ "options": {
+ "buildTarget": "rename-layers-plugin:build"
+ }
+ },
+ "buildPlugin": {
+ "executor": "@nx/esbuild:esbuild",
+ "outputs": ["{options.outputPath}"],
+ "options": {
+ "minify": true,
+ "outputPath": "apps/rename-layers-plugin/src/assets/",
+ "main": "apps/rename-layers-plugin/src/plugin.ts",
+ "tsConfig": "apps/rename-layers-plugin/tsconfig.plugin.json",
+ "generatePackageJson": false,
+ "format": ["esm"],
+ "deleteOutputPath": false
+ }
+ }
+ }
+}
diff --git a/apps/rename-layers-plugin/src/app/app.component.css b/apps/rename-layers-plugin/src/app/app.component.css
new file mode 100644
index 0000000..81a0697
--- /dev/null
+++ b/apps/rename-layers-plugin/src/app/app.component.css
@@ -0,0 +1,15 @@
+.explanation {
+ margin-block-end: var(--spacing-8);
+}
+.form {
+ padding-block: var(--spacing-12);
+}
+
+.form-group {
+ margin-block-end: var(--spacing-8);
+}
+
+.input,
+button {
+ inline-size: 100%;
+}
diff --git a/apps/rename-layers-plugin/src/app/app.component.html b/apps/rename-layers-plugin/src/app/app.component.html
new file mode 100644
index 0000000..c563c0a
--- /dev/null
+++ b/apps/rename-layers-plugin/src/app/app.component.html
@@ -0,0 +1,26 @@
+
diff --git a/apps/rename-layers-plugin/src/app/app.component.ts b/apps/rename-layers-plugin/src/app/app.component.ts
new file mode 100644
index 0000000..e2ea095
--- /dev/null
+++ b/apps/rename-layers-plugin/src/app/app.component.ts
@@ -0,0 +1,52 @@
+import { Component, inject } from '@angular/core';
+import { ActivatedRoute, RouterModule } from '@angular/router';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { CommonModule } from '@angular/common';
+import type { PluginMessageEvent, ReplaceText } from '../app/model';
+import { filter, fromEvent, map, merge, take } from 'rxjs';
+import { FormsModule } from '@angular/forms';
+
+@Component({
+ standalone: true,
+ imports: [RouterModule, CommonModule, FormsModule],
+ selector: 'app-root',
+ templateUrl: './app.component.html',
+ styleUrl: './app.component.css',
+ host: {
+ '[attr.data-theme]': 'theme()',
+ },
+})
+export class AppComponent {
+ route = inject(ActivatedRoute);
+ messages$ = fromEvent>(window, 'message');
+ public textToReplace: ReplaceText = {
+ current: '',
+ new: '',
+ };
+
+ initialTheme$ = this.route.queryParamMap.pipe(
+ map((params) => params.get('theme')),
+ filter((theme) => !!theme),
+ take(1)
+ );
+
+ theme = toSignal(
+ merge(
+ this.initialTheme$,
+ this.messages$.pipe(
+ filter((event) => event.data.type === 'theme'),
+ map((event) => {
+ return event.data.content;
+ })
+ )
+ )
+ );
+
+ public updateText() {
+ this.sendMessage({ type: 'replace-text', content: this.textToReplace });
+ }
+
+ private sendMessage(message: PluginMessageEvent): void {
+ parent.postMessage(message, '*');
+ }
+}
diff --git a/apps/rename-layers-plugin/src/app/app.config.ts b/apps/rename-layers-plugin/src/app/app.config.ts
new file mode 100644
index 0000000..ed40494
--- /dev/null
+++ b/apps/rename-layers-plugin/src/app/app.config.ts
@@ -0,0 +1,7 @@
+import { ApplicationConfig } from '@angular/core';
+import { provideRouter } from '@angular/router';
+import { appRoutes } from './app.routes';
+
+export const appConfig: ApplicationConfig = {
+ providers: [provideRouter(appRoutes)],
+};
diff --git a/apps/rename-layers-plugin/src/app/app.routes.ts b/apps/rename-layers-plugin/src/app/app.routes.ts
new file mode 100644
index 0000000..8762dfe
--- /dev/null
+++ b/apps/rename-layers-plugin/src/app/app.routes.ts
@@ -0,0 +1,3 @@
+import { Route } from '@angular/router';
+
+export const appRoutes: Route[] = [];
diff --git a/apps/rename-layers-plugin/src/app/model.ts b/apps/rename-layers-plugin/src/app/model.ts
new file mode 100644
index 0000000..7947fa8
--- /dev/null
+++ b/apps/rename-layers-plugin/src/app/model.ts
@@ -0,0 +1,25 @@
+export interface InitPluginEvent {
+ type: 'init';
+ content: {
+ theme: string;
+ };
+}
+export interface ThemePluginEvent {
+ type: 'theme';
+ content: string;
+}
+
+export interface ReplaceTextPluginEvent {
+ type: 'replace-text';
+ content: ReplaceText;
+}
+
+export type PluginMessageEvent =
+ | InitPluginEvent
+ | ThemePluginEvent
+ | ReplaceTextPluginEvent;
+
+export interface ReplaceText {
+ current: string;
+ new: string;
+}
diff --git a/apps/rename-layers-plugin/src/assets/.gitkeep b/apps/rename-layers-plugin/src/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/apps/rename-layers-plugin/src/assets/manifest.json b/apps/rename-layers-plugin/src/assets/manifest.json
new file mode 100644
index 0000000..a5c7f80
--- /dev/null
+++ b/apps/rename-layers-plugin/src/assets/manifest.json
@@ -0,0 +1,7 @@
+{
+ "name": "Rename layers plugin",
+ "host": "http://localhost:4307",
+ "description": "Change the name of one or several layers",
+ "code": "/assets/plugin.js",
+ "permissions": ["page:read", "file:read", "selection:read"]
+}
diff --git a/apps/rename-layers-plugin/src/favicon.ico b/apps/rename-layers-plugin/src/favicon.ico
new file mode 100644
index 0000000..317ebcb
Binary files /dev/null and b/apps/rename-layers-plugin/src/favicon.ico differ
diff --git a/apps/rename-layers-plugin/src/index.html b/apps/rename-layers-plugin/src/index.html
new file mode 100644
index 0000000..c5ca795
--- /dev/null
+++ b/apps/rename-layers-plugin/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ rename-layers-plugin
+
+
+
+
+
+
+
+
diff --git a/apps/rename-layers-plugin/src/main.ts b/apps/rename-layers-plugin/src/main.ts
new file mode 100644
index 0000000..514c89a
--- /dev/null
+++ b/apps/rename-layers-plugin/src/main.ts
@@ -0,0 +1,7 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { appConfig } from './app/app.config';
+import { AppComponent } from './app/app.component';
+
+bootstrapApplication(AppComponent, appConfig).catch((err) =>
+ console.error(err)
+);
diff --git a/apps/rename-layers-plugin/src/plugin.ts b/apps/rename-layers-plugin/src/plugin.ts
new file mode 100644
index 0000000..cdfcad1
--- /dev/null
+++ b/apps/rename-layers-plugin/src/plugin.ts
@@ -0,0 +1,25 @@
+import { PluginMessageEvent } from './app/model';
+
+penpot.ui.open('Plugin rename layers', `?theme=${penpot.getTheme()}`, {
+ width: 235,
+ height: 200,
+});
+
+penpot.on('themechange', (theme) => {
+ penpot.ui.sendMessage({ type: 'theme', content: theme });
+});
+
+penpot.ui.onMessage((message) => {
+ if (message.type === 'replace-text') {
+ const shapes = penpot.getPage()?.findShapes();
+ const shapesToUpdate = shapes?.filter((shape) =>
+ shape.name.includes(message.content.current)
+ );
+ shapesToUpdate?.forEach((shape) => {
+ shape.name = shape.name.replace(
+ message.content.current,
+ message.content.new
+ );
+ });
+ }
+});
diff --git a/apps/rename-layers-plugin/src/styles.css b/apps/rename-layers-plugin/src/styles.css
new file mode 100644
index 0000000..90d4ee0
--- /dev/null
+++ b/apps/rename-layers-plugin/src/styles.css
@@ -0,0 +1 @@
+/* You can add global styles to this file, and also import other style files */
diff --git a/apps/rename-layers-plugin/tsconfig.app.json b/apps/rename-layers-plugin/tsconfig.app.json
new file mode 100644
index 0000000..fff4a41
--- /dev/null
+++ b/apps/rename-layers-plugin/tsconfig.app.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "types": []
+ },
+ "files": ["src/main.ts"],
+ "include": ["src/**/*.d.ts"],
+ "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"]
+}
diff --git a/apps/rename-layers-plugin/tsconfig.editor.json b/apps/rename-layers-plugin/tsconfig.editor.json
new file mode 100644
index 0000000..4ee6393
--- /dev/null
+++ b/apps/rename-layers-plugin/tsconfig.editor.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src/**/*.ts"],
+ "compilerOptions": {
+ "types": []
+ }
+}
diff --git a/apps/rename-layers-plugin/tsconfig.json b/apps/rename-layers-plugin/tsconfig.json
new file mode 100644
index 0000000..4c48587
--- /dev/null
+++ b/apps/rename-layers-plugin/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "useDefineForClassFields": false,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.editor.json"
+ },
+ {
+ "path": "./tsconfig.plugin.json"
+ }
+ ],
+ "extends": "../../tsconfig.base.json",
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/apps/rename-layers-plugin/tsconfig.plugin.json b/apps/rename-layers-plugin/tsconfig.plugin.json
new file mode 100644
index 0000000..961987f
--- /dev/null
+++ b/apps/rename-layers-plugin/tsconfig.plugin.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": []
+ },
+ "files": ["src/plugin.ts"],
+ "include": ["../../libs/plugin-types/index.d.ts"]
+}
diff --git a/package.json b/package.json
index c761a50..79563a3 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"start:plugin:loremipsum": "npx nx run lorem-ipsum-plugin:init",
"start:plugin:palette": "npx nx run create-palette-plugin:build --watch & npx nx run create-palette-plugin:preview",
"start:plugin:table": "npx nx run table-plugin:init",
+ "start:plugin:renamelayers": "npx nx run rename-layers-plugin:init",
"build": "npx nx build plugins-runtime --emptyOutDir=true",
"lint": "nx run-many --all --target=lint --parallel",
"lint:affected": "npx nx affected --target=lint",