Ex08: New Site specific adapter

In this example we implement an adapter for Google Search and a dapplet for it.

Here is the initial code for this example: ex08-new-adapter-exercise.

The adapter and the dapplet are divided into two directories: /adapter and /dapplet-feature.

Create an adapter with one widget button for two contexts.#

At the beginning we change the adapter template. Let's add buttons under each element’s title of standard Google search results and one button in the top navigation bar.

In adapter/src/index.ts implement config. It is an object which describes different contexts on the page. Selectors for container, context data and insertion points for widgets are described here. contextBuilder defines context information that widget receives in these context types: MENU and SEARCH_RESULT in our case (named ctx in our examples).

1public config = {
2 MENU: {
3 containerSelector: '#cnt, .ndYZfc',
4 contextSelector: '#top_nav, .jZWadf',
5 insPoints: {
6 MENU: {
7 selector: '.MUFPAc, .T47uwc',
8 insert: 'inside',
9 },
10 },
11 contextBuilder: (): ContextBuilder => ({
12 id: '',
13 insertPoint: '#rcnt, .mJxzWe',
14 }),
15 },
16 SEARCH_RESULT: {
17 containerSelector: '#search',
18 contextSelector: '#rso > .g .jtfYYd, #rso > div > .g .jtfYYd, #rso > div > div > .g .jtfYYd',
19 insPoints: {
20 SEARCH_RESULT: {
21 selector: '.yuRUbf',
22 insert: 'inside',
23 },
24 },
25 // eslint-disable-next-line @typescript-eslint/no-explicit-any
26 contextBuilder: (searchNode: any): ContextBuilder => ({
27 id: searchNode.querySelector('.yuRUbf > a')?.href,
28 title: searchNode.querySelector('h3')?.textContent,
29 link: searchNode.querySelector('.yuRUbf > a')?.href,
30 description: searchNode.querySelector('.uUuwM')?.textContent || searchNode.querySelector('.IsZvec')?.textContent,
31 }),
32 },
tip

How to create an adapter's config?

Now we could talk about site-specific adapters. It means that dapplets using this adapter interact with some specific website. It also means that we should use the website's HTML structure to add our widgets to certain parts of the pages.

The idea of separating adapters from dapplets is to provide dapplets' developers with a simple interface to add their augmentations (we call them widgets) to existing pages. This way, dapplets developers don't need to worry about how to add their code in certain places or how to parse different blocks of information on the page. They get the template, customize it and add the behavior they need.

The goals of the adapters' developer are to create this template, define the data that can be parsed from the context, that can be useful in the dapplet. Adapters' developer also need to describe exact places on the pages where the widgets will be inserted. To describe them we use valid CSS selectors that can be used in the Document method querySelector().

For example, let's look at the Google Search page. Enter some search query, clouds for example. Look at the structure of the page in the browser's developer tools, Elements tab. There you can find the block with search id, that contains all the main search results. It will be our containerSelector where we will search some separate results.

document.querySelector('#search')
<div id="search"></div>

Then try to pick out the selectors' chain that provides access to separate context — contextSelector. You can choose relevant selectors manually or you can left click on the element in the Elements tab and choose Copy selector. In most cases the selector has to be edited.

document.querySelectorAll('#search #rso > .g .jtfYYd')
NodeList(6) [div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd]

In some cases there are several relevant selectors for different places on the page or different pages. In this case you can define them separating by using commas.

document.querySelectorAll('#search #rso > .g .jtfYYd, #rso > div > .g .jtfYYd, #rso > div > div > .g .jtfYYd')
NodeList(11) [div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd, div.jtfYYd]

Make sure not to include unwanted blocks.

Using the same method define selectors for insertion points — exact places where the widgets will be placed. There are 3 insert options: end, begin and inside. The first one is default.

1insPoints: {
2 SEARCH_RESULT: {
3 selector: '.yuRUbf',
4 insert: 'inside',
5 },
6},

Also in the contextBuilder you have to get all the properties for the context. There is a function that receives the node given by the contextSelector.

1contextBuilder: (searchNode: any): ContextBuilder => ({
2 id: searchNode.querySelector('.yuRUbf > a')?.href,
3 title: searchNode.querySelector('h3')?.textContent,
4 link: searchNode.querySelector('.yuRUbf > a')?.href,
5 description: searchNode.querySelector('.uUuwM')?.textContent || searchNode.querySelector('.IsZvec')?.textContent,
6}),

!! Note that websites can be changed and you will get errors trying to get properties when the nodes will not be found.

It is assumed that all interactions with DOM happen in the adapters and not in the dapplets.

So let's go back to our exercise.

Now we have two contexts: MENU and SEARCH_RESULT.

If there are many contexts of the same type on the page, like tweets or search results, you have to find a unique id for each one. It's needed for saving the states of dapplets' widgets connected to these contexts.

The next step - is creating a widget. We have a template of the button in adapter/src/button.ts.

To define the contexts in which this widget is used, you must specify contextInsPoints.

For example, let's define the contexts for MENU and SEARCH_RESULT

1public static contextInsPoints = {
2 MENU: 'MENU',
3 SEARCH_RESULT: 'SEARCH_RESULT',
4}

Let's implement at the public method mount of the class Button the HTML with label, image and tooltip for our insertion points MENU and SEARCH_RESULT.

tip

Adapters allow the dapplet to customize the widgets. This can be the text of the button, the image on the icon, the choice of one of the location options, etc. The adapter developer decides what parameters to make customizable. They should be described in the documentation as follows: parameter's name, mandatory or not, data TYPE, text description. If you need to select one of several value options for a parameter, they must be listed (this can be specified in the parameter type). If the parameter type is a number, then it is recommended to indicate in which units it will be converted: pixels, percentages, fractions, etc.

1const activeNavEl: HTMLElement = document.querySelector('.hdtb-msel, .rQEFy');
2if (this.insPointName === 'MENU') {
3 this.el.innerHTML = `
4 <div style="margin: 1px 1px 0; padding: 16px 12px 12px 10px;
5 ${isActive ? 'border-bottom: 3px solid #1a73e8; ' : 'border-bottom: none; '}
6 display: inline; cursor: pointer;"
7 ${tooltip ? `title="${tooltip}"` : ''}
8 >
9 <img style="width: 20px; margin-right: 5px; margin-bottom: -3px;" src="${img}"/>
10 <div style="display: inline-block; font-size: 13px; line-hight: 16px; ${isActive ? 'color: #1a73e8;' : '-webkit-tap-highlight-color: rgba(0,0,0,.10); color: #5f6368;'}">${label}</div>
11 </div>
12 `;
13 activeNavEl.style.borderBottom = isActive ? 'none' : '3px solid #1a73e8';
14} else if (this.insPointName === 'SEARCH_RESULT') {
15 this.el.innerHTML = `
16 <div
17 style="display: flex; align-items: center; cursor: pointer;"
18 ${tooltip ? `title="${tooltip}"` : ''}
19 >
20 <img style="width: 20px; margin-right: 1em; margin-bottom: 3px;" src="${img}"/>
21 <div style="display: inline-block; font-size: 1.1em; color: #F5504A; font-weight: bold;">${label}</div>
22 </div>
23 `;
24}

Add styles for the widget depending on the context.

1let stylesAdded = false;
2
3const addStyles = (): void => {
4 const styleTag: HTMLStyleElement = document.createElement('style');
5 styleTag.innerHTML = `
6 .dapplet-widget-menu {
7 display: inline-block;
8 }
9 .dapplet-widget-results {
10 display: block;
11 }
12 `;
13 document.head.appendChild(styleTag);
14};
15
16...
17
18export class Button {
19 ...
20 public mount(): void {
21 if (!this.el) this._createElement();
22 if (!stylesAdded) {
23 addStyles();
24 stylesAdded = true;
25 }
26 ...
27 }
28 ...
29 private _createElement() {
30 this.el = document.createElement('div');
31 if (this.insPointName === 'MENU') {
32 this.el.classList.add('dapplet-widget-menu');
33 } else if (this.insPointName === 'SEARCH_RESULT') {
34 this.el.classList.add('dapplet-widget-results');
35 }
36 ...
37 }
38}

Then change the dapplet.

Add buttons to search results and top navigation bar in /dapplet-feature/src/index.ts.

Implement an alert that would be triggered when you click the search results button. The alert should contain the title, the link to the source and a short description of the found fragment from the element.

1exec: () => {
2 const { title, link, description } = ctx;
3 alert(` title: ${title}\n link: ${link}\n description: ${description}`);
4},

Implement two states for the top navigation bar button. Actions: replace search results with HI_GIF and return to default results.

1button({
2 initial: 'RESULTS',
3 RESULTS: {
4 label: 'Hi',
5 img: GRAY_IMG,
6 tooltip: 'Hi, friend!',
7 isActive: false,
8 exec: (_, me) => {
9 const el = document.querySelector(ctx.insertPoint);
10 el.style.display = 'none';
11 if (!('replacedEl' in ctx)) {
12 ctx.replacedEl = document.createElement('div');
13 ctx.replacedEl.style.justifyContent = 'center';
14 const elImg = document.createElement('img');
15 elImg.src = `${HI_GIF}`;
16 ctx.replacedEl.appendChild(elImg);
17 el.parentElement.appendChild(ctx.replacedEl);
18 }
19 ctx.replacedEl.style.display = 'flex';
20 me.state = 'FRIENDS';
21 },
22 },
23 FRIENDS: {
24 label: 'Hi',
25 img: GOOGLE_IMG,
26 tooltip: 'Go to results',
27 isActive: true,
28 exec: (_, me) => {
29 const el = document.querySelector(ctx.insertPoint);
30 el.style.display = 'block';
31 ctx.replacedEl.style.display = 'none';
32 me.state = 'RESULTS';
33 },
34 },
35}),

Here is the result of this part: ex08.1-new-adapter-solution.

Run the dapplet:

npm i
npm start

In this example we run two servers concurrently. So you have to add two registry addresses to the Dapplet extension in the Development tab. Click here for instructions.

Add a widget result to the adapter with one context insertion point#

Add new context WIDGETS. insPoint should be on the top of Google widgets like Videos, Images of ..., People also ask etc.

Complete config in /adapter/src/index.ts:

1public config = {
2 ...
3 WIDGETS: {
4 containerSelector: '#search',
5 contextSelector: '#rso',
6 insPoints: {
7 WIDGETS: {
8 selector: '.ULSxyf',
9 insert: 'begin',
10 },
11 },
12 contextBuilder: (): ContextBuilder => ({
13 id: '',
14 }),
15 },
16}

Add a new context DAPPLET_SEARCH_RESULT, which is similar to SEARCH_RESULT but adds a button to our search widget. This is done to prevent overwriting of similar search results from different sources.

1public config = {
2 ...
3 DAPPLET_SEARCH_RESULT: {
4 containerSelector: '#search',
5 contextSelector: '.hlcw0c-dapp .tF2Cxc',
6 insPoints: {
7 DAPPLET_SEARCH_RESULT: {
8 selector: '.yuRUbf',
9 insert: 'inside',
10 },
11 },
12 // eslint-disable-next-line @typescript-eslint/no-explicit-any
13 contextBuilder: (searchNode: any): ContextBuilder => ({
14 id: searchNode.querySelector('.yuRUbf > a').href,
15 title: searchNode.querySelector('h3 > span').textContent,
16 link: searchNode.querySelector('.yuRUbf > a').href,
17 description: searchNode.querySelector('.IsZvec').textContent,
18 }),
19 }

Don't forget to add DAPPLET_SEARCH_RESULT to contextInsPoints as in the example above.

Implement module adapter/src/result.ts that exports class Result. It should have an image, a title and an artificial list of results.

See the code of result.ts

Import and add Result to /adapter/src/index.ts:

1import { Result } from './result';
2...
3@Injectable
4export default class GoogleAdapter {
5 public exports = (): Exports => ({
6 button: this.adapter.createWidgetFactory(Button),
7 result: this.adapter.createWidgetFactory(Result),
8 });
9 ...
10}

In dapplet-feature/src/index.ts add result to WIDGETS. Use searchResults from the template as a content source.

1WIDGETS: () =>
2 result({
3 initial: 'DEFAULT',
4 DEFAULT: {
5 img: GOOGLE_IMG,
6 title: 'clouds',
7 searchResults,
8 },
9 }),
1const searchResults = [
2 {
3 title: 'Types of Clouds | NOAA SciJinks - All About Weather',
4 link: 'https://scijinks.gov/clouds/',
5 description:
6 'Mammatus clouds. Mammatus clouds are actually altocumulus, cirrus,\
7 cumulonimbus, or other types of clouds that have these pouch-like shapes hanging \
8 out of the bottom. The pouches are created when cold air within the cloud sinks down \
9 toward the Earth. Weather prediction: Severe weather might be on its way!',
10 },
11 {
12 title: 'Clouds—facts and information - Science',
13 link: 'https://www.nationalgeographic.com/science/article/clouds-1',
14 description:
15 'Altostratus clouds may portend a storm. Nimbostratus clouds are thick \
16 and dark and can produce both rain and snow. Low clouds fall into four divisions: \
17 cumulus, stratus, cumulonimbus, and ...',
18 },
19 {
20 title: 'Types of Clouds | Live Science',
21 link: 'https://www.livescience.com/29436-clouds.html',
22 description:
23 'Clouds of great vertical development: These are the cumulonimbus clouds, \
24 often called a thunderhead because torrential rain, vivid lightning and thunder come \
25 from it. The tops of such clouds may ...',
26 },
27];

Implement the insertion of buttons into our widget.

1DAPPLET_SEARCH_RESULT: (ctx) =>
2 button({
3 initial: 'DEFAULT',
4 DEFAULT: {
5 label: 'Get data',
6 tooltip: 'Show in the alert',
7 img: EXAMPLE_IMG,
8 exec: () => {
9 const { title, link, description } = ctx;
10 alert(` title: ${title}\n link: ${link}\n description: ${description}`);
11 },
12 },
13 }),

Add support for DAPPLET_SEARCH_RESULT context to adapter/src/button.ts.

1// class Button
2...
3public static contextInsPoints = {
4 MENU: 'MENU',
5 SEARCH_RESULT: 'SEARCH_RESULT',
6 DAPPLET_SEARCH_RESULT: 'DAPPLET_SEARCH_RESULT',
7}
8...
9public mount(): void {
10 ...
11 if (this.insPointName === 'MENU') {
12 ...
13 } else if (
14 this.insPointName === 'SEARCH_RESULT' ||
15 this.insPointName === 'DAPPLET_SEARCH_RESULT'
16 ) {
17 ...
18 }
19}
20...
21private _createElement() {
22 ...
23 if (this.insPointName === 'MENU') {
24 this.el.classList.add('dapplet-widget-menu');
25 } else if (
26 this.insPointName === 'SEARCH_RESULT' ||
27 this.insPointName === 'DAPPLET_SEARCH_RESULT'
28 ) {
29 this.el.classList.add('dapplet-widget-results');
30 }
31 ...
32}

Here is the result: ex08.2-new-adapter-widget-solution.

Run the dapplet:

npm i
npm start

To see the full result, please enter clouds into Google