From 8b5ab100c0fbbbc72caf0c4b87fd837386f23b71 Mon Sep 17 00:00:00 2001 From: Elliott Marquez <5981958+e111077@users.noreply.github.com> Date: Fri, 7 Mar 2025 00:44:37 -0800 Subject: [PATCH] [parser] lit template parser --- .eslintignore | 3 + .eslintrc.json | 29 + .prettierignore | 3 + package-lock.json | 1948 ++++++++++++++++- packages/labs/parser/.gitignore | 3 + packages/labs/parser/LICENSE | 28 + packages/labs/parser/README.md | 76 + packages/labs/parser/package.json | 101 + packages/labs/parser/src/lib/ast/detection.ts | 244 +++ .../labs/parser/src/lib/ast/estree/helpers.ts | 17 + .../parser/src/lib/ast/html-parser/helpers.ts | 157 ++ .../modes/attribute-equals-or-tag-mode.ts | 169 ++ .../html-parser/modes/attribute-value-mode.ts | 196 ++ .../lib/ast/html-parser/modes/attribute.ts | 149 ++ .../lib/ast/html-parser/modes/closing-tag.ts | 143 ++ .../src/lib/ast/html-parser/modes/comment.ts | 122 ++ .../src/lib/ast/html-parser/modes/tag-name.ts | 116 + .../tag-or-comment-or-text-or-end-tag.ts | 192 ++ .../src/lib/ast/html-parser/modes/tag.ts | 91 + .../src/lib/ast/html-parser/modes/text.ts | 73 + .../src/lib/ast/html-parser/parse5-shim.ts | 229 ++ .../parser/src/lib/ast/html-parser/state.ts | 52 + .../ast/html-parser/template-literal-span.ts | 61 + .../lib/ast/html-parser/template-literal.ts | 49 + .../labs/parser/src/lib/ast/print/helpers.ts | 84 + .../ast/print/oxc/arrowFunctionExpression.ts | 18 + .../labs/parser/src/lib/ast/print/oxc/ast.ts | 19 + .../src/lib/ast/print/oxc/block-statement.ts | 16 + .../lib/ast/print/oxc/expression-statement.ts | 19 + .../lib/ast/print/oxc/function-declaration.ts | 20 + .../src/lib/ast/print/oxc/identifier.ts | 16 + .../src/lib/ast/print/oxc/if-statement.ts | 18 + .../parser/src/lib/ast/print/oxc/literal.ts | 26 + .../labs/parser/src/lib/ast/print/oxc/node.ts | 66 + .../print/oxc/tagged-template-expression.ts | 35 + .../src/lib/ast/print/oxc/template-element.ts | 16 + .../src/lib/ast/print/oxc/template-literal.ts | 32 + .../parser/src/lib/ast/print/oxc/types.d.ts | 4 + .../src/lib/ast/print/parse5/attribute.ts | 42 + .../src/lib/ast/print/parse5/comment-node.ts | 30 + .../lib/ast/print/parse5/document-fragment.ts | 28 + .../src/lib/ast/print/parse5/element.ts | 102 + .../src/lib/ast/print/parse5/helpers.ts | 54 + .../print/parse5/lit-element-expression.ts | 23 + .../parser/src/lib/ast/print/parse5/node.ts | 38 + .../src/lib/ast/print/parse5/text-node.ts | 16 + .../labs/parser/src/lib/ast/transform-tree.ts | 50 + .../labs/parser/src/lib/ast/tree-adapter.ts | 54 + .../src/lib/ast/tree-adapters/oxc-estree.ts | 133 ++ .../src/lib/ast/tree-adapters/ts-ast.ts | 169 ++ packages/labs/parser/src/lib/index.ts | 104 + .../playground/components/ast/ast-brackets.ts | 27 + .../playground/components/ast/ast-property.ts | 290 +++ .../components/ast/ast-summary-value.ts | 56 + .../playground/components/ast/ast-value.ts | 86 + .../src/playground/components/code-mirror.ts | 139 ++ .../components/output/estree-panel.ts | 51 + .../components/output/output-panel.ts | 113 + .../labs/parser/src/playground/index.html | 24 + packages/labs/parser/src/playground/main.ts | 1 + .../labs/parser/src/playground/tsconfig.json | 17 + .../labs/parser/src/playground/utils/shiki.ts | 78 + packages/labs/parser/src/test/ast/README.md | 145 ++ .../test/ast/estree/estree-test-harness.ts | 163 ++ .../src/test/ast/estree/estree-tree_test.ts | 175 ++ .../src/test/ast/estree/helpers_test.ts | 59 + .../parser/src/test/ast/html-parser/README.md | 193 ++ .../ast/html-parser/attribute-mode_test.ts | 198 ++ .../test/ast/html-parser/comment-mode_test.ts | 122 ++ .../html-parser/directive-attributes_test.ts | 382 ++++ .../test/ast/html-parser/edge-cases_test.ts | 213 ++ .../ast/html-parser/error-handling_test.ts | 186 ++ .../html-parser/expression-bindings_test.ts | 356 +++ .../html-parser/html-parser-examples_test.ts | 153 ++ .../html-parser/html-parser-test-harness.ts | 432 ++++ .../test/ast/html-parser/html-parser_test.ts | 200 ++ .../test/ast/html-parser/parse5-shim_test.ts | 95 + .../test/ast/html-parser/parser-mode_test.ts | 127 ++ .../html-parser/special-attributes_test.ts | 349 +++ .../src/test/ast/html-parser/tag-mode_test.ts | 177 ++ .../ast/html-parser/template-literal_test.ts | 160 ++ .../test/ast/html-parser/text-mode_test.ts | 118 + .../html-parser/ts-ast-integration_test.ts | 334 +++ .../test/ast/ts-ast/ts-ast-test-harness.ts | 165 ++ packages/labs/parser/tsconfig.json | 32 + packages/labs/parser/vite.config.mjs | 11 + .../labs/parser/web-test-runner.config.js | 69 + 87 files changed, 10872 insertions(+), 107 deletions(-) create mode 100644 packages/labs/parser/.gitignore create mode 100644 packages/labs/parser/LICENSE create mode 100644 packages/labs/parser/README.md create mode 100644 packages/labs/parser/package.json create mode 100644 packages/labs/parser/src/lib/ast/detection.ts create mode 100644 packages/labs/parser/src/lib/ast/estree/helpers.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/helpers.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/modes/attribute-equals-or-tag-mode.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/modes/attribute-value-mode.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/modes/attribute.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/modes/closing-tag.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/modes/comment.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/modes/tag-name.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/modes/tag-or-comment-or-text-or-end-tag.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/modes/tag.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/modes/text.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/parse5-shim.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/state.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/template-literal-span.ts create mode 100644 packages/labs/parser/src/lib/ast/html-parser/template-literal.ts create mode 100644 packages/labs/parser/src/lib/ast/print/helpers.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/arrowFunctionExpression.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/ast.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/block-statement.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/expression-statement.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/function-declaration.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/identifier.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/if-statement.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/literal.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/node.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/tagged-template-expression.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/template-element.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/template-literal.ts create mode 100644 packages/labs/parser/src/lib/ast/print/oxc/types.d.ts create mode 100644 packages/labs/parser/src/lib/ast/print/parse5/attribute.ts create mode 100644 packages/labs/parser/src/lib/ast/print/parse5/comment-node.ts create mode 100644 packages/labs/parser/src/lib/ast/print/parse5/document-fragment.ts create mode 100644 packages/labs/parser/src/lib/ast/print/parse5/element.ts create mode 100644 packages/labs/parser/src/lib/ast/print/parse5/helpers.ts create mode 100644 packages/labs/parser/src/lib/ast/print/parse5/lit-element-expression.ts create mode 100644 packages/labs/parser/src/lib/ast/print/parse5/node.ts create mode 100644 packages/labs/parser/src/lib/ast/print/parse5/text-node.ts create mode 100644 packages/labs/parser/src/lib/ast/transform-tree.ts create mode 100644 packages/labs/parser/src/lib/ast/tree-adapter.ts create mode 100644 packages/labs/parser/src/lib/ast/tree-adapters/oxc-estree.ts create mode 100644 packages/labs/parser/src/lib/ast/tree-adapters/ts-ast.ts create mode 100644 packages/labs/parser/src/lib/index.ts create mode 100644 packages/labs/parser/src/playground/components/ast/ast-brackets.ts create mode 100644 packages/labs/parser/src/playground/components/ast/ast-property.ts create mode 100644 packages/labs/parser/src/playground/components/ast/ast-summary-value.ts create mode 100644 packages/labs/parser/src/playground/components/ast/ast-value.ts create mode 100644 packages/labs/parser/src/playground/components/code-mirror.ts create mode 100644 packages/labs/parser/src/playground/components/output/estree-panel.ts create mode 100644 packages/labs/parser/src/playground/components/output/output-panel.ts create mode 100644 packages/labs/parser/src/playground/index.html create mode 100644 packages/labs/parser/src/playground/main.ts create mode 100644 packages/labs/parser/src/playground/tsconfig.json create mode 100644 packages/labs/parser/src/playground/utils/shiki.ts create mode 100644 packages/labs/parser/src/test/ast/README.md create mode 100644 packages/labs/parser/src/test/ast/estree/estree-test-harness.ts create mode 100644 packages/labs/parser/src/test/ast/estree/estree-tree_test.ts create mode 100644 packages/labs/parser/src/test/ast/estree/helpers_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/README.md create mode 100644 packages/labs/parser/src/test/ast/html-parser/attribute-mode_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/comment-mode_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/directive-attributes_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/edge-cases_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/error-handling_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/expression-bindings_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/html-parser-examples_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/html-parser-test-harness.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/html-parser_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/parse5-shim_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/parser-mode_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/special-attributes_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/tag-mode_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/template-literal_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/text-mode_test.ts create mode 100644 packages/labs/parser/src/test/ast/html-parser/ts-ast-integration_test.ts create mode 100644 packages/labs/parser/src/test/ast/ts-ast/ts-ast-test-harness.ts create mode 100644 packages/labs/parser/tsconfig.json create mode 100644 packages/labs/parser/vite.config.mjs create mode 100644 packages/labs/parser/web-test-runner.config.js diff --git a/.eslintignore b/.eslintignore index 7b00a5cb6d..e409dfc2a1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -299,6 +299,9 @@ packages/labs/observers/performance-controller.* packages/labs/observers/resize-controller.* packages/labs/observers/intersection-controller.* +packages/labs/parser/lib/ +packages/labs/parser/test/ +packages/labs/parser/playground/ packages/labs/preact-signals/development/ packages/labs/preact-signals/lib/ packages/labs/preact-signals/node_modules/ diff --git a/.eslintrc.json b/.eslintrc.json index 37d4cc3876..02d5851c2c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -94,6 +94,35 @@ "rules": { "import/no-extraneous-dependencies": "off" } + }, + { + // Files listed here don't need to declare their imports in their + // immediate package.json "dependencies". This should include all + // internal-only files, like tests, which are allowed to import from + // "devDependencies", and also from packages declared only in the root + // monorepo "package.json". + "files": [ + "**/goldens/**", + "**/rollup.config.js", + "**/rollup.config.*.js", + "**/src/test-gen/**", + "**/src/test/**", + "**/src/tests/**", + "**/test-output/**", + "**/web-test-runner.config.js", + "packages/benchmarks/**", + "packages/labs/compiler/rollup.source_map_tests.js", + "packages/labs/ssr/src/demo/**", + "packages/labs/eleventy-plugin-lit/demo/**", + "packages/labs/parser/src/playground/**", + "packages/labs/parser/vite.config.mjs", + "packages/tests/**", + "playground/**", + "rollup-common.js" + ], + "rules": { + "import/no-extraneous-dependencies": "off" + } } ] } diff --git a/.prettierignore b/.prettierignore index 6199b18e13..0f4e1021b9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -287,6 +287,9 @@ packages/labs/observers/performance-controller.* packages/labs/observers/resize-controller.* packages/labs/observers/intersection-controller.* +packages/labs/parser/lib/ +packages/labs/parser/test/ +packages/labs/parser/playground/ packages/labs/preact-signals/development/ packages/labs/preact-signals/lib/ packages/labs/preact-signals/node_modules/ diff --git a/package-lock.json b/package-lock.json index 3846504f8c..6cc7cecb21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3536,6 +3536,155 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.18.6", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz", + "integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz", + "integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", + "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", + "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.8.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz", + "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", + "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", + "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", + "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3997,6 +4146,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", @@ -4014,6 +4180,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", @@ -4031,6 +4214,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", @@ -4768,6 +4968,69 @@ "node": ">= 12" } }, + "node_modules/@lezer/common": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", + "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", + "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", + "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", + "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz", + "integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", + "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@lit-examples/nextjs-v13": { "resolved": "examples/nextjs-v13", "link": true @@ -4896,6 +5159,10 @@ "resolved": "packages/labs/observers", "link": true }, + "node_modules/@lit-labs/parser": { + "resolved": "packages/labs/parser", + "link": true + }, "node_modules/@lit-labs/preact-signals": { "resolved": "packages/labs/preact-signals", "link": true @@ -5064,6 +5331,13 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT" + }, "node_modules/@material/animation": { "version": "14.0.0-canary.53b3cad2f.0", "resolved": "https://registry.npmjs.org/@material/animation/-/animation-14.0.0-canary.53b3cad2f.0.tgz", @@ -6265,99 +6539,283 @@ "node": ">=6" } }, - "node_modules/@parse5/tools": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz", - "integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==", + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.56.5.tgz", + "integrity": "sha512-rj4WZqQVJQgLnGnDu2ciIOC5SqcBPc4x11RN0NwuedSGzny5mtBdNVLwt0+8iB15lIjrOKg5pjYJ8GQVPca5HA==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "parse5": "^7.0.0" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.56.5.tgz", + "integrity": "sha512-Rr7aMkqcxGIM6fgkpaj9SJj0u1O1g+AT7mJwmdi5PLSQRPR4CkDKfztEnAj5k+d2blWvh9nPZH8G0OCwxIHk1Q==", + "cpu": [ + "x64" + ], "license": "MIT", "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14" + "node": ">=14.0.0" } }, - "node_modules/@preact/signals-core": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.0.tgz", - "integrity": "sha512-etWpENXm469RHMWIZGblgWrapbIGcRcbccEGGaLkFez3PjlI3XkBrUtSiNFsIfV/DN16PxMOxbWAZUIaLFyJDg==", + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.56.5.tgz", + "integrity": "sha512-jcFCThrWUt5k1GM43tdmI1m2dEnWUPPHHTWKBJbZBXzXLrJJzkqv5OU87Spf1004rYj9swwpa13kIldFwMzglA==", + "cpu": [ + "arm" + ], "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@prefresh/babel-plugin": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.2.tgz", - "integrity": "sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@prefresh/core": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.7.tgz", - "integrity": "sha512-AsyeitiPwG7UkT0mqgKzIDuydmYSKtBlzXEb5ymzskvxewcmVGRjQkcHDy6PCNBT7soAyHpQ0mPgXX4IeyOlUg==", - "dev": true, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.56.5.tgz", + "integrity": "sha512-zo/9RDgWvugKxCpHHcAC5EW0AqoEvODJ4Iv4aT1Xonv6kcydbyPSXJBQhhZUvTXTAFIlQKl6INHl+Xki9Qs3fw==", + "cpu": [ + "arm64" + ], "license": "MIT", - "peerDependencies": { - "preact": "^10.0.0 || ^11.0.0-0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@prefresh/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@puppeteer/browsers": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-0.5.0.tgz", - "integrity": "sha512-Uw6oB7VvmPRLE4iKsjuOh8zgDabhNX67dzo8U/BB0f9527qx+4eeUs+korU98OhG5C4ubg7ufBgVi63XYwS6TQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "4.3.4", - "extract-zip": "2.0.1", - "https-proxy-agent": "5.0.1", - "progress": "2.0.3", - "proxy-from-env": "1.1.0", - "tar-fs": "2.1.1", - "unbzip2-stream": "1.4.3", - "yargs": "17.7.1" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.56.5.tgz", + "integrity": "sha512-SCIqrL5apVbrtMoqOpKX/Ez+c46WmW0Tyhtu+Xby281biH+wYu70m+fux9ZsGmbHc2ojd4FxUcaUdCZtb5uTOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.1.0" - }, - "peerDependencies": { - "typescript": ">= 4.7.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=14.0.0" } }, - "node_modules/@puppeteer/browsers/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.56.5.tgz", + "integrity": "sha512-I2mpX35NWo83hay4wrnzFLk3VuGK1BBwHaqvEdqsCode8iG8slYJRJPICVbCEWlkR3rotlTQ+608JcRU0VqZ5Q==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "ms": "2.1.2" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.56.5.tgz", + "integrity": "sha512-xfzUHGYOh3PGWZdBuY5r1czvE8EGWPAmhTWHqkw3/uAfUVWN/qrrLjMojiaiWyUgl/9XIFg05m5CJH9dnngh5Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.56.5.tgz", + "integrity": "sha512-+z3Ofmc1v5kcu8fXgG5vn7T1f52P47ceTTmTXsm5HPY7rq5EMYRUaBnxH6cesXwY1OVVCwYlIZbCiy8Pm1w8zQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.56.5.tgz", + "integrity": "sha512-pRg8QrbMh8PgnXBreiONoJBR306u+JN19BXQC7oKIaG4Zxt9Mn8XIyuhUv3ytqjLudSiG2ERWQUoCGLs+yfW0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.56.5.tgz", + "integrity": "sha512-VALZNcuyw/6rwsxOACQ2YS6rey2d/ym4cNfXqJrHB/MZduAPj4xvij72gHGu3Ywm31KVGLVWk/mrMRiM9CINcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/wasm": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/wasm/-/wasm-0.57.0.tgz", + "integrity": "sha512-IuTemVdGtslX0Pu0WYzwDRh0AFv+D92sn0zUAcaFbj6RtlPD4CXQ2YxVN4rCnAW4znhLNXHQRZisntRsNjZerQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.57.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.57.0.tgz", + "integrity": "sha512-UnR+Y4KxX/UxUPSIuM7BezELIE7tkgAWPEsFgv17aIFbej5L7LrFC9BupWT2Xus2/JZQ9WwugjHXFXg7MgFjBg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@parse5/tools": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz", + "integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.0.tgz", + "integrity": "sha512-etWpENXm469RHMWIZGblgWrapbIGcRcbccEGGaLkFez3PjlI3XkBrUtSiNFsIfV/DN16PxMOxbWAZUIaLFyJDg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.2.tgz", + "integrity": "sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.7.tgz", + "integrity": "sha512-AsyeitiPwG7UkT0mqgKzIDuydmYSKtBlzXEb5ymzskvxewcmVGRjQkcHDy6PCNBT7soAyHpQ0mPgXX4IeyOlUg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@puppeteer/browsers": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-0.5.0.tgz", + "integrity": "sha512-Uw6oB7VvmPRLE4iKsjuOh8zgDabhNX67dzo8U/BB0f9527qx+4eeUs+korU98OhG5C4ubg7ufBgVi63XYwS6TQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "4.3.4", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.1", + "progress": "2.0.3", + "proxy-from-env": "1.1.0", + "tar-fs": "2.1.1", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.1" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=14.1.0" + }, + "peerDependencies": { + "typescript": ">= 4.7.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" }, "engines": { "node": ">=6.0" @@ -6931,6 +7389,80 @@ "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", "license": "MIT" }, + "node_modules/@shikijs/core": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.11.0.tgz", + "integrity": "sha512-oJwU+DxGqp6lUZpvtQgVOXNZcVsirN76tihOLBmwILkKuRuwHteApP8oTXmL4tF5vS5FbOY0+8seXmiCoslk4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.11.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.11.0.tgz", + "integrity": "sha512-6/ov6pxrSvew13k9ztIOnSBOytXeKs5kfIR7vbhdtVRg+KPzvp2HctYGeWkqv7V6YIoLicnig/QF3iajqyElZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.11.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.3" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.11.0.tgz", + "integrity": "sha512-4DwIjIgETK04VneKbfOE4WNm4Q7WC1wo95wv82PoHKdqX4/9qLRUwrfKlmhf0gAuvT6GHy0uc7t9cailk6Tbhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.11.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.11.0.tgz", + "integrity": "sha512-Njg/nFL4HDcf/ObxcK2VeyidIq61EeLmocrwTHGGpOQx0BzrPWM1j55XtKQ1LvvDWH15cjQy7rg96aJ1/l63uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.11.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.11.0.tgz", + "integrity": "sha512-BhhWRzCTEk2CtWt4S4bgsOqPJRkapvxdsifAwqP+6mk5uxboAQchc0etiJ0iIasxnMsb764qGD24DK9albcU9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.11.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.11.0.tgz", + "integrity": "sha512-RB7IMo2E7NZHyfkqAuaf4CofyY8bPzjWPjJRzn6SEak3b46fIQyG6Vx5fG/obqkfppQ+g8vEsiD7Uc6lqQt32Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -7473,6 +8005,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/html-minifier": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/html-minifier/-/html-minifier-4.0.5.tgz", @@ -7725,6 +8267,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -7937,6 +8489,13 @@ "node": ">=0.10.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/whatwg-url": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-6.4.0.tgz", @@ -12266,6 +12825,17 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.1.tgz", @@ -12447,6 +13017,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/character-entities-legacy": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", @@ -12919,41 +13500,118 @@ "node": ">=8.0.0" } }, - "node_modules/collapse-white-space": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz", - "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==", + "node_modules/codemirror-elements": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/codemirror-elements/-/codemirror-elements-0.0.8.tgz", + "integrity": "sha512-D6kI8FPne4bRHas97j20X2mYgRXBwhljygR0rujmmcWepI0p8y/B9dceGVY59hLmW6OXzUMghlUknrOgJJtSnQ==", "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "dependencies": { + "@codemirror/autocomplete": "^6.4.0", + "@codemirror/commands": "^6.2.0", + "@codemirror/lang-css": "^6.0.2", + "@codemirror/lang-html": "^6.4.2", + "@codemirror/lang-javascript": "^6.1.3", + "@codemirror/language": "^6.4.0", + "@codemirror/lint": "^6.1.0", + "@codemirror/search": "^6.2.3", + "@codemirror/state": "^6.2.0", + "@codemirror/theme-one-dark": "^6.1.0", + "@codemirror/view": "^6.7.3", + "@lit-labs/context": "^0.3.0", + "lit": "^2.6.1" } }, - "node_modules/collection-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", - "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==", + "node_modules/codemirror-elements/node_modules/@lit-labs/context": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@lit-labs/context/-/context-0.3.3.tgz", + "integrity": "sha512-5pWPLiXJnx8fZREF4w7RXBwJOxqRBJ57tujo7k23s0ZDfnSltomvYGW4kTOurXfyzDR0OLBBkv9xsWGDhauqew==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "map-visit": "^1.0.0", - "object-visit": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" + "@lit/reactive-element": "^1.5.0", + "lit": "^2.7.0" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, + "node_modules/codemirror-elements/node_modules/@lit/reactive-element": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", + "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.0.0" + } + }, + "node_modules/codemirror-elements/node_modules/lit": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", + "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.6.0", + "lit-element": "^3.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/codemirror-elements/node_modules/lit-element": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", + "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.1.0", + "@lit/reactive-element": "^1.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/codemirror-elements/node_modules/lit-html": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", + "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/collapse-white-space": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz", + "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, "engines": { "node": ">=12.5.0" } @@ -13017,6 +13675,17 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/command-line-args": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", @@ -13557,6 +14226,13 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -14198,6 +14874,20 @@ "node": ">= 0.8.0" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/devtools": { "version": "7.40.0", "resolved": "https://registry.npmjs.org/devtools/-/devtools-7.40.0.tgz", @@ -18147,6 +18837,44 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -18264,6 +18992,17 @@ "tslib": "^2.0.3" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -22771,6 +23510,117 @@ "node": ">=0.10.0" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast/node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast/node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast/node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast/node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast/node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast/node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdurl": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", @@ -22812,6 +23662,100 @@ "node": ">= 0.6" } }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -24298,12 +25242,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/only": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", - "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "dev": true, + "license": "MIT" }, - "node_modules/open": { + "node_modules/oniguruma-to-es": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.3.tgz", + "integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + }, + "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", @@ -24401,6 +25364,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxc-parser": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.56.5.tgz", + "integrity": "sha512-MNT32sqiTFeSbQZP2WZIRQ/mlIpNNq4sua+/4hBG4qT5aef2iQe+1/BjezZURPlvucZeSfN1Y6b60l7OgBdyUA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.56.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-darwin-arm64": "0.56.5", + "@oxc-parser/binding-darwin-x64": "0.56.5", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.56.5", + "@oxc-parser/binding-linux-arm64-gnu": "0.56.5", + "@oxc-parser/binding-linux-arm64-musl": "0.56.5", + "@oxc-parser/binding-linux-x64-gnu": "0.56.5", + "@oxc-parser/binding-linux-x64-musl": "0.56.5", + "@oxc-parser/binding-wasm32-wasi": "0.56.5", + "@oxc-parser/binding-win32-arm64-msvc": "0.56.5", + "@oxc-parser/binding-win32-x64-msvc": "0.56.5" + } + }, + "node_modules/oxc-parser/node_modules/@oxc-project/types": { + "version": "0.56.5", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.56.5.tgz", + "integrity": "sha512-skY3kOJwp22W4RkaadH1hZ3hqFHjkRrIIE0uQ4VUg+/Chvbl+2pF+B55IrIk2dgsKXS57YEUsJuN6I6s4rgFjA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -25476,6 +26475,17 @@ "dev": true, "license": "ISC" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -26431,6 +27441,16 @@ "dev": true, "license": "MIT" }, + "node_modules/regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", + "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, "node_modules/regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -26485,6 +27505,23 @@ "node": ">=0.10.0" } }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -28047,6 +29084,23 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.11.0.tgz", + "integrity": "sha512-VgKumh/ib38I1i3QkMn6mAQA6XjjQubqaAYhfge71glAll0/4xnt8L2oSuC45Qcr/G5Kbskj4RliMQddGmy/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.11.0", + "@shikijs/engine-javascript": "3.11.0", + "@shikijs/engine-oniguruma": "3.11.0", + "@shikijs/langs": "3.11.0", + "@shikijs/themes": "3.11.0", + "@shikijs/types": "3.11.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -28669,6 +29723,17 @@ "dev": true, "license": "MIT" }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spawndamnit": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", @@ -29198,6 +30263,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities/node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -29302,6 +30393,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", + "dev": true, + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -30414,6 +31512,17 @@ "deprecated": "Use String.prototype.trim() instead", "dev": true }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/trim-trailing-lines": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz", @@ -31074,6 +32183,20 @@ "dev": true, "license": "MIT" }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-remove-position": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-1.1.4.tgz", @@ -31602,6 +32725,13 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT" + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -32865,6 +33995,17 @@ "node": ">= 6" } }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "packages/benchmarks": { "name": "@lit-internal/benchmarks", "version": "1.0.6", @@ -33396,6 +34537,599 @@ "@types/trusted-types": "^2.0.2" } }, + "packages/labs/parser": { + "name": "@lit-labs/parser", + "version": "0.13.2", + "license": "BSD-3-Clause", + "dependencies": { + "@parse5/tools": "^0.5.0", + "oxc-parser": "^0.56.0" + }, + "devDependencies": { + "@lit/task": "^1.0.2", + "@oxc-parser/wasm": "^0.57.0", + "@web/dev-server-legacy": "^1.0.0", + "@web/test-runner": "^0.15.0", + "@web/test-runner-playwright": "^0.9.0", + "chai": "^5.2.0", + "codemirror-elements": "^0.0.8", + "lit": "^3.2.1", + "shiki": "^3.2.1", + "typescript": "~5.5.0", + "vite": "^6.2.1", + "vite-plugin-wasm": "^3.4.1" + } + }, + "packages/labs/parser/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/labs/parser/node_modules/@parse5/tools": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.5.0.tgz", + "integrity": "sha512-vyYK20atGm9Kwwk/vi5jTFxb7m67EG1PLTUN31+WAUsvgOThu/PjsZD57P6A1hAm2TunkzxSD9esnYv6gcWrdA==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + } + }, + "packages/labs/parser/node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "packages/labs/parser/node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "packages/labs/parser/node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "packages/labs/parser/node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "packages/labs/parser/node_modules/vite-plugin-wasm": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.5.0.tgz", + "integrity": "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" + } + }, "packages/labs/preact-signals": { "name": "@lit-labs/preact-signals", "version": "1.0.3", diff --git a/packages/labs/parser/.gitignore b/packages/labs/parser/.gitignore new file mode 100644 index 0000000000..e9a1f04cdb --- /dev/null +++ b/packages/labs/parser/.gitignore @@ -0,0 +1,3 @@ +/lib/ +/test/ +/playground/ \ No newline at end of file diff --git a/packages/labs/parser/LICENSE b/packages/labs/parser/LICENSE new file mode 100644 index 0000000000..82c4744c32 --- /dev/null +++ b/packages/labs/parser/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2025 Google LLC. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/labs/parser/README.md b/packages/labs/parser/README.md new file mode 100644 index 0000000000..85c5b10525 --- /dev/null +++ b/packages/labs/parser/README.md @@ -0,0 +1,76 @@ +# @lit-labs/analyzer + +A static analyzer for Lit + +> [!WARNING] +> +> This package is part of [Lit Labs](https://lit.dev/docs/libraries/labs/). It +> is published in order to get feedback on the design and may receive breaking +> changes or stop being supported. +> +> Please read our [Lit Labs documentation](https://lit.dev/docs/libraries/labs/) +> before using this library in production. + +## Overview + +This package contains static analysis utilities for analyzing source code that contain Lit templates and elements, that might be useful for other programs like linters, IDE plugins, code generators, etc. + +This is a very early stage Lit Labs package and is not ready for use. + +## Usage + +_This section is incomplete_ + +### Node + +```ts +import {createPackageAnalyzer} from '@lit-labs/analyzer/package-analyzer.js'; +import * as path from 'path'; + +const packagePath = path.resolve('./my-package'); +const analyzer = createPackageAnalyzer(packagePath); +const module = analyzer.getModule( + path.resolve(packagePath, 'src/my-element.ts') +); +``` + +### Browser + +You must use a bundler to bundle TypeScript, such as Rollup with the CommonJS plugin. + +With `@rollup/plugin-commonjs` you need to ignore built-in libraries like `os`, `fs`, etc. You can isgnore these in your Rollup config: + +rollup.config.js: + +```ts +import commonjs from '@rollup/plugin-commonjs'; + +// ... + plugins: [ + commonjs({ + ignore: (id) => ['fs', 'os', 'inspector'].includes(id), + }), + ], +// ... +``` + +You may need to install the `'path'` package: + +```sh +npm i path +``` + +Then you can make an Analyzer using these imports: + +```ts +import {Analyzer} from '@lit-labs/analyzer/lib/analyzer.js'; +import {AbsolutePath} from '@lit-labs/analyzer/lib/paths.js'; +import ts from 'typescript'; +import * as path from 'path'; + +// TODO: show constructing an Analyzer in browser contexts +``` + +## Contributing + +Please see [CONTRIBUTING.md](../../../CONTRIBUTING.md). diff --git a/packages/labs/parser/package.json b/packages/labs/parser/package.json new file mode 100644 index 0000000000..4674bd10d9 --- /dev/null +++ b/packages/labs/parser/package.json @@ -0,0 +1,101 @@ +{ + "name": "@lit-labs/parser", + "version": "0.13.2", + "publishConfig": { + "access": "public" + }, + "description": "A static parser for Lit", + "license": "BSD-3-Clause", + "author": "Google LLC", + "homepage": "https://github.com/lit/lit/tree/main/packages/labs/parser", + "repository": { + "type": "git", + "url": "git+https://github.com/lit/lit.git", + "directory": "packages/labs/parser" + }, + "main": "index.js", + "type": "module", + "scripts": { + "build": "wireit", + "start": "wireit", + "playground": "wireit", + "test": "wireit", + "test:debug": "wireit" + }, + "wireit": { + "start": { + "command": "node ./lib/index.js", + "dependencies": [ + "build" + ] + }, + "build": { + "command": "tsc --build --pretty", + "dependencies": [], + "files": [ + "src/**/*.ts", + "!src/playground/**/*.ts", + "tsconfig.json" + ], + "output": [ + "lib/", + "test/", + "tsconfig.tsbuildinfo" + ], + "clean": "if-file-deleted" + }, + "playground": { + "command": "vite src/playground/ -c vite.config.mjs", + "service": true, + "files": [ + "vite.config.mjs", + "src/playground/**/*.ts" + ] + }, + "test": { + "command": "wtr --config web-test-runner.config.js", + "dependencies": [ + "build" + ], + "files": [ + "web-test-runner.config.js" + ] + }, + "test:debug": { + "command": "wtr --config web-test-runner.config.js --watch", + "service": true, + "dependencies": [ + { + "cascade": false, + "script": "build" + } + ], + "files": [ + "web-test-runner.config.js", + "test/**/*.js" + ] + } + }, + "files": [ + "/lib/", + "!/lib/.tsbuildinfo" + ], + "dependencies": { + "@parse5/tools": "^0.5.0", + "oxc-parser": "^0.56.0" + }, + "devDependencies": { + "@lit/task": "^1.0.2", + "@oxc-parser/wasm": "^0.57.0", + "@web/dev-server-legacy": "^1.0.0", + "@web/test-runner": "^0.15.0", + "@web/test-runner-playwright": "^0.9.0", + "chai": "^5.2.0", + "codemirror-elements": "^0.0.8", + "lit": "^3.2.1", + "shiki": "^3.2.1", + "typescript": "~5.5.0", + "vite": "^6.2.1", + "vite-plugin-wasm": "^3.4.1" + } +} diff --git a/packages/labs/parser/src/lib/ast/detection.ts b/packages/labs/parser/src/lib/ast/detection.ts new file mode 100644 index 0000000000..2a7b081ed7 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/detection.ts @@ -0,0 +1,244 @@ +import { + Comment, + NativeTemplate, + TaggedTemplateExpression, + TreeAdapter, +} from './tree-adapter.js'; + +/** + * Finds all Lit HTML tagged template literals in the AST. + * + * This function identifies Lit templates through multiple methods: + * 1. Looking for templates with @litHtmlTemplate annotations + * 2. Identifying templates with the 'html' template tag name + * 3. Detecting Lit binding syntax patterns (.property, ?boolean, @event) + * + * @param config The configuration object + * @returns An array of tagged template expressions that are identified as Lit templates + */ +export function findLitTaggedTemplates({ + tree, + sourceText, + infer, +}: { + tree: T; + /** + * The original source code text + */ + sourceText: string; + /** + * Configuration for inference rules (whether to check for html tag and Lit bindings) + */ + infer: { + /** + * Whether to check for the 'html' template tag + */ + htmlTag: boolean; + /** + * Whether to check for Lit binding syntax patterns + */ + litBindings: boolean; + }; +}): TaggedTemplateExpression>[] { + const templates = + tree.findTaggedTemplateLiterals() as TaggedTemplateExpression< + NativeTemplate + >[]; + const comments = tree.findComments(); + return findLikelyLitTaggedTemplates>({ + sourceText, + comments, + templates, + infer, + }); +} + +/** + * Determines if a tagged template is likely a Lit template based on inference rules. + * + * Checks for: + * 1. The 'html' template tag name (if htmlTag inference is enabled) + * 2. Lit binding syntax patterns (if litBindings inference is enabled) + * + * @param config The configuration object + * @returns True if the template is likely a Lit template, false otherwise + */ +export function isLikelyTaggedTemplateLiteral({ + node, + fullText, + infer, +}: { + /** + * The tagged template expression to check + */ + node: TaggedTemplateExpression; + /** + * The original source code text + */ + fullText: string; + /** + * Configuration for inference rules + */ + infer: { + /** + * Whether to check for the 'html' template tag + */ + htmlTag: boolean; + /** + * Whether to check for Lit binding syntax patterns + */ + litBindings: boolean; + }; +}): boolean { + if (infer.htmlTag && node.tagName === 'html') { + return true; + } + + if (!infer.litBindings) { + return false; + } + + // Regular expressions for Lit binding sigils + // + // Starts off with a space + // then one of ., ?, or @ which is a Lit binding sigil + // then a word character which is the start of a valid attribute name + // then possible dashes and more word characters + // then followed by = + // then followed by an optional single (') or double ("") quote + // then followed by ${ + const sigilBindingRegex = /\s[.?@]\w+[-\w]*=["']?\$\{/; + + // Extract the template content + if ( + node.template && + node.template.start !== undefined && + node.template.end !== undefined + ) { + const templateContent = fullText.substring( + node.template.start - node.start, + node.template.end - node.start + ); + + // Check for Lit binding sigils + if (sigilBindingRegex.test(templateContent)) { + return true; + } + } + + return false; +} + +/** + * Identifies Lit HTML tagged templates through annotations and inference rules. + * + * This function uses multiple strategies to identify Lit templates: + * 1. Looks for templates with @litHtmlTemplate annotations in nearby comments + * 2. Skips templates with @litHtmlIgnore annotations + * 3. Uses inference rules to identify templates based on tag name and binding syntax + * + * Uses an optimized comment indexing approach to avoid O(tc) complexity when + * matching comments to templates. This works by sorting comments once and then + * using a sliding window approach to find relevant comments for each template. + * + * @returns An array of tagged template expressions that are identified as Lit templates + */ +function findLikelyLitTaggedTemplates({ + sourceText, + comments, + templates, + infer, +}: { + /** + * The original source code text + */ + sourceText: string; + comments: Comment[]; + /** + * The tagged template expressions to analyze + */ + templates: TaggedTemplateExpression[]; + /** + * Configuration for inference rules + */ + infer: { + /** + * Whether to check for the 'html' template tag + */ + htmlTag: boolean; + /** + * Whether to check for Lit binding syntax patterns + */ + litBindings: boolean; + }; +}): TaggedTemplateExpression[] { + const litTaggedTemplates: TaggedTemplateExpression[] = []; + + // Sort comments by their start position to process them in order + const sortedComments = comments.sort((a, b) => a.start - b.start); + + // This is the key optimization: we maintain a sliding window through the comments array + // by keeping track of the starting index for each template. This avoids having to + // scan through all comments for each template, which would be O(tc) where t + // is the number of templates and c is the number of comments. + let commentStartIndex = 0; + + for (const template of templates) { + // Flag to track if we should skip this template (e.g., if it has @litHtmlIgnore) + let skip = false; + + // Iterate through comments starting from our current position in the comments array. + // This is much more efficient than starting from the beginning for each template, + // especially in large files with many comments and templates. + for ( + let commentIndex = commentStartIndex; + commentIndex < sortedComments.length; + commentIndex++ + ) { + const comment = sortedComments[commentIndex]; + // Comment must end before the template starts + if (comment.end > template.start) { + commentStartIndex = commentIndex; + break; + } + + // Check if the comment is close enough to the template to be considered related. + // We only consider comments that are within 1 line of the template to avoid + // associating unrelated comments with templates. + const textBetween = sourceText.substring(comment.end, template.start); + const numLinesBetween = textBetween.match(/\r?\n/g)?.length ?? 0; + + if (numLinesBetween > 1) { + continue; + } + + // Check for special annotations that determine if this template + // should be included or explicitly ignored + if (comment.value.includes('@litHtmlIgnore')) { + skip = true; + commentStartIndex = commentIndex; + break; + } + + // If we found a @litHtmlTemplate annotation, include this template + if (comment.value.includes('@litHtmlTemplate')) { + litTaggedTemplates.push(template); + commentStartIndex = commentIndex; + break; + } + } + + // If we didn't explicitly skip this template, add it to the remaining list for further analysis + if (skip) { + continue; + } + + const fullText = sourceText.substring(template.start, template.end); + + if (isLikelyTaggedTemplateLiteral({node: template, fullText, infer})) { + litTaggedTemplates.push(template); + } + } + + return litTaggedTemplates; +} diff --git a/packages/labs/parser/src/lib/ast/estree/helpers.ts b/packages/labs/parser/src/lib/ast/estree/helpers.ts new file mode 100644 index 0000000000..36bc3dfe8d --- /dev/null +++ b/packages/labs/parser/src/lib/ast/estree/helpers.ts @@ -0,0 +1,17 @@ +import type { + Directive, + Statement, + TemplateElement, + Expression, +} from 'oxc-parser'; +import type {LitTaggedTemplateExpression, With} from '../tree-adapter.js'; + +export function isLitTaggedTemplateExpression< + T extends Directive | Statement | Expression | TemplateElement, +>(node: T): node is With { + return ( + node.type === 'TaggedTemplateExpression' && + 'isLit' in node && + (node.isLit as boolean) + ); +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/helpers.ts b/packages/labs/parser/src/lib/ast/html-parser/helpers.ts new file mode 100644 index 0000000000..fd10e60629 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/helpers.ts @@ -0,0 +1,157 @@ +import { + LitHtmlExpression, + LitTagLiteral, + SimpleLocation, + SimpleElementLocation, + Attribute, +} from './parse5-shim.js'; +import {AttributeMode, State} from './state.js'; + +/** + * Converts an array of LitTagLiteral and LitHtmlExpression objects into a string representation. + * + * This utility function is used to create a string representation of tag names or other HTML content + * that may contain a mix of literal strings and expressions. Literal values are preserved as-is, + * while expressions are replaced with the placeholder '[[EXPRESSION]]'. + * + * @param expressionList - An array of LitTagLiteral and/or LitHtmlExpression objects to convert + * @returns A string where literals are preserved and expressions are replaced with '[[EXPRESSION]]' + */ +export function coerceLiteralExpressionString( + ...expressionList: Array +): string { + return expressionList + .map((tag) => (tag.type === 'LitTagLiteral' ? tag.value : '[[EXPRESSION]]')) + .join(''); +} + +/** + * Generates a unique identifier for a LitHtmlExpression in an attribute context. + * + * This function creates a unique name by combining the expression's nodeName with its + * source code start offset position, ensuring each expression can be uniquely identified. + * + * @param expression - The LitHtmlExpression to generate a unique name for + * @returns A string in the format `nodeName--startOffset` that uniquely identifies the expression + */ +export function getUniqueAttributeExpressionName( + expression: LitHtmlExpression +) { + return `#LitElementExpression-${expression.sourceCodeLocation.startOffset}`; +} + +/** + * Type guard to check if a location is a SimpleElementLocation + * + * @param location The location to check + */ +export function isElementLocation( + location: SimpleLocation | SimpleElementLocation +): location is SimpleElementLocation { + return 'startTag' in location; +} + +/** + * Updates the source code location for a node. + * + * @param charLocation The current character location + * @param options Additional options for updating the source location + */ +export function updateSourceLocation( + charLocation: number, + node: { + sourceCodeLocation?: SimpleLocation | SimpleElementLocation | undefined; + }, + options?: { + /** Additional offset to add to the end offset */ + additionalEndOffset?: number; + /** Update start tag end offset (for element nodes) */ + updateStartTag?: boolean; + } +) { + if (node.sourceCodeLocation) { + node.sourceCodeLocation.endOffset = + charLocation + (options?.additionalEndOffset ?? 0); + + if ( + options?.updateStartTag && + isElementLocation(node.sourceCodeLocation) && + node.sourceCodeLocation.startTag + ) { + node.sourceCodeLocation.startTag!.endOffset = + node.sourceCodeLocation.endOffset; + } + } +} + +/** + * Updates the attribute source locations for an element node. + * + * @param charLocation The current character location + * @param attributeNode The attribute node + * @param sigil The attribute sigil (., ?, @, or empty string) + */ +export function updateAttributeSourceLocation( + charLocation: number, + elementNode: { + sourceCodeLocation?: SimpleElementLocation | undefined; + }, + attributeNode: Exclude +) { + if (elementNode.sourceCodeLocation) { + // Calculate the attribute name string and first part start offset + const attrNameString = coerceLiteralExpressionString(...attributeNode.name); + const firstAttrPartStart = + attributeNode.name[0]?.sourceCodeLocation?.startOffset ?? charLocation; + + // Update the source location using the provided character location + elementNode.sourceCodeLocation.attrs![attrNameString] = { + startOffset: firstAttrPartStart, + endOffset: charLocation, + }; + elementNode.sourceCodeLocation.startTag!.attrs = + elementNode.sourceCodeLocation.attrs!; + } +} + +export function isValidAttributeStartCharacter(char: string): boolean { + if (char === '/') { + return false; + } + return !char.match(/[\s\n]/); +} + +/** + * Prepares the parser state for attribute mode. + * + * This function is used when the parser encounters the start of a new attribute. + * It creates a new tag literal with the provided character (if any) and sets up + * a new attribute node with the current attribute mode. + * + * @param state The current parser state + * @param char The character to include in the new tag literal (empty for binding sigils) + */ +export function prepareForAttributeMode(state: State, char = '') { + if (!state.attributeMode) { + state.attributeMode = AttributeMode.STRING; + } + + state.currentTagLiteral = { + type: 'LitTagLiteral', + value: char, + sourceCodeLocation: { + // we don't pass the binding sigil, so start offset is the length of the + // current char, because this handles string attributes as well which + // don't have a binding sigil + startOffset: state.charLocation - char.length, + endOffset: state.charLocation, + }, + }; + + state.currentAttributeNode = { + name: [], + value: [], + type: state.attributeMode, + element: state.currentElementNode!, + }; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/modes/attribute-equals-or-tag-mode.ts b/packages/labs/parser/src/lib/ast/html-parser/modes/attribute-equals-or-tag-mode.ts new file mode 100644 index 0000000000..462678b7fe --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/modes/attribute-equals-or-tag-mode.ts @@ -0,0 +1,169 @@ +import {AttributeMode, Mode, State} from '../state.js'; +import {createLitHtmlExpression} from '../parse5-shim.js'; +import { + getUniqueAttributeExpressionName, + isValidAttributeStartCharacter, + prepareForAttributeMode, + updateSourceLocation, + updateAttributeSourceLocation, +} from '../helpers.js'; +import {LitLinkedExpression, With} from '../../tree-adapter.js'; + +export function attributeEqualsOrTagMode( + char: string | undefined, + nextExpression: With | undefined, + state: State +): State { + if (state.lastExpressionNode) { + state.lastExpressionNode.sourceCodeLocation!.endOffset = state.charLocation; + state.lastExpressionNode = null; + } + + if (!state.currentElementNode) { + throw new Error( + 'Started attribute-equals-or-tag mode, but no current tag is being tracked.' + ); + } + + if (!state.currentAttributeNode || !state.attributeMode) { + throw new Error( + 'Started attribute-equals-or-tag mode, but no current attribute is being tracked.' + ); + } + + // This is the case of + // "
" + // or "
') { + state.mode = Mode.TEXT; + // Commit the current attribute node + state.currentElementNode.attrs.push(state.currentAttributeNode); + + // Set empty value if none exists + if (!state.currentAttributeNode.value.length) { + state.currentAttributeNode.value = [ + { + type: 'LitTagLiteral', + value: '', + }, + ]; + } + + // Update source locations + updateAttributeSourceLocation( + state.charLocation, + state.currentElementNode, + state.currentAttributeNode! + ); + + // Update element structure + parent.childNodes.push(state.currentElementNode); + state.currentElementNode.parentNode = parent; + state.elementStack.push(state.currentElementNode); + updateSourceLocation(state.charLocation, state.currentElementNode, { + additionalEndOffset: 1, + updateStartTag: true, + }); + + // Reset current nodes + state.currentAttributeNode = null; + state.currentElementNode = null; + } else if (char === '=') { + // we found an equals, so we are moving to an attribute value + state.mode = Mode.ATTRIBUTE_VALUE; + } else if (isValidAttributeStartCharacter(char)) { + state.mode = Mode.ATTRIBUTE; + // This is a new attribute - commit the current one first + state.currentElementNode.attrs.push(state.currentAttributeNode); + + // Set empty value if none exists + if (!state.currentAttributeNode.value.length) { + state.currentAttributeNode.value = [ + { + type: 'LitTagLiteral', + value: '', + }, + ]; + } + + // Update source locations for the attribute we're finishing + updateAttributeSourceLocation( + state.charLocation, + state.currentElementNode, + state.currentAttributeNode! + ); + + // Set up for the new attribute + // Determine the attribute mode based on the character + switch (char) { + case '.': + state.attributeMode = AttributeMode.PROPERTY; + prepareForAttributeMode(state, char); + break; + case '?': + state.attributeMode = AttributeMode.BOOLEAN; + prepareForAttributeMode(state, char); + break; + case '@': + state.attributeMode = AttributeMode.EVENT; + prepareForAttributeMode(state, char); + break; + default: + state.attributeMode = AttributeMode.STRING; + prepareForAttributeMode(state, char); + break; + } + } else if (char === ' ' || char === '\n') { + // Just whitespace, stay in the same mode + } + + return state; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/modes/attribute-value-mode.ts b/packages/labs/parser/src/lib/ast/html-parser/modes/attribute-value-mode.ts new file mode 100644 index 0000000000..e4ad325dbf --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/modes/attribute-value-mode.ts @@ -0,0 +1,196 @@ +import {Mode, State} from '../state.js'; +import {createLitHtmlExpression, createTagLiteral} from '../parse5-shim.js'; +import { + updateSourceLocation, + updateAttributeSourceLocation, +} from '../helpers.js'; +import {LitLinkedExpression, With} from '../../tree-adapter.js'; + +export function attributeValueMode( + char: string | undefined, + nextExpression: With | undefined, + state: State +): State { + if (state.lastExpressionNode) { + state.lastExpressionNode.sourceCodeLocation!.endOffset = state.charLocation; + state.lastExpressionNode = null; + } + + if (!state.currentElementNode) { + throw new Error( + 'Started attribute-value mode, but no current tag is being tracked.' + ); + } + + if (!state.currentAttributeNode || !state.attributeMode) { + throw new Error( + 'Started attribute-value mode, but no current attribute is being tracked.' + ); + } + + // Initialize the current tag literal if it doesn't exist + if (!state.currentTagLiteral) { + state.currentTagLiteral = createTagLiteral(''); + state.currentTagLiteral.sourceCodeLocation = { + startOffset: state.charLocation, + endOffset: state.charLocation, + }; + } + + // Handle end of input + if (!char) { + // Commit the current tag literal to the attribute value if it has content + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode.value.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + + // Handle next expression if present + if (nextExpression) { + state.lastExpressionNode = createLitHtmlExpression( + nextExpression, + state.charLocation + ); + state.currentAttributeNode.value.push(state.lastExpressionNode); + } + + // Commit the attribute to the element if it hasn't been committed yet + if (state.currentAttributeNode && state.currentElementNode) { + // Update source locations + updateAttributeSourceLocation( + state.charLocation, + state.currentElementNode, + state.currentAttributeNode + ); + state.currentElementNode.attrs.push(state.currentAttributeNode); + } + return state; + } + + const parent = + state.elementStack[state.elementStack.length - 1] ?? state.document; + + // Handle attribute value quotes + if (char === '"' || char === "'") { + if (!state.currentAttributeQuote) { + // Opening quote - set the current quote character + state.currentAttributeQuote = char; + // Don't add the quote character to the value + return state; + } else if (state.currentAttributeQuote === char) { + // Closing quote - finish the attribute value + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode.value.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + + // Commit the attribute to the element + updateAttributeSourceLocation( + state.charLocation, + state.currentElementNode, + state.currentAttributeNode + ); + state.currentElementNode.attrs.push(state.currentAttributeNode); + + // Reset state + state.currentAttributeNode = null; + state.currentAttributeQuote = null; + state.mode = Mode.TAG; + return state; + } + // If we reach here, it's a quote character within the attribute value + // Just add it to the current literal value + } + + // Handle whitespace when not inside quotes (unquoted attribute values) + if ( + (char === ' ' || char === '\n' || char === '\t') && + !state.currentAttributeQuote + ) { + // End of unquoted attribute value - finish the attribute value + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode.value.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + + // Commit the attribute to the element + updateAttributeSourceLocation( + state.charLocation, + state.currentElementNode, + state.currentAttributeNode + ); + state.currentElementNode.attrs.push(state.currentAttributeNode); + + // Reset state + state.currentAttributeNode = null; + state.currentAttributeQuote = null; + state.mode = Mode.TAG; + return state; + } + + // Handle self-closing tag + if (char === '/' && state.currentAttributeQuote === null) { + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode.value.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + + // Commit the attribute to the element + updateAttributeSourceLocation( + state.charLocation, + state.currentElementNode, + state.currentAttributeNode + ); + state.currentElementNode.attrs.push(state.currentAttributeNode); + + // Reset state + state.currentAttributeNode = null; + state.mode = Mode.TAG; + return state; + } + + // Handle end of tag - this also handles the case of missing closing quotes + if (char === '>') { + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode.value.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + + // Commit the attribute to the element + updateAttributeSourceLocation( + state.charLocation, + state.currentElementNode, + state.currentAttributeNode + ); + state.currentElementNode.attrs.push(state.currentAttributeNode); + + // Update element and push to parent + updateSourceLocation(state.charLocation, state.currentElementNode, { + additionalEndOffset: 1, + updateStartTag: true, + }); + parent.childNodes.push(state.currentElementNode); + state.currentElementNode.parentNode = parent; + state.elementStack.push(state.currentElementNode); + + // Reset state + state.currentAttributeNode = null; + state.currentElementNode = null; + // Always reset quote state when ending a tag (helps with malformed attribute handling) + state.currentAttributeQuote = null; + state.mode = Mode.TEXT; + return state; + } + + // Add character to the current literal value + if (state.currentTagLiteral) { + state.currentTagLiteral.value = `${state.currentTagLiteral.value}${char}`; + } + + return state; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/modes/attribute.ts b/packages/labs/parser/src/lib/ast/html-parser/modes/attribute.ts new file mode 100644 index 0000000000..9b28e90fdc --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/modes/attribute.ts @@ -0,0 +1,149 @@ +import {Mode, State} from '../state.js'; +import {createLitHtmlExpression, createTagLiteral} from '../parse5-shim.js'; +import { + updateSourceLocation, + updateAttributeSourceLocation, +} from '../helpers.js'; +import {LitLinkedExpression, With} from '../../tree-adapter.js'; + +export function attributeMode( + char: string | undefined, + nextExpression: With | undefined, + state: State +): State { + if (state.lastExpressionNode) { + state.lastExpressionNode.sourceCodeLocation!.endOffset = state.charLocation; + state.lastExpressionNode = null; + } + + if (!state.currentElementNode) { + throw new Error( + 'Started attribute mode, but no current tag is being tracked.' + ); + } + + if (!state.currentTagLiteral) { + state.currentTagLiteral = createTagLiteral(''); + state.currentTagLiteral.sourceCodeLocation = { + startOffset: state.charLocation, + endOffset: state.charLocation, + }; + } + + if (!state.currentAttributeNode) { + state.currentAttributeNode = { + type: state.attributeMode!, + name: [], + value: [], + element: state.currentElementNode, + }; + } + + if (!char) { + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode!.name.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + + if (nextExpression) { + state.lastExpressionNode = createLitHtmlExpression( + nextExpression, + state.charLocation + ); + state.currentAttributeNode!.name.push(state.lastExpressionNode); + } + + return state; + } + + const parent = + state.elementStack[state.elementStack.length - 1] ?? state.document; + + switch (char) { + case ' ': + state.mode = Mode.ATTRIBUTE_EQUALS_OR_TAG; + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode!.name.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + break; + case '\n': + state.mode = Mode.ATTRIBUTE_EQUALS_OR_TAG; + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode!.name.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + break; + case '=': + state.mode = Mode.ATTRIBUTE_VALUE; + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode!.name.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + break; + case '>': + state.mode = Mode.TEXT; + // In this case the attribute doesn't have a value, so we commit it as is + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode!.name.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + + updateAttributeSourceLocation( + state.charLocation, + state.currentElementNode, + state.currentAttributeNode! + ); + state.currentElementNode.attrs.push(state.currentAttributeNode!); + + if (!state.currentAttributeNode!.value.length) { + state.currentAttributeNode!.value = [createTagLiteral('')]; + } + + updateSourceLocation(state.charLocation, state.currentElementNode, { + additionalEndOffset: 1, + updateStartTag: true, + }); + parent.childNodes.push(state.currentElementNode); + state.currentElementNode.parentNode = parent; + state.elementStack.push(state.currentElementNode); + state.currentElementNode = null; + state.currentAttributeNode = null; + break; + case '/': + // Handle self-closing tags + state.mode = Mode.TAG; + if (state.currentTagLiteral && state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentAttributeNode!.name.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + + // Commit the attribute + updateAttributeSourceLocation( + state.charLocation, + state.currentElementNode, + state.currentAttributeNode! + ); + state.currentElementNode.attrs.push(state.currentAttributeNode!); + + if (!state.currentAttributeNode!.value.length) { + state.currentAttributeNode!.value = [createTagLiteral('')]; + } + + state.currentAttributeNode = null; + break; + default: + if (state.currentTagLiteral) { + state.currentTagLiteral.value = `${state.currentTagLiteral.value}${char}`; + } + break; + } + + return state; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/modes/closing-tag.ts b/packages/labs/parser/src/lib/ast/html-parser/modes/closing-tag.ts new file mode 100644 index 0000000000..e9e64a7d1f --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/modes/closing-tag.ts @@ -0,0 +1,143 @@ +import {Mode, State} from '../state.js'; +import {createLitHtmlExpression, Element} from '../parse5-shim.js'; +import { + coerceLiteralExpressionString, + updateSourceLocation, +} from '../helpers.js'; +import {LitLinkedExpression, With} from '../../tree-adapter.js'; + +export function closingTagMode( + char: string | undefined, + nextExpression: With | undefined, + state: State +): State { + if (state.lastExpressionNode) { + state.lastExpressionNode.sourceCodeLocation!.endOffset = state.charLocation; + state.lastExpressionNode = null; + } + + if (!state.currentEndTag && !state.endTagIgnore) { + state.currentEndTag = []; + } + + if (!state.currentTagLiteral && !state.endTagIgnore) { + state.currentTagLiteral = { + type: 'LitTagLiteral', + value: '', + sourceCodeLocation: { + startOffset: state.charLocation, + endOffset: state.charLocation, + }, + }; + } + + if (!char && state.currentEndTag && state.currentTagLiteral) { + if (state.currentTagLiteral.value.length) { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentEndTag.push(state.currentTagLiteral); + state.currentTagLiteral = null; + } + + if (nextExpression) { + state.lastExpressionNode = createLitHtmlExpression( + nextExpression, + state.charLocation + ); + state.currentEndTag.push(state.lastExpressionNode); + } + + return state; + } + + let parent: Element | null = null; + let numPops = 0; + + if (state.currentTagLiteral) { + const coercedTagName = coerceLiteralExpressionString( + ...state.currentEndTag, + state.currentTagLiteral + ); + + // Find the potential parent element + for (let i = state.elementStack.length - 1; i >= 0; i--) { + numPops++; + const potentialParent = state.elementStack[i]; + const coercedParentTagName = coerceLiteralExpressionString( + ...potentialParent.tagName + ); + if (coercedParentTagName === coercedTagName) { + parent = potentialParent; + break; + } + } + } + + switch (char) { + case ' ': + state.endTagIgnore = true; + if ( + state.currentEndTag && + state.currentTagLiteral && + state.currentTagLiteral.value.length + ) { + state.currentEndTag.push(state.currentTagLiteral); + updateSourceLocation(state.charLocation, state.currentTagLiteral); + } + state.currentTagLiteral = null; + break; + case '\n': + state.endTagIgnore = true; + if ( + state.currentEndTag && + state.currentTagLiteral && + state.currentTagLiteral.value.length + ) { + state.currentEndTag.push(state.currentTagLiteral); + updateSourceLocation(state.charLocation, state.currentTagLiteral); + } + state.currentTagLiteral = null; + break; + case '>': + state.mode = Mode.TEXT; + if ( + state.currentEndTag && + state.currentTagLiteral && + state.currentTagLiteral.value.length + ) { + state.currentEndTag.push(state.currentTagLiteral); + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentEndTag.push(state.currentTagLiteral); + } + + if (parent) { + // Update parent element's end offset + updateSourceLocation(state.charLocation, parent, { + additionalEndOffset: 1, + }); + + // Set up the end tag location + parent.sourceCodeLocation!.endTag = { + startOffset: + state.currentEndTag[0].sourceCodeLocation!.startOffset - 2, + endOffset: state.charLocation + 1, + }; + // No need to update endOffset again as it's already set by updateSourceLocation + + for (let i = 0; i < numPops; i++) { + state.elementStack.pop(); + } + } + + state.currentEndTag = []; + state.currentTagLiteral = null; + state.endTagIgnore = false; + break; + default: + if (state.currentTagLiteral) { + state.currentTagLiteral.value = `${state.currentTagLiteral.value}${char}`; + } + break; + } + + return state; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/modes/comment.ts b/packages/labs/parser/src/lib/ast/html-parser/modes/comment.ts new file mode 100644 index 0000000000..96c86ad2cf --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/modes/comment.ts @@ -0,0 +1,122 @@ +import {Mode, State} from '../state.js'; +import {createCommentNode, createLitHtmlExpression} from '../parse5-shim.js'; +import {updateSourceLocation} from '../helpers.js'; +import {LitLinkedExpression, With} from '../../tree-adapter.js'; + +export function commentMode( + char: string | undefined, + nextExpression: With | undefined, + state: State +): State { + if (state.lastExpressionNode) { + state.lastExpressionNode.sourceCodeLocation!.endOffset = state.charLocation; + state.lastExpressionNode = null; + } + + if (!state.currentTagLiteral) { + state.currentTagLiteral = { + type: 'LitTagLiteral', + value: '', + sourceCodeLocation: { + startOffset: state.charLocation, + endOffset: state.charLocation, + }, + }; + } + + // Initialize the comment node if it doesn't exist + if (!state.currentCommentNode) { + state.currentCommentNode = createCommentNode(); + state.currentCommentNode = { + ...state.currentCommentNode, + data: [], + sourceCodeLocation: { + startOffset: state.charLocation - 2, // Account for the ") + if (isFirstChar && char === '-') { + state.currentTagLiteral.value = '-'; + // First character is a dash, might be a standard comment + state.checkingSecondDash = true; + return state; // Skip adding this dash to the comment data + } else if (state.checkingSecondDash && char === '-') { + // Second character is also a dash, this is a standard HTML comment + state.commentIsBogus = false; // Not a bogus comment + state.checkingSecondDash = false; + state.currentTagLiteral = null; + return state; // Skip adding these two dashes to the comment data + } + + state.checkingSecondDash = false; + + // Check for the end of the comment + if ( + char === '>' && + (state.commentIsBogus || + (!state.commentIsBogus && state.currentTagLiteral.value.endsWith('--'))) + ) { + if (!state.commentIsBogus && state.currentTagLiteral.value.endsWith('--')) { + // Remove the trailing "--" from the comment + state.currentTagLiteral.value = state.currentTagLiteral.value.slice( + 0, + -2 + ); + // update the source location of the tag literal + state.currentTagLiteral.sourceCodeLocation!.endOffset = + state.charLocation - 2; + } else { + updateSourceLocation(state.charLocation, state.currentTagLiteral); + } + + state.mode = Mode.TEXT; + + // Update source location and commit the comment node + updateSourceLocation(state.charLocation, state.currentCommentNode, { + additionalEndOffset: 1, + }); + + if (state.currentTagLiteral.value.length) { + state.currentCommentNode.data.push(state.currentTagLiteral); + } + + // Reset the comment node + state.currentCommentNode = null; + state.currentTagLiteral = null; + state.commentIsBogus = false; + return state; + } + + state.currentTagLiteral.value = `${state.currentTagLiteral.value}${char}`; + + return state; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/modes/tag-name.ts b/packages/labs/parser/src/lib/ast/html-parser/modes/tag-name.ts new file mode 100644 index 0000000000..1419e6420e --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/modes/tag-name.ts @@ -0,0 +1,116 @@ +import {Mode, State} from '../state.js'; +import { + createElement, + createLitHtmlExpression, + createTagLiteral, +} from '../parse5-shim.js'; +import {updateSourceLocation} from '../helpers.js'; +import {LitLinkedExpression, With} from '../../tree-adapter.js'; + +export function tagNameMode( + char: string | undefined, + nextExpression: With | undefined, + state: State +): State { + if (state.lastExpressionNode) { + state.lastExpressionNode.sourceCodeLocation!.endOffset = state.charLocation; + state.lastExpressionNode = null; + } + + if (!state.currentElementNode) { + state.currentElementNode = createElement(''); + state.currentElementNode = { + ...state.currentElementNode, + sourceCodeLocation: { + startOffset: state.charLocation - 1, // Account for "<" character + startTag: { + startOffset: state.charLocation - 1, // Account for "<" character + endOffset: state.charLocation - 1, // Will be updated later + }, + endOffset: state.charLocation - 1, // Will be updated later + }, + tagName: [], + }; + } + + if (!state.currentTagLiteral) { + state.currentTagLiteral = createTagLiteral(''); + state.currentTagLiteral.sourceCodeLocation = { + startOffset: state.charLocation, + endOffset: state.charLocation, + }; + } + + if (!char) { + // Commit the current tag literal if it has content + if (state.currentTagLiteral && state.currentTagLiteral.value.length > 0) { + state.currentElementNode.tagName.push(state.currentTagLiteral); + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentTagLiteral = null; + } + + // Handle expression in tag name + if (nextExpression) { + state.lastExpressionNode = createLitHtmlExpression( + nextExpression, + state.charLocation + ); + state.currentElementNode.tagName.push(state.lastExpressionNode); + } + + return state; + } + + const parent = + state.elementStack[state.elementStack.length - 1] ?? state.document; + + switch (char) { + case ' ': + if (state.currentTagLiteral) { + state.currentElementNode.tagName.push(state.currentTagLiteral); + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentTagLiteral = null; + } + state.mode = Mode.TAG; + break; + case '/': + if (state.currentTagLiteral) { + state.currentElementNode.tagName.push(state.currentTagLiteral); + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentTagLiteral = null; + } + state.mode = Mode.TAG; + break; + case '\n': + if (state.currentTagLiteral) { + state.currentElementNode.tagName.push(state.currentTagLiteral); + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentTagLiteral = null; + } + state.mode = Mode.TAG; + break; + case '>': + if (state.currentTagLiteral) { + state.currentElementNode.tagName.push(state.currentTagLiteral); + updateSourceLocation(state.charLocation, state.currentTagLiteral); + state.currentTagLiteral = null; + } + state.mode = Mode.TEXT; + updateSourceLocation(state.charLocation, state.currentElementNode, { + additionalEndOffset: 1, + updateStartTag: true, + }); + parent.childNodes.push(state.currentElementNode); + state.currentElementNode.parentNode = parent; + state.elementStack.push(state.currentElementNode); + state.currentElementNode = null; + break; + default: + if (state.currentTagLiteral) { + state.currentTagLiteral.value = `${state.currentTagLiteral.value}${char}`; + } + break; + } + + return state; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/modes/tag-or-comment-or-text-or-end-tag.ts b/packages/labs/parser/src/lib/ast/html-parser/modes/tag-or-comment-or-text-or-end-tag.ts new file mode 100644 index 0000000000..b4ba50183a --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/modes/tag-or-comment-or-text-or-end-tag.ts @@ -0,0 +1,192 @@ +import {Mode, State} from '../state.js'; +import { + createLitHtmlExpression, + createTextNode, + createElement, + createCommentNode, +} from '../parse5-shim.js'; +import {LitLinkedExpression, With} from '../../tree-adapter.js'; + +export function tagOrCommentOrTexOrEndTagMode( + char: string | undefined, + nextExpression: With | undefined, + state: State +): State { + if (state.lastExpressionNode) { + state.lastExpressionNode.sourceCodeLocation!.endOffset = state.charLocation; + state.lastExpressionNode = null; + } + + if (!state.currentTagLiteral) { + state.currentTagLiteral = { + type: 'LitTagLiteral', + value: '', + sourceCodeLocation: { + startOffset: state.charLocation, + endOffset: state.charLocation, + }, + }; + } + + if (!state.currentElementNode) { + state.currentElementNode = createElement('', {}); + + state.currentElementNode = { + ...state.currentElementNode, + nodeName: [], + get tagName() { + return this.nodeName; + }, + set tagName(value) { + this.nodeName = value; + }, + sourceCodeLocation: { + startOffset: state.charLocation - 1, + endOffset: state.currentTagLiteral.sourceCodeLocation!.endOffset, + attrs: {}, + startTag: { + startOffset: state.charLocation - 1, + endOffset: state.currentTagLiteral.sourceCodeLocation!.endOffset, + }, + }, + }; + } + + if (!state.currentTextNode) { + state.currentTextNode = createTextNode(''); + state.currentTextNode = { + ...state.currentTextNode, + sourceCodeLocation: { + startOffset: state.charLocation, + endOffset: state.charLocation, + }, + }; + } + + // Some Possible cases: + // <${literal`tag-mame`} ...>... + // + // Some Unsupported Cases: + // <${literal`!--`} + // <${literal`/`}endTagName> + // <${literal`7-not-valid-tag-name`} + if (!char) { + // we assume the best of people and that this is a valid tag name binding + // Also NOTE Lit does not support `4 <${5}` so that saves us here since + // HTML supports that + state.mode = Mode.TAG; + + // update tag literal + state.currentTagLiteral.sourceCodeLocation!.endOffset = state.charLocation; + + // update element node + if (state.currentTagLiteral.value.length) { + state.currentElementNode.tagName.push(state.currentTagLiteral); + } + + state.currentTagLiteral = null; + + // we are not at tail of template literal. This is probably a tag name + // binding like so: + // html`<${literal`tag-name`} ...` + if (nextExpression) { + state.lastExpressionNode = createLitHtmlExpression( + nextExpression, + state.charLocation + ); + state.currentElementNode.tagName.push(state.lastExpressionNode); + } + + // we are at tail of template literal commit as text node + // We handle the case of text node at the end like: + // html`
Hello world<` + // where "Hello world<" is a text node + if (!nextExpression && state.currentTextNode.value.length) { + const parentNode = + state.elementStack[state.elementStack.length - 1] ?? state.document; + state.currentTextNode.parentNode = parentNode; + parentNode.childNodes.push(state.currentTextNode); + } + + // cleanup state + state.currentTextNode = null; + state.currentCommentNode = null; + + return state; + } + + const parent = + state.elementStack[state.elementStack.length - 1] ?? state.document; + state.currentTextNode.value = `${state.currentTextNode.value}${char}`; + state.currentTagLiteral.value = `${state.currentTagLiteral.value}${char}`; + + switch (char) { + case '!': + state.mode = Mode.COMMENT; + // cleanup state + state.currentTagLiteral = null; + state.currentElementNode = null; + state.currentTextNode = null; + + if (state.potentialTextNode && state.potentialTextNode.value.length) { + parent.childNodes.push(state.potentialTextNode); + state.potentialTextNode.parentNode = parent; + } + + state.currentCommentNode = createCommentNode(); + state.currentCommentNode = { + ...state.currentCommentNode, + data: [], + parentNode: parent, + sourceCodeLocation: { + startOffset: state.charLocation - 1, + endOffset: state.charLocation, + }, + }; + + parent.childNodes.push(state.currentCommentNode); + + state.commentIsBogus = true; + state.potentialTextNode = null; + break; + case '/': + state.mode = Mode.CLOSING_TAG; + // cleanup state + state.currentTagLiteral = null; + state.currentElementNode = null; + state.currentTextNode = null; + + if (state.potentialTextNode && state.potentialTextNode.value.length) { + parent.childNodes.push(state.potentialTextNode); + state.potentialTextNode.parentNode = parent; + } + + state.potentialTextNode = null; + break; + default: + if (isValidTagStartCharacter(char)) { + state.mode = Mode.TAG_NAME; + if (state.potentialTextNode && state.potentialTextNode.value.length) { + parent.childNodes.push(state.potentialTextNode); + state.potentialTextNode.parentNode = parent; + } + + state.potentialTextNode = null; + + // cleanup state + state.currentTextNode = null; + state.currentCommentNode = null; + break; + } + + state.mode = Mode.TEXT; + state.potentialTextNode = null; + break; + } + + return state; +} + +function isValidTagStartCharacter(char: string): boolean { + return !!char.match(/[a-zA-Z]/); +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/modes/tag.ts b/packages/labs/parser/src/lib/ast/html-parser/modes/tag.ts new file mode 100644 index 0000000000..6c6604ddd7 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/modes/tag.ts @@ -0,0 +1,91 @@ +import {AttributeMode, Mode, State} from '../state.js'; +import {createLitHtmlExpression} from '../parse5-shim.js'; +import { + getUniqueAttributeExpressionName, + prepareForAttributeMode, + isValidAttributeStartCharacter, + updateSourceLocation, +} from '../helpers.js'; +import {LitLinkedExpression, With} from '../../tree-adapter.js'; + +export function tagMode( + char: string | undefined, + nextExpression: With | undefined, + state: State +): State { + if (state.lastExpressionNode) { + state.lastExpressionNode.sourceCodeLocation!.endOffset = state.charLocation; + state.lastExpressionNode = null; + } + + if (!state.currentElementNode) { + throw new Error('Started tag mode, but no current tag is being tracked.'); + } + + if (!char) { + if (nextExpression) { + state.lastExpressionNode = createLitHtmlExpression( + nextExpression, + state.charLocation + ); + state.currentElementNode.attrs.push(state.lastExpressionNode); + + if (!state.currentElementNode.sourceCodeLocation!.attrs) { + state.currentElementNode.sourceCodeLocation!.attrs = {}; + } + + const uniqueExprName = getUniqueAttributeExpressionName( + state.lastExpressionNode + ); + state.currentElementNode.sourceCodeLocation!.attrs[uniqueExprName] = + state.lastExpressionNode.sourceCodeLocation!; + + return state; + } + + return state; + } + + const parent = + state.elementStack[state.elementStack.length - 1] ?? state.document; + + switch (char) { + case '>': + state.mode = Mode.TEXT; + parent.childNodes.push(state.currentElementNode); + state.currentElementNode.parentNode = parent; + state.elementStack.push(state.currentElementNode); + updateSourceLocation(state.charLocation, state.currentElementNode, { + additionalEndOffset: 1, + updateStartTag: true, + }); + state.currentElementNode = null; + break; + // tslint:disable-next-line: no-switch-case-fall-through + case '.': + state.mode = Mode.ATTRIBUTE; + state.attributeMode = AttributeMode.PROPERTY; + prepareForAttributeMode(state, char); + break; + case '?': + state.mode = Mode.ATTRIBUTE; + state.attributeMode = AttributeMode.BOOLEAN; + prepareForAttributeMode(state, char); + break; + case '@': + state.mode = Mode.ATTRIBUTE; + state.attributeMode = AttributeMode.EVENT; + prepareForAttributeMode(state, char); + break; + default: + if (!isValidAttributeStartCharacter(char)) { + break; + } + state.mode = Mode.ATTRIBUTE; + state.attributeMode = AttributeMode.STRING; + prepareForAttributeMode(state, char); + break; + } + + return state; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/modes/text.ts b/packages/labs/parser/src/lib/ast/html-parser/modes/text.ts new file mode 100644 index 0000000000..cc59b624c9 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/modes/text.ts @@ -0,0 +1,73 @@ +import {Mode, State} from '../state.js'; +import {createLitHtmlExpression, createTextNode} from '../parse5-shim.js'; +import {LitLinkedExpression, With} from '../../tree-adapter.js'; + +export function textMode( + char: string | undefined, + nextExpression: With | undefined, + state: State +): State { + if (state.lastExpressionNode) { + state.lastExpressionNode.sourceCodeLocation!.endOffset = state.charLocation; + state.lastExpressionNode = null; + } + + if (!state.currentTextNode) { + state.currentTextNode = createTextNode(''); + state.currentTextNode = { + ...state.currentTextNode, + sourceCodeLocation: { + startOffset: state.charLocation, + endOffset: state.charLocation, + }, + }; + } + + if (!char) { + const parentNode = + state.elementStack[state.elementStack.length - 1] ?? state.document; + + if (state.currentTextNode.value.length) { + state.currentTextNode.sourceCodeLocation!.endOffset = state.charLocation; + state.currentTextNode.parentNode = parentNode; + parentNode.childNodes.push(state.currentTextNode); + } + state.currentTextNode = null; + + if (nextExpression) { + state.lastExpressionNode = createLitHtmlExpression( + nextExpression, + state.charLocation + ); + parentNode.childNodes.push(state.lastExpressionNode); + + state.currentTextNode = createTextNode(''); + state.currentTextNode = { + ...state.currentTextNode, + sourceCodeLocation: { + startOffset: state.charLocation, + endOffset: state.charLocation, + }, + }; + } + + return state; + } + + switch (char) { + case '<': + state.mode = Mode.TAG_OR_COMMENT_OR_TEXT_OR_END_TAG; + state.currentTextNode.sourceCodeLocation!.endOffset = state.charLocation; + state.potentialTextNode = { + ...state.currentTextNode, + sourceCodeLocation: {...state.currentTextNode.sourceCodeLocation!}, + }; + state.currentTextNode.value = `${state.currentTextNode.value}${char}`; + break; + default: + state.currentTextNode.value = `${state.currentTextNode.value}${char}`; + break; + } + + return state; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/parse5-shim.ts b/packages/labs/parser/src/lib/ast/html-parser/parse5-shim.ts new file mode 100644 index 0000000000..089e765f12 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/parse5-shim.ts @@ -0,0 +1,229 @@ +import type * as p5t from '@parse5/tools'; +import type * as p5 from 'parse5/dist/common/token'; +import { + createDocumentFragment as p5tCreateDocumentFragment, + createCommentNode as p5tCreateCommentNode, + createTextNode as p5tCreateTextNode, + createElement as p5tCreateElement, +} from '@parse5/tools'; +import {LitLinkedExpression, With} from '../tree-adapter.js'; + +/** + * Simplified location type that only includes offset information + * without line and column data. + */ +export interface SimpleLocation { + startOffset: number; + endOffset: number; +} + +/** + * Extended location type for elements that includes attribute locations + */ +export interface SimpleElementLocation extends WithSimpleAttrs { + startTag?: WithSimpleAttrs; + endTag?: WithSimpleAttrs; +} + +export type WithSimpleAttrs = T extends {attrs?: unknown} + ? Omit & {attrs?: Record} + : T & {attrs?: Record}; + +/** + * Utility type for transforming any parse5 type that uses sourceCodeLocation + * to use SimpleLocation instead + */ +export type WithSimpleLocation = T extends {sourceCodeLocation?: unknown} + ? Omit & {sourceCodeLocation?: SimpleLocation} + : never; + +export type WithChildNodes = + T extends {childNodes: unknown} ? Omit : never; + +export type WithParentNode = + T extends {parentNode: unknown} ? Omit : never; + +/** + * Utility type for transforming any parse5 type that uses elementLocation + * to use SimpleElementLocation instead + */ +export type WithSimpleElementLocation = T extends { + sourceCodeLocation?: unknown; +} + ? Omit & {sourceCodeLocation?: SimpleElementLocation} + : never; + +/** + * Base Node with simplified location information + */ +export type Node = ChildNode | ParentNode; + +/** + * DocumentFragment with simplified location information + */ +export type DocumentFragment = WithChildNodes< + WithSimpleLocation +> & { + childNodes: ChildNode[]; +}; + +/** + * Document with simplified location information + */ +export type Document = WithChildNodes> & { + childNodes: ChildNode[]; +}; + +/** + * Template with simplified location information + */ +export type Template = WithParentNode< + WithChildNodes> +> & { + childNodes: ChildNode[]; + parentNode: ParentNode | null; +}; + +/** + * DocumentType with simplified location information + */ +export type DocumentType = WithParentNode< + WithSimpleElementLocation +> & { + parentNode: ParentNode | null; +}; + +/** + * CommentNode with simplified location information + */ +export type CommentNode = Omit< + WithParentNode>, + 'data' +> & { + data: (LitTagLiteral | LitHtmlExpression)[]; + parentNode: ParentNode | null; +}; + +/** + * TextNode with simplified location information + */ +export type TextNode = WithParentNode> & { + parentNode: ParentNode | null; +}; + +type NodeName = (LitTagLiteral | LitHtmlExpression)[]; + +/** + * Element with simplified location information + */ +export type Element = Omit< + WithParentNode>>, + 'nodeName' | 'tagName' | 'attrs' +> & { + childNodes: ChildNode[]; + parentNode: ParentNode | null; + attrs: Attribute[]; + tagName: NodeName; + nodeName: NodeName; +}; + +interface AttributeBase { + type: T; + name: Array; + value: Array; + element: Element; +} + +export type LitPropertyAttribute = Omit & + AttributeBase<'Property'>; +export type LitBooleanAttribute = Omit & + AttributeBase<'Boolean'>; +export type LitStringAttribute = Omit & + AttributeBase<'String'>; +export type LitEventAttribute = Omit & + AttributeBase<'Event'>; + +export interface LitTagLiteral { + type: 'LitTagLiteral'; + value: string; + sourceCodeLocation?: SimpleLocation; +} + +export type LitHtmlExpression = { + nodeName: '#lit-html-expression'; + type: 'LitHtmlExpression'; + sourceCodeLocation: SimpleLocation; + value: With; + element?: Element; +}; + +export type Attribute = + | LitPropertyAttribute + | LitBooleanAttribute + | LitStringAttribute + | LitEventAttribute + | LitHtmlExpression; + +export type ParentNode = Document | DocumentFragment | Element | Template; +export type ChildNode = + | Element + | Template + | CommentNode + | TextNode + | DocumentType + | LitHtmlExpression; + +export function createDocumentFragment(): DocumentFragment { + return p5tCreateDocumentFragment() as unknown as DocumentFragment; +} + +export function createCommentNode(): CommentNode { + return p5tCreateCommentNode('') as unknown as CommentNode; +} + +export function createTextNode(value: string): TextNode { + return p5tCreateTextNode(value) as unknown as TextNode; +} + +export function createElement( + tagName: string, + attrs?: Record, + namespaceURI?: string +): Element { + return p5tCreateElement(tagName, attrs, namespaceURI) as unknown as Element; +} + +export function createLitHtmlExpression( + value: With, + start: number, + element?: Element +): LitHtmlExpression { + const litExpression: LitHtmlExpression = { + nodeName: '#lit-html-expression', + type: 'LitHtmlExpression', + sourceCodeLocation: { + startOffset: start, + endOffset: start, + }, + value, + }; + + if (element) { + litExpression.element = element; + } + + value.litHtmlExpression = litExpression; + return litExpression; +} + +/** + * Creates a new LitTagLiteral with the given value + * @param value The string value for the literal + * @returns A new LitTagLiteral + */ +export function createTagLiteral(value: string): LitTagLiteral { + return { + type: 'LitTagLiteral', + value, + }; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/state.ts b/packages/labs/parser/src/lib/ast/html-parser/state.ts new file mode 100644 index 0000000000..5a0b159979 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/state.ts @@ -0,0 +1,52 @@ +import { + Attribute, + CommentNode, + DocumentFragment, + Element, + LitHtmlExpression, + LitTagLiteral, + TextNode, +} from './parse5-shim.js'; + +export const Mode = { + TEXT: 'TEXT', + TAG_OR_COMMENT_OR_TEXT_OR_END_TAG: 'TAG_OR_COMMENT_OR_TEXT_OR_END_TAG', + TAG_NAME: 'TAG_NAME', + COMMENT: 'COMMENT', + TAG: 'TAG', + CLOSING_TAG: 'CLOSING_TAG', + ATTRIBUTE: 'ATTRIBUTE', + ATTRIBUTE_EQUALS_OR_TAG: 'ATTRIBUTE_EQUALS_OR_TAG', + ATTRIBUTE_VALUE: 'ATTRIBUTE_VALUE', +} as const; + +export type Mode = (typeof Mode)[keyof typeof Mode]; + +export const AttributeMode = { + STRING: 'String', + PROPERTY: 'Property', + BOOLEAN: 'Boolean', + EVENT: 'Event', +} as const; + +export type AttributeMode = (typeof AttributeMode)[keyof typeof AttributeMode]; + +export interface State { + mode: Mode; + attributeMode: AttributeMode | null; + elementStack: Element[]; + document: DocumentFragment; + charLocation: number; + endTagIgnore: boolean; + potentialTextNode: TextNode | null; + commentIsBogus: boolean; + checkingSecondDash: boolean; + lastExpressionNode: LitHtmlExpression | null; + currentAttributeQuote: '"' | "'" | null; + currentElementNode: Element | null; + currentAttributeNode: Exclude | null; + currentTextNode: TextNode | null; + currentCommentNode: CommentNode | null; + currentEndTag: Array; + currentTagLiteral: LitTagLiteral | null; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/template-literal-span.ts b/packages/labs/parser/src/lib/ast/html-parser/template-literal-span.ts new file mode 100644 index 0000000000..a251a176b8 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/template-literal-span.ts @@ -0,0 +1,61 @@ +import {Mode, State} from './state.js'; +import {textMode} from './modes/text.js'; +import {tagOrCommentOrTexOrEndTagMode} from './modes/tag-or-comment-or-text-or-end-tag.js'; +import {tagMode} from './modes/tag.js'; +import {tagNameMode} from './modes/tag-name.js'; +import {closingTagMode} from './modes/closing-tag.js'; +import {attributeMode} from './modes/attribute.js'; +import {attributeEqualsOrTagMode} from './modes/attribute-equals-or-tag-mode.js'; +import {attributeValueMode} from './modes/attribute-value-mode.js'; +import {commentMode} from './modes/comment.js'; +import { + LitLinkedExpression, + TemplateExpression, + With, +} from '../tree-adapter.js'; + +export function parseTemplateLiteralSpan( + template: TemplateExpression, + nextExpression: With | undefined, + state: State +): State { + for (let i = 0; i < template.value.raw.length + 1; i++) { + const char: string | undefined = template.value.raw[i]; + state.charLocation = template.start + i; + // console.log(char, state.mode); + + switch (state.mode) { + case Mode.TEXT: + state = textMode(char, nextExpression, state); + break; + case Mode.TAG_OR_COMMENT_OR_TEXT_OR_END_TAG: + state = tagOrCommentOrTexOrEndTagMode(char, nextExpression, state); + break; + case Mode.TAG_NAME: + state = tagNameMode(char, nextExpression, state); + break; + case Mode.COMMENT: + state = commentMode(char, nextExpression, state); + break; + case Mode.TAG: + state = tagMode(char, nextExpression, state); + break; + case Mode.CLOSING_TAG: + state = closingTagMode(char, nextExpression, state); + break; + case Mode.ATTRIBUTE: + state = attributeMode(char, nextExpression, state); + break; + case Mode.ATTRIBUTE_EQUALS_OR_TAG: + state = attributeEqualsOrTagMode(char, nextExpression, state); + break; + case Mode.ATTRIBUTE_VALUE: + state = attributeValueMode(char, nextExpression, state); + break; + default: + break; + } + } + + return state; +} diff --git a/packages/labs/parser/src/lib/ast/html-parser/template-literal.ts b/packages/labs/parser/src/lib/ast/html-parser/template-literal.ts new file mode 100644 index 0000000000..1dd1407bc9 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/html-parser/template-literal.ts @@ -0,0 +1,49 @@ +import {createDocumentFragment, DocumentFragment} from './parse5-shim.js'; +import {parseTemplateLiteralSpan} from './template-literal-span.js'; +import {Mode, State} from './state.js'; +import {TaggedTemplateExpression} from '../tree-adapter.js'; + +export function parseTemplateLiteral( + template: TaggedTemplateExpression +): DocumentFragment { + // Cast to Lit template and initialize the custom properties + const litTemplate = template; + const fragment = createDocumentFragment(); + + litTemplate.native.documentFragment = fragment; + litTemplate.native.isLit = true; + fragment.sourceCodeLocation = { + startOffset: litTemplate.template.start + 1, + endOffset: litTemplate.template.end - 1, + }; + + let state: State = { + mode: Mode.TEXT, + attributeMode: null, + elementStack: [], + document: fragment, + endTagIgnore: false, + charLocation: litTemplate.template.start + 1, + checkingSecondDash: false, + commentIsBogus: false, + lastExpressionNode: null, + potentialTextNode: null, + currentAttributeQuote: null, + currentAttributeNode: null, + currentElementNode: null, + currentTextNode: null, + currentCommentNode: null, + currentTagLiteral: null, + currentEndTag: [], + }; + + const spans = litTemplate.template.spans; + + for (let i = 0; i < spans.length; i++) { + const segment = spans[i]; + const nextExpression = litTemplate.template.expressions[i]; + state = parseTemplateLiteralSpan(segment, nextExpression, state); + } + + return fragment; +} diff --git a/packages/labs/parser/src/lib/ast/print/helpers.ts b/packages/labs/parser/src/lib/ast/print/helpers.ts new file mode 100644 index 0000000000..6878bd871d --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/helpers.ts @@ -0,0 +1,84 @@ +import { + DocumentFragment, + Element, + TextNode, + CommentNode, + Node, + Document, + DocumentType, + Template, + LitHtmlExpression, +} from '../html-parser/parse5-shim.js'; +import {prettyPrintNode} from './parse5/node.js'; + +/** + * Pretty prints an AST node + * + * @param node The node to pretty print + * @param options Options for pretty printing + * @returns A string representation of the AST + */ +export function prettyPrintAst(node: Node): string { + return prettyPrintNode(node, 0); +} + +/** + * Type guard for DocumentFragment + */ +export function isDocumentFragment(node: Node): node is DocumentFragment { + return node.nodeName === '#document-fragment'; +} + +/** + * Type guard for Document + */ +export function isDocument(node: Node): node is Document { + return node.nodeName === '#document'; +} + +/** + * Type guard for DocumentType + */ +export function isDocumentType(node: Node): node is DocumentType { + return node.nodeName === '#documentType'; +} + +/** + * Type guard for Template + */ +export function isTemplate(node: Node): node is Template { + return node.nodeName === 'template'; +} + +export function isLitHtmlExpression(node: Node): node is LitHtmlExpression { + return node.nodeName === '#lit-html-expression'; +} + +/** + * Type guard for Element + */ +export function isElement(node: Node): node is Element { + return ( + !isDocument(node) && + !isDocumentFragment(node) && + !isTextNode(node) && + !isCommentNode(node) && + !isDocumentType(node) && + !isTemplate(node) && + !isLitHtmlExpression(node) + ); +} + +/** + * Type guard for TextNode + */ +export function isTextNode(node: Node): node is TextNode { + return node.nodeName === '#text'; +} + +/** + * Type guard for CommentNode + */ +export function isCommentNode(node: Node): node is CommentNode { + return node.nodeName === '#comment'; +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/arrowFunctionExpression.ts b/packages/labs/parser/src/lib/ast/print/oxc/arrowFunctionExpression.ts new file mode 100644 index 0000000000..cddd019db4 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/arrowFunctionExpression.ts @@ -0,0 +1,18 @@ +import type {ArrowFunctionExpression} from 'oxc-parser'; +import {prettyPrintNode} from './node.js'; + +export function printArrowFunctionExpression({ + lines, + prefix, + node, + level, +}: { + lines: string[]; + prefix: string; + node: ArrowFunctionExpression; + level: number; +}) { + lines.push(`${prefix}ArrowFunction (loc: ${node.start}, ${node.end}) {`); + lines.push(prettyPrintNode(node.body, level + 1)); + lines.push(`${prefix}}`); +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/ast.ts b/packages/labs/parser/src/lib/ast/print/oxc/ast.ts new file mode 100644 index 0000000000..fb05518834 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/ast.ts @@ -0,0 +1,19 @@ +import type {Directive, Statement} from 'oxc-parser'; +import {prettyPrintNode} from './node.js'; + +/** + * Pretty prints an oxc-parser AST + * + * @param ast The AST to pretty print + * @param options Options for pretty printing + * @returns A string representation of the AST + */ + +export function prettyPrintAst(ast: (Directive | Statement)[]): string { + let result = 'Program {\n'; + for (const node of ast) { + result += prettyPrintNode(node, 0) + '\n'; + } + result += '}'; + return result; +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/block-statement.ts b/packages/labs/parser/src/lib/ast/print/oxc/block-statement.ts new file mode 100644 index 0000000000..aa1dc0c81d --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/block-statement.ts @@ -0,0 +1,16 @@ +import type {BlockStatement} from 'oxc-parser'; +import {prettyPrintNode} from './node.js'; + +export function printBlockStatement({ + lines, + node, + level, +}: { + lines: string[]; + node: BlockStatement; + level: number; +}) { + for (const statement of node.body) { + lines.push(prettyPrintNode(statement, level)); + } +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/expression-statement.ts b/packages/labs/parser/src/lib/ast/print/oxc/expression-statement.ts new file mode 100644 index 0000000000..bbc2d36930 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/expression-statement.ts @@ -0,0 +1,19 @@ +import type {ExpressionStatement} from 'oxc-parser'; +import {prettyPrintNode} from './node.js'; + +export function printExpressionStatement({ + lines, + prefix, + node, + level, +}: { + lines: string[]; + prefix: string; + node: ExpressionStatement; + level: number; +}) { + const expression = node.expression; + lines.push(`${prefix}ExpressionStatement {`); + lines.push(prettyPrintNode(expression, level + 1)); + lines.push(`${prefix}}`); +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/function-declaration.ts b/packages/labs/parser/src/lib/ast/print/oxc/function-declaration.ts new file mode 100644 index 0000000000..d946f27600 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/function-declaration.ts @@ -0,0 +1,20 @@ +import type {Function} from 'oxc-parser'; +import {prettyPrintNode} from './node.js'; + +export function printFunctionDeclaration({ + lines, + prefix, + node, + level, +}: { + lines: string[]; + prefix: string; + node: Function; + level: number; +}) { + lines.push(`${prefix}Function {`); + for (const child of node.body?.body || []) { + lines.push(prettyPrintNode(child, level + 1)); + } + lines.push(`${prefix}}`); +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/identifier.ts b/packages/labs/parser/src/lib/ast/print/oxc/identifier.ts new file mode 100644 index 0000000000..c8400a2ab6 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/identifier.ts @@ -0,0 +1,16 @@ +import type {IdentifierReference} from 'oxc-parser'; + +export function printIdentifier({ + lines, + prefix, + node, +}: { + lines: string[]; + prefix: string; + node: IdentifierReference; + level: number; +}) { + lines.push( + `${prefix}Identifier: ${node.name} (loc: ${node.start}, ${node.end})` + ); +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/if-statement.ts b/packages/labs/parser/src/lib/ast/print/oxc/if-statement.ts new file mode 100644 index 0000000000..81766efdbe --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/if-statement.ts @@ -0,0 +1,18 @@ +import type {IfStatement} from 'oxc-parser'; +import {prettyPrintNode} from './node.js'; + +export function printIfStatement({ + lines, + prefix, + node, + level, +}: { + lines: string[]; + prefix: string; + node: IfStatement; + level: number; +}) { + lines.push(`${prefix} IfStatement {`); + lines.push(prettyPrintNode(node.consequent, level + 1)); + lines.push(`${prefix} }`); +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/literal.ts b/packages/labs/parser/src/lib/ast/print/oxc/literal.ts new file mode 100644 index 0000000000..8cda0dc900 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/literal.ts @@ -0,0 +1,26 @@ +import type { + NullLiteral, + BigIntLiteral, + RegExpLiteral, + StringLiteral, + BooleanLiteral, + NumericLiteral, +} from 'oxc-parser'; + +export function printLiteral({ + lines, + prefix, + node, +}: { + lines: string[]; + prefix: string; + node: + | NullLiteral + | BigIntLiteral + | RegExpLiteral + | StringLiteral + | BooleanLiteral + | NumericLiteral; +}) { + lines.push(`${prefix}Literal: ${node.raw} (loc: ${node.start}, ${node.end})`); +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/node.ts b/packages/labs/parser/src/lib/ast/print/oxc/node.ts new file mode 100644 index 0000000000..be8a9105ad --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/node.ts @@ -0,0 +1,66 @@ +import {printExpressionStatement} from './expression-statement.js'; +import {printTaggedTemplateExpression} from './tagged-template-expression.js'; +import {Node} from './types.js'; +import {printTemplateLiteral} from './template-literal.js'; +import {printIfStatement} from './if-statement.js'; +import {printBlockStatement} from './block-statement.js'; +import {printFunctionDeclaration} from './function-declaration.js'; +import {printTemplateElement} from './template-element.js'; +import {printIdentifier} from './identifier.js'; +import {printArrowFunctionExpression} from './arrowFunctionExpression.js'; +import {printLiteral} from './literal.js'; + +/** + * Pretty prints an oxc-parser node. + * + * This function was completely reworked to produce a more readable, hierarchical, multi-line output. + * + * @param node The node to pretty print + * @param level The indentation level + * @param options Options for pretty printing + * @returns A string representation of the node + */ + +export function prettyPrintNode(node: Node, level: number): string { + const prefix = ' '.repeat(level); + const lines: string[] = []; + + // Dispatch based on node type for custom formatting. + switch (node.type) { + case 'ExpressionStatement': + printExpressionStatement({lines, prefix, node, level}); + break; + case 'TaggedTemplateExpression': + printTaggedTemplateExpression({node, lines, prefix, level}); + break; + case 'TemplateLiteral': + printTemplateLiteral({lines, prefix, node, level}); + break; + case 'TemplateElement': + printTemplateElement({lines, prefix, node, level}); + break; + case 'FunctionDeclaration': + printFunctionDeclaration({lines, prefix, node, level}); + break; + case 'IfStatement': + printIfStatement({lines, prefix, node, level}); + break; + case 'BlockStatement': + printBlockStatement({lines, node, level}); + break; + case 'Identifier': + printIdentifier({lines, prefix, node, level}); + break; + case 'ArrowFunctionExpression': + printArrowFunctionExpression({lines, prefix, node, level}); + break; + case 'Literal': + printLiteral({lines, prefix, node}); + break; + default: + // For other node types, no custom dispatch. + break; + } + + return lines.join('\n'); +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/tagged-template-expression.ts b/packages/labs/parser/src/lib/ast/print/oxc/tagged-template-expression.ts new file mode 100644 index 0000000000..d591717263 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/tagged-template-expression.ts @@ -0,0 +1,35 @@ +import type {IdentifierName, TaggedTemplateExpression} from 'oxc-parser'; +import {isLitTaggedTemplateExpression} from '../../estree/helpers.js'; +import {With, LitTaggedTemplateExpression} from '../../tree-adapter.js'; +import {prettyPrintNode} from './node.js'; +import {prettyPrintNode as prettyPrintp5Node} from '../parse5/node.js'; + +export function printTaggedTemplateExpression({ + node, + lines, + prefix, + level, +}: { + node: + | TaggedTemplateExpression + | With; + lines: string[]; + prefix: string; + level: number; +}) { + const isLit = isLitTaggedTemplateExpression(node); + console.log(node); + lines.push( + `${prefix}${isLit ? 'Lit' : ''}TaggedTemplateExpression (loc: ${node.start}, ${node.end}) {` + ); + lines.push(`${prefix} tag: ${(node.tag as IdentifierName)?.name || '???'},`); + + lines.push(`${prefix} quasi: [`); + lines.push(prettyPrintNode(node.quasi, level + 1)); + lines.push(`${prefix} ],`); + + if (isLit) { + lines.push(prettyPrintp5Node(node.documentFragment, level + 1)); + } + lines.push(`${prefix}}`); +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/template-element.ts b/packages/labs/parser/src/lib/ast/print/oxc/template-element.ts new file mode 100644 index 0000000000..3bb4c00654 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/template-element.ts @@ -0,0 +1,16 @@ +import type {TemplateElement} from 'oxc-parser'; + +export function printTemplateElement({ + node, + lines, + prefix, +}: { + node: TemplateElement; + lines: string[]; + prefix: string; + level: number; +}) { + lines.push( + `${prefix} "${node.value.raw.replaceAll('\n', '\\n')}", (loc: ${node.start}, ${node.end})` + ); +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/template-literal.ts b/packages/labs/parser/src/lib/ast/print/oxc/template-literal.ts new file mode 100644 index 0000000000..29a49dce05 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/template-literal.ts @@ -0,0 +1,32 @@ +import {TemplateLiteral} from 'oxc-parser'; +import {prettyPrintNode} from './node.js'; + +export function printTemplateLiteral({ + lines, + prefix, + node, + level, +}: { + lines: string[]; + prefix: string; + node: TemplateLiteral; + level: number; +}) { + if (node.quasis.length) { + lines.push( + `${prefix} TemplateLiteral (loc: ${node.start}, ${node.end}): [` + ); + for (const quasi of node.quasis) { + lines.push(prettyPrintNode(quasi, level + 2)); + } + lines.push(`${prefix} ]`); + } + + if (node.expressions.length) { + lines.push(`${prefix} expressions: [`); + for (const expression of node.expressions) { + lines.push(prettyPrintNode(expression, level + 2)); + } + lines.push(`${prefix} ]`); + } +} diff --git a/packages/labs/parser/src/lib/ast/print/oxc/types.d.ts b/packages/labs/parser/src/lib/ast/print/oxc/types.d.ts new file mode 100644 index 0000000000..c5645bd7fd --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/oxc/types.d.ts @@ -0,0 +1,4 @@ +import type {Directive, Statement, TemplateElement} from 'oxc-parser'; +import {Expression} from '../../types.js'; + +export type Node = Directive | Statement | Expression | TemplateElement; diff --git a/packages/labs/parser/src/lib/ast/print/parse5/attribute.ts b/packages/labs/parser/src/lib/ast/print/parse5/attribute.ts new file mode 100644 index 0000000000..77d80581ae --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/parse5/attribute.ts @@ -0,0 +1,42 @@ +import {Attribute, SimpleLocation} from '../../html-parser/parse5-shim.js'; +import {prettyPrintNode as oxcPrettyPrintNode} from '../oxc/node.js'; +import { + printAttributeArray, + stringifyLiteralExpressionArray, +} from './helpers.js'; + +export function printAttribute({ + node, + location, + level, + prefix, +}: { + node: Attribute; + location: SimpleLocation; + level: number; + prefix: string; +}): string { + const lines: string[] = []; + switch (node.type) { + case 'LitHtmlExpression': + lines.push(oxcPrettyPrintNode(node.value, level + 1)); + break; + default: + lines.push( + `${prefix}ATTRIBUTE (loc: ${location.startOffset}, ${location.endOffset}):` + ); + lines.push(`${prefix} type: ${node.type}`); + lines.push( + `${prefix} name (stringified): ${stringifyLiteralExpressionArray(node.name)}` + ); + lines.push( + printAttributeArray(node.name, prefix, level + 1, 'name (raw)') + ); + if (node.value.length) { + lines.push(printAttributeArray(node.value, prefix, level)); + } + break; + } + + return lines.join('\n'); +} diff --git a/packages/labs/parser/src/lib/ast/print/parse5/comment-node.ts b/packages/labs/parser/src/lib/ast/print/parse5/comment-node.ts new file mode 100644 index 0000000000..33a18e043a --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/parse5/comment-node.ts @@ -0,0 +1,30 @@ +import {CommentNode} from '../../html-parser/parse5-shim.js'; +import { + printAttributeArray, + stringifyLiteralExpressionArray, +} from './helpers.js'; + +/** + * Pretty prints a comment node + */ + +export function prettyPrintCommentNode({ + node, + level, + prefix, +}: { + node: CommentNode; + level: number; + prefix: string; +}): string { + const lines: string[] = []; + lines.push( + `${prefix}COMMENT (loc: ${node?.sourceCodeLocation?.startOffset}, ${node?.sourceCodeLocation?.endOffset})` + ); + lines.push( + `${prefix} data (stringified): "${stringifyLiteralExpressionArray(node.data)}"` + ); + lines.push(`${prefix} data (raw):`); + lines.push(printAttributeArray(node.data, prefix, level + 1)); + return lines.join('\n'); +} diff --git a/packages/labs/parser/src/lib/ast/print/parse5/document-fragment.ts b/packages/labs/parser/src/lib/ast/print/parse5/document-fragment.ts new file mode 100644 index 0000000000..8885ee87a7 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/parse5/document-fragment.ts @@ -0,0 +1,28 @@ +import {DocumentFragment} from '../../html-parser/parse5-shim.js'; +import {prettyPrintNode} from './node.js'; + +/** + * Pretty prints a document fragment + */ + +export function prettyPrintDocumentFragment({ + node, + level, + prefix, +}: { + node: DocumentFragment; + level: number; + prefix: string; +}): string { + const lines: string[] = []; + lines.push( + `${prefix}DocumentFragment (loc: ${node.sourceCodeLocation?.startOffset}, ${node.sourceCodeLocation?.endOffset}) {` + ); + if (node.childNodes && node.childNodes.length > 0) { + for (const child of node.childNodes) { + lines.push(`${prettyPrintNode(child, level + 1)}`); + } + } + lines.push(`${prefix}}`); + return lines.join('\n'); +} diff --git a/packages/labs/parser/src/lib/ast/print/parse5/element.ts b/packages/labs/parser/src/lib/ast/print/parse5/element.ts new file mode 100644 index 0000000000..96e32598bc --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/parse5/element.ts @@ -0,0 +1,102 @@ +import {coerceLiteralExpressionString} from '../../html-parser/helpers.js'; +import {Element} from '../../html-parser/parse5-shim.js'; +import {printAttribute} from './attribute.js'; +import {prettyPrintNode} from './node.js'; + +/** + * Pretty prints an element + */ + +export function prettyPrintElement({ + node, + prefix, + level, +}: { + node: Element; + prefix: string; + level: number; +}): string { + const lines: string[] = []; + lines.push( + `${prefix}ELEMENT (loc: ${node.sourceCodeLocation?.startOffset}, ${node.sourceCodeLocation?.endOffset})` + ); + + let tagName = ''; + const locs: string[] = []; + + for (const tag of node.tagName) { + if (tag.type === 'LitTagLiteral') { + tagName += tag.value; + } else { + tagName += '${...}'; + } + locs.push( + `(${tag.sourceCodeLocation?.startOffset}, ${tag.sourceCodeLocation?.endOffset})` + ); + } + lines.push(`${prefix} tagName: ${tagName} ${locs.join(' ')}`); + + if (node.sourceCodeLocation?.startTag) { + lines.push( + `${prefix} startTag: (loc: ${node.sourceCodeLocation?.startTag?.startOffset}, ${node.sourceCodeLocation?.startTag?.endOffset})` + ); + + if (node.sourceCodeLocation?.startTag?.attrs) { + lines.push(`${prefix} attrs: {`); + for (const [name, loc] of Object.entries( + node.sourceCodeLocation?.startTag?.attrs + )) { + lines.push( + `${prefix} ${name}: (${loc.startOffset}, ${loc.endOffset})` + ); + } + lines.push(`${prefix} }`); + } + } + + if (node.sourceCodeLocation?.endTag) { + lines.push( + `${prefix} endTag: ${node.sourceCodeLocation?.endTag?.startOffset}, ${node.sourceCodeLocation?.endTag?.endOffset}` + ); + } + + // Add attributes + if (node.attrs && node.attrs.length > 0) { + lines.push(`${prefix} attrs: [`); + + for (const attr of node.attrs) { + if (attr.type === 'LitHtmlExpression') { + lines.push( + printAttribute({ + node: attr, + location: attr.sourceCodeLocation, + level: level + 2, + prefix: prefix + ' ', + }) + ); + continue; + } + lines.push( + printAttribute({ + node: attr, + location: (node.sourceCodeLocation?.attrs ?? {})[ + coerceLiteralExpressionString(...attr.name) + ], + level: level + 2, + prefix: prefix + ' ', + }) + ); + } + lines.push(`${prefix} ],`); + } + + if (node.childNodes && node.childNodes.length > 0) { + lines.push(`${prefix} childNodes: (${node.childNodes.length}) [`); + // Add child nodes + for (const child of node.childNodes) { + lines.push(prettyPrintNode(child, level + 2)); + } + lines.push(`${prefix} ],`); + } + return lines.join('\n'); +} diff --git a/packages/labs/parser/src/lib/ast/print/parse5/helpers.ts b/packages/labs/parser/src/lib/ast/print/parse5/helpers.ts new file mode 100644 index 0000000000..3d93857dbf --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/parse5/helpers.ts @@ -0,0 +1,54 @@ +import { + LitHtmlExpression, + LitTagLiteral, +} from '../../html-parser/parse5-shim.js'; +import {printLitHtmlExpression} from './lit-element-expression.js'; + +export function printAttributeArray( + values: (LitHtmlExpression | LitTagLiteral)[], + prefix: string, + level: number, + propName = 'value' +) { + const lines: string[] = []; + lines.push(`${prefix} ${propName}: [`); + for (const value of values) { + switch (value.type) { + case 'LitTagLiteral': + lines.push( + `${prefix} LitTagLiteral ${value.sourceCodeLocation ? `(loc: ${value.sourceCodeLocation.startOffset}, ${value.sourceCodeLocation.endOffset})` : '(virtual)'}` + ); + lines.push( + `${prefix} value: "${value.value.replaceAll('\n', '\\n')}"` + ); + break; + default: + lines.push( + printLitHtmlExpression({ + node: value, + level: level + 1, + prefix: prefix + ' ', + }) + ); + break; + } + } + lines.push(`${prefix} ]`); + return lines.join('\n'); +} + +export function stringifyLiteralExpressionArray( + expressions: (LitTagLiteral | LitHtmlExpression)[] +) { + let output = ''; + + for (const expression of expressions) { + if (expression.type === 'LitTagLiteral') { + output += expression.value; + } else { + output += '${...}'; + } + } + + return output.replaceAll('\n', '\\n'); +} diff --git a/packages/labs/parser/src/lib/ast/print/parse5/lit-element-expression.ts b/packages/labs/parser/src/lib/ast/print/parse5/lit-element-expression.ts new file mode 100644 index 0000000000..9ab388c56a --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/parse5/lit-element-expression.ts @@ -0,0 +1,23 @@ +import {LitHtmlExpression} from '../../html-parser/parse5-shim.js'; +import {prettyPrintNode as oxcPrettyPrintNode} from '../oxc/node.js'; + +/** + * Pretty prints a comment node + */ + +export function printLitHtmlExpression({ + node, + prefix, + level, +}: { + node: LitHtmlExpression; + prefix: string; + level: number; +}): string { + const lines: string[] = []; + lines.push( + `${prefix}LitHtmlExpression (loc: ${node.sourceCodeLocation?.startOffset}, ${node.sourceCodeLocation?.endOffset}):` + ); + lines.push(oxcPrettyPrintNode(node.value, level + 1)); + return lines.join('\n'); +} diff --git a/packages/labs/parser/src/lib/ast/print/parse5/node.ts b/packages/labs/parser/src/lib/ast/print/parse5/node.ts new file mode 100644 index 0000000000..e8fce19463 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/parse5/node.ts @@ -0,0 +1,38 @@ +import {Node} from '../../html-parser/parse5-shim.js'; +import {prettyPrintCommentNode} from './comment-node.js'; +import {prettyPrintTextNode} from './text-node.js'; +import {prettyPrintElement} from './element.js'; +import {prettyPrintDocumentFragment} from './document-fragment.js'; +import {isElement} from '../helpers.js'; +import {printLitHtmlExpression} from './lit-element-expression.js'; + +/** + * Pretty prints a node with the given indentation level. + * + * This function dispatches based on node type. + * + * @param node The node to pretty print + * @param level The indentation level + * @param options Options for pretty printing + * @returns A string representation of the node + */ + +export function prettyPrintNode(node: Node, level: number): string { + const prefix = ' '.repeat(level); + if (isElement(node)) { + return prettyPrintElement({node, level, prefix}); + } + + switch (node.nodeName) { + case '#document-fragment': + return prettyPrintDocumentFragment({node, level, prefix}); + case '#text': + return prettyPrintTextNode({node, prefix}); + case '#comment': + return prettyPrintCommentNode({node, level, prefix}); + case '#lit-html-expression': + return printLitHtmlExpression({node, level, prefix}); + default: + return ''; + } +} diff --git a/packages/labs/parser/src/lib/ast/print/parse5/text-node.ts b/packages/labs/parser/src/lib/ast/print/parse5/text-node.ts new file mode 100644 index 0000000000..99b65dbf59 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/print/parse5/text-node.ts @@ -0,0 +1,16 @@ +import {TextNode} from '../../html-parser/parse5-shim.js'; + +/** + * Pretty prints a text node + */ + +export function prettyPrintTextNode({ + node, + prefix, +}: { + node: TextNode; + prefix: string; +}): string { + return `${prefix}TEXT (loc: ${node.sourceCodeLocation?.startOffset}, ${node.sourceCodeLocation?.endOffset}) +${prefix} value: "${node.value.replaceAll('\n', '\\n')}"`; +} diff --git a/packages/labs/parser/src/lib/ast/transform-tree.ts b/packages/labs/parser/src/lib/ast/transform-tree.ts new file mode 100644 index 0000000000..3ac69f8768 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/transform-tree.ts @@ -0,0 +1,50 @@ +import {findLitTaggedTemplates} from './detection.js'; +import {parseTemplateLiteral} from './html-parser/template-literal.js'; +import {NativeTemplate, TreeAdapter} from './tree-adapter.js'; + +export interface Comment { + type: 'Line' | 'Block'; + value: string; + start: number; + end: number; +} + +/** + * + * @param tree OXC estree output + */ +export function transformTree({ + tree, + sourceText, + infer, +}: { + /** + * The parsed estree. By default, we use OXC's parser. + */ + tree: T; + /** + * The original source code text + */ + sourceText: string; + /** + * Configuration for inference rules (whether to check for html tag and Lit bindings) + */ + infer: { + /** + * Whether to check for the html tag + */ + htmlTag: boolean; + /** + * Whether to check for Lit bindings + */ + litBindings: boolean; + }; +}): NativeTemplate[] { + const litTaggedTemplates = findLitTaggedTemplates({ + tree, + sourceText, + infer, + }); + litTaggedTemplates.map((template) => parseTemplateLiteral(template)); + return litTaggedTemplates.map((template) => template.native); +} diff --git a/packages/labs/parser/src/lib/ast/tree-adapter.ts b/packages/labs/parser/src/lib/ast/tree-adapter.ts new file mode 100644 index 0000000000..55f5f4ad0c --- /dev/null +++ b/packages/labs/parser/src/lib/ast/tree-adapter.ts @@ -0,0 +1,54 @@ +import type { + DocumentFragment, + LitHtmlExpression, +} from './html-parser/parse5-shim.js'; + +export interface LitTaggedTemplateExpression { + isLit: boolean; + documentFragment: DocumentFragment; + litHtmlExpression?: LitHtmlExpression; +} + +export type LitLinkedExpression = { + litHtmlExpression: LitHtmlExpression; +}; + +export type With = T & U; +export type MaybeWith = T & Partial; + +export interface TemplateLiteral { + spans: TemplateExpression[]; + start: number; + end: number; + expressions: With[]; +} + +export interface TaggedTemplateExpression { + start: number; + end: number; + tagName: string; + template: TemplateLiteral; + native: With; +} + +export interface TemplateExpression { + start: number; + end: number; + value: { + raw: string; + }; +} + +export interface Comment { + start: number; + end: number; + value: string; +} + +export type NativeTemplate = + T extends TreeAdapter ? With : never; + +export interface TreeAdapter { + findTaggedTemplateLiterals(): TaggedTemplateExpression[]; + findComments(): Comment[]; +} diff --git a/packages/labs/parser/src/lib/ast/tree-adapters/oxc-estree.ts b/packages/labs/parser/src/lib/ast/tree-adapters/oxc-estree.ts new file mode 100644 index 0000000000..bdbda82646 --- /dev/null +++ b/packages/labs/parser/src/lib/ast/tree-adapters/oxc-estree.ts @@ -0,0 +1,133 @@ +import { + TaggedTemplateExpression, + TreeAdapter, + With, + LitTaggedTemplateExpression, + TemplateLiteral, + LitLinkedExpression, +} from '../tree-adapter.js'; +import type { + ParseResult, + TemplateLiteral as OxcTemplateLiteral, + TaggedTemplateExpression as OxcTaggedTemplateExpression, + Program, + Comment as OxcComment, + Expression as OxcExpression, +} from 'oxc-parser'; + +export class ESTreeTreeAdapter + implements TreeAdapter +{ + private program: Program; + private comments: OxcComment[]; + private templates: TaggedTemplateExpression[] = + []; + + constructor({program, comments}: ParseResult) { + this.program = program; + this.comments = comments; + this.templates = []; + // Start traversal with each top-level node + for (const node of this.program.body) { + visit(node, this.templates); + } + } + + findTaggedTemplateLiterals() { + return this.templates; + } + + findComments() { + return this.comments; + } +} + +function isTaggedTemplate(node: unknown): node is OxcTaggedTemplateExpression { + return ( + !!node && + typeof node === 'object' && + 'type' in node && + node.type === 'TaggedTemplateExpression' + ); +} + +function extractTagName({tag}: OxcTaggedTemplateExpression): string { + if (tag.type === 'Identifier') { + return tag.name; + } + + return ''; +} + +export function normalizeTaggedTemplateLiteral( + node: OxcTaggedTemplateExpression +): TaggedTemplateExpression { + const native = node as With< + OxcTaggedTemplateExpression, + LitTaggedTemplateExpression + >; + + return { + start: node.start, + end: node.end, + tagName: extractTagName(node), + native, + template: normalizeTemplateLiteral(node.quasi), + }; +} + +function normalizeTemplateLiteral( + templateLiteral: OxcTemplateLiteral +): TemplateLiteral { + return { + start: templateLiteral.start, + end: templateLiteral.end, + spans: templateLiteral.quasis, + expressions: normalizeExpressions(templateLiteral.expressions), + }; +} + +function normalizeExpressions( + expressions: OxcExpression[] +): With[] { + return expressions as With[]; +} + +function visit(node: unknown, templates: TaggedTemplateExpression[]): void { + if (!node || typeof node !== 'object') { + return; + } + + // Check if this is a tagged template expression + if (isTaggedTemplate(node)) { + templates.push(normalizeTaggedTemplateLiteral(node)); + } + + // Recursively visit all properties that could contain nodes + for (const [key, value] of Object.entries(node)) { + // For performance, skip known values that can't contain nodes + if ( + key === 'type' || + key === 'loc' || + key === 'range' || + key === 'start' || + key === 'end' + ) { + continue; + } + + if (Array.isArray(value)) { + // Handle arrays of nodes + for (const item of value) { + visit(item, templates); + } + + continue; + } + + if (value && typeof value === 'object') { + // Handle single node + visit(value, templates); + } + } +} diff --git a/packages/labs/parser/src/lib/ast/tree-adapters/ts-ast.ts b/packages/labs/parser/src/lib/ast/tree-adapters/ts-ast.ts new file mode 100644 index 0000000000..2414e31c8c --- /dev/null +++ b/packages/labs/parser/src/lib/ast/tree-adapters/ts-ast.ts @@ -0,0 +1,169 @@ +import { + TaggedTemplateExpression, + TreeAdapter, + Comment, + With, + LitTaggedTemplateExpression, + TemplateLiteral, + LitLinkedExpression, + TemplateExpression, +} from '../tree-adapter.js'; +import { + forEachLeadingCommentRange, + SourceFile, + TemplateLiteral as TsTemplateLiteral, + TaggedTemplateExpression as TsTaggedTemplateExpression, + Expression as TsExpression, + SyntaxKind, + Node, + forEachChild, +} from 'typescript'; + +export class TsTreeAdapter implements TreeAdapter { + private sourceFile: SourceFile; + private taggedTemplateLiterals: TaggedTemplateExpression[] = + []; + private comments: Comment[] = []; + + constructor(source: SourceFile) { + this.sourceFile = source; + const sourceText = source.getFullText(); + // Start traversal with each top-level node + forEachChild(this.sourceFile, (node: Node) => { + visit(node, this.taggedTemplateLiterals); + }); + + forEachLeadingCommentRange(sourceText, 0, (pos, end) => { + this.comments.push({ + start: pos, + end, + value: sourceText.slice(pos, end), + }); + }); + } + + findTaggedTemplateLiterals() { + return this.taggedTemplateLiterals; + } + + findComments() { + return this.comments; + } +} + +function isTaggedTemplate(node: Node): node is TsTaggedTemplateExpression { + return node.kind === SyntaxKind.TaggedTemplateExpression; +} + +function extractTagName({tag}: TsTaggedTemplateExpression): string { + if (tag.kind === SyntaxKind.Identifier) { + return tag.getText(); + } + + return ''; +} + +function normalizeTaggedTemplateExpression( + node: TsTaggedTemplateExpression +): TaggedTemplateExpression { + const native = node as With< + TsTaggedTemplateExpression, + LitTaggedTemplateExpression + >; + + return { + start: node.getStart(), + end: node.end, + tagName: extractTagName(node), + native, + template: normalizeTemplateLiteral(node.template), + }; +} + +function normalizeTemplateLiteral( + template: TsTemplateLiteral +): TemplateLiteral { + const {spans, expressions} = normalizeTemplateSpans(template); + + return { + start: template.getStart(), + end: template.end, + spans: spans, + expressions: expressions, + }; +} + +function normalizeTemplateSpans(template: TsTemplateLiteral): { + spans: TemplateExpression[]; + expressions: With[]; +} { + if (template.kind === SyntaxKind.NoSubstitutionTemplateLiteral) { + return { + spans: [ + { + start: template.getStart() + 1, + end: template.end - 1, + value: { + raw: template.getText().substring(1, template.getText().length - 1), + }, + }, + ], + expressions: [], + }; + } + + const expressions: With[] = []; + const spans: TemplateExpression[] = []; + + spans.push({ + // ignore the opening backtick like estree + start: template.head.getStart() + 1, + // ignore the ${ expression start like estree + end: template.head.end - 2, + value: { + raw: template.head.text, + }, + }); + + template.templateSpans.forEach((span) => { + expressions.push(normalizeExpression(span.expression)); + + const normalizedSpan = { + // ignore the closing expression } like estree + start: span.getStart() + 1, + end: + span.literal.kind === SyntaxKind.TemplateTail + ? // ignore the closing backtick like estree + span.literal.end - 1 + : // ignore the ${ expression start like estree + span.literal.end - 2, + value: { + raw: span.literal.text, + }, + }; + + spans.push(normalizedSpan); + }); + + return {spans, expressions}; +} + +function normalizeExpression( + expression: TsExpression +): With { + return expression as With; +} + +function visit( + node: Node, + templates: TaggedTemplateExpression[] +): void { + // Check if this is a tagged template expression + if (isTaggedTemplate(node)) { + templates.push(normalizeTaggedTemplateExpression(node)); + } + // Recursively visit each child node. + forEachChild(node, (child: Node) => { + visit(child, templates); + }); +} diff --git a/packages/labs/parser/src/lib/index.ts b/packages/labs/parser/src/lib/index.ts new file mode 100644 index 0000000000..eee0d3bba1 --- /dev/null +++ b/packages/labs/parser/src/lib/index.ts @@ -0,0 +1,104 @@ +import oxc from 'oxc-parser'; +import {transformTree} from './ast/transform-tree.js'; +// import {prettyPrintAst} from './ast/print/oxc/ast.js'; +import {ESTreeTreeAdapter} from './ast/tree-adapters/oxc-estree.js'; +import {createSourceFile, ScriptTarget} from 'typescript'; +import {TsTreeAdapter} from './ast/tree-adapters/ts-ast.js'; + +export function parseAst( + filename: string, + sourceText: string, + infer: {htmlTag: boolean; litBindings: boolean} = { + htmlTag: true, + litBindings: true, + } +) { + // typescript + console.log('TYPESCRIPT'); + const tsResult = createSourceFile( + filename, + sourceText, + ScriptTarget.ESNext, + true + ); + const tsTree = new TsTreeAdapter(tsResult); + const tsTemplates = transformTree({tree: tsTree, sourceText, infer}); + tsTemplates.forEach((template) => { + console.log( + template.documentFragment.childNodes.map((node) => { + if (typeof node.nodeName === 'string') { + return node; + } + return node.attrs; + }) + ); + }); + + // estree + console.log('ESTREE'); + const result = oxc.parseSync(filename, sourceText, { + preserveParens: true, + }); + const tree = new ESTreeTreeAdapter(result); + const templates = transformTree({tree, sourceText, infer}); + // console.log(prettyPrintAst(result.program.body)); + return [templates, tsTemplates]; +} + +// parseAst('foo.ts', `html\`\``); +parseAst('foo', 'html`
${3}
`'); +// parseAst('foo.ts', `html\`asdf
{}} attr="asdf \${123}">Hello \${'world'}
\``); + +// parseAst( +// 'foo.ts', +// ` +// // # asdf +// function html (strings: TemplateStringsArray, ...values: unknown[]) { +// } +// const asdf = 3; + +// html\` +//
\${asdf}
+//
\${html\` +// \${asdf} +// \`}
+//
\${() => asdf}
+// \`; + +// html\`asdf\` + +// 'asdf' + +// @asdf() +// function zzz() { + +// html\` +//
\${asdf}
+//
\${ +// html\` +// \${asdf} +// \`}
+//
\${() => asdf}
+// \`; + +// html\`asdf\` +// } + +// if (false) { +// html\` +//
\${asdf}
+//
\${html\` +// \${asdf} +// \`}
+//
\${() => asdf}
+// \`; + +// htmlz\`asdf
\` +// htmlz\`asdf <\${asdf}>
\` + +// htmlz\`asdf\` + +// htmlf\`asdf\` +// } +// ` +// ); diff --git a/packages/labs/parser/src/playground/components/ast/ast-brackets.ts b/packages/labs/parser/src/playground/components/ast/ast-brackets.ts new file mode 100644 index 0000000000..ab184db719 --- /dev/null +++ b/packages/labs/parser/src/playground/components/ast/ast-brackets.ts @@ -0,0 +1,27 @@ +import {html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +@customElement('ast-brackets') +export class AstBrackets extends LitElement { + @property({attribute: false}) data: unknown = null; + + override render() { + const isArray = Array.isArray(this.data); + const openBracket = isArray ? '[' : '{'; + const closeBracket = isArray ? ']' : '}'; + + return html` + + ${openBracket} + + ${closeBracket} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ast-brackets': AstBrackets; + } +} diff --git a/packages/labs/parser/src/playground/components/ast/ast-property.ts b/packages/labs/parser/src/playground/components/ast/ast-property.ts new file mode 100644 index 0000000000..bb2456dec5 --- /dev/null +++ b/packages/labs/parser/src/playground/components/ast/ast-property.ts @@ -0,0 +1,290 @@ +import {css, html, LitElement, PropertyValues} from 'lit'; +import {customElement, property, state, query} from 'lit/decorators.js'; +import './ast-summary-value.js'; +import './ast-value.js'; +import {getHighlightColor} from '../../utils/shiki.js'; +import { + LitHtmlExpression, + LitTagLiteral, +} from '../../../lib/ast/html-parser/parse5-shim.js'; + +// Determines whether selectionStart is in range +function inRange(value: unknown, selectionStart: number): boolean { + if ( + value && + typeof value === 'object' && + 'start' in value && + 'end' in value + ) { + const start = (value as Record).start as number; + const end = (value as Record).end as number; + return selectionStart >= start && selectionStart <= end; + } + + // Check for sourceCodeLocation format + if (value && typeof value === 'object' && 'sourceCodeLocation' in value) { + const location = (value as Record).sourceCodeLocation as { + startOffset: number; + endOffset: number; + }; + return ( + selectionStart >= location.startOffset && + selectionStart <= location.endOffset + ); + } + + if (Array.isArray(value)) { + return Array.from(value).some((v) => inRange(v, selectionStart)); + } + + return false; +} + +export function stringifyLiteralExpressionArray( + expressions: (LitTagLiteral | LitHtmlExpression)[] +) { + let output = ''; + + if (!Array.isArray(expressions)) { + return expressions; + } + for (const expression of expressions) { + if (expression.type === 'LitTagLiteral') { + output += expression.value; + } else { + output += '${...}'; + } + } + + return output.replaceAll('\n', '\\n'); +} + +@customElement('ast-property') +export class AstProperty extends LitElement { + @property({attribute: false}) propId?: string | number = undefined; + @property({attribute: false}) value?: unknown; + @property({type: Boolean}) root = false; + @property({type: Boolean}) open = false; + @property({type: Number}) selectionStart = 0; + @property({type: Boolean}) isArrayItem = false; + @property({type: Boolean}) isLastItem = false; + @property({attribute: false}) propsToSkip: string[] = []; + @property({type: Boolean}) shouldTryStringify = false; + + @state() private openManual: boolean | null = null; + @state() private exactAutofocused = false; + @state() private titleColor = ''; + @state() private keyStringColor = ''; + @state() private autofocused = false; + + @query('.container') container?: HTMLDivElement; + + // Computed properties + private get propTitle(): string | undefined { + if (this.value && typeof this.value === 'object' && 'type' in this.value) { + return (this.value as Record).type as string; + } + if ( + this.value && + typeof this.value === 'object' && + 'nodeName' in this.value + ) { + const nodeName = (this.value as Record).nodeName as + | string + | (LitTagLiteral | LitHtmlExpression)[]; + return Array.isArray(nodeName) + ? stringifyLiteralExpressionArray(nodeName) + : nodeName; + } + return ''; + } + + private get openable(): boolean { + return ( + typeof this.value === 'object' && + this.value != null && + Object.keys(this.value as object).length > 0 + ); + } + + override willUpdate(changed: PropertyValues) { + if (changed.has('selectionStart')) { + this.autofocused = inRange(this.value, this.selectionStart); + this.dispatchEvent( + new CustomEvent('autofocused', { + detail: this.autofocused, + }) + ); + } + + if (changed.has('propId') || changed.has('value')) { + const title = this.propTitle; + if (title) { + const lang = title.startsWith('#') ? 'css' : 'typescript'; + getHighlightColor(`${title}()`, lang).then((color) => { + this.titleColor = color; + }); + } + if (this.keyString) { + getHighlightColor(this.keyString, 'typescript').then( + (color) => (this.keyStringColor = color) + ); + } + } + } + + private get isOpen(): boolean { + return ( + (this.openable && this.openManual) || + (this.openManual === null && this.autofocused) + ); + } + + private get keyString(): string | undefined { + return this.propId !== undefined ? String(this.propId) : undefined; + } + + private get keyClass(): string { + return this.openable ? 'cursor-pointer' : ''; + } + + // Event handlers + private toggleOpen(): void { + if (!this.openable) return; + + // Simply toggle the openManual state + this.openManual = !this.isOpen; + } + + private handleSubAutofocus(event: CustomEvent): void { + const subAutofocused = event.detail; + this.exactAutofocused = this.autofocused && !subAutofocused; + + // Propagate the hover state up + this.dispatchEvent( + new CustomEvent('autofocused', { + detail: this.autofocused, + }) + ); + } + + override render() { + return html` +
+ ${this.openable + ? html` + ${this.isOpen ? '-' : '+'}   + ` + : ''} + ${!this.openable && !this.isArrayItem ? html` ` : ''} + ${this.keyString && !this.isArrayItem + ? html` + + ${this.keyString} + + : + ` + : ''}${this.propTitle + ? html` + + ${this.propTitle} + + ` + : ''}${!this.openable || this.isOpen + ? html` + + ` + : ''}${this.openable && !this.isOpen + ? html` + + ` + : ''}${this.isArrayItem + ? html`,` + : ''} +
+ `; + } + + static override styles = css` + .container { + position: relative; + width: fit-content; + font-family: monospace; + display: block; + } + + .ast-highlight { + border: 1px solid #60a5fa33; + } + + .array-item { + margin-left: 0; + } + + .toggle-indicator { + position: absolute; + left: -3.5px; + user-select: none; + font-size: 14px; + font-weight: 600; + opacity: 0.7; + } + + .toggle-indicator:is(:where(.open)) { + color: rgb(248, 113, 113); + } + + .toggle-indicator:is(:where(:not(.open))) { + color: rgb(74, 222, 128); + } + + .separator { + opacity: 0.7; + } + + .cursor-pointer { + cursor: pointer; + } + + .cursor-pointer:hover { + text-decoration: underline; + } + + :host { + white-space: nowrap; + } + `; +} + +// Expose the component to the global scope +declare global { + interface HTMLElementTagNameMap { + 'ast-property': AstProperty; + } +} + +// Export utility functions for other components to use +export {inRange}; diff --git a/packages/labs/parser/src/playground/components/ast/ast-summary-value.ts b/packages/labs/parser/src/playground/components/ast/ast-summary-value.ts new file mode 100644 index 0000000000..f2ff3b2fdc --- /dev/null +++ b/packages/labs/parser/src/playground/components/ast/ast-summary-value.ts @@ -0,0 +1,56 @@ +import {html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import './ast-brackets.js'; +import {stringifyLiteralExpressionArray} from './ast-property.js'; + +@customElement('ast-summary-value') +export class AstSummaryValue extends LitElement { + @property({attribute: false}) data: unknown = null; + @property({type: Number}) selectionStart = 0; + @property({type: Boolean}) tryStrinfigy = false; + @state() private isHovering = false; + + private getContentSummary(): string { + if (Array.isArray(this.data)) { + const len = this.data.length; + return `${len} element${len === 1 ? '' : 's'}`; + } else if (this.data && typeof this.data === 'object') { + const keys = Object.keys(this.data); + const len = keys.length; + return keys.slice(0, 5).join(', ') + (len > 5 ? `, ... +${len - 5}` : ''); + } + return ''; + } + + private handleClick() { + this.dispatchEvent(new CustomEvent('toggle')); + } + + private handleMouseEnter() { + this.isHovering = true; + } + + private handleMouseLeave() { + this.isHovering = false; + } + + override render() { + return html` + + ${this.tryStrinfigy ? stringifyLiteralExpressionArray(this.data as []) : this.getContentSummary()} + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ast-summary-value': AstSummaryValue; + } +} diff --git a/packages/labs/parser/src/playground/components/ast/ast-value.ts b/packages/labs/parser/src/playground/components/ast/ast-value.ts new file mode 100644 index 0000000000..59ab8d399d --- /dev/null +++ b/packages/labs/parser/src/playground/components/ast/ast-value.ts @@ -0,0 +1,86 @@ +import {html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import './ast-brackets.js'; +import './ast-property.js'; + +@customElement('ast-value') +export class AstValue extends LitElement { + @property({attribute: false}) data: unknown = null; + @property({type: Number}) selectionStart = 0; + @property({attribute: false}) propsToSkip: string[] = []; + + private getValueString(): string | undefined { + if (typeof this.data === 'object' && this.data !== null) return undefined; + if (typeof this.data === 'bigint') return String(this.data); + return this.data == null ? String(this.data) : JSON.stringify(this.data); + } + + private hasChildren(): boolean { + return ( + typeof this.data === 'object' && + this.data !== null && + Object.keys(this.data).length > 0 + ); + } + + private handleChildAutofocused(autofocused: CustomEvent) { + this.dispatchEvent( + new CustomEvent('autofocused', {detail: autofocused.detail}) + ); + } + + override render() { + const value = this.getValueString(); + + if (typeof this.data === 'object' && this.data !== null) { + return html` + + ${this.hasChildren() + ? html`
+ ${Object.entries(this.data).map( + ([key, value], index, array) => { + if (key === 'parentNode' || this.propsToSkip.includes(key)) + return; + const isArray = Array.isArray(this.data); + const isLastItem = index === array.length - 1; + return html` + + `; + } + )} +
` + : ''} +
+ `; + } else { + return html` + + ${value} + , + + `; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ast-value': AstValue; + } +} diff --git a/packages/labs/parser/src/playground/components/code-mirror.ts b/packages/labs/parser/src/playground/components/code-mirror.ts new file mode 100644 index 0000000000..05fc34d35f --- /dev/null +++ b/packages/labs/parser/src/playground/components/code-mirror.ts @@ -0,0 +1,139 @@ +import {css, html, LitElement, type PropertyValues} from 'lit'; +import {customElement, query, state, property} from 'lit/decorators.js'; +import type {CodeMirrorEditor, SelectionChangeEvent} from 'codemirror-elements'; +import 'codemirror-elements'; +import './output/output-panel.js'; +import {LanguageSupport, LRLanguage} from '@codemirror/language'; +import {LRParser} from '@lezer/lr'; +import {Input, parseMixed, SyntaxNodeRef} from '@lezer/common'; +import {htmlLanguage} from '@codemirror/lang-html'; +import {javascript} from '@codemirror/lang-javascript'; +import {CodeMirrorExtensionElement} from 'codemirror-elements/lib/cm-extension-element.js'; + +function mixedTaggedTemplate(node: SyntaxNodeRef, input: Input) { + const isTaggedTemplate = node.type.name === 'TaggedTemplateExpression'; + let isHtmlTag = false; + + if (isTaggedTemplate) { + const tag = node.node.getChild('VariableName'); + isHtmlTag = !!tag && input.read(tag.from, tag.to) === 'html'; + } + + // Check if the node is a TaggedTemplate literal and the tag is "html" + if (isHtmlTag) { + const content = node.node.getChild('TemplateString'); + // jump into the html language parser + if (content) { + return { + from: content.from, + to: content.to, + parser: htmlLanguage.parser, + delims: {open: '${', close: '}'}, + }; + } + } + return null; +} + +// Create our mixed wrapper using parseMixed. +const mixedWrapper = parseMixed(mixedTaggedTemplate); +@customElement('cm-lang-javascript') +export class CodeMirrorLangJavascript extends CodeMirrorExtensionElement { + @property({type: Boolean}) + jsx = false; + + @property({type: Boolean}) + typescript = false; + + override update(changedProperties: PropertyValues) { + if (changedProperties.has('jsx') || changedProperties.has('typescript')) { + // Create the base JavaScript language support extension + const jsSupport = javascript({ + jsx: this.jsx, + typescript: this.typescript, + }); + + const baseParser = jsSupport.language.parser as LRParser; + // Configure the parser to use our mixed-language wrapper. + const wrappedParser = baseParser.configure({wrap: mixedWrapper}); + + // Create a language from the wrapped parser + const taggedTemplateLanguage = LRLanguage.define({ + parser: wrappedParser, + languageData: jsSupport.language.data.of({}), + }); + + // Build a new LanguageSupport extension with the new parser. + const taggedTemplateLiteralLanguageSupport = new LanguageSupport( + taggedTemplateLanguage, + jsSupport.extension + ); + + // Set the new extension on the editor. + this.setExtensions([taggedTemplateLiteralLanguageSupport]); + } + super.update(changedProperties); + } +} + +@customElement('code-mirror') +export class CodeMirrorElement extends LitElement { + @query('cm-editor') editor!: CodeMirrorEditor; + @state() value = ''; + @state() selectionStart = 0; + + override render() { + return html` +
+ + + + +
+ `; + } + + override firstUpdated() { + this.value = this.editor.value ?? ''; + } + + #onChange() { + this.value = this.editor.value ?? ''; + } + + #onSelectionChange(e: SelectionChangeEvent) { + if (e.selection.ranges.length === 0) { + return; + } + + this.selectionStart = e.selection.ranges[0].from; + } + + static override styles = css` + :host { + display: flex; + flex-direction: column; + } + div { + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + 'code-mirror': CodeMirrorElement; + 'cm-lang-javascript': CodeMirrorLangJavascript; + } +} diff --git a/packages/labs/parser/src/playground/components/output/estree-panel.ts b/packages/labs/parser/src/playground/components/output/estree-panel.ts new file mode 100644 index 0000000000..a12bb167f4 --- /dev/null +++ b/packages/labs/parser/src/playground/components/output/estree-panel.ts @@ -0,0 +1,51 @@ +import {css, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import type {Directive, Statement} from 'oxc-parser'; +import type { + LitLinkedExpression, + LitTaggedTemplateExpression, +} from '../../../lib/lib/ast/tree-adapter.js'; +import '../ast/ast-property.js'; + +@customElement('estree-panel') +export class EstreePanel extends LitElement { + @property({attribute: false}) oxc: + | ( + | Directive + | Statement + | LitTaggedTemplateExpression + | LitLinkedExpression + )[] + | null = null; + @property({type: Number}) selectionStart = 0; + + override render() { + return html` +
+
+ +
+
+ `; + } + + static override styles = css` + :host { + display: block; + height: 100%; + overflow: auto; + background-color: #fbfdff; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + 'estree-panel': EstreePanel; + } +} diff --git a/packages/labs/parser/src/playground/components/output/output-panel.ts b/packages/labs/parser/src/playground/components/output/output-panel.ts new file mode 100644 index 0000000000..a0f8f5d476 --- /dev/null +++ b/packages/labs/parser/src/playground/components/output/output-panel.ts @@ -0,0 +1,113 @@ +import {css, html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {Task} from '@lit/task'; +import init, {parseSync} from '@oxc-parser/wasm'; +import {ESTreeTreeAdapter} from '../../../lib/ast/tree-adapters/oxc-estree.js'; +import {transformTree} from '../../../lib/ast/transform-tree.js'; +import type {ParseResult} from 'oxc-parser'; +import './estree-panel.js'; + +// Initialize the WASM module +const wasmInit = (init as unknown as () => Promise)(); + +@customElement('output-panel') +export class OutputPanel extends LitElement { + @property({attribute: false}) source = ''; + @property({type: Number}) selectionStart = 0; + @state() duration = 0; + + #parseTask = new Task( + this, + async ([source]) => { + if (!source) return null; + + const startTime = performance.now(); + await wasmInit; + + const result = parseSync(source, { + sourceFilename: 'index.ts', + }) as unknown as ParseResult; + const tree = new ESTreeTreeAdapter(result); + transformTree({ + tree, + sourceText: source, + infer: {htmlTag: true, litBindings: true}, + }); + + this.duration = Math.round(performance.now() - startTime); + + return result.program.body; + }, + () => [this.source] + ); + + override render() { + return html` +
+
+ ${this.#parseTask.render({ + pending: () => html`
Loading...
`, + complete: (result) => html` + + `, + error: (error) => + html`
+ Error: ${error instanceof Error ? error.message : String(error)} +
`, + })} +
+ +
${this.duration} ms
+
+ `; + } + + static override styles = css` + :host { + display: block; + height: 100%; + overflow: hidden; + } + + .container { + display: flex; + flex-direction: column; + height: 100%; + position: relative; + } + + .content { + flex: 1; + overflow: auto; + min-height: 0; + min-width: 0; + } + + .duration { + position: absolute; + bottom: 8px; + right: 8px; + opacity: 0.6; + font-size: 12px; + } + + .loading { + padding: 16px; + text-align: center; + } + + .error { + padding: 16px; + color: red; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + 'output-panel': OutputPanel; + } +} diff --git a/packages/labs/parser/src/playground/index.html b/packages/labs/parser/src/playground/index.html new file mode 100644 index 0000000000..555cb5cbd3 --- /dev/null +++ b/packages/labs/parser/src/playground/index.html @@ -0,0 +1,24 @@ + + + + + Codestin Search App + + + + + + + diff --git a/packages/labs/parser/src/playground/main.ts b/packages/labs/parser/src/playground/main.ts new file mode 100644 index 0000000000..a8d1ba3668 --- /dev/null +++ b/packages/labs/parser/src/playground/main.ts @@ -0,0 +1 @@ +import './components/code-mirror.js'; diff --git a/packages/labs/parser/src/playground/tsconfig.json b/packages/labs/parser/src/playground/tsconfig.json new file mode 100644 index 0000000000..c15c8260a3 --- /dev/null +++ b/packages/labs/parser/src/playground/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "es2021", + "lib": ["es2021", "DOM"], + "noEmit": true, + "rootDir": "..", + "useDefineForClassFields": false, + "experimentalDecorators": true + }, + "include": [ + "**/*.ts", + "../../../../../node_modules/@types/codemirror/index.d.ts", + "../lib/**/*" + ], + "exclude": [] +} diff --git a/packages/labs/parser/src/playground/utils/shiki.ts b/packages/labs/parser/src/playground/utils/shiki.ts new file mode 100644 index 0000000000..b94482c87d --- /dev/null +++ b/packages/labs/parser/src/playground/utils/shiki.ts @@ -0,0 +1,78 @@ +import type * as shiki from 'shiki'; +import {createHighlighter} from 'shiki'; +import {html, TemplateResult} from 'lit'; + +export type ShikiLang = string; + +let highlighterPromise: Promise; + +export function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: ['github-light'], + langs: ['json', 'typescript', 'css'], + }); + } + return highlighterPromise; +} + +export async function highlight( + code: string, + lang: ShikiLang +): Promise { + try { + const highlighter = await getHighlighter(); + const tokens = highlighter.codeToTokens(code, { + lang: lang as shiki.BundledLanguage, + theme: 'github-light', + }); + + // Convert tokens to lit-html template + return html` +
+        
+          ${tokens.tokens.map(
+        (line, i) => html`
+          
+            ${line.map(
+              (token) => html`
+                ${token.content}
+              `
+            )}
+          
+          ${i < tokens.tokens.length - 1 ? html`` : ''}
+        `
+      )}
+        
+      
+ `; + } catch (error) { + console.error('Error highlighting code:', error); + return html` +
+        ${code}
+      
+ `; + } +} + +export async function getHighlightColor( + code: string | undefined, + lang: shiki.BundledLanguage | shiki.SpecialLanguage +): Promise { + if (code == null) return ''; + try { + const highlighter = await getHighlighter(); + const result = highlighter.codeToTokens(code, { + lang, + theme: 'github-light', + }); + const token = result.tokens[0]; + const idx = code.startsWith('"') && token.length > 1 ? 1 : 0; + return token[idx].color || ''; + } catch (error) { + console.error('Error getting highlight color:', error); + return ''; + } +} diff --git a/packages/labs/parser/src/test/ast/README.md b/packages/labs/parser/src/test/ast/README.md new file mode 100644 index 0000000000..6d6ab1fab0 --- /dev/null +++ b/packages/labs/parser/src/test/ast/README.md @@ -0,0 +1,145 @@ +# AST Test Harnesses + +This directory contains test harnesses for testing the AST-related functionality in the Lit parser. + +## HTML Parser Test Harnesses + +### HtmlParserTestHarness + +Located in `html-parser/html-parser-test-harness.ts`, this is a general-purpose test harness for testing HTML parsing functionality. It provides methods for: + +- Creating mock templates with expressions +- Parsing HTML content +- Creating mock parser states +- Asserting on the structure of parsed HTML nodes + +Example usage: + +```typescript +const harness = new HtmlParserTestHarness(); + +// Parse HTML and assert on the result +harness.testParse('
Hello
', (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + harness.assertAttribute(div, 'class', 'test'); + harness.assertChildCount(div, 1); + harness.assertTextNode(div.childNodes[0], 'Hello'); +}); +``` + +### HtmlParserModeTestHarness + +Located in `html-parser/html-parser-mode-test-harness.ts`, this test harness focuses specifically on testing the mode transitions within the HTML parser. It provides methods for: + +- Getting the current parser mode after parsing content +- Asserting on the current mode +- Testing sequences of inputs and mode transitions + +Example usage: + +```typescript +const harness = new HtmlParserModeTestHarness(); + +// Test a specific mode transition +harness.assertParserMode('
', 'Hello', '
'], + [Mode.TEXT, Mode.TEXT, Mode.TEXT] +); +``` + +## ESTree Test Harness + +Located in `estree/estree-test-harness.ts`, this test harness is designed for testing ESTree AST-related functionality. It provides methods for: + +- Creating mock tagged template expressions +- Asserting on node types and structure +- Serializing nodes for comparison + +Example usage: + +```typescript +const harness = new EsTreeTestHarness(); + +// Create a mock tagged template expression +const node = harness.createMockTaggedTemplateExpression('html', '
'); + +// Assert it's a Lit tagged template +harness.assertIsLitTaggedTemplateExpression(node); + +// Assert on tag name +harness.assertTagName(node, 'html'); +``` + +## TypeScript AST Test Harness + +Located in `ts-ast/ts-ast-test-harness.ts`, this test harness is designed for testing TypeScript AST-related functionality. It provides methods for: + +- Serializing TS AST nodes into human-readable formats +- Asserting on node structure and kind +- Partial matching of node properties + +Example usage: + +```typescript +const harness = new TsAstTestHarness(); + +// Assert on node kind +harness.assertNodeKind(node, ts.SyntaxKind.TaggedTemplateExpression); + +// Assert partial structure match +harness.assertNodePartialMatch(node, { + kind: 'TaggedTemplateExpression', + tag: { + kind: 'Identifier', + text: 'html', + }, +}); +``` + +## Usage Guidelines + +1. Choose the appropriate test harness based on what you're testing (HTML parsing, ESTree nodes, or TS AST). +2. Use the harness to create mock objects and assert on their structure. +3. When writing new tests: + - Focus on testing the behavior, not the implementation details. + - Consider edge cases and error conditions. + - Use descriptive test names that clearly indicate what's being tested. + +## Creating a New Test File + +When creating a new test file: + +1. Import the appropriate test harness. +2. Create a new instance of the harness in the `setup` function. +3. Organize tests into logical suites. +4. Use the harness methods to create test objects and make assertions. + +Example structure: + +```typescript +import {HtmlParserTestHarness} from './html-parser-test-harness.js'; + +suite('Feature Name', () => { + let harness: HtmlParserTestHarness; + + setup(() => { + harness = new HtmlParserTestHarness(); + }); + + suite('Specific Functionality', () => { + test('should handle specific case', () => { + harness.testParse('...', (fragment) => { + // Make assertions... + }); + }); + }); +}); +``` diff --git a/packages/labs/parser/src/test/ast/estree/estree-test-harness.ts b/packages/labs/parser/src/test/ast/estree/estree-test-harness.ts new file mode 100644 index 0000000000..a36aaa1fff --- /dev/null +++ b/packages/labs/parser/src/test/ast/estree/estree-test-harness.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from 'chai'; +import type {Expression, Statement, Directive} from 'oxc-parser'; +import {isLitTaggedTemplateExpression} from '../../../lib/ast/estree/helpers.js'; +import type { + LitTaggedTemplateExpression, + With, +} from '../../../lib/ast/tree-adapter.js'; + +/** + * A test harness for the ESTree AST tests. + * + * This class provides utilities for testing the ESTree-related AST functionality, + * including creating mock nodes and asserting on their properties. + */ +export class EsTreeTestHarness { + /** + * Creates a mock tagged template expression for testing. + * + * @param tag The tag name (e.g., 'html', 'css', 'js') + * @param templateContent The template content + * @param isLit Whether this is a Lit tagged template + * @returns A mock tagged template expression + */ + createMockTaggedTemplateExpression( + tag: string, + templateContent: string, + isLit: boolean = true + ): Expression { + // Create a simple tagged template expression with required properties + const expr = { + type: 'TaggedTemplateExpression', + span: { + start: 0, + end: templateContent.length + tag.length + 2, // +2 for the backticks + ctxt: 0, + }, + tag: { + type: 'Identifier', + span: { + start: 0, + end: tag.length, + ctxt: 0, + }, + value: tag, + optional: false, + }, + typeParameters: null, + template: { + type: 'TemplateLiteral', + span: { + start: tag.length, + end: tag.length + templateContent.length + 2, + ctxt: 0, + }, + expressions: [], + quasis: [ + { + type: 'TemplateElement', + span: { + start: tag.length + 1, + end: tag.length + templateContent.length + 1, + ctxt: 0, + }, + tail: true, + cooked: templateContent, + raw: templateContent, + }, + ], + }, + // Add required properties for Expression + start: 0, + end: templateContent.length + tag.length + 2, + // Add the isLit property + isLit, + }; + + return expr as unknown as Expression; + } + + /** + * Asserts that a node is a Lit tagged template expression. + * + * @param node The node to check + * @param message Optional assertion message + */ + assertIsLitTaggedTemplateExpression< + T extends Directive | Statement | Expression, + >( + node: T, + message: string = 'Node is not a Lit tagged template expression' + ): asserts node is With { + assert.isTrue(isLitTaggedTemplateExpression(node), message); + } + + /** + * Asserts that a node is not a Lit tagged template expression. + * + * @param node The node to check + * @param message Optional assertion message + */ + assertNotLitTaggedTemplateExpression< + T extends Directive | Statement | Expression, + >( + node: T, + message: string = 'Node should not be a Lit tagged template expression' + ): void { + assert.isFalse(isLitTaggedTemplateExpression(node), message); + } + + /** + * Asserts that a Lit tagged template expression has the expected tag name. + * + * @param node The tagged template expression to check + * @param expectedTag The expected tag name + */ + assertTagName( + node: Expression & {tag?: {type?: string; value?: string}}, + expectedTag: string + ): void { + assert.isDefined(node.tag, 'Node has no tag property'); + assert.isDefined(node.tag?.type, 'Node tag has no type property'); + assert.equal(node.tag?.type, 'Identifier'); + assert.equal(node.tag?.value, expectedTag); + } + + /** + * Serializes an AST node into a human-readable format for testing. + * + * @param node The node to serialize + * @returns A human-readable representation of the node + */ + serializeNode(node: unknown): string { + // Simple serialization for testing + return JSON.stringify( + node, + (key, value) => { + // Skip span information to make output more readable + if (key === 'span') { + return undefined; + } + return value; + }, + 2 + ); + } + + /** + * Compares a node against an expected serialized representation. + * + * @param node The node to check + * @param expectedSerialized The expected serialized representation + */ + assertNodeMatchesSerialized(node: unknown, expectedSerialized: string): void { + const serialized = this.serializeNode(node); + assert.equal(serialized, expectedSerialized); + } +} diff --git a/packages/labs/parser/src/test/ast/estree/estree-tree_test.ts b/packages/labs/parser/src/test/ast/estree/estree-tree_test.ts new file mode 100644 index 0000000000..168b6af1ae --- /dev/null +++ b/packages/labs/parser/src/test/ast/estree/estree-tree_test.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from 'chai'; +import type {Expression} from 'oxc-parser'; +import {isLitTaggedTemplateExpression} from '../../../lib/ast/estree/helpers.js'; + +suite('ESTree AST Structure', () => { + suite('Serialized Tree Representation', () => { + test('can serialize and compare Lit template expressions', () => { + // Create a mock Lit tagged template expression + const litTemplate = { + type: 'TaggedTemplateExpression', + start: 0, + end: 25, + tag: { + type: 'Identifier', + start: 0, + end: 4, + name: 'html', + }, + quasi: { + type: 'TemplateLiteral', + start: 4, + end: 25, + expressions: [], + quasis: [ + { + type: 'TemplateElement', + start: 5, + end: 24, + value: { + raw: '
Hello world
', + cooked: '
Hello world
', + }, + tail: true, + }, + ], + }, + isLit: true, + } as unknown as Expression; + + // Check that it's recognized as a Lit template + assert.isTrue(isLitTaggedTemplateExpression(litTemplate)); + + // Demonstrate serialization and comparison + const serialized = JSON.stringify( + litTemplate, + (key, value) => { + // Skip position info in serialization for readability + if (key === 'start' || key === 'end') { + return undefined; + } + return value; + }, + 2 + ); + + const expectedSerialized = JSON.stringify( + { + type: 'TaggedTemplateExpression', + tag: { + type: 'Identifier', + name: 'html', + }, + quasi: { + type: 'TemplateLiteral', + expressions: [], + quasis: [ + { + type: 'TemplateElement', + value: { + raw: '
Hello world
', + cooked: '
Hello world
', + }, + tail: true, + }, + ], + }, + isLit: true, + }, + null, + 2 + ); + + assert.equal(serialized, expectedSerialized); + }); + + test('handles Lit template with expressions', () => { + // Create a more complex template with expressions + const complexTemplate = { + type: 'TaggedTemplateExpression', + start: 0, + end: 35, + tag: { + type: 'Identifier', + start: 0, + end: 4, + name: 'html', + }, + quasi: { + type: 'TemplateLiteral', + start: 4, + end: 35, + expressions: [ + { + type: 'Identifier', + start: 14, + end: 20, + name: 'value', + }, + ], + quasis: [ + { + type: 'TemplateElement', + start: 5, + end: 12, + value: { + raw: '
', + cooked: '
', + }, + tail: false, + }, + { + type: 'TemplateElement', + start: 22, + end: 34, + value: { + raw: '
', + cooked: '
', + }, + tail: true, + }, + ], + }, + isLit: true, + } as unknown as Expression; + + // Check that it's recognized as a Lit template + assert.isTrue(isLitTaggedTemplateExpression(complexTemplate)); + + // Simplified serialization for comparison + function serializeForCompare(node: any): any { + return JSON.parse( + JSON.stringify(node, (key, value) => { + if (key === 'start' || key === 'end') { + return undefined; + } + return value; + }) + ); + } + + const serialized = serializeForCompare(complexTemplate); + + // Check the structural elements + assert.equal(serialized.type, 'TaggedTemplateExpression'); + assert.equal(serialized.tag.name, 'html'); + assert.isTrue(serialized.isLit); + + // Check expressions + assert.lengthOf(serialized.quasi.expressions, 1); + assert.equal(serialized.quasi.expressions[0].type, 'Identifier'); + assert.equal(serialized.quasi.expressions[0].name, 'value'); + + // Check quasis + assert.lengthOf(serialized.quasi.quasis, 2); + assert.equal(serialized.quasi.quasis[0].value.raw, '
'); + assert.equal(serialized.quasi.quasis[1].value.raw, '
'); + }); + }); +}); diff --git a/packages/labs/parser/src/test/ast/estree/helpers_test.ts b/packages/labs/parser/src/test/ast/estree/helpers_test.ts new file mode 100644 index 0000000000..7b965441a7 --- /dev/null +++ b/packages/labs/parser/src/test/ast/estree/helpers_test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from 'chai'; +import type {Expression} from 'oxc-parser'; +import {isLitTaggedTemplateExpression} from '../../../lib/ast/estree/helpers.js'; + +suite('ESTree Helpers', () => { + suite('isLitTaggedTemplateExpression', () => { + test('returns true for Lit tagged template expressions', () => { + const node = { + type: 'TaggedTemplateExpression', + isLit: true, + // Required properties for Expression + start: 0, + end: 10, + } as unknown as Expression; + + assert.isTrue(isLitTaggedTemplateExpression(node)); + }); + + test('returns false for non-Lit tagged template expressions', () => { + const node = { + type: 'TaggedTemplateExpression', + isLit: false, + // Required properties for Expression + start: 0, + end: 10, + } as unknown as Expression; + + assert.isFalse(isLitTaggedTemplateExpression(node)); + }); + + test('returns false for non-tagged template expressions', () => { + const node = { + type: 'TemplateLiteral', + // Required properties for Expression + start: 0, + end: 10, + } as unknown as Expression; + + assert.isFalse(isLitTaggedTemplateExpression(node)); + }); + + test('returns false when isLit property is missing', () => { + const node = { + type: 'TaggedTemplateExpression', + // Required properties for Expression + start: 0, + end: 10, + } as unknown as Expression; + + assert.isFalse(isLitTaggedTemplateExpression(node)); + }); + }); +}); diff --git a/packages/labs/parser/src/test/ast/html-parser/README.md b/packages/labs/parser/src/test/ast/html-parser/README.md new file mode 100644 index 0000000000..b667ca3c69 --- /dev/null +++ b/packages/labs/parser/src/test/ast/html-parser/README.md @@ -0,0 +1,193 @@ +# HTML Parser Test Harness + +This directory contains a test harness for the HTML parser in the `@lit-labs/parser` package. The test harness provides utilities for testing the HTML parser functionality, including creating mock template expressions, parsing templates, and asserting on the resulting AST. + +## Files + +- `html-parser-test-harness.ts`: The main test harness class with utilities for testing the HTML parser +- `parse5-shim_test.ts`: Tests for the parse5-shim module +- `template-literal_test.ts`: Tests for the template-literal module +- `html-parser_test.ts`: Integration tests for the HTML parser + +## Using the Test Harness + +The `HtmlParserTestHarness` class provides several utilities for testing the HTML parser: + +### Creating Mock Templates + +```typescript +const harness = new HtmlParserTestHarness(); +const mockTemplate = harness.createMockTemplate('
Hello world
'); +``` + +### Parsing HTML + +```typescript +const harness = new HtmlParserTestHarness(); +const fragment = harness.parseHtml('
Hello world
'); +``` + +### Testing with Expressions + +```typescript +const harness = new HtmlParserTestHarness(); +const expr = harness.createMockExpression({}); +const fragment = harness.parseHtml('Hello ${expr} world', [expr]); +``` + +### Making Assertions + +```typescript +harness.testParse('
Hello world
', (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + harness.assertChildCount(div, 1); + harness.assertTextNode(div.childNodes[0], 'Hello world'); +}); +``` + +### Testing Parser State + +```typescript +const template = { + start: 0, + end: 5, + value: { + raw: '
', + }, +}; + +const initialState = harness.createMockState(Mode.TEXT); + +harness.testParseSpan(template, initialState, (state) => { + assert.equal(state.mode, Mode.TAG); + assert.isNotNull(state.currentElementNode); + assert.equal(String(state.currentElementNode!.tagName), 'div'); +}); +``` + +## Running Tests + +To run the tests, use the following command: + +```bash +npm test # This will automatically build the test files and run them +``` + +This will run all the tests in the `packages/labs/parser/test` directory using the web-test-runner. + +## Adding New Tests + +To add new tests, create a new test file in this directory and import the `HtmlParserTestHarness` class. Then use the utilities provided by the harness to test the HTML parser functionality. + +For example: + +```typescript +import {assert} from 'chai'; +import {HtmlParserTestHarness} from './html-parser-test-harness.js'; + +suite('My New Tests', () => { + let harness: HtmlParserTestHarness; + + setup(() => { + harness = new HtmlParserTestHarness(); + }); + + test('my test', () => { + harness.testParse('', (fragment) => { + // Make assertions here + }); + }); +}); +``` + +## Parser State Machine + +The HTML parser operates as a state machine, transitioning between different modes as it processes HTML content. Understanding these transitions is crucial for writing effective tests. + +### Main Parser Modes + +- `TEXT`: The parser is in text mode, parsing text content outside of tags +- `TAG`: The parser is in tag mode, parsing tag content (attributes, etc.) +- `TAG_NAME`: The parser is parsing a tag name +- `ATTRIBUTE`: The parser is parsing an attribute name +- `ATTRIBUTE_VALUE`: The parser is parsing an attribute value +- `COMMENT`: The parser is parsing a comment +- `CLOSING_TAG`: The parser is parsing a closing tag + +### Attribute Modes + +When in `ATTRIBUTE_VALUE` mode, the parser can be in one of the following attribute modes: + +- `STRING`: Standard string attribute (e.g., `class="foo"`) +- `PROPERTY`: Property binding (e.g., `.property="value"`) +- `BOOLEAN`: Boolean attribute (e.g., `?checked="${expr}"`) +- `EVENT`: Event binding (e.g., `@click="${handler}"`) + +### Expected State Transitions + +Here are the expected transitions for common HTML parsing scenarios: + +1. **Opening tags**: `TEXT` → `TAG` → `TAG_NAME` → `TAG` → `TEXT` + + - Example: `
` transitions from `TEXT` to `TAG` when encountering `<`, then to `TAG_NAME` after a character, then back to `TAG` after the name, and finally to `TEXT` after `>` + +2. **Attributes**: `TAG` → `ATTRIBUTE` → `ATTRIBUTE_VALUE` → `TAG` + + - Example: `
` enters `ATTRIBUTE` mode after the space, then `ATTRIBUTE_VALUE` after `=`, and back to `TAG` after the closing quote + +3. **Self-closing tags**: `TAG` → `TAG_NAME` → `TAG` → `TEXT` + + - Example: `` ends in `TEXT` mode after parsing the `/>` + +4. **Closing tags**: `TEXT` → `CLOSING_TAG` → `TEXT` + + - Example: `
` transitions from `TEXT` to `CLOSING_TAG` when encountering `` + +5. **Comments**: `TEXT` → `COMMENT` → `TEXT` + - Example: `` enters `COMMENT` mode after `` + +When writing tests, make sure to assert the expected mode after each parsing step to validate the parser's behavior correctly. + +### State Machine Diagram + +``` + Opening '>' + │ + ▼ + ┌───────────────┐ '<' ┌───────────────┐ + │ │◄────────────┤ │ + │ TEXT │ │ TAG │◄─┐ + │ │─────────────► │ │ + └───────────────┘ '' │ │ TAG_NAME │ │ + ───────── │ │ │ + │ └───────────────┘ │ + ┌───────────────┐ │ │ + │ │ │ ' ' │ + │ CLOSING_TAG │ └────────────┘ + │ │ ┌─────────────┐ + └───────────────┘ │ │ + │ ATTRIBUTE │ + │ │ + ┌──────────────┐ └─────────────┘ + │ │ │ + ────►│ COMMENT │ │ '=' + '!--'│ │ ▼ + └──────────────┘ ┌─────────────┐ + │ '-->' │ ATTRIBUTE │ + └────────────────────────────────► VALUE │ + │ │ + └─────────────┘ + │ + │ '"' or "'" + │ ' ' (unquoted) + └─────► +``` + +This diagram shows the primary state transitions in the HTML parser. The arrows represent the characters or conditions that trigger each transition. Understanding this flow is essential for writing tests that correctly validate the parser's behavior. diff --git a/packages/labs/parser/src/test/ast/html-parser/attribute-mode_test.ts b/packages/labs/parser/src/test/ast/html-parser/attribute-mode_test.ts new file mode 100644 index 0000000000..8e0e8ed537 --- /dev/null +++ b/packages/labs/parser/src/test/ast/html-parser/attribute-mode_test.ts @@ -0,0 +1,198 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from 'chai'; +import {HtmlParserTestHarness} from './html-parser-test-harness.js'; +import {Mode} from '../../../lib/ast/html-parser/state.js'; +import {Element} from '../../../lib/ast/html-parser/parse5-shim.js'; + +suite('HTML Parser Attribute Mode', () => { + let harness: HtmlParserTestHarness; + + setup(() => { + harness = new HtmlParserTestHarness(); + }); + + suite('Attribute Mode Transitions', () => { + test('transitions to TAG mode when encountering whitespace after tag name', () => { + // Test for transition to TAG mode with space (not ATTRIBUTE as before) + harness.assertParserMode('
{ + // Create a mock HTML string that includes tag with attribute + harness.assertParserMode('
{ + // Create a mock HTML string that includes tag with attribute and equals + harness.assertParserMode('
{ + // Let's create a multi-step test that ensures we get back to TAG after a value + harness.testModeSequence( + ['
{ + test('parses attribute with double quotes', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + harness.assertAttribute(div, 'id', 'test'); + }); + }); + + test('parses attribute with single quotes', () => { + harness.testParse("
", (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + harness.assertAttribute(div, 'id', 'test'); + }); + }); + + test('parses attribute without quotes', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + harness.assertAttribute(div, 'id', 'test'); + }); + }); + + test('parses attribute without value', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + + // Check that the attribute exists but has an empty value + const attr = div.attrs.find( + (a) => + a.type === 'String' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === 'disabled' + ) + ); + assert.isDefined(attr, 'Disabled attribute should exist'); + + if (attr && attr.type === 'String') { + const value = attr.value + .filter((item) => item.type === 'LitTagLiteral') + .map((item) => (item as any).value) + .join(''); + + assert.equal(value, '', 'Attribute should have empty value'); + } + }); + }); + + test('parses multiple attributes', () => { + harness.testParse( + '
', + (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + + assert.equal(div.attrs.length, 3, 'Should have 3 attributes'); + harness.assertAttribute(div, 'id', 'test'); + harness.assertAttribute(div, 'class', 'container'); + + // Check disabled attribute + const disabledAttr = div.attrs.find( + (a) => + a.type === 'String' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === 'disabled' + ) + ); + assert.isDefined(disabledAttr, 'Disabled attribute should exist'); + } + ); + }); + + test('parses attribute with expression', () => { + const expr = harness.createMockExpression(); + + harness.testParse( + '
', + (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + + // Find the id attribute + const idAttr = div.attrs.find( + (a) => + a.type === 'String' && + a.name.some((n) => n.type === 'LitTagLiteral' && n.value === 'id') + ); + assert.isDefined(idAttr, 'id attribute should exist'); + + if (idAttr && idAttr.type === 'String') { + // Check that we have the expression in the value + assert.equal(idAttr.value.length, 1, 'Should have one value part'); + assert.equal( + idAttr.value[0].type, + 'LitHtmlExpression', + 'Value should be an expression' + ); + } + }, + [expr] + ); + }); + + test('parses attribute with mixed literal and expression', () => { + const expr = harness.createMockExpression(); + + harness.testParse( + '
', + (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + + // Find the class attribute + const classAttr = div.attrs.find( + (a) => + a.type === 'String' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === 'class' + ) + ); + assert.isDefined(classAttr, 'class attribute should exist'); + + if (classAttr && classAttr.type === 'String') { + // Should have three parts: prefix, expression, suffix + assert.equal( + classAttr.value.length, + 3, + 'Should have three value parts' + ); + assert.equal(classAttr.value[0].type, 'LitTagLiteral'); + assert.equal(classAttr.value[1].type, 'LitHtmlExpression'); + assert.equal(classAttr.value[2].type, 'LitTagLiteral'); + + // Check the prefix and suffix + const prefix = (classAttr.value[0] as any).value; + const suffix = (classAttr.value[2] as any).value; + assert.equal(prefix, 'prefix-'); + assert.equal(suffix, '-suffix'); + } + }, + [expr] + ); + }); + }); +}); diff --git a/packages/labs/parser/src/test/ast/html-parser/comment-mode_test.ts b/packages/labs/parser/src/test/ast/html-parser/comment-mode_test.ts new file mode 100644 index 0000000000..20e3d0e394 --- /dev/null +++ b/packages/labs/parser/src/test/ast/html-parser/comment-mode_test.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from 'chai'; +import {HtmlParserTestHarness} from './html-parser-test-harness.js'; +import {Mode} from '../../../lib/ast/html-parser/state.js'; +import { + CommentNode, + Element, +} from '../../../lib/ast/html-parser/parse5-shim.js'; + +suite('HTML Parser Comment Mode', () => { + let harness: HtmlParserTestHarness; + + setup(() => { + harness = new HtmlParserTestHarness(); + }); + + suite('Comment Mode Transitions', () => { + test('transitions to COMMENT mode when encountering comment start', () => { + const state = harness.getParserState(' { + harness.assertParserMode('', Mode.TEXT); + }); + }); + + suite('Comment Parsing', () => { + test('parses simple comments', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const comment = fragment.childNodes[0] as CommentNode; + assert.equal(comment.nodeName, '#comment'); + harness.assertCommentNode(comment, ' A simple comment '); + }); + }); + + test('parses comments with special characters', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const comment = fragment.childNodes[0] as CommentNode; + harness.assertCommentNode(comment, ' Special chars: !@#$%^&*()_+ '); + }); + }); + + test('parses comments inside elements', () => { + harness.testParse( + '
', + (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + + harness.assertChildCount(div, 1); + const comment = div.childNodes[0] as CommentNode; + harness.assertCommentNode(comment, ' Comment inside element '); + } + ); + }); + + test('parses comments with expressions', () => { + const expr = harness.createMockExpression(); + + harness.testParse( + '', + (fragment) => { + harness.assertChildCount(fragment, 1); + const comment = fragment.childNodes[0] as CommentNode; + assert.equal(comment.nodeName, '#comment'); + + // The comment data should include the expression + assert.equal(comment.data.length, 3); + assert.equal(comment.data[0].type, 'LitTagLiteral'); + assert.equal(comment.data[1].type, 'LitHtmlExpression'); + assert.equal(comment.data[2].type, 'LitTagLiteral'); + + const commentText = comment.data + .filter((item) => item.type === 'LitTagLiteral') + .map((item) => (item as any).value) + .join(''); + + assert.equal(commentText, ' Comment with '); + }, + [expr] + ); + }); + + test('parses multiple comments', () => { + harness.testParse( + '', + (fragment) => { + harness.assertChildCount(fragment, 2); + + const comment1 = fragment.childNodes[0] as CommentNode; + harness.assertCommentNode(comment1, ' First comment '); + + const comment2 = fragment.childNodes[1] as CommentNode; + harness.assertCommentNode(comment2, ' Second comment '); + } + ); + }); + + test('parses comments with HTML-like content', () => { + harness.testParse( + '', + (fragment) => { + harness.assertChildCount(fragment, 1); + const comment = fragment.childNodes[0] as CommentNode; + harness.assertCommentNode( + comment, + ' This comment has
HTML
inside ' + ); + } + ); + }); + }); +}); diff --git a/packages/labs/parser/src/test/ast/html-parser/directive-attributes_test.ts b/packages/labs/parser/src/test/ast/html-parser/directive-attributes_test.ts new file mode 100644 index 0000000000..3ec6c211f2 --- /dev/null +++ b/packages/labs/parser/src/test/ast/html-parser/directive-attributes_test.ts @@ -0,0 +1,382 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from 'chai'; +import {HtmlParserTestHarness} from './html-parser-test-harness.js'; +import {Mode, AttributeMode} from '../../../lib/ast/html-parser/state.js'; +import {Element} from '../../../lib/ast/html-parser/parse5-shim.js'; + +suite('HTML Parser Directive Attributes', () => { + let harness: HtmlParserTestHarness; + + setup(() => { + harness = new HtmlParserTestHarness(); + }); + + suite('Property Binding', () => { + test('parses property binding with .prop syntax', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'div'); + + // Find the property attribute - note that the attribute name includes the '.' prefix + const propAttr = element.attrs.find( + (a) => + a.type === 'Property' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '.myProp' + ) + ); + + assert.isDefined(propAttr, 'Property attribute should exist'); + if (propAttr && propAttr.type === 'Property') { + // Check the attribute value + const value = propAttr.value + .filter((item) => item.type === 'LitTagLiteral') + .map((item) => (item as any).value) + .join(''); + + assert.equal(value, 'value', 'Property value should be "value"'); + } + }); + }); + + test('parses property binding with expression', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'div'); + + // Find the property attribute - note that the attribute name includes the '.' prefix + const propAttr = element.attrs.find( + (a) => + a.type === 'Property' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '.myProp' + ) + ); + + assert.isDefined(propAttr, 'Property attribute should exist'); + if (propAttr && propAttr.type === 'Property') { + // Check that we have an expression in the value + assert.equal(propAttr.value.length, 1, 'Should have one value part'); + assert.equal( + propAttr.value[0].type, + 'LitHtmlExpression', + 'Value should be an expression' + ); + } + }); + }); + + test('transitions to ATTRIBUTE mode with correct AttributeMode when encountering property binding', () => { + const state = harness.getParserState('
n.type === 'LitTagLiteral') + .map((n) => (n as any).value) + .join(''); + + // The attribute name keeps the '.' prefix + assert.equal( + attributeName, + '.myProp', + 'Attribute name should be ".myProp"' + ); + } + }); + }); + + suite('Boolean Attributes', () => { + test('parses boolean attribute with ?attr syntax', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'input'); + + // Find the boolean attribute - note that the attribute name includes the '?' prefix + const boolAttr = element.attrs.find( + (a) => + a.type === 'Boolean' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '?disabled' + ) + ); + + assert.isDefined(boolAttr, 'Boolean attribute should exist'); + if (boolAttr && boolAttr.type === 'Boolean') { + // Check the attribute value + const value = boolAttr.value + .filter((item) => item.type === 'LitTagLiteral') + .map((item) => (item as any).value) + .join(''); + + assert.equal(value, 'true', 'Boolean value should be "true"'); + } + }); + }); + + test('parses boolean attribute with expression', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'input'); + + // Find the boolean attribute - note that the attribute name includes the '?' prefix + const boolAttr = element.attrs.find( + (a) => + a.type === 'Boolean' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '?disabled' + ) + ); + + assert.isDefined(boolAttr, 'Boolean attribute should exist'); + if (boolAttr && boolAttr.type === 'Boolean') { + // Check that we have an expression in the value + assert.equal(boolAttr.value.length, 1, 'Should have one value part'); + assert.equal( + boolAttr.value[0].type, + 'LitHtmlExpression', + 'Value should be an expression' + ); + } + }); + }); + + test('transitions to ATTRIBUTE mode with correct AttributeMode when encountering boolean attribute', () => { + const state = harness.getParserState(' n.type === 'LitTagLiteral') + .map((n) => (n as any).value) + .join(''); + + // The attribute name keeps the '?' prefix + assert.equal( + attributeName, + '?disabled', + 'Attribute name should be "?disabled"' + ); + } + }); + }); + + suite('Event Binding', () => { + test('parses event binding with @event syntax', () => { + harness.testParse( + '', + (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'button'); + + // Find the event attribute - note that the attribute name includes the '@' prefix + const eventAttr = element.attrs.find( + (a) => + a.type === 'Event' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '@click' + ) + ); + + assert.isDefined(eventAttr, 'Event attribute should exist'); + if (eventAttr && eventAttr.type === 'Event') { + // Check the attribute value + const value = eventAttr.value + .filter((item) => item.type === 'LitTagLiteral') + .map((item) => (item as any).value) + .join(''); + + assert.equal( + value, + 'handleClick', + 'Event handler should be "handleClick"' + ); + } + } + ); + }); + + test('parses event binding with expression', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'button'); + + // Find the event attribute - note that the attribute name includes the '@' prefix + const eventAttr = element.attrs.find( + (a) => + a.type === 'Event' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '@click' + ) + ); + + assert.isDefined(eventAttr, 'Event attribute should exist'); + if (eventAttr && eventAttr.type === 'Event') { + // Check that we have an expression in the value + assert.equal(eventAttr.value.length, 1, 'Should have one value part'); + assert.equal( + eventAttr.value[0].type, + 'LitHtmlExpression', + 'Value should be an expression' + ); + } + }); + }); + + test('transitions to ATTRIBUTE mode with correct AttributeMode when encountering event binding', () => { + const state = harness.getParserState('
', (fragment) => { + // Should parse the first div correctly and ignore the extra closing tag + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + }); + }); + + test('handles mismatched closing tags', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + + // The span should be a child of div and closed implicitly + harness.assertChildCount(div, 1); + const span = div.childNodes[0] as Element; + harness.assertElement(span, 'span'); + }); + }); + + test('handles self-closing tags without slash', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const img = fragment.childNodes[0] as Element; + harness.assertElement(img, 'img'); + harness.assertAttribute(img, 'src', 'example.jpg'); + }); + }); + }); + + suite('Malformed Attributes', () => { + test('handles missing attribute values', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const input = fragment.childNodes[0] as Element; + harness.assertElement(input, 'input'); + + // Check that attribute exists without a value + const attr = input.attrs.find((a) => { + if (a.type === 'String') { + const nameNodes = a.name.filter( + (n) => n.type === 'LitTagLiteral' + ) as LitTagLiteral[]; + return nameNodes.some((n) => n.value === 'disabled'); + } + return false; + }); + + assert.isDefined(attr, 'Disabled attribute should exist'); + + // The value should be empty + if (attr && attr.type === 'String') { + const valueNodes = attr.value.filter( + (item) => item.type === 'LitTagLiteral' + ) as LitTagLiteral[]; + const value = valueNodes.map((item) => item.value).join(''); + + assert.equal(value, '', 'Attribute value should be empty'); + } + }); + }); + + test('handles malformed attribute syntax without quotes', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + + // Check that attribute was parsed despite missing quotes + harness.assertAttribute(div, 'id', 'test'); + }); + }); + + test('handles missing closing quote in attribute value', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + + // Both attributes should be present + const idAttrs = div.attrs.filter( + (a) => + a.type === 'String' && + a.name.some((n) => n.type === 'LitTagLiteral' && n.value === 'id') + ); + assert.isAtLeast( + idAttrs.length, + 1, + 'Should have at least one id attribute' + ); + }); + }); + }); + + suite('Malformed Comments', () => { + test('handles unclosed comments', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + harness.assertCommentNode( + fragment.childNodes[0], + ' This is a comment ' + ); + }); + }); + + test('parses self-closing tags', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const input = fragment.childNodes[0] as Element; + harness.assertElement(input, 'input'); + harness.assertAttribute(input, 'type', 'text'); + harness.assertChildCount(input, 0); + }); + }); + }); + + suite('Parser State Management', () => { + test('transitions from TEXT to TAG mode', () => { + const template = { + start: 0, + end: 5, + value: { + raw: '
', + }, + }; + + const initialState = harness.createInitialState(Mode.TEXT); + + harness.testParseSpan(template, initialState, (state) => { + // After parsing '
', we should be in TEXT mode + // and have a div element on the stack + assert.equal(state.mode, Mode.TEXT); + assert.equal(state.elementStack.length, 1); + + // Check that we have a div element (checking the node name safely) + const element = state.elementStack[0]; + assert.isDefined(element); + assert.property(element, 'tagName'); + + // The tagName is an array of LitTagLiteral objects, so we need to extract the value + const tagName = element.tagName + .filter((item) => item.type === 'LitTagLiteral') + .map((item: any) => item.value) + .join(''); + + assert.equal(tagName, 'div'); + }); + }); + + test('transitions from TAG to ATTRIBUTE mode', () => { + const template = { + start: 0, + end: 9, + value: { + raw: '
{ + const template = { + start: 0, + end: 11, + value: { + raw: '
', + }, + }; + + const initialState = harness.createInitialState(Mode.TEXT); + + harness.testParseSpan(template, initialState, (state) => { + assert.equal(state.mode, Mode.TEXT); + assert.isEmpty(state.elementStack); + assert.isNull(state.currentElementNode); + }); + }); + }); + + suite('Expression Handling', () => { + test('handles expressions in text context', () => { + harness.testParse('Hello ${expr} world', (fragment) => { + assert.isAtLeast(fragment.childNodes.length, 2); + harness.assertTextNode(fragment.childNodes[0], 'Hello '); + + // Assert that the second node is an expression + assert.isDefined(fragment.childNodes[1]); + assert.property(fragment.childNodes[1], 'type'); + assert.equal((fragment.childNodes[1] as any).type, 'LitHtmlExpression'); + + // If there's a third node, it should be the " world" text + if (fragment.childNodes.length > 2) { + harness.assertTextNode(fragment.childNodes[2], ' world'); + } + }); + }); + + test('handles expressions in attribute values', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + + // Check that the attribute has both a literal part and an expression + const idAttr = div.attrs.find( + (a) => + a.type !== 'LitHtmlExpression' && + a.name.some((n) => n.type === 'LitTagLiteral' && n.value === 'id') + ); + assert.isDefined(idAttr); + if (idAttr && idAttr.type !== 'LitHtmlExpression') { + assert.equal(idAttr.value.length, 2); + assert.equal(idAttr.value[0].type, 'LitTagLiteral'); + assert.equal((idAttr.value[0] as any).value, 'test-'); + assert.equal(idAttr.value[1].type, 'LitHtmlExpression'); + } + }); + }); + }); +}); diff --git a/packages/labs/parser/src/test/ast/html-parser/parse5-shim_test.ts b/packages/labs/parser/src/test/ast/html-parser/parse5-shim_test.ts new file mode 100644 index 0000000000..c564e5d4eb --- /dev/null +++ b/packages/labs/parser/src/test/ast/html-parser/parse5-shim_test.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from 'chai'; +import { + createDocumentFragment, + createCommentNode, + createTextNode, + createElement, + createLitHtmlExpression, + DocumentFragment, + CommentNode, + TextNode, + Element, + LitHtmlExpression, +} from '../../../lib/ast/html-parser/parse5-shim.js'; + +suite('parse5-shim', () => { + test('createDocumentFragment creates a document fragment', () => { + const fragment: DocumentFragment = createDocumentFragment(); + assert.equal(fragment.childNodes.length, 0); + }); + + test('createCommentNode creates a comment node', () => { + const comment: CommentNode = createCommentNode(); + assert.equal(comment.data.length, 0); + assert.equal(comment.parentNode, null); + }); + + test('createTextNode creates a text node with value', () => { + const text: TextNode = createTextNode('test text'); + assert.equal(text.value, 'test text'); + assert.equal(text.parentNode, null); + }); + + test('createElement creates an element with tagName', () => { + const div: Element = createElement('div'); + assert.equal(String(div.tagName), 'div'); + assert.equal(String(div.nodeName), 'div'); + assert.equal(div.parentNode, null); + assert.equal(div.childNodes.length, 0); + assert.equal(div.attrs.length, 0); + }); + + test('createElement creates an element with attributes', () => { + const div: Element = createElement('div', {id: 'test', class: 'foo'}); + assert.equal(div.attrs.length, 2); + // Note: The actual attribute objects are complex and implementation-specific + // so we're just checking that attributes were added + }); + + test('createLitHtmlExpression creates a lit expression', () => { + const value = { + foo: 'bar', + litHtmlExpression: null as unknown as LitHtmlExpression, + }; + const element: Element = createElement('div'); + const expr: LitHtmlExpression = createLitHtmlExpression(value, 10, element); + + assert.equal(expr.nodeName, '#lit-html-expression'); + assert.equal(expr.type, 'LitHtmlExpression'); + assert.equal(expr.value, value); + assert.equal(expr.element, element); + assert.deepEqual(expr.sourceCodeLocation, { + startOffset: 10, + endOffset: 10, + }); + + // Check that the value has a reference back to the expression + assert.equal(value.litHtmlExpression, expr); + }); + + test('createLitHtmlExpression works without element', () => { + const value = { + foo: 'bar', + litHtmlExpression: null as unknown as LitHtmlExpression, + }; + const expr: LitHtmlExpression = createLitHtmlExpression(value, 10); + + assert.equal(expr.nodeName, '#lit-html-expression'); + assert.equal(expr.type, 'LitHtmlExpression'); + assert.equal(expr.value, value); + assert.isUndefined(expr.element); + assert.deepEqual(expr.sourceCodeLocation, { + startOffset: 10, + endOffset: 10, + }); + + // Check that the value has a reference back to the expression + assert.equal(value.litHtmlExpression, expr); + }); +}); diff --git a/packages/labs/parser/src/test/ast/html-parser/parser-mode_test.ts b/packages/labs/parser/src/test/ast/html-parser/parser-mode_test.ts new file mode 100644 index 0000000000..9760b74bf8 --- /dev/null +++ b/packages/labs/parser/src/test/ast/html-parser/parser-mode_test.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {HtmlParserTestHarness} from './html-parser-test-harness.js'; +import {Mode, AttributeMode} from '../../../lib/ast/html-parser/state.js'; + +suite('HTML Parser Mode Transitions', () => { + let harness: HtmlParserTestHarness; + + setup(() => { + harness = new HtmlParserTestHarness(); + }); + + suite('Basic Mode Transitions', () => { + test('starts in TEXT mode', () => { + harness.assertParserMode('', Mode.TEXT); + }); + + test('transitions to TAG mode when encountering <', () => { + harness.assertParserMode('<', Mode.TAG); + }); + + test('transitions to TAG_NAME mode after < and a character', () => { + harness.assertParserMode(' { + harness.assertParserMode('
{ + harness.assertParserMode('
{ + harness.assertParserMode("
{ + harness.assertParserMode('
{ + harness.assertParserMode('
{ + harness.assertParserMode('
{ + harness.assertParserMode('
{ + harness.assertParserMode('
', Mode.TEXT); + }); + + test('transitions to TEXT after closing a tag', () => { + harness.assertParserMode('
', Mode.TEXT); + }); + + test('transitions to CLOSING_TAG when encountering { + harness.assertParserMode('
{ + harness.assertParserMode('
', Mode.TEXT); + }); + }); + + suite('Comment Mode Transitions', () => { + test('transitions to COMMENT mode when encountering { + harness.assertParserMode(' { + harness.assertParserMode('', Mode.TEXT); + }); + }); + + suite('Complex Mode Sequences', () => { + test('sequence of opening tag, text, closing tag', () => { + harness.testModeSequence( + ['
', 'Hello world', '
'], + [Mode.TEXT, Mode.TEXT, Mode.TEXT] + ); + }); + + test('sequence with multiple attributes', () => { + harness.testModeSequence( + ['
'], + [Mode.TAG, Mode.TAG, Mode.TAG, Mode.TEXT] + ); + }); + + test('incomplete tag with attribute', () => { + harness.assertParserMode('
{ + harness.assertParserMode('
{ + let harness: HtmlParserTestHarness; + + setup(() => { + harness = new HtmlParserTestHarness(); + }); + + suite('Property Binding', () => { + test('parses property binding with .prop syntax', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'div'); + + // Find the property attribute - note that the attribute name includes the '.' prefix + const propAttr = element.attrs.find( + (a) => + a.type === 'Property' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '.myProp' + ) + ); + + assert.isDefined(propAttr, 'Property attribute should exist'); + if (propAttr && propAttr.type === 'Property') { + // Check the attribute value + const value = propAttr.value + .filter((item) => item.type === 'LitTagLiteral') + .map((item) => (item as any).value) + .join(''); + + assert.equal(value, 'value', 'Property value should be "value"'); + } + }); + }); + + test('parses property binding with expression', () => { + harness.testParse('
', (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'div'); + + // Find the property attribute - note that the attribute name includes the '.' prefix + const propAttr = element.attrs.find( + (a) => + a.type === 'Property' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '.myProp' + ) + ); + + assert.isDefined(propAttr, 'Property attribute should exist'); + if (propAttr && propAttr.type === 'Property') { + // Check that we have an expression in the value + assert.equal(propAttr.value.length, 1, 'Should have one value part'); + assert.equal( + propAttr.value[0].type, + 'LitHtmlExpression', + 'Value should be an expression' + ); + } + }); + }); + + test('transitions to ATTRIBUTE mode with correct AttributeMode when encountering property binding', () => { + const state = harness.getParserState('
n.type === 'LitTagLiteral') + .map((n) => (n as any).value) + .join(''); + + // The attribute name keeps the '.' prefix + assert.equal( + attributeName, + '.myProp', + 'Attribute name should be ".myProp"' + ); + } + }); + }); + + suite('Boolean Attributes', () => { + test('parses boolean attribute with ?attr syntax', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'input'); + + // Find the boolean attribute - note that the attribute name includes the '?' prefix + const boolAttr = element.attrs.find( + (a) => + a.type === 'Boolean' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '?disabled' + ) + ); + + assert.isDefined(boolAttr, 'Boolean attribute should exist'); + if (boolAttr && boolAttr.type === 'Boolean') { + // Check the attribute value + const value = boolAttr.value + .filter((item) => item.type === 'LitTagLiteral') + .map((item) => (item as any).value) + .join(''); + + assert.equal(value, 'true', 'Boolean value should be "true"'); + } + }); + }); + + test('parses boolean attribute with expression', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'input'); + + // Find the boolean attribute - note that the attribute name includes the '?' prefix + const boolAttr = element.attrs.find( + (a) => + a.type === 'Boolean' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '?disabled' + ) + ); + + assert.isDefined(boolAttr, 'Boolean attribute should exist'); + if (boolAttr && boolAttr.type === 'Boolean') { + // Check that we have an expression in the value + assert.equal(boolAttr.value.length, 1, 'Should have one value part'); + assert.equal( + boolAttr.value[0].type, + 'LitHtmlExpression', + 'Value should be an expression' + ); + } + }); + }); + + test('transitions to ATTRIBUTE mode with correct AttributeMode when encountering boolean attribute', () => { + const state = harness.getParserState(' n.type === 'LitTagLiteral') + .map((n) => (n as any).value) + .join(''); + + // The attribute name keeps the '?' prefix + assert.equal( + attributeName, + '?disabled', + 'Attribute name should be "?disabled"' + ); + } + }); + }); + + suite('Event Binding', () => { + test('parses event binding with @event syntax', () => { + harness.testParse( + '', + (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'button'); + + // Find the event attribute - note that the attribute name includes the '@' prefix + const eventAttr = element.attrs.find( + (a) => + a.type === 'Event' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '@click' + ) + ); + + assert.isDefined(eventAttr, 'Event attribute should exist'); + if (eventAttr && eventAttr.type === 'Event') { + // Check the attribute value + const value = eventAttr.value + .filter((item) => item.type === 'LitTagLiteral') + .map((item) => (item as any).value) + .join(''); + + assert.equal( + value, + 'handleClick', + 'Event handler should be "handleClick"' + ); + } + } + ); + }); + + test('parses event binding with expression', () => { + harness.testParse('', (fragment) => { + harness.assertChildCount(fragment, 1); + const element = fragment.childNodes[0] as Element; + harness.assertElement(element, 'button'); + + // Find the event attribute - note that the attribute name includes the '@' prefix + const eventAttr = element.attrs.find( + (a) => + a.type === 'Event' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '@click' + ) + ); + + assert.isDefined(eventAttr, 'Event attribute should exist'); + if (eventAttr && eventAttr.type === 'Event') { + // Check that we have an expression in the value + assert.equal(eventAttr.value.length, 1, 'Should have one value part'); + assert.equal( + eventAttr.value[0].type, + 'LitHtmlExpression', + 'Value should be an expression' + ); + } + }); + }); + + test('transitions to ATTRIBUTE mode with correct AttributeMode when encountering event binding', () => { + const state = harness.getParserState('
', + (fragment) => { + harness.assertChildCount(fragment, 1); + const div = fragment.childNodes[0] as Element; + harness.assertElement(div, 'div'); + + // Check h1 with first expression + harness.assertChildCount(div, 3); + const h1 = div.childNodes[0] as Element; + harness.assertElement(h1, 'h1'); + + const expr1Node = h1.childNodes[0] as LitHtmlExpression; + assert.equal(expr1Node.nodeName, '#lit-html-expression'); + assert.equal((expr1Node.value as any).kind, 'Identifier'); + assert.equal((expr1Node.value as any).name, 'title'); + + // Check p with second expression + const p = div.childNodes[1] as Element; + harness.assertElement(p, 'p'); + + const expr2Node = p.childNodes[0] as LitHtmlExpression; + assert.equal(expr2Node.nodeName, '#lit-html-expression'); + assert.equal( + (expr2Node.value as any).kind, + 'PropertyAccessExpression' + ); + assert.equal((expr2Node.value as any).expression.name, 'item'); + + // Check button with third expression (in event handler) + const button = div.childNodes[2] as Element; + harness.assertElement(button, 'button'); + + const clickAttr = button.attrs.find( + (a) => + a.type === 'Event' && + a.name.some( + (n) => n.type === 'LitTagLiteral' && n.value === '@click' + ) + ); + assert.isDefined(clickAttr, 'click event attribute should exist'); + + if (clickAttr && clickAttr.type === 'Event') { + const expr3Node = clickAttr.value[0] as LitHtmlExpression; + assert.equal(expr3Node.type, 'LitHtmlExpression'); + assert.equal((expr3Node.value as any).kind, 'CallExpression'); + assert.equal( + (expr3Node.value as any).expression.name, + 'handleClick' + ); + } + }, + [expr1, expr2, expr3] + ); + }); + }); +}); diff --git a/packages/labs/parser/src/test/ast/ts-ast/ts-ast-test-harness.ts b/packages/labs/parser/src/test/ast/ts-ast/ts-ast-test-harness.ts new file mode 100644 index 0000000000..43d13c12f0 --- /dev/null +++ b/packages/labs/parser/src/test/ast/ts-ast/ts-ast-test-harness.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {assert} from 'chai'; +import {Node, SyntaxKind} from 'typescript'; + +/** + * A test harness for testing the TypeScript AST parsing. + * + * This class provides utilities for testing the TypeScript AST functionality, + * including serializing nodes and asserting on their structure. + */ +export class TsAstTestHarness { + /** + * Serializes a TypeScript AST node into a human-readable format. + * + * @param node The node to serialize + * @returns A human-readable representation of the node + */ + serializeNode(node: Node): Record { + // Skip circular references and position information + const seenNodes = new Set(); + return this._serializeNode(node, seenNodes); + } + + /** + * Helper method for serializeNode that handles circular references. + * + * @param node The node to serialize + * @param seenNodes Set of already processed nodes + * @returns A serialized representation of the node + */ + private _serializeNode( + node: Node, + seenNodes: Set + ): Record { + if (seenNodes.has(node)) { + return {circular: true}; + } + + seenNodes.add(node); + + // Create a simplified representation + const result: Record = { + kind: SyntaxKind[node.kind], + }; + + // Add other properties based on node type + for (const key in node) { + if ( + key !== 'parent' && + key !== 'pos' && + key !== 'end' && + key !== 'flags' && + key !== 'modifierFlagsCache' && + key !== 'transformFlags' && + key !== 'original' && + Object.prototype.hasOwnProperty.call(node, key) + ) { + const value = (node as unknown as Record)[key]; + + if (value === undefined) { + continue; + } + + if (value === null) { + result[key] = null; + } else if (Array.isArray(value)) { + result[key] = value.map((item) => { + if (item && typeof item === 'object' && 'kind' in item) { + // Create a new set with the same nodes for recursion + const newSet = new Set(seenNodes); + return this._serializeNode(item as Node, newSet); + } + return item; + }); + } else if ( + typeof value === 'object' && + value !== null && + 'kind' in value + ) { + // Create a new set with the same nodes for recursion + const newSet = new Set(seenNodes); + result[key] = this._serializeNode(value as Node, newSet); + } else { + result[key] = value; + } + } + } + + return result; + } + + /** + * Asserts that a node's structure matches the expected serialized representation. + * + * @param node The node to check + * @param expectedSerialized The expected serialized representation + */ + assertNodeStructure(node: Node, expectedSerialized: string): void { + const serialized = JSON.stringify(this.serializeNode(node), null, 2); + assert.equal(serialized, expectedSerialized); + } + + /** + * Asserts that a node is of the expected kind. + * + * @param node The node to check + * @param expectedKind The expected syntax kind + */ + assertNodeKind(node: Node, expectedKind: SyntaxKind): void { + assert.equal(node.kind, expectedKind); + } + + /** + * Creates a basic expected structure object for a specific node kind. + * This is useful for quickly starting a test case. + * + * @param kind The syntax kind + * @returns A basic expected structure + */ + createBasicExpectedStructure(kind: SyntaxKind): Record { + return { + kind: SyntaxKind[kind], + }; + } + + /** + * Simplifies comparing a real node to an expected structure by allowing + * partial matching of properties. + * + * @param node The actual node + * @param expectedPartial An object with the expected properties + */ + assertNodePartialMatch( + node: Node, + expectedPartial: Record + ): void { + const serialized = this.serializeNode(node); + + for (const key in expectedPartial) { + if (Object.prototype.hasOwnProperty.call(expectedPartial, key)) { + const expectedValue = expectedPartial[key]; + const actualValue = serialized[key]; + + if (typeof expectedValue === 'object' && expectedValue !== null) { + assert.deepEqual( + actualValue, + expectedValue, + `Mismatch in property "${key}"` + ); + } else { + assert.equal( + actualValue, + expectedValue, + `Mismatch in property "${key}"` + ); + } + } + } + } +} diff --git a/packages/labs/parser/tsconfig.json b/packages/labs/parser/tsconfig.json new file mode 100644 index 0000000000..a5232d799b --- /dev/null +++ b/packages/labs/parser/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "incremental": true, + "composite": true, + "tsBuildInfoFile": "tsconfig.tsbuildinfo", + "target": "es2021", + "lib": ["es2021"], + "module": "ES2020", + "rootDir": "./src", + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": ".", + "importHelpers": true, + "inlineSources": true, + "stripInternal": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "types": ["node", "mocha"] + }, + "include": ["src"], + "exclude": ["src/playground"] +} diff --git a/packages/labs/parser/vite.config.mjs b/packages/labs/parser/vite.config.mjs new file mode 100644 index 0000000000..43e9841c18 --- /dev/null +++ b/packages/labs/parser/vite.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import wasm from 'vite-plugin-wasm'; + +export default defineConfig({ + plugins: [wasm()], + optimizeDeps: { + exclude: [ + "@oxc-parser/wasm", + ] + } +}); \ No newline at end of file diff --git a/packages/labs/parser/web-test-runner.config.js b/packages/labs/parser/web-test-runner.config.js new file mode 100644 index 0000000000..3cbf8f638f --- /dev/null +++ b/packages/labs/parser/web-test-runner.config.js @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {playwrightLauncher} from '@web/test-runner-playwright'; +import {legacyPlugin} from '@web/dev-server-legacy'; + +const mode = process.env.MODE || 'dev'; +if (!['dev', 'prod'].includes(mode)) { + throw new Error(`MODE must be "dev" or "prod", was "${mode}"`); +} + +const browsers = { + // Local browser testing via playwright + chromium: playwrightLauncher({product: 'chromium'}), + firefox: playwrightLauncher({product: 'firefox'}), + webkit: playwrightLauncher({product: 'webkit'}), +}; + +// Prepend BROWSERS=x,y to `npm run test` to run a subset of browsers +// e.g. `BROWSERS=chromium,firefox npm run test` +const noBrowser = (b) => { + throw new Error(`No browser configured named '${b}'; using defaults`); +}; +let commandLineBrowsers; +try { + commandLineBrowsers = process.env.BROWSERS?.split(',').map( + (b) => browsers[b] ?? noBrowser(b) + ); +} catch (e) { + console.warn(e); +} + +// https://modern-web.dev/docs/test-runner/cli-and-configuration/ +export default { + rootDir: '.', + files: ['./test/**/*_test.js'], + nodeResolve: {exportConditions: mode === 'dev' ? ['development'] : []}, + preserveSymlinks: true, + browsers: commandLineBrowsers ?? Object.values(browsers), + testFramework: { + // https://mochajs.org/api/mocha + config: { + ui: 'tdd', + timeout: '60000', + }, + }, + plugins: [ + // Detect browsers without modules (e.g. IE11) and transform to SystemJS + // (https://modern-web.dev/docs/dev-server/plugins/legacy/). + legacyPlugin({ + polyfills: { + webcomponents: true, + // Inject lit's polyfill-support module into test files, which is required + // for interfacing with the webcomponents polyfills + custom: [ + { + name: 'lit-polyfill-support', + path: 'node_modules/lit/polyfill-support.js', + test: "!('attachShadow' in Element.prototype) || !('getRootNode' in Element.prototype) || window.ShadyDOM && window.ShadyDOM.force", + module: false, + }, + ], + }, + }), + ], +};