Am I a trembling creature or do I have the right? We take other people's sites under our control. Chrome extension on Angular 18. Part 1

  • Another, very simple example. Website of the Lenta chain of stores. They recently rolled out a new design, in some places I want to tear my hands off… (familiar story? Such thoughts, I think, occur to many from time to time when they see another unsuccessful creation by the designers of this or that site). So, I type the product I need in the search feed, I get a list of product varieties in which the names are cut off (see screenshot below), and you can distinguish the products from each other only by pointing at each of them, waiting each time until the title with the full name appears . This design flaw was corrected with just one line of CSS code. .lu-product-card-name_sale {-webkit-line-clamp: unset!important;}. Thus, with just one line of adjustment, we can sometimes significantly improve our user experience and ease of use of sites.

Preparing an Angular project to develop a Chrome extension

The latest version of Angular is currently 18th, which is what we will be working with. The first thing after creating a clean project is to add manifest.json to a folder publicit will be transferred to the folder with the assembled project without changes. This file describes some parameters of the extension being developed. The file format is described in detail in official documentationso I won’t dwell on the basic things; I’ll only note those options that you need to pay attention to when developing our project.

The structure of Chrome extensions suggests several possible entry points into the application – several separate pages, divided by functionality. In our case, we will use two of them – the options page and the popup page. The parameters page is the main page on which we will manage (add, edit, etc.) our entire collection of created codes (js + css bundles) for different sites. You can get to this page by calling the context menu on the icon of the installed extension and selecting “Options”. A pop-up page is the page that is shown in a pop-up window when you left-click on the icon of our extension. On it we will display the registered program codes for the currently active page in the browser, as well as a set of possible actions for this page (add code for the page, add code for the entire domain, etc.).

Options and popup pages

IN manifest.json we must specify the html files that will open for the options page and for the popup, usually options.html and popup.html. And this is where a problem immediately arises. The fact is that Angular outputs a single html file, index.html. Of course, you can create separate projects for the settings page and the popup page, resulting in two different html files. However, this is an absolutely wrong approach, because… you will have to duplicate most of the code, because both pages use common entities and common functionality.

There is a solution to this problem – for both pages we will use the same index.html file, but in order for them to display different content, we will use hash routing mode. What does it mean? The default router determines the current path from the path specified in the url. When switching to hash routing mode, the current path is taken from the hash part of the url. For example, in the normal version the path '/options' corresponds to the url address '/options', but after activating hash routing the url address becomes /#/options. Accordingly, in our case there will be two urls /#/options and /#/popup.

To file app.config.ts add an option withHashLocation to activate hash routing:

providers: [  
  provideRouter(routes, withHashLocation()),  
]

After this we create 2 components:

ng g c components/options/options
ng g c components/popup/popup

And we prescribe routes in app.route.ts, components/options/options.route.ts And components/popup/popup.route.ts:

// app.route.ts

import { Routes } from '@angular/router';  
  
export const routes: Routes = [  
  {  
    path: '',  
    pathMatch: 'full',  
    redirectTo: 'options',  
  },  
  {  
    path: 'popup',  
    loadChildren: () => import('./components/popup/popup.routes').then(c => c.routes)  
  },  
  {  
    path: 'options',  
    loadChildren: () => import('./components/options/options.routes').then(c => c.routes)  
  }  
];
// components/options/options.route.ts

import {Routes} from "@angular/router";  
import {OptionsComponent} from "./options/options.component";  
  
export const routes: Routes = [  
  {  
    path: '',  
    pathMatch: 'full',  
    component: OptionsComponent  
  }  
]
// components/popup/popup.route.ts

import {Routes} from "@angular/router";  
import {PopupComponent} from "./popup/popup.component";  
  
export const routes: Routes = [  
  {  
    path: '',  
    pathMatch: 'full',  
    component: PopupComponent  
  }  
]

We launch the project, check that it opens correctly and http://localhost:4200/#/options And http://localhost:4200/#/popup. Now let's build the project and install it as a Chrome extension. To do this, in the browser, find the “Manage Extensions” menu item, turn on the “Developer Mode” option in the upper right corner, click “Load unpacked extension” and select the folder with our assembled project.

After this, if we click on the icon of the installed extension, we will see our popup:

If we right-click on the icon of the installed extension and select “Options”, our page of parameters of this type will open chrome-extension://ioiccgeogdlddigdjodhdchkhncbddpk/#/options. Everything seems to be working? Not really. Click “Update” on the options page and get the error:

Getting rid of unnecessary redirects

Why is this happening? The fact is that in manifest.json for the Chrome extension we must indicate exactly html files in the address, and not the usual path on the server (after all, there is no server as such, the extension uses local files installed on the user’s PC). Those. paths should be given as index.html#/options And index.html#/popup respectively. If you open such an address, Angular will process it correctly, but will redirect (without reloading the page) to the URL form /#/options. Now, when we try to refresh the page, Chrome will quite logically throw an error, because… such a path is not registered or provided in the extension.
Actually, what we need to solve the problem is to get rid of the redirect. Why does the redirect even happen? The reason is simple and it lies in <base href="https://habr.com/">specified in index.html. We correct this misunderstanding with one stroke, simply replace it with <base href=""> and no redirect will occur, the correct page will open /index.html#/options.

Disabling the asynchronous style loading mechanism

In order to optimize application loading speed, by default Angular provides an internal mechanism for separating styles into synchronous loading of “critical” styles and asynchronous loading of other styles, so that the loading of other content and important resources does not slow down. True, this mechanism will not turn on immediately, but will work only after adding styles to style.css project, so at first you may not even encounter the problem described, but it will still pop up in the process. Asynchronous loading is implemented by a construction like:

<link rel="stylesheet" href="https://habr.com/ru/articles/851234/styles-ZHBDMHPG.css" media="print" onload="this.media="all"">

The problem is that this kind of js code inclusion (onload="this.media="all"") in the html code of the page are not allowed for Chrome extensions (you can read more in the official documentation about Content Security Policy: CSP – inline event handlers). The following error message will appear in the Chrome console:

Refused to execute inline event handler because it violates the following Content Security Policy directive: “script-src 'self'”. Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce ('nonce-…') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present.
Refused to execute inline event handler because it violates the following Content Security Policy directive: “script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*”. Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce ('nonce-…') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present.

The problem can be solved simply by angular.json disable the described mechanism in the branch architect.build.configurations.production:

"optimization": {  
  "styles": {  
    "inlineCritical": false  
  }  
}

Actually, this is where the nuances specific to the development of Chrome extensions on Angular end. There are, of course, other features (for example, the formation of separate script files background.js, content.js, etc.), but within the framework of the functionality that will be implemented in our extension, this is not required yet.

Taming the Ace Editor

Now we could move on to the main part of the development, but… Let's stop at one important stage – connecting the editor. Many people may also have difficulty connecting it.

Let's create the component right away EditorComponentwhich we will then insert into the component template OptionsComponent:

ng g c components/options/editor

Which editor to choose was not a question for me – editor Acewhich is used in the extension User Javascript and CSS I'm completely satisfied. It has support for a huge bunch of languages ​​(which we, of course, won’t need), code coloring and formatting, syntax checking, autocompletion, open source. In general, considering that we are not writing an IDE for professional development, but a simple environment with an editor for writing small scripts, there is more than enough functionality.

The editor can be connected to a regular Ace html page quite simply – with a standard inclusion:

<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.2/ace.min.js" integrity="sha512-xylzfb6LZn1im1ge493MNv0fISAU4QkshbKz/jVh6MJFAlZ6T1NRDJa0ZKb7ECuhSTO7fVy8wkXkT95/f4R4nA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

But with connection in Angular, and even taking into account working as a Chrome extension, difficulties arise. Let's figure it out in order. First, install the npm package with the necessary files to connect the editor:

npm i ace-builds

Next in the component EditorComponentin which we will display the editor, we write:

import {Ace} from "ace-builds"
import * as ace from 'ace-builds/src-noconflict/ace'  
import "ace-builds/src-noconflict/ext-language_tools"  
import "ace-builds/src-noconflict/snippets/javascript"  
import "ace-builds/src-noconflict/snippets/css"

With the first and second lines, everything is simple – first we connect the definitions of types, interfaces, classes from the Ace space, then we connect the main functionality of the editor. The remaining lines include additional functionality – code completion and the ability to use predefined snippets for JS and CSS.

That's not all. In order to enable syntax recognition of a specific language when initializing the editor, we must specify the mode, for example, “ace/mode/css” or “ace/mode/javascript”. After we specify the mode, Ace editor will dynamically load the js file for the corresponding mode – mode-javascript.js or mode-css.js accordingly, and also worker-javascript.js And worker-css.js (for syntax checking). But there are no such files in our assembled project. What should I do? We need to make sure that these files are automatically copied unchanged from ace-builds to the output folder of our project when building. IN angular.json add to option architect.build.options.assets instructions for copying the necessary files:

"assets": [  
  {  
    "glob": "(mode-javascript|worker-javascript|mode-css|worker-css).js",  
    "input": "./node_modules/ace-builds/src-min-noconflict/",  
    "output": "./ace/"  
  }, 
]

So that Ace editor knows from which place to load additional files, we set in the component code EditorComponent:

ace.config.set('basePath', 'ace');

And the last nuance – the editor runs the Ace syntax check in a separate worker via Blob. This approach is not allowed for Chrome extensions. It is necessary for the worker to be launched using a regular url, so let’s write:

ace.config.set("loadWorkerFromBlob", false)

Integrating the editor into the FormGroup of the parent component

We have the editor connected and working, but in order to completely close the issue with the editor, let's figure out how to properly integrate it into the application. We will need two copies of the editor – for Javascript and for CSS code. In this case, the editor component will be connected from another, parent component – OptionsComponent. In this parent component we will create FormGroup for which the code typed in the editor should become one of the child properties. It turns out that the component has EditorComponent no direct access to FormGroup parent component, but they need to be connected somehow.

How to achieve this? Let me immediately note that you can come up with different options for solving this problem and the proposed option is not the only one.

Turn on EditorComponent to template OptionsComponent inside the scope of the directive formGroup. For a child component to access the instance FormGroup parent component, in the constructor we use the dependecy injection mechanism to gain access to the FormGroupDirective:

constructor(  
  private formGroupDirective: FormGroupDirective,  
) { }

After that, after this.formGroupDirective.form we access the object FormGroup parent component and can be freely implemented as receiving (via a subscription to this.formGroupDirective.form.get('_CONTROL_NAME_').valueChanges), and transmission (via this.formGroupDirective.form.patchValue) data into an object.

We sorted out all the difficulties. At this point the preparatory work is completed, you can roll up your sleeves and get down to the fun part, but… You will see this in the next episode…

PS Do you use similar extensions? Tell us about your user cases for using them, what do you use them for? I think it will be very useful for me and everyone else to learn about possible application options in order to improve the usability of various popular services and sites.

P.P.S. This is my first experience of public publication. I will be glad to receive any feedback, both on the content of the publication and on the style, structure of the publication, and the code. Positive comments and approval are always very valuable because… increase motivation for further activities. Comments, recommendations and adequate criticism are absolutely priceless, because if you are able to perceive criticism impartially and in a healthy way, then it becomes a source of development and self-improvement. In general, thank you all in advance for any comments, I will definitely read everything and take it into account for the future.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *