libretto 0.6.16 → 0.6.18
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.
- package/dist/cli/cli.js +32 -13
- package/dist/cli/commands/browser.js +2 -2
- package/dist/cli/commands/execution.js +1 -1
- package/dist/cli/commands/search.js +69 -0
- package/dist/cli/commands/update.js +122 -0
- package/dist/cli/core/context.js +4 -0
- package/dist/cli/core/daemon/daemon.js +3 -0
- package/dist/cli/core/experiments.js +14 -1
- package/dist/cli/core/providers/index.js +5 -1
- package/dist/cli/core/providers/steel.js +56 -0
- package/dist/cli/core/session-telemetry.js +143 -7
- package/dist/cli/core/skill-version.js +1 -0
- package/dist/cli/router.js +14 -3
- package/dist/shared/html-search/search-html.d.ts +9 -0
- package/dist/shared/html-search/search-html.js +46 -0
- package/dist/shared/html-search/search-html.spec.d.ts +2 -0
- package/dist/shared/html-search/search-html.spec.js +57 -0
- package/docs/releasing.md +3 -9
- package/package.json +18 -19
- package/scripts/generate-changelog.ts +207 -12
- package/skills/libretto/SKILL.md +22 -15
- package/skills/libretto/references/code-generation-rules.md +2 -2
- package/skills/libretto/references/configuration-file-reference.md +3 -2
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/cli.ts +38 -13
- package/src/cli/commands/browser.ts +2 -3
- package/src/cli/commands/execution.ts +1 -1
- package/src/cli/commands/search.ts +74 -0
- package/src/cli/commands/update.ts +149 -0
- package/src/cli/core/context.ts +4 -0
- package/src/cli/core/daemon/daemon.ts +3 -0
- package/src/cli/core/experiments.ts +15 -1
- package/src/cli/core/providers/index.ts +5 -1
- package/src/cli/core/providers/steel.ts +75 -0
- package/src/cli/core/session-telemetry.ts +176 -13
- package/src/cli/core/skill-version.ts +1 -1
- package/src/cli/core/telemetry.ts +19 -3
- package/src/cli/router.ts +13 -2
- package/src/shared/html-search/search-html.spec.ts +65 -0
- package/src/shared/html-search/search-html.ts +75 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { BrowserContext, Page } from "playwright";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gzipSync } from "node:zlib";
|
|
2
5
|
import {
|
|
3
6
|
filterSemanticClasses,
|
|
4
7
|
INTERACTIVE_ROLE_NAMES,
|
|
@@ -16,18 +19,69 @@ type InstallSessionTelemetryOptions = {
|
|
|
16
19
|
logAction: (entry: TelemetryEntry) => void;
|
|
17
20
|
logNetwork: (entry: TelemetryEntry) => void;
|
|
18
21
|
includeUserDomActions?: boolean;
|
|
22
|
+
rawNetworkDir?: string;
|
|
19
23
|
};
|
|
20
24
|
|
|
25
|
+
const BODY_PREVIEW_CHARS = 4096;
|
|
26
|
+
const MAX_SAVED_BODY_BYTES = 10 * 1024 * 1024;
|
|
27
|
+
const LOG_RESOURCE_TYPES = new Set(["document", "xhr", "fetch"]);
|
|
28
|
+
const SKIP_RESOURCE_TYPES = new Set(["image", "font", "media", "stylesheet"]);
|
|
29
|
+
const NOISE_URL_RE =
|
|
30
|
+
/(google-analytics|googletagmanager|googleadservices|googlesyndication|doubleclick|facebook\.com\/tr|pinterest|criteo|snapchat|2mdn\.net|adtrafficquality|safeframe|recaptcha|analytics|beacon|pixel|\/ads?\/|\/collect|\/event|\/pagead\/|\/gmp\/conversion|\/ccm\/|\/rmkt\/|favicon|\.map(?:\?|$))/i;
|
|
31
|
+
const TEXT_CONTENT_TYPE_RE =
|
|
32
|
+
/json|html|text|xml|graphql|javascript|x-www-form-urlencoded/i;
|
|
33
|
+
|
|
34
|
+
function shouldLogNetworkEntry(
|
|
35
|
+
method: string,
|
|
36
|
+
url: string,
|
|
37
|
+
resourceType: string,
|
|
38
|
+
): boolean {
|
|
39
|
+
if (url.startsWith("chrome-extension://")) return false;
|
|
40
|
+
if (NOISE_URL_RE.test(url)) return false;
|
|
41
|
+
if (resourceType === "ping") return false;
|
|
42
|
+
if (LOG_RESOURCE_TYPES.has(resourceType)) return true;
|
|
43
|
+
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) return true;
|
|
44
|
+
if (SKIP_RESOURCE_TYPES.has(resourceType)) return false;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isTextLikeContentType(contentType: string | null): boolean {
|
|
49
|
+
return contentType !== null && TEXT_CONTENT_TYPE_RE.test(contentType);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function bodyPreview(value: string): string {
|
|
53
|
+
return value.slice(0, BODY_PREVIEW_CHARS);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function saveBodySidecar(
|
|
57
|
+
rawNetworkDir: string | undefined,
|
|
58
|
+
id: number,
|
|
59
|
+
kind: "request" | "response",
|
|
60
|
+
contentType: string | null,
|
|
61
|
+
body: string,
|
|
62
|
+
): string | null {
|
|
63
|
+
if (!rawNetworkDir) return null;
|
|
64
|
+
mkdirSync(rawNetworkDir, { recursive: true });
|
|
65
|
+
const ext = contentType?.includes("json")
|
|
66
|
+
? "json"
|
|
67
|
+
: contentType?.includes("html")
|
|
68
|
+
? "html"
|
|
69
|
+
: "txt";
|
|
70
|
+
const filename = `${String(id).padStart(6, "0")}.${kind}.${ext}.gz`;
|
|
71
|
+
writeFileSync(join(rawNetworkDir, filename), gzipSync(body));
|
|
72
|
+
return `raw-network/${filename}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
21
75
|
export async function installSessionTelemetry(
|
|
22
76
|
options: InstallSessionTelemetryOptions,
|
|
23
77
|
): Promise<void> {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const { context, initialPage, logAction, logNetwork } = options;
|
|
78
|
+
const { context, initialPage, logAction, logNetwork, rawNetworkDir } =
|
|
79
|
+
options;
|
|
27
80
|
const includeUserDomActions = options.includeUserDomActions ?? false;
|
|
28
81
|
const pageIdCache = new WeakMap<Page, string>();
|
|
29
82
|
const wrappedPages = new WeakSet<Page>();
|
|
30
83
|
const exposedPages = new WeakSet<Page>();
|
|
84
|
+
let networkId = 0;
|
|
31
85
|
|
|
32
86
|
const resolvePageId = async (page: Page): Promise<string> => {
|
|
33
87
|
if (pageIdCache.has(page)) return pageIdCache.get(page)!;
|
|
@@ -748,20 +802,129 @@ export async function installSessionTelemetry(
|
|
|
748
802
|
page.on("response", async (response) => {
|
|
749
803
|
const request = response.request();
|
|
750
804
|
const url = request.url();
|
|
751
|
-
|
|
752
|
-
|
|
805
|
+
const method = request.method();
|
|
806
|
+
const resourceType = request.resourceType();
|
|
807
|
+
if (!shouldLogNetworkEntry(method, url, resourceType)) return;
|
|
808
|
+
|
|
809
|
+
const id = ++networkId;
|
|
810
|
+
const requestHeaders = request.headers();
|
|
811
|
+
const responseHeaders = response.headers();
|
|
812
|
+
const contentType = responseHeaders["content-type"] ?? null;
|
|
813
|
+
const requestContentType = requestHeaders["content-type"] ?? null;
|
|
814
|
+
const requestBody = request.postData();
|
|
815
|
+
const requestBodyBytes =
|
|
816
|
+
requestBody === null ? null : Buffer.byteLength(requestBody);
|
|
817
|
+
let requestBodyPath: string | null = null;
|
|
818
|
+
let requestBodyOmittedReason: string | null = null;
|
|
819
|
+
let responseBodyPreview: string | null = null;
|
|
820
|
+
let responseBodyPath: string | null = null;
|
|
821
|
+
let responseBodyBytes: number | null = null;
|
|
822
|
+
let responseBodyTruncated = false;
|
|
823
|
+
let responseBodyOmittedReason: string | null = null;
|
|
824
|
+
let errorText: string | null = null;
|
|
825
|
+
|
|
826
|
+
if (requestBody === null) {
|
|
827
|
+
requestBodyOmittedReason = "no-request-body";
|
|
828
|
+
} else if (!isTextLikeContentType(requestContentType)) {
|
|
829
|
+
requestBodyOmittedReason = "binary-content-type";
|
|
830
|
+
} else if (requestBodyBytes !== null && requestBodyBytes > MAX_SAVED_BODY_BYTES) {
|
|
831
|
+
requestBodyOmittedReason = "body-too-large";
|
|
832
|
+
} else {
|
|
833
|
+
requestBodyPath = saveBodySidecar(
|
|
834
|
+
rawNetworkDir,
|
|
835
|
+
id,
|
|
836
|
+
"request",
|
|
837
|
+
requestContentType,
|
|
838
|
+
requestBody,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (!isTextLikeContentType(contentType) || !LOG_RESOURCE_TYPES.has(resourceType)) {
|
|
843
|
+
responseBodyOmittedReason = "binary-content-type";
|
|
844
|
+
} else {
|
|
845
|
+
try {
|
|
846
|
+
const responseBody = await response.text();
|
|
847
|
+
responseBodyBytes = Buffer.byteLength(responseBody);
|
|
848
|
+
responseBodyPreview = bodyPreview(responseBody);
|
|
849
|
+
if (responseBodyBytes > MAX_SAVED_BODY_BYTES) {
|
|
850
|
+
responseBodyTruncated = true;
|
|
851
|
+
responseBodyOmittedReason = "body-too-large";
|
|
852
|
+
} else {
|
|
853
|
+
responseBodyPath = saveBodySidecar(
|
|
854
|
+
rawNetworkDir,
|
|
855
|
+
id,
|
|
856
|
+
"response",
|
|
857
|
+
contentType,
|
|
858
|
+
responseBody,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
} catch (error: any) {
|
|
862
|
+
responseBodyOmittedReason = "read-error";
|
|
863
|
+
errorText = error?.message ?? String(error);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
753
867
|
emitNetwork({
|
|
868
|
+
id,
|
|
754
869
|
pageId,
|
|
755
|
-
method
|
|
870
|
+
method,
|
|
756
871
|
url,
|
|
872
|
+
resourceType,
|
|
757
873
|
status: response.status(),
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
874
|
+
statusText: response.statusText(),
|
|
875
|
+
contentType,
|
|
876
|
+
requestHeaders,
|
|
877
|
+
responseHeaders,
|
|
878
|
+
requestBodyPreview: requestBody ? bodyPreview(requestBody) : null,
|
|
879
|
+
requestBodyPath,
|
|
880
|
+
requestBodyBytes,
|
|
881
|
+
requestBodyTruncated:
|
|
882
|
+
requestBody !== null &&
|
|
883
|
+
requestBodyBytes !== null &&
|
|
884
|
+
requestBodyBytes > MAX_SAVED_BODY_BYTES,
|
|
885
|
+
requestBodyOmittedReason,
|
|
886
|
+
responseBodyPreview,
|
|
887
|
+
responseBodyPath,
|
|
888
|
+
responseBodyBytes,
|
|
889
|
+
responseBodyTruncated,
|
|
890
|
+
responseBodyOmittedReason,
|
|
891
|
+
errorText,
|
|
892
|
+
postData: requestBody ? bodyPreview(requestBody) : undefined,
|
|
893
|
+
responseBody: null,
|
|
894
|
+
size: null,
|
|
895
|
+
durationMs: null,
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
page.on("requestfailed", async (request) => {
|
|
900
|
+
const url = request.url();
|
|
901
|
+
const method = request.method();
|
|
902
|
+
const resourceType = request.resourceType();
|
|
903
|
+
if (!shouldLogNetworkEntry(method, url, resourceType)) return;
|
|
904
|
+
|
|
905
|
+
const id = ++networkId;
|
|
906
|
+
emitNetwork({
|
|
907
|
+
id,
|
|
908
|
+
pageId,
|
|
909
|
+
method,
|
|
910
|
+
url,
|
|
911
|
+
resourceType,
|
|
912
|
+
status: null,
|
|
913
|
+
statusText: null,
|
|
914
|
+
contentType: null,
|
|
915
|
+
requestHeaders: request.headers(),
|
|
916
|
+
responseHeaders: null,
|
|
917
|
+
requestBodyPreview: null,
|
|
918
|
+
requestBodyPath: null,
|
|
919
|
+
requestBodyBytes: null,
|
|
920
|
+
requestBodyTruncated: false,
|
|
921
|
+
requestBodyOmittedReason: null,
|
|
922
|
+
responseBodyPreview: null,
|
|
923
|
+
responseBodyPath: null,
|
|
924
|
+
responseBodyBytes: null,
|
|
925
|
+
responseBodyTruncated: false,
|
|
926
|
+
responseBodyOmittedReason: "request-failed",
|
|
927
|
+
errorText: request.failure()?.errorText ?? null,
|
|
765
928
|
responseBody: null,
|
|
766
929
|
size: null,
|
|
767
930
|
durationMs: null,
|
|
@@ -11,16 +11,32 @@ import {
|
|
|
11
11
|
import { assertSessionStateExistsOrThrow } from "./session.js";
|
|
12
12
|
|
|
13
13
|
export type NetworkLogEntry = {
|
|
14
|
+
id?: number;
|
|
14
15
|
ts: string;
|
|
15
16
|
pageId?: string;
|
|
16
17
|
method: string;
|
|
17
18
|
url: string;
|
|
18
|
-
|
|
19
|
+
resourceType?: string;
|
|
20
|
+
status: number | null;
|
|
21
|
+
statusText?: string | null;
|
|
19
22
|
contentType: string | null;
|
|
23
|
+
requestHeaders?: Record<string, string> | null;
|
|
24
|
+
responseHeaders?: Record<string, string> | null;
|
|
25
|
+
requestBodyPreview?: string | null;
|
|
26
|
+
requestBodyPath?: string | null;
|
|
27
|
+
requestBodyBytes?: number | null;
|
|
28
|
+
requestBodyTruncated?: boolean;
|
|
29
|
+
requestBodyOmittedReason?: string | null;
|
|
30
|
+
responseBodyPreview?: string | null;
|
|
31
|
+
responseBodyPath?: string | null;
|
|
32
|
+
responseBodyBytes?: number | null;
|
|
33
|
+
responseBodyTruncated?: boolean;
|
|
34
|
+
responseBodyOmittedReason?: string | null;
|
|
35
|
+
errorText?: string | null;
|
|
20
36
|
postData?: string;
|
|
21
37
|
responseBody?: string | null;
|
|
22
|
-
size
|
|
23
|
-
durationMs
|
|
38
|
+
size?: number | null;
|
|
39
|
+
durationMs?: number | null;
|
|
24
40
|
};
|
|
25
41
|
|
|
26
42
|
export function readNetworkLog(
|
package/src/cli/router.ts
CHANGED
|
@@ -7,12 +7,14 @@ import { experimentsCommand } from "./commands/experiments.js";
|
|
|
7
7
|
import { setupCommand } from "./commands/setup.js";
|
|
8
8
|
import { statusCommand } from "./commands/status.js";
|
|
9
9
|
import { snapshotCommand } from "./commands/snapshot.js";
|
|
10
|
+
import { searchCommand } from "./commands/search.js";
|
|
11
|
+
import { updateCommand } from "./commands/update.js";
|
|
10
12
|
import { SimpleCLI } from "affordance";
|
|
11
13
|
|
|
12
14
|
export const cliRoutes = {
|
|
13
15
|
...browserCommands,
|
|
14
16
|
cloud: SimpleCLI.group({
|
|
15
|
-
description: "
|
|
17
|
+
description: "Deploy workflows and manage hosted Libretto",
|
|
16
18
|
routes: {
|
|
17
19
|
deploy: deployCommand,
|
|
18
20
|
auth: authCommands,
|
|
@@ -21,11 +23,20 @@ export const cliRoutes = {
|
|
|
21
23
|
}),
|
|
22
24
|
experiments: experimentsCommand,
|
|
23
25
|
...executionCommands,
|
|
26
|
+
search: searchCommand,
|
|
24
27
|
setup: setupCommand,
|
|
25
28
|
status: statusCommand,
|
|
26
29
|
snapshot: snapshotCommand,
|
|
30
|
+
update: updateCommand,
|
|
27
31
|
};
|
|
28
32
|
|
|
29
33
|
export function createCLIApp() {
|
|
30
|
-
return SimpleCLI.define("libretto", cliRoutes
|
|
34
|
+
return SimpleCLI.define("libretto", cliRoutes, {
|
|
35
|
+
appendHelpText: [
|
|
36
|
+
"Options:",
|
|
37
|
+
" --session <name> Required for session-scoped commands",
|
|
38
|
+
" -h, --help",
|
|
39
|
+
" -v, --version",
|
|
40
|
+
].join("\n"),
|
|
41
|
+
});
|
|
31
42
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { formatHtmlForSearch, searchFormattedHtml } from "./search-html.js";
|
|
3
|
+
|
|
4
|
+
describe("HTML search", () => {
|
|
5
|
+
test("formats condensed HTML before searching", () => {
|
|
6
|
+
const formatted = formatHtmlForSearch(
|
|
7
|
+
'<!doctype html><html><body><main><p data-testid="target">Needle</p></main></body></html>',
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
expect(formatted).toContain('<p data-testid="target">');
|
|
11
|
+
expect(formatted).toContain("Needle");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("returns merged matching regions with context", () => {
|
|
15
|
+
const formatted = [
|
|
16
|
+
"<html>",
|
|
17
|
+
"<body>",
|
|
18
|
+
"<main>",
|
|
19
|
+
"<section>",
|
|
20
|
+
"<h1>Heading</h1>",
|
|
21
|
+
'<p class="target">Needle</p>',
|
|
22
|
+
"<p>More content</p>",
|
|
23
|
+
"</section>",
|
|
24
|
+
"</main>",
|
|
25
|
+
"</body>",
|
|
26
|
+
"</html>",
|
|
27
|
+
].join("\n");
|
|
28
|
+
|
|
29
|
+
const matches = searchFormattedHtml(formatted, "Needle", 2);
|
|
30
|
+
|
|
31
|
+
expect(matches).toEqual([
|
|
32
|
+
{
|
|
33
|
+
startLine: 4,
|
|
34
|
+
endLine: 8,
|
|
35
|
+
lines: [
|
|
36
|
+
"<section>",
|
|
37
|
+
"<h1>Heading</h1>",
|
|
38
|
+
'<p class="target">Needle</p>',
|
|
39
|
+
"<p>More content</p>",
|
|
40
|
+
"</section>",
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("limits matching regions before adding context", () => {
|
|
47
|
+
const formatted = Array.from(
|
|
48
|
+
{ length: 12 },
|
|
49
|
+
(_value, index) => `<p>Needle ${index}</p>`,
|
|
50
|
+
).join("\n");
|
|
51
|
+
|
|
52
|
+
const matches = searchFormattedHtml(formatted, "Needle", 0, 8);
|
|
53
|
+
|
|
54
|
+
expect(matches).toEqual([
|
|
55
|
+
{
|
|
56
|
+
startLine: 1,
|
|
57
|
+
endLine: 8,
|
|
58
|
+
lines: Array.from(
|
|
59
|
+
{ length: 8 },
|
|
60
|
+
(_value, index) => `<p>Needle ${index}</p>`,
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { condenseDom } from "../condense-dom/condense-dom.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CONTEXT_LINES = 4;
|
|
4
|
+
const DEFAULT_MATCH_LIMIT = 8;
|
|
5
|
+
|
|
6
|
+
export type SearchHtmlMatch = {
|
|
7
|
+
startLine: number;
|
|
8
|
+
endLine: number;
|
|
9
|
+
lines: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function formatHtmlForSearch(html: string): string {
|
|
13
|
+
const condensed = condenseDom(html).html;
|
|
14
|
+
const separated = condensed
|
|
15
|
+
.replace(/>\s+</g, ">\n<")
|
|
16
|
+
.replace(/(<[^/!][^>]*>)([^<\n][\s\S]*?)(<\/[^>]+>)/g, "$1\n$2\n$3");
|
|
17
|
+
|
|
18
|
+
const lines = separated
|
|
19
|
+
.split("\n")
|
|
20
|
+
.map((line) => line.trim())
|
|
21
|
+
.filter((line) => line.length > 0);
|
|
22
|
+
|
|
23
|
+
let indent = 0;
|
|
24
|
+
return lines
|
|
25
|
+
.map((line) => {
|
|
26
|
+
if (/^<\//.test(line)) indent = Math.max(0, indent - 1);
|
|
27
|
+
const formatted = `${" ".repeat(indent)}${line}`;
|
|
28
|
+
if (isOpeningTag(line)) indent += 1;
|
|
29
|
+
return formatted;
|
|
30
|
+
})
|
|
31
|
+
.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function searchFormattedHtml(
|
|
35
|
+
formattedHtml: string,
|
|
36
|
+
pattern: string,
|
|
37
|
+
contextLines = DEFAULT_CONTEXT_LINES,
|
|
38
|
+
matchLimit = DEFAULT_MATCH_LIMIT,
|
|
39
|
+
): SearchHtmlMatch[] {
|
|
40
|
+
const regex = new RegExp(pattern);
|
|
41
|
+
const lines = formattedHtml.split("\n");
|
|
42
|
+
const matchingIndexes = lines
|
|
43
|
+
.map((line, index) => (regex.test(line) ? index : -1))
|
|
44
|
+
.filter((index) => index >= 0)
|
|
45
|
+
.slice(0, matchLimit);
|
|
46
|
+
|
|
47
|
+
const matches: SearchHtmlMatch[] = [];
|
|
48
|
+
for (const matchingIndex of matchingIndexes) {
|
|
49
|
+
const startLine = Math.max(0, matchingIndex - contextLines);
|
|
50
|
+
const endLine = Math.min(lines.length - 1, matchingIndex + contextLines);
|
|
51
|
+
const previous = matches.at(-1);
|
|
52
|
+
if (previous && startLine <= previous.endLine) {
|
|
53
|
+
previous.endLine = Math.max(previous.endLine, endLine + 1);
|
|
54
|
+
previous.lines = lines.slice(previous.startLine - 1, previous.endLine);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
matches.push({
|
|
58
|
+
startLine: startLine + 1,
|
|
59
|
+
endLine: endLine + 1,
|
|
60
|
+
lines: lines.slice(startLine, endLine + 1),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return matches;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isOpeningTag(line: string): boolean {
|
|
67
|
+
return (
|
|
68
|
+
/^<[^/!?][^>]*>$/.test(line) &&
|
|
69
|
+
!/\/>$/.test(line) &&
|
|
70
|
+
!/^<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)\b/i.test(
|
|
71
|
+
line,
|
|
72
|
+
) &&
|
|
73
|
+
!/^<[^>]+>.*<\/[^>]+>$/.test(line)
|
|
74
|
+
);
|
|
75
|
+
}
|