@adobe/aem-cli 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ /*
2
+ * Copyright 2018 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import path from 'path';
13
+ import fs from 'fs-extra';
14
+ import chalk from 'chalk-template';
15
+ import {
16
+ ConsoleLogger,
17
+ FileLogger,
18
+ messageFormatJsonString,
19
+ messageFormatTechnical, MultiLogger,
20
+ serializeMessage,
21
+ SimpleInterface,
22
+ } from '@adobe/helix-log';
23
+
24
+ const LEVEL_NAMES = {
25
+ info: chalk`{green info}`,
26
+ warn: chalk`{yellow warn}`,
27
+ error: chalk`{red error}`,
28
+ };
29
+
30
+ /**
31
+ * Log message filter that removes log entries produced during a progress bar
32
+ * (fields.progress === true), but only on a tty.
33
+ */
34
+ const suppressProgress = (fields) => {
35
+ // eslint-disable-next-line no-underscore-dangle,no-console
36
+ if (fields.progress && console._stderr.isTTY) {
37
+ return undefined;
38
+ }
39
+ // eslint-disable-next-line no-param-reassign
40
+ delete fields.progress;
41
+ return fields;
42
+ };
43
+
44
+ /**
45
+ * Log message filter that removes the `progress` field so that it doesn't get logged.
46
+ */
47
+ const filterProgress = (fields) => {
48
+ // eslint-disable-next-line no-param-reassign
49
+ delete fields.progress;
50
+ return fields;
51
+ };
52
+
53
+ /**
54
+ * Message format for the console that doesn't show the `info` level keyword for the `cli`
55
+ * category. prefixes the log entries with the category otherwise.
56
+ */
57
+ const categoryAwareMessageFormatConsole = (fields) => {
58
+ // eslint-disable-next-line
59
+ const {level, timestamp, message, category = 'cli', ...rest} = fields;
60
+
61
+ const fullMsg = Object.keys(rest).length === 0 ? message : [...message, ' ', rest];
62
+ const ser = serializeMessage(fullMsg, { colors: false });
63
+
64
+ const lvl = LEVEL_NAMES[level] ?? level.toLowerCase();
65
+ if (category === 'cli' && level === 'info') {
66
+ return `${ser}`;
67
+ }
68
+ return `${lvl}: ${ser}`;
69
+ };
70
+
71
+ // module global loggers by category
72
+ const loggersByCategory = new Map();
73
+
74
+ /**
75
+ * Gets the logger for the respective category or creates a new one if it does not exist yet.
76
+ * @param {object|string} [config='cli'] The log config or the category name.
77
+ * @param {string} [config.category='cli'] The log category
78
+ * @param {string} [config.level='cli'] The log level
79
+ * @param {string} [config.logsDir='logs'] The log directory.
80
+ * @param {Array|string} [config.logFle=['-', '${category}-server.log']] The log files(s).
81
+ *
82
+ * @returns {SimpleInterface} a helix-log simple interface.
83
+ */
84
+ export function getOrCreateLogger(config = 'cli') {
85
+ let categ;
86
+ if (typeof config === 'string') {
87
+ categ = config;
88
+ } else {
89
+ categ = (config && config.category) || 'cli';
90
+ }
91
+
92
+ if (loggersByCategory.has(categ)) {
93
+ return loggersByCategory.get(categ);
94
+ }
95
+
96
+ // setup helix logger
97
+ const level = (config && config.logLevel) || 'info';
98
+ const logsDir = path.normalize((config && config.logsDir) || 'logs');
99
+ const logFiles = config && Array.isArray(config.logFile)
100
+ ? config.logFile
101
+ : ['-', (config && config.logFile) || path.join(logsDir, `${categ}-server.log`)];
102
+
103
+ const loggers = new Map();
104
+ logFiles.forEach((logFile) => {
105
+ const name = loggers.has('default') ? logFile : 'default';
106
+ if (logFile === '-') {
107
+ loggers.set(name, new ConsoleLogger({
108
+ filter: categ === 'cli' ? suppressProgress : filterProgress,
109
+ level,
110
+ formatter: categoryAwareMessageFormatConsole,
111
+ }));
112
+ } else {
113
+ fs.ensureDirSync(path.dirname(logFile));
114
+ loggers.set(name, new FileLogger(logFile, {
115
+ level: 'debug',
116
+ formatter: /\.json/.test(logFile) ? messageFormatJsonString : messageFormatTechnical,
117
+ }));
118
+ }
119
+ });
120
+
121
+ // create simple interface
122
+ const log = new SimpleInterface({
123
+ level,
124
+ defaultFields: {
125
+ category: categ,
126
+ },
127
+ logger: new MultiLogger(loggers),
128
+ });
129
+
130
+ loggersByCategory.set(categ, log);
131
+ return log;
132
+ }
package/src/md5.js ADDED
@@ -0,0 +1,34 @@
1
+ /*
2
+ * Copyright 2018 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import crypto from 'crypto';
13
+ import fs from 'fs';
14
+
15
+ export default function md5(string, encoding = 'hex') {
16
+ return crypto
17
+ .createHash('md5')
18
+ .update(string)
19
+ .digest(encoding);
20
+ }
21
+
22
+ md5.file = async function md5File(filename) {
23
+ return new Promise((resolve, reject) => {
24
+ const hash = crypto.createHash('md5').setEncoding('hex');
25
+ fs.createReadStream(filename)
26
+ .on('data', (data) => {
27
+ hash.update(data);
28
+ })
29
+ .on('end', () => {
30
+ resolve(hash.digest('hex'));
31
+ })
32
+ .on('error', reject);
33
+ });
34
+ };
@@ -0,0 +1,12 @@
1
+ /*
2
+ * Copyright 2021 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ module.exports = require('../package.json');
@@ -0,0 +1,137 @@
1
+ /*
2
+ * Copyright 2018 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import EventEmitter from 'events';
13
+ import { ConsoleLogger, deriveLogger, SimpleInterface } from '@adobe/helix-log';
14
+
15
+ export class BaseProject extends EventEmitter {
16
+ constructor(Server) {
17
+ super();
18
+ this._cwd = process.cwd();
19
+ this._server = new Server(this);
20
+ this._server.on('stopped', async () => {
21
+ await this.stop();
22
+ });
23
+ this._stopping = false;
24
+ this._logger = null;
25
+ this._cacheDirectory = null;
26
+ this._kill = false;
27
+ }
28
+
29
+ withCwd(cwd) {
30
+ this._cwd = cwd;
31
+ return this;
32
+ }
33
+
34
+ withKill(kill) {
35
+ this._kill = !!kill;
36
+ return this;
37
+ }
38
+
39
+ withHttpPort(port) {
40
+ this._server.withPort(port);
41
+ return this;
42
+ }
43
+
44
+ withBindAddr(addr) {
45
+ this._server.withAddr(addr);
46
+ return this;
47
+ }
48
+
49
+ withTLS(key, cert) {
50
+ this._server.withTLS(key, cert);
51
+ return this;
52
+ }
53
+
54
+ withLogger(logger) {
55
+ this._logger = logger;
56
+ return this;
57
+ }
58
+
59
+ withCacheDirectory(value) {
60
+ this._cacheDirectory = value;
61
+ return this;
62
+ }
63
+
64
+ get log() {
65
+ return this._logger;
66
+ }
67
+
68
+ get started() {
69
+ return this._server.isStarted();
70
+ }
71
+
72
+ get cacheDirectory() {
73
+ return this._cacheDirectory;
74
+ }
75
+
76
+ get directory() {
77
+ return this._cwd;
78
+ }
79
+
80
+ get kill() {
81
+ return this._kill;
82
+ }
83
+
84
+ /**
85
+ * Returns the helix server
86
+ * @returns {HelixServer}
87
+ */
88
+ get server() {
89
+ return this._server;
90
+ }
91
+
92
+ async init() {
93
+ if (!this._logger) {
94
+ this._logger = new SimpleInterface({
95
+ logger: new ConsoleLogger(),
96
+ level: 'debug',
97
+ defaultFields: {
98
+ category: 'hlx',
99
+ },
100
+ filter: (fields) => {
101
+ // eslint-disable-next-line no-param-reassign
102
+ fields.message[0] = `[${fields.category}] ${fields.message[0]}`;
103
+ // eslint-disable-next-line no-param-reassign
104
+ delete fields.category;
105
+ return fields;
106
+ },
107
+ });
108
+ } else {
109
+ this._logger = deriveLogger(this._logger, {
110
+ defaultFields: {
111
+ category: 'hlx',
112
+ },
113
+ });
114
+ }
115
+ return this;
116
+ }
117
+
118
+ async start() {
119
+ await this._server.start(this);
120
+ return this;
121
+ }
122
+
123
+ async doStop() {
124
+ await this._server.stop();
125
+ }
126
+
127
+ async stop() {
128
+ if (this._stopping) {
129
+ return this;
130
+ }
131
+ this._stopping = true;
132
+ this.emit('stopping');
133
+ await this.doStop();
134
+ this.emit('stopped');
135
+ return this;
136
+ }
137
+ }
@@ -0,0 +1,205 @@
1
+ /*
2
+ * Copyright 2018 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import https from 'https';
13
+ import EventEmitter from 'events';
14
+ import express from 'express';
15
+ import cookieParser from 'cookie-parser';
16
+ import { getFetch } from '../fetch-utils.js';
17
+ import utils from './utils.js';
18
+ import packageJson from '../package.cjs';
19
+
20
+ const DEFAULT_PORT = 3000;
21
+
22
+ /**
23
+ * Wraps the route middleware so it can catch potential promise rejections
24
+ * during the async invocation.
25
+ *
26
+ * @param {ExpressMiddleware} fn an extended express middleware function
27
+ * @returns {ExpressMiddleware} an express middleware function.
28
+ */
29
+ export function asyncHandler(fn) {
30
+ return (req, res, next) => (Promise.resolve(fn(req, res, next)).catch(next));
31
+ }
32
+
33
+ export class BaseServer extends EventEmitter {
34
+ /**
35
+ * Creates a new HelixServer for the given project.
36
+ * @param {BaseProject} project
37
+ */
38
+ constructor(project) {
39
+ super();
40
+ this._project = project;
41
+ this._app = express();
42
+ this._port = DEFAULT_PORT;
43
+ this._addr = '127.0.0.1';
44
+ this._tls = false;
45
+ this._scheme = 'http';
46
+ this._key = '';
47
+ this._cert = '';
48
+ this._server = null;
49
+ this._sockets = new Set();
50
+ }
51
+
52
+ /**
53
+ * Returns the logger.
54
+ * @returns {Logger} the logger.
55
+ */
56
+ get log() {
57
+ return this._project.log;
58
+ }
59
+
60
+ async setupApp() {
61
+ this._app.use(cookieParser());
62
+ this._app.get('/.kill', async (req, res) => {
63
+ res.send('Goodbye!');
64
+ this.stop();
65
+ });
66
+ }
67
+
68
+ withPort(port) {
69
+ this._port = port;
70
+ return this;
71
+ }
72
+
73
+ withAddr(addr) {
74
+ // prefer IPv4
75
+ this._addr = addr === '*' ? '0.0.0.0' : addr;
76
+ return this;
77
+ }
78
+
79
+ withTLS(key, cert) {
80
+ this._tls = true;
81
+ this._scheme = 'https';
82
+ this._key = key;
83
+ this._cert = cert;
84
+ return this;
85
+ }
86
+
87
+ isStarted() {
88
+ return this._server !== null;
89
+ }
90
+
91
+ get port() {
92
+ return this._port;
93
+ }
94
+
95
+ get addr() {
96
+ return this._addr;
97
+ }
98
+
99
+ get hostname() {
100
+ return this._addr === '127.0.0.1' || this._addr === '0.0.0.0'
101
+ ? 'localhost'
102
+ : this._addr;
103
+ }
104
+
105
+ get scheme() {
106
+ return this._scheme;
107
+ }
108
+
109
+ get app() {
110
+ return this._app;
111
+ }
112
+
113
+ async start() {
114
+ const { log } = this;
115
+ if (this._port !== 0) {
116
+ let retries = 1;
117
+ if (this._project.kill && await utils.checkPortInUse(this._port, this._addr)) {
118
+ try {
119
+ const res = await getFetch()(`${this._scheme}://${this._addr}:${this._port}/.kill`);
120
+ await res.text();
121
+ } catch (e) {
122
+ // ignore errors, in case the other server closes connection
123
+ }
124
+ retries = 10;
125
+ }
126
+ let inUse = true;
127
+ while (inUse && retries > 0) {
128
+ // eslint-disable-next-line no-await-in-loop
129
+ inUse = await utils.checkPortInUse(this._port, this._addr);
130
+ if (inUse) {
131
+ // eslint-disable-next-line no-await-in-loop,no-promise-executor-return
132
+ await new Promise((resolve) => setTimeout(resolve, 100));
133
+ }
134
+ retries -= 1;
135
+ }
136
+ if (inUse) {
137
+ throw new Error(`Port ${this._port} already in use by another process.`);
138
+ }
139
+ }
140
+ log.info(`Starting AEM dev server v${packageJson.version}`);
141
+ await new Promise((resolve, reject) => {
142
+ const listenCb = (err) => {
143
+ if (err) {
144
+ reject(new Error(`Error while starting ${this._scheme} server: ${err}`));
145
+ }
146
+ this._port = this._server.address().port;
147
+ this._addr = this._server.address().address;
148
+ log.info(`Local AEM dev server up and running: ${this.scheme}://${this.hostname}:${this.port}/`);
149
+ if (this._project.proxyUrl) {
150
+ log.info(`Enabled reverse proxy to ${this._project.proxyUrl}`);
151
+ }
152
+ this._server.on('connection', (socket) => {
153
+ log.debug(`new connection from ${socket.remoteAddress}:${socket.remotePort}`);
154
+ this._sockets.add(socket);
155
+ socket.once('close', () => {
156
+ log.debug(`closed connection from ${socket.remoteAddress}:${socket.remotePort}`);
157
+ this._sockets.delete(socket);
158
+ });
159
+ });
160
+ resolve();
161
+ };
162
+ if (this._tls) {
163
+ this._server = https.createServer({
164
+ key: this._key,
165
+ cert: this._cert,
166
+ }, this._app);
167
+ this._server.listen(this._port, this._addr, listenCb);
168
+ } else {
169
+ this._server = this._app.listen(this._port, this._addr, listenCb);
170
+ }
171
+ });
172
+ await this.setupApp();
173
+ this.emit('started', this.server);
174
+ }
175
+
176
+ // eslint-disable-next-line class-methods-use-this
177
+ async doStop() {
178
+ // ignore
179
+ }
180
+
181
+ async stop() {
182
+ if (!this._server) {
183
+ return;
184
+ }
185
+ const server = this._server;
186
+ this._server = null;
187
+ this.emit('stopping');
188
+ await new Promise((resolve, reject) => {
189
+ this.log.debug('Stopping AEM dev server..');
190
+ for (const socket of this._sockets) {
191
+ socket.destroy();
192
+ this._sockets.delete(socket);
193
+ }
194
+ server.close((err) => {
195
+ if (err) {
196
+ reject(new Error(`Error while stopping http server: ${err}`));
197
+ }
198
+ this.log.info('AEM dev server stopped.');
199
+ resolve();
200
+ });
201
+ });
202
+ await this.doStop();
203
+ this.emit('stopped');
204
+ }
205
+ }
@@ -0,0 +1,180 @@
1
+ /*
2
+ * Copyright 2021 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import fs from 'fs/promises';
13
+ import { createHash } from 'crypto';
14
+ import { resolve } from 'path';
15
+ import { unified } from 'unified';
16
+ import rehypeParse from 'rehype-parse';
17
+ import { select } from 'hast-util-select';
18
+ import { getFetch } from '../fetch-utils.js';
19
+
20
+ export default class HeadHtmlSupport {
21
+ /**
22
+ * prepares the dom tree for easy comparison. it hashes the nodes and stored them in
23
+ * a `hash` property. It also removes all whitespace-empty text nodes.
24
+ *
25
+ * @param tree
26
+ * @returns {string}
27
+ */
28
+ static hash(tree) {
29
+ const h = createHash('sha1');
30
+
31
+ const update = (obj, keys) => {
32
+ keys.sort();
33
+ for (const k of keys) {
34
+ let v = obj[k];
35
+ if (v !== undefined) {
36
+ if (Array.isArray(v)) {
37
+ v = JSON.stringify(v);
38
+ }
39
+ h.update(String(v));
40
+ }
41
+ }
42
+ };
43
+
44
+ update(tree, ['type', 'tagName', 'value']);
45
+ if (tree.properties) {
46
+ update(tree.properties, Object.keys(tree.properties));
47
+ }
48
+
49
+ if (tree.children) {
50
+ for (let i = 0; i < tree.children.length; i += 1) {
51
+ const child = tree.children[i];
52
+ // remove empty text nodes
53
+ if (child.type === 'text' && child.value.trim() === '') {
54
+ tree.children.splice(i, 1);
55
+ i -= 1;
56
+ } else {
57
+ h.update(HeadHtmlSupport.hash(child));
58
+ }
59
+ }
60
+ }
61
+
62
+ // eslint-disable-next-line no-param-reassign
63
+ tree.hash = h.digest('base64');
64
+ return tree.hash;
65
+ }
66
+
67
+ /**
68
+ * Parses the html and returns the dom
69
+ * @param {string} html
70
+ * @returns {Promise<HastNode>}
71
+ */
72
+ static async toDom(html) {
73
+ return unified()
74
+ .use(rehypeParse, { fragment: true })
75
+ .parse(html);
76
+ }
77
+
78
+ constructor({ proxyUrl, directory, log }) {
79
+ this.remoteHtml = '';
80
+ this.remoteDom = null;
81
+ this.remoteStatus = 0;
82
+ this.localHtml = '';
83
+ this.localStatus = 0;
84
+ this.url = new URL(proxyUrl);
85
+ this.url.pathname = '/head.html';
86
+ this.filePath = resolve(directory, 'head.html');
87
+ this.log = log;
88
+ }
89
+
90
+ async loadRemote() {
91
+ // load head from server
92
+ const resp = await getFetch()(this.url, {
93
+ cache: 'no-store',
94
+ });
95
+ this.remoteStatus = resp.status;
96
+ if (resp.ok) {
97
+ this.remoteHtml = (await resp.text()).trim();
98
+ this.remoteDom = await HeadHtmlSupport.toDom(this.remoteHtml);
99
+ HeadHtmlSupport.hash(this.remoteDom);
100
+ this.log.debug(`loaded remote head.html from from ${this.url}`);
101
+ } else {
102
+ this.log.error(`error while loading head.html from ${this.url}: ${resp.status}`);
103
+ }
104
+ }
105
+
106
+ async loadLocal() {
107
+ try {
108
+ this.localHtml = (await fs.readFile(this.filePath, 'utf-8')).trim();
109
+ this.localStatus = 200;
110
+ this.log.debug('loaded local head.html from from', this.filePath);
111
+ } catch (e) {
112
+ this.log.error(`error while loading local head.html from ${this.filePath}: ${e.code}`);
113
+ this.localStatus = 404;
114
+ }
115
+ }
116
+
117
+ async init() {
118
+ if (!this.localStatus) {
119
+ await this.loadLocal();
120
+ }
121
+ if (!this.remoteStatus) {
122
+ await this.loadRemote();
123
+ }
124
+ this.isModified = this.localStatus === 200
125
+ && this.remoteStatus === 200
126
+ && this.localHtml !== this.remoteHtml;
127
+ }
128
+
129
+ async replace(source) {
130
+ if (!this.isModified) {
131
+ this.log.trace('head.html ignored: not modified locally.');
132
+ return source;
133
+ }
134
+
135
+ const $html = await unified()
136
+ .use(rehypeParse)
137
+ .parse(source);
138
+
139
+ const $head = select('head', $html);
140
+ if (!$head) {
141
+ this.log.trace('head.html ignored: source html has no matching <head>...</head> pair.');
142
+ return source;
143
+ }
144
+
145
+ const $dst = this.remoteDom;
146
+ if (!$dst) {
147
+ // inject local content and the end of the head
148
+ const $last = $head.children[$head.children.length - 1];
149
+ const to = $last.position.end.offset;
150
+ return `${source.substring(0, to)}${this.localHtml}${source.substring(to)}`;
151
+ }
152
+
153
+ // find remote head elements in source head
154
+ HeadHtmlSupport.hash($head);
155
+ const srcLen = $head.children.length;
156
+ const dstLen = $dst.children.length;
157
+
158
+ let $first;
159
+ let $last;
160
+ for (let s = 0; !$last && s <= srcLen - dstLen; s += 1) {
161
+ $first = $head.children[s];
162
+ for (let d = 0; d < dstLen; d += 1) {
163
+ $last = $head.children[s + d];
164
+ if ($last.hash !== $dst.children[d].hash) {
165
+ $last = null;
166
+ break;
167
+ }
168
+ }
169
+ }
170
+
171
+ if (!$last) {
172
+ this.log.debug('head.html ignored: remote not found in HTML.');
173
+ return source;
174
+ }
175
+
176
+ const from = $first.position.start.offset;
177
+ const to = $last.position.end.offset;
178
+ return `${source.substring(0, from)}${this.localHtml}${source.substring(to)}`;
179
+ }
180
+ }