first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,11 @@
/node_modules
/dist
/test/*.js
/test/*.d.ts
/test/*.d.ts.map
.tern-*
/src/grammar/parser.*
/src/grammar/dist
.DS_Store
@@ -0,0 +1,3 @@
/src
/test
/node_modules
@@ -0,0 +1,142 @@
## 6.4.0 (2022-11-30)
### Bug fixes
Directly depend on @lang/css 1.1.0, since we're using a new top rule name introduced in that.
### New features
Add a `globalAttrs` property to (completion) `TagSpec` objects that controls whether global attributes are completed in that tag.
## 6.3.1 (2022-11-29)
### Bug fixes
Remove incorrect pure annotation that broke the code after tree-shaking.
## 6.3.0 (2022-11-28)
### Bug fixes
Parse type=text/babel script tags as JSX.
### New features
The new `nestedLanguages` option can be used to configure how the content of script, style, and textarea tags is parsed.
The content of style attributes will now be parsed as CSS, and the content of on[event] attributes as JavaScript. The new `nestedAttributes` option can be used to configure the parsing of other attribute values.
## 6.2.0 (2022-11-16)
### New features
Add a `selfClosingTags` option to `html` that enables `/>` syntax.
## 6.1.4 (2022-11-15)
### Bug fixes
Parse the content of text/javascript or lang=ts script tags as TypeScript, use JS for text/jsx, and TSX for text/typescript-jsx.
## 6.1.3 (2022-10-24)
### Bug fixes
Remove deprecated HTML tags from the completions.
## 6.1.2 (2022-09-27)
### Bug fixes
Make tag auto-closing consume `>` characters after the cursor.
## 6.1.1 (2022-09-05)
### Bug fixes
Properly list the dependency on @codemirror/view in package.json.
## 6.1.0 (2022-06-22)
### New features
It is now possible to pass in options to extend the set of tags and attributes provided by autocompletion.
## 6.0.0 (2022-06-08)
### Breaking changes
Update dependencies to 6.0.0
## 0.20.0 (2022-04-20)
### New features
Autocompletion now suggests the `<template>` and `<slot>` elements.
## 0.19.4 (2021-11-30)
### Bug fixes
Fix an issue where autoclosing a tag directly in front of alphanumeric text would include nonsense text in the completed tag name.
## 0.19.3 (2021-09-23)
### New features
The package now exports a completion source function, rather than a prebuilt completion extension.
Use more specific highlighting tags for attribute names and values.
## 0.19.2 (2021-09-21)
### New features
The new `autoCloseTags` extension (included by default in `html()`) finishes closing tags when you type a `>` or `/` character.
## 0.19.1 (2021-08-11)
### Bug fixes
Fix incorrect versions for @lezer dependencies.
## 0.19.0 (2021-08-11)
### Bug fixes
Improve autocompletion in/after unclosed opening tags.
### New features
`html()` now takes a `matchClosingTags` option to turn off closing tag matching.
## 0.18.1 (2021-05-05)
### Bug fixes
Fix an issue where the completer would sometimes try to complete an opening tag to its own close tag.
Fix a bug that would sometimes produce the wrong indentation in HTML elements.
Fix a bug that broke tag-specific attribute completion in tags like `<input>` or `<script>`.
Move a new version of lezer-html which solves some issues with autocompletion.
## 0.18.0 (2021-03-03)
### Bug fixes
Improves indentation at end of implicitly closed elements.
## 0.17.1 (2021-01-06)
### New features
The package now also exports a CommonJS module.
## 0.17.0 (2020-12-29)
### Breaking changes
First numbered release.
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijnh@gmail.com> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,11 @@
# @n8n/codemirror-lang-html
HTML + n8n expression language support for CodeMirror 6.
Based on:
- [`@codemirror/lang-html`](https://github.com/codemirror/lang-html) (src)
- [`@lezer/html`](https://github.com/lezer-parser/html) (src/grammar)
## Author
© 2022 [Iván Ovejero](https://github.com/ivov)
@@ -0,0 +1,55 @@
{
"name": "@n8n/codemirror-lang-html",
"version": "1.3.0",
"description": "HTML + n8n expression language support for CodeMirror 6",
"scripts": {
"test": "vitest run",
"test:unit": "vitest run",
"build": "cm-buildhelper src/html.ts",
"grammar:build": "lezer-generator src/grammar/html.grammar -o src/grammar/parser && rollup -c src/grammar/rollup.config.js",
"grammar:build-debug": "lezer-generator src/grammar/html.grammar --names -o src/grammar/parser && rollup -c src/grammar/rollup.config.js",
"grammar:test": "mocha src/grammar/test/test-*.js"
},
"keywords": [
"editor",
"code"
],
"author": {
"name": "Iván Ovejero",
"email": "ivov.src@gmail.com",
"url": "https://ivov.dev"
},
"type": "module",
"main": "dist/index.cjs",
"exports": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"types": "dist/index.d.ts",
"module": "dist/index.js",
"sideEffects": false,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "catalog:",
"@codemirror/lang-css": "catalog:",
"@codemirror/lang-javascript": "catalog:",
"@codemirror/language": "catalog:",
"@codemirror/state": "catalog:",
"@codemirror/view": "catalog:",
"@lezer/common": "catalog:"
},
"devDependencies": {
"@codemirror/buildhelper": "^0.1.5",
"@lezer/generator": "catalog:",
"@n8n/typescript-config": "workspace:*",
"@rollup/plugin-node-resolve": "^9.0.0",
"@n8n/vitest-config": "workspace:*",
"vitest": "catalog:",
"rollup": "^2.52.2"
},
"repository": {
"type": "git",
"url": "https://github.com/n8n-io/n8n",
"directory": "packages/@n8n/codemirror-lang-html"
}
}
@@ -0,0 +1,728 @@
import { EditorState, Text } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { SyntaxNode } from '@lezer/common';
/// Type used to specify tags to complete.
export interface TagSpec {
/// Define tag-specific attributes. Property names are attribute
/// names, and property values can be null to indicate free-form
/// attributes, or a list of strings for suggested attribute values.
attrs?: Record<string, null | readonly string[]>;
/// When set to false, don't complete global attributes on this tag.
globalAttrs?: boolean;
/// Can be used to specify a list of child tags that are valid
/// inside this tag. The default is to allow any tag.
children?: readonly string[];
}
const Targets = ['_blank', '_self', '_top', '_parent'];
const Charsets = ['ascii', 'utf-8', 'utf-16', 'latin1'];
const Methods = ['get', 'post', 'put', 'delete'];
const Encs = ['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'];
const Bool = ['true', 'false'];
const S: TagSpec = {}; // Empty tag spec
const Tags: Record<string, TagSpec> = {
a: {
attrs: {
href: null,
ping: null,
type: null,
media: null,
target: Targets,
hreflang: null,
},
},
abbr: S,
address: S,
area: {
attrs: {
alt: null,
coords: null,
href: null,
target: null,
ping: null,
media: null,
hreflang: null,
type: null,
shape: ['default', 'rect', 'circle', 'poly'],
},
},
article: S,
aside: S,
audio: {
attrs: {
src: null,
mediagroup: null,
crossorigin: ['anonymous', 'use-credentials'],
preload: ['none', 'metadata', 'auto'],
autoplay: ['autoplay'],
loop: ['loop'],
controls: ['controls'],
},
},
b: S,
base: { attrs: { href: null, target: Targets } },
bdi: S,
bdo: S,
blockquote: { attrs: { cite: null } },
body: S,
br: S,
button: {
attrs: {
form: null,
formaction: null,
name: null,
value: null,
autofocus: ['autofocus'],
disabled: ['disabled'],
formenctype: Encs,
formmethod: Methods,
formnovalidate: ['novalidate'],
formtarget: Targets,
type: ['submit', 'reset', 'button'],
},
},
canvas: { attrs: { width: null, height: null } },
caption: S,
center: S,
cite: S,
code: S,
col: { attrs: { span: null } },
colgroup: { attrs: { span: null } },
command: {
attrs: {
type: ['command', 'checkbox', 'radio'],
label: null,
icon: null,
radiogroup: null,
command: null,
title: null,
disabled: ['disabled'],
checked: ['checked'],
},
},
data: { attrs: { value: null } },
datagrid: { attrs: { disabled: ['disabled'], multiple: ['multiple'] } },
datalist: { attrs: { data: null } },
dd: S,
del: { attrs: { cite: null, datetime: null } },
details: { attrs: { open: ['open'] } },
dfn: S,
div: S,
dl: S,
dt: S,
em: S,
embed: { attrs: { src: null, type: null, width: null, height: null } },
eventsource: { attrs: { src: null } },
fieldset: { attrs: { disabled: ['disabled'], form: null, name: null } },
figcaption: S,
figure: S,
footer: S,
form: {
attrs: {
action: null,
name: null,
'accept-charset': Charsets,
autocomplete: ['on', 'off'],
enctype: Encs,
method: Methods,
novalidate: ['novalidate'],
target: Targets,
},
},
h1: S,
h2: S,
h3: S,
h4: S,
h5: S,
h6: S,
head: {
children: ['title', 'base', 'link', 'style', 'meta', 'script', 'noscript', 'command'],
},
header: S,
hgroup: S,
hr: S,
html: {
attrs: { manifest: null },
},
i: S,
iframe: {
attrs: {
src: null,
srcdoc: null,
name: null,
width: null,
height: null,
sandbox: ['allow-top-navigation', 'allow-same-origin', 'allow-forms', 'allow-scripts'],
seamless: ['seamless'],
},
},
img: {
attrs: {
alt: null,
src: null,
ismap: null,
usemap: null,
width: null,
height: null,
crossorigin: ['anonymous', 'use-credentials'],
},
},
input: {
attrs: {
alt: null,
dirname: null,
form: null,
formaction: null,
height: null,
list: null,
max: null,
maxlength: null,
min: null,
name: null,
pattern: null,
placeholder: null,
size: null,
src: null,
step: null,
value: null,
width: null,
accept: ['audio/*', 'video/*', 'image/*'],
autocomplete: ['on', 'off'],
autofocus: ['autofocus'],
checked: ['checked'],
disabled: ['disabled'],
formenctype: Encs,
formmethod: Methods,
formnovalidate: ['novalidate'],
formtarget: Targets,
multiple: ['multiple'],
readonly: ['readonly'],
required: ['required'],
type: [
'hidden',
'text',
'search',
'tel',
'url',
'email',
'password',
'datetime',
'date',
'month',
'week',
'time',
'datetime-local',
'number',
'range',
'color',
'checkbox',
'radio',
'file',
'submit',
'image',
'reset',
'button',
],
},
},
ins: { attrs: { cite: null, datetime: null } },
kbd: S,
keygen: {
attrs: {
challenge: null,
form: null,
name: null,
autofocus: ['autofocus'],
disabled: ['disabled'],
keytype: ['RSA'],
},
},
label: { attrs: { for: null, form: null } },
legend: S,
li: { attrs: { value: null } },
link: {
attrs: {
href: null,
type: null,
hreflang: null,
media: null,
sizes: ['all', '16x16', '16x16 32x32', '16x16 32x32 64x64'],
},
},
map: { attrs: { name: null } },
mark: S,
menu: { attrs: { label: null, type: ['list', 'context', 'toolbar'] } },
meta: {
attrs: {
content: null,
charset: Charsets,
name: ['viewport', 'application-name', 'author', 'description', 'generator', 'keywords'],
'http-equiv': ['content-language', 'content-type', 'default-style', 'refresh'],
},
},
meter: { attrs: { value: null, min: null, low: null, high: null, max: null, optimum: null } },
nav: S,
noscript: S,
object: {
attrs: {
data: null,
type: null,
name: null,
usemap: null,
form: null,
width: null,
height: null,
typemustmatch: ['typemustmatch'],
},
},
ol: {
attrs: { reversed: ['reversed'], start: null, type: ['1', 'a', 'A', 'i', 'I'] },
children: ['li', 'script', 'template', 'ul', 'ol'],
},
optgroup: { attrs: { disabled: ['disabled'], label: null } },
option: { attrs: { disabled: ['disabled'], label: null, selected: ['selected'], value: null } },
output: { attrs: { for: null, form: null, name: null } },
p: S,
param: { attrs: { name: null, value: null } },
pre: S,
progress: { attrs: { value: null, max: null } },
q: { attrs: { cite: null } },
rp: S,
rt: S,
ruby: S,
samp: S,
script: {
attrs: {
type: ['text/javascript'],
src: null,
async: ['async'],
defer: ['defer'],
charset: Charsets,
},
},
section: S,
select: {
attrs: {
form: null,
name: null,
size: null,
autofocus: ['autofocus'],
disabled: ['disabled'],
multiple: ['multiple'],
},
},
slot: { attrs: { name: null } },
small: S,
source: { attrs: { src: null, type: null, media: null } },
span: S,
strong: S,
style: {
attrs: {
type: ['text/css'],
media: null,
scoped: null,
},
},
sub: S,
summary: S,
sup: S,
table: S,
tbody: S,
td: { attrs: { colspan: null, rowspan: null, headers: null } },
template: S,
textarea: {
attrs: {
dirname: null,
form: null,
maxlength: null,
name: null,
placeholder: null,
rows: null,
cols: null,
autofocus: ['autofocus'],
disabled: ['disabled'],
readonly: ['readonly'],
required: ['required'],
wrap: ['soft', 'hard'],
},
},
tfoot: S,
th: {
attrs: {
colspan: null,
rowspan: null,
headers: null,
scope: ['row', 'col', 'rowgroup', 'colgroup'],
},
},
thead: S,
time: { attrs: { datetime: null } },
title: S,
tr: S,
track: {
attrs: {
src: null,
label: null,
default: null,
kind: ['subtitles', 'captions', 'descriptions', 'chapters', 'metadata'],
srclang: null,
},
},
ul: { children: ['li', 'script', 'template', 'ul', 'ol'] },
var: S,
video: {
attrs: {
src: null,
poster: null,
width: null,
height: null,
crossorigin: ['anonymous', 'use-credentials'],
preload: ['auto', 'metadata', 'none'],
autoplay: ['autoplay'],
mediagroup: ['movie'],
muted: ['muted'],
controls: ['controls'],
},
},
wbr: S,
};
const GlobalAttrs: Record<string, null | readonly string[]> = {
accesskey: null,
class: null,
contenteditable: Bool,
contextmenu: null,
dir: ['ltr', 'rtl', 'auto'],
draggable: ['true', 'false', 'auto'],
dropzone: ['copy', 'move', 'link', 'string:', 'file:'],
hidden: ['hidden'],
id: null,
inert: ['inert'],
itemid: null,
itemprop: null,
itemref: null,
itemscope: ['itemscope'],
itemtype: null,
lang: [
'ar',
'bn',
'de',
'en-GB',
'en-US',
'es',
'fr',
'hi',
'id',
'ja',
'pa',
'pt',
'ru',
'tr',
'zh',
],
spellcheck: Bool,
autocorrect: Bool,
autocapitalize: Bool,
style: null,
tabindex: null,
title: null,
translate: ['yes', 'no'],
rel: [
'stylesheet',
'alternate',
'author',
'bookmark',
'help',
'license',
'next',
'nofollow',
'noreferrer',
'prefetch',
'prev',
'search',
'tag',
],
role: 'alert application article banner button cell checkbox complementary contentinfo dialog document feed figure form grid gridcell heading img list listbox listitem main navigation region row rowgroup search switch tab table tabpanel textbox timer'.split(
' ',
),
'aria-activedescendant': null,
'aria-atomic': Bool,
'aria-autocomplete': ['inline', 'list', 'both', 'none'],
'aria-busy': Bool,
'aria-checked': ['true', 'false', 'mixed', 'undefined'],
'aria-controls': null,
'aria-describedby': null,
'aria-disabled': Bool,
'aria-dropeffect': null,
'aria-expanded': ['true', 'false', 'undefined'],
'aria-flowto': null,
'aria-grabbed': ['true', 'false', 'undefined'],
'aria-haspopup': Bool,
'aria-hidden': Bool,
'aria-invalid': ['true', 'false', 'grammar', 'spelling'],
'aria-label': null,
'aria-labelledby': null,
'aria-level': null,
'aria-live': ['off', 'polite', 'assertive'],
'aria-multiline': Bool,
'aria-multiselectable': Bool,
'aria-owns': null,
'aria-posinset': null,
'aria-pressed': ['true', 'false', 'mixed', 'undefined'],
'aria-readonly': Bool,
'aria-relevant': null,
'aria-required': Bool,
'aria-selected': ['true', 'false', 'undefined'],
'aria-setsize': null,
'aria-sort': ['ascending', 'descending', 'none', 'other'],
'aria-valuemax': null,
'aria-valuemin': null,
'aria-valuenow': null,
'aria-valuetext': null,
};
export const eventAttributes = (
'beforeunload copy cut dragstart dragover dragleave dragenter dragend ' +
'drag paste focus blur change click load mousedown mouseenter mouseleave ' +
'mouseup keydown keyup resize scroll unload'
)
.split(' ')
.map((n) => 'on' + n);
for (let a of eventAttributes) GlobalAttrs[a] = null;
export class Schema {
tags: Record<string, TagSpec>;
globalAttrs: Record<string, null | readonly string[]>;
allTags: readonly string[];
globalAttrNames: readonly string[];
constructor(
extraTags?: Record<string, TagSpec>,
extraAttrs?: Record<string, null | readonly string[]>,
) {
this.tags = { ...Tags, ...extraTags };
this.globalAttrs = { ...GlobalAttrs, ...extraAttrs };
this.allTags = Object.keys(this.tags);
this.globalAttrNames = Object.keys(this.globalAttrs);
}
static default = new Schema();
}
export function elementName(doc: Text, tree: SyntaxNode | null | undefined, max = doc.length) {
if (!tree) return '';
let tag = tree.firstChild;
let name = tag && tag.getChild('TagName');
return name ? doc.sliceString(name.from, Math.min(name.to, max)) : '';
}
function findParentElement(tree: SyntaxNode, skip = false) {
for (let cur = tree.parent; cur; cur = cur.parent)
if (cur.name == 'Element') {
if (skip) skip = false;
else return cur;
}
return null;
}
function allowedChildren(doc: Text, tree: SyntaxNode, schema: Schema) {
let parentInfo = schema.tags[elementName(doc, findParentElement(tree, true))];
return parentInfo?.children || schema.allTags;
}
function openTags(doc: Text, tree: SyntaxNode) {
let open = [];
for (let parent: SyntaxNode | null = tree; (parent = findParentElement(parent)); ) {
let tagName = elementName(doc, parent);
if (tagName && parent.lastChild!.name == 'CloseTag') break;
if (
tagName &&
open.indexOf(tagName) < 0 &&
(tree.name == 'EndTag' || tree.from >= parent.firstChild!.to)
)
open.push(tagName);
}
return open;
}
const identifier = /^[:\-\.\w\u00b7-\uffff]*$/;
function completeTag(
state: EditorState,
schema: Schema,
tree: SyntaxNode,
from: number,
to: number,
) {
let end = /\s*>/.test(state.sliceDoc(to, to + 5)) ? '' : '>';
return {
from,
to,
options: allowedChildren(state.doc, tree, schema)
.map((tagName) => ({ label: tagName, type: 'type' }))
.concat(
openTags(state.doc, tree).map((tag, i) => ({
label: '/' + tag,
apply: '/' + tag + end,
type: 'type',
boost: 99 - i,
})),
),
validFor: /^\/?[:\-\.\w\u00b7-\uffff]*$/,
};
}
function completeCloseTag(state: EditorState, tree: SyntaxNode, from: number, to: number) {
let end = /\s*>/.test(state.sliceDoc(to, to + 5)) ? '' : '>';
return {
from,
to,
options: openTags(state.doc, tree).map((tag, i) => ({
label: tag,
apply: tag + end,
type: 'type',
boost: 99 - i,
})),
validFor: identifier,
};
}
function completeStartTag(state: EditorState, schema: Schema, tree: SyntaxNode, pos: number) {
let options = [],
level = 0;
for (let tagName of allowedChildren(state.doc, tree, schema))
options.push({ label: '<' + tagName, type: 'type' });
for (let open of openTags(state.doc, tree))
options.push({ label: '</' + open + '>', type: 'type', boost: 99 - level++ });
return { from: pos, to: pos, options, validFor: /^<\/?[:\-\.\w\u00b7-\uffff]*$/ };
}
function completeAttrName(
state: EditorState,
schema: Schema,
tree: SyntaxNode,
from: number,
to: number,
) {
let elt = findParentElement(tree),
info = elt ? schema.tags[elementName(state.doc, elt)] : null;
let localAttrs = info && info.attrs ? Object.keys(info.attrs) : [];
let names =
info && info.globalAttrs === false
? localAttrs
: localAttrs.length
? localAttrs.concat(schema.globalAttrNames)
: schema.globalAttrNames;
return {
from,
to,
options: names.map((attrName) => ({ label: attrName, type: 'property' })),
validFor: identifier,
};
}
function completeAttrValue(
state: EditorState,
schema: Schema,
tree: SyntaxNode,
from: number,
to: number,
) {
let nameNode = tree.parent?.getChild('AttributeName');
let options = [],
token = undefined;
if (nameNode) {
let attrName = state.sliceDoc(nameNode.from, nameNode.to);
let attrs: readonly string[] | null | undefined = schema.globalAttrs[attrName];
if (!attrs) {
let elt = findParentElement(tree),
info = elt ? schema.tags[elementName(state.doc, elt)] : null;
attrs = info?.attrs && info.attrs[attrName];
}
if (attrs) {
let base = state.sliceDoc(from, to).toLowerCase(),
quoteStart = '"',
quoteEnd = '"';
if (/^['"]/.test(base)) {
token = base[0] == '"' ? /^[^"]*$/ : /^[^']*$/;
quoteStart = '';
quoteEnd = state.sliceDoc(to, to + 1) == base[0] ? '' : base[0];
base = base.slice(1);
from++;
} else {
token = /^[^\s<>='"]*$/;
}
for (let value of attrs)
options.push({ label: value, apply: quoteStart + value + quoteEnd, type: 'constant' });
}
}
return { from, to, options, validFor: token };
}
function htmlCompletionFor(schema: Schema, context: CompletionContext): CompletionResult | null {
let { state, pos } = context,
around = syntaxTree(state).resolveInner(pos),
tree = around.resolve(pos, -1);
for (let scan = pos, before; around == tree && (before = tree.childBefore(scan)); ) {
let last = before.lastChild;
if (!last || !last.type.isError || last.from < last.to) break;
around = tree = before;
scan = last.from;
}
if (tree.name == 'TagName') {
return tree.parent && /CloseTag$/.test(tree.parent.name)
? completeCloseTag(state, tree, tree.from, pos)
: completeTag(state, schema, tree, tree.from, pos);
} else if (tree.name == 'StartTag') {
return completeTag(state, schema, tree, pos, pos);
} else if (tree.name == 'StartCloseTag' || tree.name == 'IncompleteCloseTag') {
return completeCloseTag(state, tree, pos, pos);
} else if (
(context.explicit && (tree.name == 'OpenTag' || tree.name == 'SelfClosingTag')) ||
tree.name == 'AttributeName'
) {
return completeAttrName(
state,
schema,
tree,
tree.name == 'AttributeName' ? tree.from : pos,
pos,
);
} else if (
tree.name == 'Is' ||
tree.name == 'AttributeValue' ||
tree.name == 'UnquotedAttributeValue'
) {
return completeAttrValue(state, schema, tree, tree.name == 'Is' ? pos : tree.from, pos);
} else if (
context.explicit &&
(around.name == 'Element' || around.name == 'Text' || around.name == 'Document')
) {
return completeStartTag(state, schema, tree, pos);
} else {
return null;
}
}
/// HTML tag completion. Opens and closes tags and attributes in a
/// context-aware way.
export function htmlCompletionSource(context: CompletionContext) {
return htmlCompletionFor(Schema.default, context);
}
/// Create a completion source for HTML extended with additional tags
/// or attributes.
export function htmlCompletionSourceWith(config: {
/// Define extra tag names to complete.
extraTags?: Record<string, TagSpec>;
/// Add global attributes that are available on all tags.
extraGlobalAttributes?: Record<string, null | readonly string[]>;
}) {
let { extraTags, extraGlobalAttributes: extraAttrs } = config;
let schema = extraAttrs || extraTags ? new Schema(extraTags, extraAttrs) : Schema.default;
return (context: CompletionContext) => htmlCompletionFor(schema, context);
}
@@ -0,0 +1,117 @@
import {
ScriptText,
StyleText,
TextareaText,
Element,
TagName,
Attribute,
AttributeName,
OpenTag,
CloseTag,
AttributeValue,
UnquotedAttributeValue,
} from './parser.terms.js';
import { parseMixed } from '@lezer/common';
function getAttrs(openTag, input) {
let attrs = Object.create(null);
for (let att of openTag.getChildren(Attribute)) {
let name = att.getChild(AttributeName),
value = att.getChild(AttributeValue) || att.getChild(UnquotedAttributeValue);
if (name)
attrs[input.read(name.from, name.to)] = !value
? ''
: value.type.id == AttributeValue
? input.read(value.from + 1, value.to - 1)
: input.read(value.from, value.to);
}
return attrs;
}
function findTagName(openTag, input) {
let tagNameNode = openTag.getChild(TagName);
return tagNameNode ? input.read(tagNameNode.from, tagNameNode.to) : ' ';
}
function maybeNest(node, input, tags) {
let attrs;
for (let tag of tags) {
if (!tag.attrs || tag.attrs(attrs || (attrs = getAttrs(node.node.parent.firstChild, input))))
return { parser: tag.parser };
}
return null;
}
// tags?: {
// tag: string,
// attrs?: ({[attr: string]: string}) => boolean,
// parser: Parser
// }[]
// attributes?: {
// name: string,
// tagName?: string,
// parser: Parser
// }[]
export function configureNesting(tags = [], attributes = []) {
let script = [],
style = [],
textarea = [],
other = [];
for (let tag of tags) {
let array =
tag.tag == 'script'
? script
: tag.tag == 'style'
? style
: tag.tag == 'textarea'
? textarea
: other;
array.push(tag);
}
let attrs = attributes.length ? Object.create(null) : null;
for (let attr of attributes) (attrs[attr.name] || (attrs[attr.name] = [])).push(attr);
return parseMixed((node, input) => {
let id = node.type.id;
if (id == ScriptText) return maybeNest(node, input, script);
if (id == StyleText) return maybeNest(node, input, style);
if (id == TextareaText) return maybeNest(node, input, textarea);
if (id == OpenTag && other.length) {
let n = node.node,
tagName = findTagName(n, input),
attrs;
for (let tag of other) {
if (
tag.tag == tagName &&
(!tag.attrs || tag.attrs(attrs || (attrs = getAttrs(n, input))))
) {
let close = n.parent.lastChild;
return {
parser: tag.parser,
overlay: [{ from: node.to, to: close.type.id == CloseTag ? close.from : n.parent.to }],
};
}
}
}
if (attrs && id == Attribute) {
let n = node.node,
nameNode;
if ((nameNode = n.firstChild)) {
let matches = attrs[input.read(nameNode.from, nameNode.to)];
if (matches)
for (let attr of matches) {
if (attr.tagName && attr.tagName != findTagName(n.parent, input)) continue;
let value = n.lastChild;
if (value.type.id == AttributeValue)
return { parser: attr.parser, overlay: [{ from: value.from + 1, to: value.to - 1 }] };
else if (value.type.id == UnquotedAttributeValue)
return { parser: attr.parser, overlay: [{ from: value.from, to: value.to }] };
}
}
}
return null;
});
}
@@ -0,0 +1,15 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const htmlHighlighting = styleTags({
'Text RawText': t.content,
'StartTag StartCloseTag SelfClosingEndTag EndTag': t.angleBracket,
TagName: t.tagName,
'MismatchedCloseTag/TagName': [t.tagName, t.invalid],
AttributeName: t.attributeName,
'AttributeValue UnquotedAttributeValue': t.attributeValue,
Is: t.definitionOperator,
'EntityReference CharacterReference': t.character,
Comment: t.blockComment,
ProcessingInst: t.processingInstruction,
DoctypeDecl: t.documentMeta,
});
@@ -0,0 +1,204 @@
@top Document { (entity | DoctypeDecl)+ }
@dialects { noMatch, selfClosing }
entity[@isGroup=Entity] {
Text |
Resolvable |
EntityReference |
CharacterReference |
InvalidEntity |
Element |
Comment |
ProcessingInst |
IncompleteCloseTag |
MismatchedCloseTag |
NoMatchCloseTag
}
Element {
OpenScriptTag ScriptText (CloseScriptTag | missingCloseTag) |
OpenStyleTag StyleText (CloseStyleTag | missingCloseTag) |
OpenTextareaTag TextareaText (CloseTextareaTag | missingCloseTag) |
OpenTag entity* (CloseTag | missingCloseTag) |
SelfClosingTag
}
ScriptText[group="TextContent Entity"] { scriptText* }
StyleText[group="TextContent Entity"] { styleText* }
TextareaText[group="TextContent Entity"] { textareaText* }
@skip { space } {
OpenTag[closedBy=CloseTag] {
StartTag TagName Attribute* EndTag
}
SelfClosingTag {
StartSelfClosingTag TagName Attribute* EndTag |
StartTag TagName Attribute* SelfClosingEndTag
}
MismatchedCloseTag {
MismatchedStartCloseTag TagName EndTag
}
NoMatchCloseTag[@name=CloseTag] {
NoMatchStartCloseTag TagName EndTag
}
CloseTag[openedBy=OpenTag] {
StartCloseTag TagName EndTag
}
OpenScriptTag[@name=OpenTag,closedBy=CloseTag] {
StartScriptTag TagName Attribute* EndTag
}
CloseScriptTag[@name=CloseTag,openedBy=OpenTag] {
StartCloseScriptTag TagName EndTag
}
OpenStyleTag[@name=OpenTag,closedBy=CloseTag] {
StartStyleTag TagName Attribute* EndTag
}
CloseStyleTag[@name=CloseTag,openedBy=OpenTag] {
StartCloseStyleTag TagName EndTag
}
OpenTextareaTag[@name=OpenTag,closedBy=CloseTag] {
StartTextareaTag TagName Attribute* EndTag
}
CloseTextareaTag[@name=CloseTag,openedBy=OpenTag] {
StartCloseTextareaTag TagName EndTag
}
Attribute {
AttributeName (Is (AttributeValue | UnquotedAttributeValue))?
}
}
AttributeValue {
"\"" (attributeContentDouble | singleBrace | Resolvable | EntityReference | CharacterReference | InvalidEntity)* "\"" |
"\'" (attributeContentSingle | singleBrace | Resolvable | EntityReference | CharacterReference | InvalidEntity)* "\'"
}
Comment { commentStart commentContent* commentEnd }
@context elementContext from "./tokens.js"
@external tokens scriptTokens from "./tokens.js" {
scriptText
StartCloseScriptTag[@name=StartCloseTag,closedBy=EndTag]
}
@external tokens styleTokens from "./tokens.js" {
styleText
StartCloseStyleTag[@name=StartCloseTag,closedBy=EndTag]
}
@external tokens textareaTokens from "./tokens.js" {
textareaText
StartCloseTextareaTag[@name=StartCloseTag,closedBy=EndTag]
}
@external tokens tagStart from "./tokens.js" {
StartTag[closedBy="EndTag SelfClosingEndTag"],
StartScriptTag[@name=StartTag,closedBy=EndTag],
StartStyleTag[@name=StartTag,closedBy=EndTag],
StartTextareaTag[@name=StartTag,closedBy=EndTag],
StartSelfClosingTag[@name=StartTag,closedBy=EndTag],
StartCloseTag[closedBy=EndTag],
NoMatchStartCloseTag[@name=StartCloseTag,closedBy=EndTag],
MismatchedStartCloseTag[@name=StartCloseTag,closedBy=EndTag],
missingCloseTag,
IncompleteCloseTag
}
@external tokens commentContent from "./tokens.js" {
commentContent
}
@external tokens singleBraceToken from "./tokens.js" {
singleBrace
}
@tokens {
Resolvable { ResolvableStart resolvableContent* ResolvableEnd }
ResolvableStart[closedBy="ResolvableEnd"] { "{{" }
ResolvableEnd[openedBy="ResolvableStart"] { "}}" }
resolvableContent {
@asciiLetter |
@digit |
@whitespace |
unicodeChar |
"}" ![}] |
"\\}}"
}
unicodeChar { $[\u0000-\u007C] | $[\u007E-\u06FF] }
Text[group=TextContent] { ![{<&] Text? | "{" (@eof | ![{<&] Text?) }
@precedence { Resolvable, Text }
EndTag[openedBy="StartTag StartCloseTag"] { "/"? ">" }
SelfClosingEndTag[openedBy=StartTag,@dialect=selfClosing] { "/>" }
@precedence { SelfClosingEndTag, EndTag }
nameStart {
":" | @asciiLetter | "_" |
$[\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D] |
$[\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}]
}
nameChar {
nameStart | "-" | "." | @digit | $[\u00B7\u0300-\u036F\u203F-\u2040]
}
identifier { nameStart nameChar* }
TagName { identifier }
AttributeName { ![\u0000-\u0020\u007F-\u009F"'>/=\uFDD0-\uFDEF\uFFFE\uFFFF]+ }
UnquotedAttributeValue { ![ \t\n\r\u000C=<>"'`]+ }
attributeContentDouble { !["&{]+ }
attributeContentSingle { !['&{]+ }
@precedence { Resolvable, attributeContentDouble, attributeContentSingle }
Is { "=" }
EntityReference { "&" ![#; ]+ ";" }
CharacterReference { "&#" ![; ]+ ";" }
InvalidEntity { "&" }
@precedence { CharacterReference, EntityReference, InvalidEntity }
commentStart { "<!--" }
commentEnd { "-->" }
ProcessingInst { "<?" piContent }
piContent { ![?] piContent | "?" piQuestion }
piQuestion { ![>] piContent | ">" }
DoctypeDecl { "<!" ("doctype" | "DOCTYPE") ![>]* ">" }
@precedence { commentStart, ProcessingInst, DoctypeDecl }
space { (" " | "\t" | "\r" | "\n")+ }
}
@external propSource htmlHighlighting from "./highlight"
@@ -0,0 +1,2 @@
export { parser } from './parser';
export { configureNesting } from './content';
@@ -0,0 +1,19 @@
import { nodeResolve } from '@rollup/plugin-node-resolve';
export default {
input: './src/grammar/index.js',
output: [
{
format: 'cjs',
file: './src/grammar/dist/index.cjs',
},
{
format: 'es',
file: './src/grammar/dist/index.es.js',
},
],
external(id) {
return !/^[\.\/]/.test(id);
},
plugins: [nodeResolve()],
};
@@ -0,0 +1,69 @@
# Doesn't parse VB as JS
<script type="text/visualbasic">let something = 20</script>
==>
Document(Element(OpenTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue),EndTag),
ScriptText,
CloseTag(StartCloseTag,TagName,EndTag)))
# Does parse type-less script tags as JS
<script>/foo/</script>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Script(ExpressionStatement(RegExp)),
CloseTag(StartCloseTag,TagName,EndTag)))
# Still doesn't end script tags on closing tags
<script type=something></foo></script>
==>
Document(Element(OpenTag(StartTag,TagName,Attribute(AttributeName,Is,UnquotedAttributeValue),EndTag),
ScriptText,
CloseTag(StartCloseTag,TagName,EndTag)))
# Missing end tag
<html><script>null
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Element(OpenTag(StartTag,TagName,EndTag),
Script(ExpressionStatement(null)))))
# JS with script type
<script type="text/javascript">console.log(2)</script>
==>
Document(Element(OpenTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue),EndTag),
Script(...),
CloseTag(StartCloseTag,TagName,EndTag)))
# JS with unquoted script type
<script type=module>console.log(2)</script>
==>
Document(Element(OpenTag(StartTag,TagName,Attribute(AttributeName,Is,UnquotedAttributeValue),EndTag),
Script(...),
CloseTag(StartCloseTag,TagName,EndTag)))
# Error in JS
<script>a b</script>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Script(...),
CloseTag(StartCloseTag,TagName,EndTag)))
@@ -0,0 +1,215 @@
# Resolvable
{{ abc }}
==>
Document(
Resolvable
)
# Resolvable, Text
{{ abc }}xyz
==>
Document(Resolvable, Text)
# Text, Resolvable
xyz{{ abc }}
==>
Document(Text, Resolvable)
# Regular tag, Resolvable, regular tag
<foo>{{ abc }}</foo>
==>
Document(
Element(
OpenTag(StartTag,TagName,EndTag),
Resolvable,
CloseTag(StartCloseTag,TagName,EndTag)
)
)
# Resolvable then regular tags
{{ abc }}<foo></foo>
==>
Document(
Resolvable,
Element(
OpenTag(StartTag,TagName,EndTag),
CloseTag(StartCloseTag,TagName,EndTag)
)
)
# Regular tags then resolvable
<foo></foo>{{ abc }}
==>
Document(
Element(
OpenTag(StartTag,TagName,EndTag),
CloseTag(StartCloseTag,TagName,EndTag)
),
Resolvable
)
# Regular tag, Resolvable, Text, regular tag
<foo>{{ abc }}xyz</foo>
==>
Document(
Element(
OpenTag(StartTag,TagName,EndTag),
Resolvable,
Text,
CloseTag(StartCloseTag,TagName,EndTag)
)
)
# Regular tag, Text, Resolvable, regular tag
<foo>xyz{{ abc }}</foo>
==>
Document(
Element(
OpenTag(StartTag,TagName,EndTag),
Text,
Resolvable,
CloseTag(StartCloseTag,TagName,EndTag)
)
)
# Regular tag, Text, Resolvable, Text, regular tag
<foo>xyz{{ abc }}xyz</foo>
==>
Document(
Element(
OpenTag(StartTag,TagName,EndTag),
Text,
Resolvable,
Text,
CloseTag(StartCloseTag,TagName,EndTag)
)
)
# Resolvable inside attribute value (double quotes)
<img src="{{ $json.url }}" />
==>
Document(
Element(
SelfClosingTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue(Resolvable)),EndTag)
)
)
# Resolvable mixed with text inside attribute value
<img src="https://example.com/{{ $json.id }}.jpg" />
==>
Document(
Element(
SelfClosingTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue(Resolvable)),EndTag)
)
)
# Single brace only in double-quoted attribute
<div attr="{"></div>
==>
Document(
Element(
OpenTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue),EndTag),
CloseTag(StartCloseTag,TagName,EndTag)
)
)
# Single brace only in single-quoted attribute
<div attr='{'></div>
==>
Document(
Element(
OpenTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue),EndTag),
CloseTag(StartCloseTag,TagName,EndTag)
)
)
# Single brace at end of double-quoted attribute
<div attr="a{"></div>
==>
Document(
Element(
OpenTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue),EndTag),
CloseTag(StartCloseTag,TagName,EndTag)
)
)
# Single brace at end of single-quoted attribute
<div attr='a{'></div>
==>
Document(
Element(
OpenTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue),EndTag),
CloseTag(StartCloseTag,TagName,EndTag)
)
)
# Single brace at start of double-quoted attribute
<div attr="{a"></div>
==>
Document(
Element(
OpenTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue),EndTag),
CloseTag(StartCloseTag,TagName,EndTag)
)
)
# Resolvable followed by single brace in attribute
<div attr="{{x}}{"></div>
==>
Document(
Element(
OpenTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue(Resolvable)),EndTag),
CloseTag(StartCloseTag,TagName,EndTag)
)
)
@@ -0,0 +1,346 @@
# Regular tag
<foo>bar</foo>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),Text,CloseTag(StartCloseTag,TagName,EndTag)))
# Nested tag
<a><b>c</b><br></a>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Element(OpenTag(StartTag,TagName,EndTag),Text,CloseTag(StartCloseTag,TagName,EndTag)),
Element(SelfClosingTag(StartTag,TagName,EndTag)),
CloseTag(StartCloseTag,TagName,EndTag)))
# Attribute
<br foo="bar">
==>
Document(Element(SelfClosingTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue),EndTag)))
# Multiple attributes
<a x="one" y="two" z="three"></a>
==>
Document(Element(OpenTag(StartTag,TagName,
Attribute(AttributeName,Is,AttributeValue),
Attribute(AttributeName,Is,AttributeValue),
Attribute(AttributeName,Is,AttributeValue),EndTag),
CloseTag(StartCloseTag,TagName,EndTag)))
# Value-less attributes
<a x y="one" z></a>
==>
Document(Element(OpenTag(StartTag,TagName,
Attribute(AttributeName),
Attribute(AttributeName,Is,AttributeValue),
Attribute(AttributeName),EndTag),
CloseTag(StartCloseTag,TagName,EndTag)))
# Unquoted attributes
<a x=one y z=two></a>
==>
Document(Element(OpenTag(StartTag,TagName,
Attribute(AttributeName,Is,UnquotedAttributeValue),
Attribute(AttributeName),
Attribute(AttributeName,Is,UnquotedAttributeValue),EndTag),
CloseTag(StartCloseTag,TagName,EndTag)))
# Unquoted attributes with slashes
<link as=font crossorigin=anonymous href=/fonts/google-sans/regular/latin.woff2 rel=preload>
==>
Document(Element(SelfClosingTag(StartTag,TagName,
Attribute(AttributeName,Is,UnquotedAttributeValue),
Attribute(AttributeName,Is,UnquotedAttributeValue),
Attribute(AttributeName,Is,UnquotedAttributeValue),
Attribute(AttributeName,Is,UnquotedAttributeValue),
EndTag)))
# Single-quoted attributes
<link x='one' z='two&amp;'>
==>
Document(Element(SelfClosingTag(StartTag, TagName,
Attribute(AttributeName, Is, AttributeValue),
Attribute(AttributeName, Is, AttributeValue(EntityReference)),
EndTag)))
# Entities
<a attr="one&amp;two">&amp;&#67;</a>
==>
Document(Element(OpenTag(StartTag,TagName,
Attribute(AttributeName,Is,AttributeValue(EntityReference)),EndTag),
EntityReference,CharacterReference,
CloseTag(StartCloseTag,TagName,EndTag)))
# Doctype
<!doctype html>
<doc></doc>
==>
Document(DoctypeDecl,Text,Element(OpenTag(StartTag,TagName,EndTag),CloseTag(StartCloseTag,TagName,EndTag)))
# Processing instructions
<?foo?><bar><?baz?></bar>
==>
Document(ProcessingInst,Element(OpenTag(StartTag,TagName,EndTag),ProcessingInst,CloseTag(StartCloseTag,TagName,EndTag)))
# Comments
<!-- top comment -->
<element><!-- inner comment --> text</element>
==>
Document(Comment,Text,Element(OpenTag(StartTag,TagName,EndTag),Comment,Text,CloseTag(StartCloseTag,TagName,EndTag)))
# Mismatched tag
<a></b>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),MismatchedCloseTag(StartCloseTag,TagName,EndTag)))
# Unclosed tag
<a>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag)))
# Ignore pseudo-xml self-closers
<br/>
==>
Document(Element(SelfClosingTag(StartTag,TagName,EndTag)))
# Unclosed implicitly closed tag
<p>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag)))
# Nested mismatched tag
<a><b><c></c></x></a>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Element(OpenTag(StartTag,TagName,EndTag),
Element(OpenTag(StartTag,TagName,EndTag),CloseTag(StartCloseTag,TagName,EndTag)),
MismatchedCloseTag(StartCloseTag,TagName,EndTag),
⚠),
CloseTag(StartCloseTag,TagName,EndTag)))
# Mismatched tag with whitespace
<doc>
<
foo bar="10">
blah
</oof>
</doc>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Text,
Element(OpenTag(StartTag,TagName,Attribute(AttributeName,Is,AttributeValue),EndTag),
Text,
MismatchedCloseTag(StartCloseTag,TagName,EndTag),
Text, ⚠),
CloseTag(StartCloseTag,TagName,EndTag)))
# Incomplete close tag
<html><body></</html>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Element(OpenTag(StartTag,TagName,EndTag), IncompleteCloseTag, ⚠),
CloseTag(StartCloseTag,TagName,EndTag)))
# Re-synchronize close tags
<a><b><c></x></c></a>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Element(OpenTag(StartTag,TagName,EndTag),
Element(OpenTag(StartTag,TagName,EndTag),
MismatchedCloseTag(StartCloseTag,TagName,EndTag),
CloseTag(StartCloseTag,TagName,EndTag)),
⚠),
CloseTag(StartCloseTag,TagName,EndTag)))
# Top-level mismatched close tag
<a></a></a>
==>
Document(
Element(OpenTag(StartTag,TagName,EndTag),CloseTag(StartCloseTag,TagName,EndTag)),
MismatchedCloseTag(StartCloseTag,TagName,EndTag))
# Self-closing tags
<a><img src=blah></a>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Element(SelfClosingTag(StartTag,TagName,Attribute(AttributeName,Is,UnquotedAttributeValue),EndTag)),
CloseTag(StartCloseTag,TagName,EndTag)))
# Implicitly closed
<dl><dd>Hello</dl>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Element(OpenTag(StartTag,TagName,EndTag),Text),
CloseTag(StartCloseTag,TagName,EndTag)))
# Closed by sibling
<div>
<p>Foo
<p>Bar
</div>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Text,
Element(OpenTag(StartTag,TagName,EndTag),Text),
Element(OpenTag(StartTag,TagName,EndTag),Text),
CloseTag(StartCloseTag,TagName,EndTag)))
# Closed by sibling at top
<p>Foo
<p>Bar
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),Text),Element(OpenTag(StartTag,TagName,EndTag),Text))
# Textarea
<p>Enter something: <textarea code-lang=javascript>function foo() {
return "</bar>"
}</textarea>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),
Text,
Element(OpenTag(StartTag,TagName,Attribute(AttributeName,Is,UnquotedAttributeValue),EndTag),
TextareaText,
CloseTag(StartCloseTag,TagName,EndTag))))
# Script
<script>This is not an entity: &lt;</script>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),ScriptText,CloseTag(StartCloseTag,TagName,EndTag)))
# Allows whitespace in tags
<
body
foo=bar
>hi</
body
>
==>
Document(Element(
OpenTag(StartTag,TagName,Attribute(AttributeName,Is,UnquotedAttributeValue),EndTag),
Text,
CloseTag(StartCloseTag,TagName,EndTag)))
# Doesn't get confused by a stray ampersand
<html>a&b</html>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),Text,InvalidEntity,Text,CloseTag(StartCloseTag,TagName,EndTag)))
# Can ignore mismatches {"dialect": "noMatch"}
<div>foo</p>
==>
Document(Element(OpenTag(StartTag,TagName,EndTag),Text,CloseTag(StartCloseTag,TagName,EndTag)))
# Can handle lone close tags {"dialect": "noMatch"}
</strong>
==>
Document(CloseTag(StartCloseTag,TagName,EndTag))
# Parses ampersands in attributes
<img src="foo&bar">
==>
Document(Element(SelfClosingTag(StartTag, TagName, Attribute(AttributeName, Is, AttributeValue(InvalidEntity)), EndTag)))
# Supports self-closing dialect {"dialect": "selfClosing"}
<section><image id=i2 /></section>
==>
Document(Element(
OpenTag(StartTag,TagName,EndTag),
Element(SelfClosingTag(StartTag,TagName,Attribute(AttributeName,Is,UnquotedAttributeValue),SelfClosingEndTag)),
CloseTag(StartCloseTag,TagName,EndTag)))
@@ -0,0 +1,33 @@
import { parser, configureNesting } from '../dist/index.es.js';
import { parser as jsParser } from '@lezer/javascript';
import { fileTests } from '@lezer/generator/dist/test';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
let caseDir = path.dirname(fileURLToPath(import.meta.url));
let mixed = parser.configure({
wrap: configureNesting([
{
tag: 'script',
attrs(attrs) {
return (
!attrs.type ||
/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i.test(attrs.type)
);
},
parser: jsParser,
},
]),
});
for (let file of fs.readdirSync(caseDir)) {
if (!/\.txt$/.test(file)) continue;
let name = /^[^\.]*/.exec(file)[0];
describe(name, () => {
let p = name == 'mixed' ? mixed : parser;
for (let { name, run } of fileTests(fs.readFileSync(path.join(caseDir, file), 'utf8'), file))
it(name, () => run(p));
});
}
@@ -0,0 +1,120 @@
import { parser as baseParser } from '../dist/index.es.js';
import { TreeFragment } from '@lezer/common';
let parser = baseParser.configure({ bufferLength: 2 });
let r = (n) => Math.floor(Math.random() * n);
let tags = [
'p',
'ul',
'li',
'div',
'span',
'th',
'tr',
'body',
'head',
'title',
'dd',
'code',
'em',
'strong',
];
function randomDoc(size) {
let doc = '';
if (!r(5)) doc += '<!doctype html>';
let scope = [];
for (let i = 0; i < size; i++) {
let sel = r(20);
if (sel < 5) {
let tag = tags[r(tags.length)];
doc += `<${tag}${r(2) ? ' a=b' : ''}>`;
scope.push(tag);
} else if (sel < 10 && scope.length) {
let name = scope.pop();
doc += `</${r(5) ? name : 'div'}>`;
} else if (sel == 10) {
doc += `<img>`;
} else if (sel == 11) {
doc += '<script>a()</script>';
} else if (sel == 12) {
doc += r(2) ? '&amp;' : '<!--@-->';
} else {
for (let i = r(6) + 1; i >= 0; i--) doc += String.fromCharCode(97 + r(26));
}
}
while (scope.length) {
let name = scope.pop();
if (r(5)) doc += `</${name}>`;
}
return doc;
}
function check(doc, [tp, pos, txt], prevAST) {
let change = { fromA: pos, toA: pos, fromB: pos, toB: pos },
newDoc;
if (tp == 'insert') {
newDoc = doc.slice(0, pos) + txt + doc.slice(pos);
change.toA += txt.length;
} else if (tp == 'del') {
newDoc = doc.slice(0, pos) + doc.slice(pos + 1);
change.toB++;
} else {
newDoc = doc.slice(0, pos) + txt + doc.slice(pos + 1);
change.toA += txt.length;
change.toB++;
}
let fragments = TreeFragment.applyChanges(
TreeFragment.addTree(prevAST || parser.parse(doc)),
[change],
2,
);
let ast = parser.parse(newDoc, fragments);
let orig = parser.parse(newDoc);
if (ast.toString() != orig.toString()) {
throw new Error(
`Mismatch:\n ${ast}\nvs\n ${orig}\ndocument: ${JSON.stringify(
doc,
)}\naction: ${JSON.stringify([tp, pos, ch])}`,
);
}
return [newDoc, ast];
}
// Call this to just run random tests until a failing one is found.
// Not directly called in the tests because there's a bunch of
// circumstances in which uninteresting deviations in error recovery
// will create differing parses, so results have to be manually
// inspected.
function generate() {
for (let count = 0, size = 2; ; size = Math.min(40, size + 1)) {
let doc = randomDoc(size),
prev = null;
for (let i = 0; i < 2; i++) {
console.log('Attempt', ++count);
let action = [['del', 'insert', 'replace'][r(3)], r(doc.length - 1), '<>/piabc '[r(9)]];
[doc, prev] = check(doc, action, prev);
}
}
}
describe('Incremental parsing', () => {
it("doesn't get confused by reused opening tags", () => {
check('<code><code>mgnbni</code></code>', ['del', 29]);
});
it('can handle a renamed opening tag after a self-closing', () => {
check('<p>one two three four five six seven<p>eight', ['replace', 37, 'a']);
});
it('is okay with nameless elements', () => {
check('<body><code><img></code><>body>', ['replace', 14, '>']);
check('abcde<>fghij<', ['replace', 12, '>']);
});
it("doesn't get confused by an invalid close tag receiving a matching open tag", () => {
check('<div><p>foo</body>', ['insert', 0, '<body>']);
});
});
@@ -0,0 +1,56 @@
# Parses Vue builtin directives
<span v-text="msg"></span>
==>
Document(
Element(
OpenTag(StartTag, TagName, Attribute(AttributeName, Is, AttributeValue), EndTag),
CloseTag(StartCloseTag, TagName, EndTag)))
# Parses Vue :is shorthand syntax
<Component :is="view"></Component>
==>
Document(
Element(
OpenTag(StartTag, TagName, Attribute(AttributeName, Is, AttributeValue),EndTag),
CloseTag(StartCloseTag, TagName, EndTag)))
# Parses Vue @click shorthand syntax
<button @click="handler()">Click me</button>
==>
Document(
Element(
OpenTag(StartTag, TagName, Attribute(AttributeName, Is, AttributeValue), EndTag),
Text,
CloseTag(StartCloseTag, TagName, EndTag)))
# Parses Vue @submit.prevent shorthand syntax
<form @submit.prevent="onSubmit"></form>
==>
Document(
Element(
OpenTag(StartTag, TagName, Attribute(AttributeName, Is, AttributeValue), EndTag),
CloseTag(StartCloseTag, TagName, EndTag)))
# Parses Vue Dynamic Arguments
<a v-bind:[attributeName]="url">Link</a>
==>
Document(
Element(
OpenTag(StartTag, TagName, Attribute(AttributeName, Is, AttributeValue), EndTag),
Text,
CloseTag(StartCloseTag, TagName, EndTag)))
@@ -0,0 +1,303 @@
/* Hand-written tokenizers for HTML. */
import { ExternalTokenizer, ContextTracker } from '@lezer/lr';
import {
StartTag,
StartCloseTag,
NoMatchStartCloseTag,
MismatchedStartCloseTag,
missingCloseTag,
StartSelfClosingTag,
IncompleteCloseTag,
Element,
OpenTag,
StartScriptTag,
scriptText,
StartCloseScriptTag,
StartStyleTag,
styleText,
StartCloseStyleTag,
StartTextareaTag,
textareaText,
StartCloseTextareaTag,
Dialect_noMatch,
commentContent as cmntContent,
singleBrace,
} from './parser.terms.js';
const selfClosers = {
area: true,
base: true,
br: true,
col: true,
command: true,
embed: true,
frame: true,
hr: true,
img: true,
input: true,
keygen: true,
link: true,
meta: true,
param: true,
source: true,
track: true,
wbr: true,
menuitem: true,
};
const implicitlyClosed = {
dd: true,
li: true,
optgroup: true,
option: true,
p: true,
rp: true,
rt: true,
tbody: true,
td: true,
tfoot: true,
th: true,
tr: true,
};
const closeOnOpen = {
dd: { dd: true, dt: true },
dt: { dd: true, dt: true },
li: { li: true },
option: { option: true, optgroup: true },
optgroup: { optgroup: true },
p: {
address: true,
article: true,
aside: true,
blockquote: true,
dir: true,
div: true,
dl: true,
fieldset: true,
footer: true,
form: true,
h1: true,
h2: true,
h3: true,
h4: true,
h5: true,
h6: true,
header: true,
hgroup: true,
hr: true,
menu: true,
nav: true,
ol: true,
p: true,
pre: true,
section: true,
table: true,
ul: true,
},
rp: { rp: true, rt: true },
rt: { rp: true, rt: true },
tbody: { tbody: true, tfoot: true },
td: { td: true, th: true },
tfoot: { tbody: true },
th: { td: true, th: true },
thead: { tbody: true, tfoot: true },
tr: { tr: true },
};
function nameChar(ch) {
return (
ch == 45 ||
ch == 46 ||
ch == 58 ||
(ch >= 65 && ch <= 90) ||
ch == 95 ||
(ch >= 97 && ch <= 122) ||
ch >= 161
);
}
function isSpace(ch) {
return ch == 9 || ch == 10 || ch == 13 || ch == 32;
}
let cachedName = null,
cachedInput = null,
cachedPos = 0;
function tagNameAfter(input, offset) {
let pos = input.pos + offset;
if (cachedPos == pos && cachedInput == input) return cachedName;
let next = input.peek(offset);
while (isSpace(next)) next = input.peek(++offset);
let name = '';
for (;;) {
if (!nameChar(next)) break;
name += String.fromCharCode(next);
next = input.peek(++offset);
}
// Undefined to signal there's a <? or <!, null for just missing
cachedInput = input;
cachedPos = pos;
return (cachedName = name
? name.toLowerCase()
: next == question || next == bang
? undefined
: null);
}
const lessThan = 60,
greaterThan = 62,
slash = 47,
question = 63,
bang = 33,
dash = 45;
function ElementContext(name, parent) {
this.name = name;
this.parent = parent;
this.hash = parent ? parent.hash : 0;
for (let i = 0; i < name.length; i++)
this.hash += (this.hash << 4) + name.charCodeAt(i) + (name.charCodeAt(i) << 8);
}
const startTagTerms = [
StartTag,
StartSelfClosingTag,
StartScriptTag,
StartStyleTag,
StartTextareaTag,
];
export const elementContext = new ContextTracker({
start: null,
shift(context, term, stack, input) {
return startTagTerms.indexOf(term) > -1
? new ElementContext(tagNameAfter(input, 1) || '', context)
: context;
},
reduce(context, term) {
return term == Element && context ? context.parent : context;
},
reuse(context, node, stack, input) {
let type = node.type.id;
return type == StartTag || type == OpenTag
? new ElementContext(tagNameAfter(input, 1) || '', context)
: context;
},
hash(context) {
return context ? context.hash : 0;
},
strict: false,
});
export const tagStart = new ExternalTokenizer(
(input, stack) => {
if (input.next != lessThan) {
// End of file, close any open tags
if (input.next < 0 && stack.context) input.acceptToken(missingCloseTag);
return;
}
input.advance();
let close = input.next == slash;
if (close) input.advance();
let name = tagNameAfter(input, 0);
if (name === undefined) return;
if (!name) return input.acceptToken(close ? IncompleteCloseTag : StartTag);
let parent = stack.context ? stack.context.name : null;
if (close) {
if (name == parent) return input.acceptToken(StartCloseTag);
if (parent && implicitlyClosed[parent]) return input.acceptToken(missingCloseTag, -2);
if (stack.dialectEnabled(Dialect_noMatch)) return input.acceptToken(NoMatchStartCloseTag);
for (let cx = stack.context; cx; cx = cx.parent) if (cx.name == name) return;
input.acceptToken(MismatchedStartCloseTag);
} else {
if (name == 'script') return input.acceptToken(StartScriptTag);
if (name == 'style') return input.acceptToken(StartStyleTag);
if (name == 'textarea') return input.acceptToken(StartTextareaTag);
if (selfClosers.hasOwnProperty(name)) return input.acceptToken(StartSelfClosingTag);
if (parent && closeOnOpen[parent] && closeOnOpen[parent][name])
input.acceptToken(missingCloseTag, -1);
else input.acceptToken(StartTag);
}
},
{ contextual: true },
);
export const commentContent = new ExternalTokenizer((input) => {
for (let dashes = 0, i = 0; ; i++) {
if (input.next < 0) {
if (i) input.acceptToken(cmntContent);
break;
}
if (input.next == dash) {
dashes++;
} else if (input.next == greaterThan && dashes >= 2) {
if (i > 3) input.acceptToken(cmntContent, -2);
break;
} else {
dashes = 0;
}
input.advance();
}
});
function contentTokenizer(tag, textToken, endToken) {
let lastState = 2 + tag.length;
return new ExternalTokenizer((input) => {
// state means:
// - 0 nothing matched
// - 1 '<' matched
// - 2 '</' + possibly whitespace matched
// - 3-(1+tag.length) part of the tag matched
// - lastState whole tag + possibly whitespace matched
for (let state = 0, matchedLen = 0, i = 0; ; i++) {
if (input.next < 0) {
if (i) input.acceptToken(textToken);
break;
}
if (
(state == 0 && input.next == lessThan) ||
(state == 1 && input.next == slash) ||
(state >= 2 && state < lastState && input.next == tag.charCodeAt(state - 2))
) {
state++;
matchedLen++;
} else if ((state == 2 || state == lastState) && isSpace(input.next)) {
matchedLen++;
} else if (state == lastState && input.next == greaterThan) {
if (i > matchedLen) input.acceptToken(textToken, -matchedLen);
else input.acceptToken(endToken, -(matchedLen - 2));
break;
} else if ((input.next == 10 /* '\n' */ || input.next == 13) /* '\r' */ && i) {
input.acceptToken(textToken, 1);
break;
} else {
state = matchedLen = 0;
}
input.advance();
}
});
}
export const scriptTokens = contentTokenizer('script', scriptText, StartCloseScriptTag);
export const styleTokens = contentTokenizer('style', styleText, StartCloseStyleTag);
export const textareaTokens = contentTokenizer('textarea', textareaText, StartCloseTextareaTag);
const openBrace = 123; // '{'
// Tokenizer for a single '{' that is NOT the start of a '{{' resolvable.
// This handles the edge case where '{' appears at the end of an attribute value
// (e.g., attr="{" or attr="a{") which can't be matched by grammar-only patterns.
export const singleBraceToken = new ExternalTokenizer((input) => {
if (input.next === openBrace) {
input.advance();
// Only accept if NOT followed by another '{' (which would start a resolvable)
if (input.next !== openBrace) {
input.acceptToken(singleBrace);
}
}
});
@@ -0,0 +1,233 @@
// import {parser, configureNesting} from "@lezer/html"
import { parser, configureNesting } from './grammar/index.js';
import { Parser } from '@lezer/common';
import { cssLanguage, css } from '@codemirror/lang-css';
import {
javascriptLanguage,
typescriptLanguage,
jsxLanguage,
tsxLanguage,
javascript,
} from '@codemirror/lang-javascript';
import { EditorView } from '@codemirror/view';
import { EditorSelection } from '@codemirror/state';
import {
LRLanguage,
indentNodeProp,
foldNodeProp,
LanguageSupport,
syntaxTree,
} from '@codemirror/language';
import { elementName, htmlCompletionSourceWith, TagSpec, eventAttributes } from './complete';
export { htmlCompletionSource, TagSpec, htmlCompletionSourceWith } from './complete';
type NestedLang = {
tag: string;
attrs?: (attrs: { [attr: string]: string }) => boolean;
parser: Parser;
};
const defaultNesting: NestedLang[] = [
{
tag: 'script',
attrs: (attrs) => attrs.type == 'text/typescript' || attrs.lang == 'ts',
parser: typescriptLanguage.parser,
},
{
tag: 'script',
attrs: (attrs) => attrs.type == 'text/babel' || attrs.type == 'text/jsx',
parser: jsxLanguage.parser,
},
{
tag: 'script',
attrs: (attrs) => attrs.type == 'text/typescript-jsx',
parser: tsxLanguage.parser,
},
{
tag: 'script',
attrs(attrs) {
return (
!attrs.type ||
/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i.test(attrs.type)
);
},
parser: javascriptLanguage.parser,
},
{
tag: 'style',
attrs(attrs) {
return (
(!attrs.lang || attrs.lang == 'css') &&
(!attrs.type || /^(text\/)?(x-)?(stylesheet|css)$/i.test(attrs.type))
);
},
parser: cssLanguage.parser,
},
];
type NestedAttr = {
name: string;
tagName?: string;
parser: Parser;
};
const defaultAttrs: NestedAttr[] = [
{ name: 'style', parser: cssLanguage.parser.configure({ top: 'Styles' }) },
].concat(eventAttributes.map((name) => ({ name, parser: javascriptLanguage.parser })));
/// A language provider based on the [Lezer HTML
/// parser](https://github.com/lezer-parser/html), extended with the
/// JavaScript and CSS parsers to parse the content of `<script>` and
/// `<style>` tags.
export const htmlLanguage = LRLanguage.define({
name: 'html',
parser: parser.configure({
props: [
indentNodeProp.add({
Element(context) {
let after = /^(\s*)(<\/)?/.exec(context.textAfter)!;
if (context.node.to <= context.pos + after[0].length) return context.continue();
return context.lineIndent(context.node.from) + (after[2] ? 0 : context.unit);
},
'OpenTag CloseTag SelfClosingTag'(context) {
return context.column(context.node.from) + context.unit;
},
Document(context) {
if (context.pos + /\s*/.exec(context.textAfter)![0].length < context.node.to)
return context.continue();
let endElt = null,
close;
for (let cur = context.node; ; ) {
let last = cur.lastChild;
if (!last || last.name != 'Element' || last.to != cur.to) break;
endElt = cur = last;
}
if (
endElt &&
!(
(close = endElt.lastChild) &&
(close.name == 'CloseTag' || close.name == 'SelfClosingTag')
)
)
return context.lineIndent(endElt.from) + context.unit;
return null;
},
}),
foldNodeProp.add({
Element(node) {
let first = node.firstChild,
last = node.lastChild!;
if (!first || first.name != 'OpenTag') return null;
return { from: first.to, to: last.name == 'CloseTag' ? last.from : node.to };
},
}),
],
wrap: configureNesting(defaultNesting, defaultAttrs),
}),
languageData: {
commentTokens: { block: { open: '<!--', close: '-->' } },
indentOnInput: /^\s*<\/\w+\W$/,
wordChars: '-._',
},
});
/// Language support for HTML, including
/// [`htmlCompletion`](#lang-html.htmlCompletion) and JavaScript and
/// CSS support extensions.
export function html(
config: {
/// By default, the syntax tree will highlight mismatched closing
/// tags. Set this to `false` to turn that off (for example when you
/// expect to only be parsing a fragment of HTML text, not a full
/// document).
matchClosingTags?: boolean;
// By default, the parser does not allow arbitrary self-closing tags.
// Set this to `true` to turn on support for `/>` self-closing tag
// syntax.
selfClosingTags?: boolean;
/// Determines whether [`autoCloseTags`](#lang-html.autoCloseTags)
/// is included in the support extensions. Defaults to true.
autoCloseTags?: boolean;
/// Add additional tags that can be completed.
extraTags?: Record<string, TagSpec>;
/// Add additional completable attributes to all tags.
extraGlobalAttributes?: Record<string, null | readonly string[]>;
/// Register additional languages to parse the content of specific
/// tags. If given, `attrs` should be a function that, given an
/// object representing the tag's attributes, returns `true` if this
/// language applies.
nestedLanguages?: NestedLang[];
/// Register additional languages to parse attribute values with.
nestedAttributes?: NestedAttr[];
} = {},
) {
let dialect = '',
wrap;
if (config.matchClosingTags === false) dialect = 'noMatch';
if (config.selfClosingTags === true) dialect = (dialect ? dialect + ' ' : '') + 'selfClosing';
if (
(config.nestedLanguages && config.nestedLanguages.length) ||
(config.nestedAttributes && config.nestedAttributes.length)
)
wrap = configureNesting(
(config.nestedLanguages || []).concat(defaultNesting),
(config.nestedAttributes || []).concat(defaultAttrs),
);
let lang = wrap || dialect ? htmlLanguage.configure({ dialect, wrap }) : htmlLanguage;
return new LanguageSupport(lang, [
htmlLanguage.data.of({ autocomplete: htmlCompletionSourceWith(config) }),
config.autoCloseTags !== false ? autoCloseTags : [],
javascript().support,
css().support,
]);
}
/// Extension that will automatically insert close tags when a `>` or
/// `/` is typed.
export const autoCloseTags = EditorView.inputHandler.of((view, from, to, text) => {
if (
view.composing ||
view.state.readOnly ||
from != to ||
(text != '>' && text != '/') ||
!htmlLanguage.isActiveAt(view.state, from, -1)
)
return false;
let { state } = view;
let changes = state.changeByRange((range) => {
let { head } = range,
around = syntaxTree(state).resolveInner(head, -1),
name;
if (around.name == 'TagName' || around.name == 'StartTag') around = around.parent!;
if (text == '>' && around.name == 'OpenTag') {
if (
around.parent?.lastChild?.name != 'CloseTag' &&
(name = elementName(state.doc, around.parent, head))
) {
let hasRightBracket = view.state.doc.sliceString(head, head + 1) === '>';
let insert = `${hasRightBracket ? '' : '>'}</${name}>`;
return {
range: EditorSelection.cursor(head + 1),
changes: { from: head + (hasRightBracket ? 1 : 0), insert },
};
}
} else if (text == '/' && around.name == 'OpenTag') {
let empty = around.parent,
base = empty?.parent;
if (
empty!.from == head - 1 &&
base!.lastChild?.name != 'CloseTag' &&
(name = elementName(state.doc, base, head))
) {
let hasRightBracket = view.state.doc.sliceString(head, head + 1) === '>';
let insert = `/${name}${hasRightBracket ? '' : '>'}`;
let pos = head + insert.length + (hasRightBracket ? 1 : 0);
return { range: EditorSelection.cursor(pos), changes: { from: head, insert } };
}
}
return { range };
});
if (changes.changes.empty) return false;
view.dispatch(changes, { userEvent: 'input.type', scrollIntoView: true });
return true;
});
@@ -0,0 +1,135 @@
import { EditorState } from '@codemirror/state';
import { CompletionContext, CompletionResult, CompletionSource } from '@codemirror/autocomplete';
import { html } from '../dist/index.js';
import ist from 'ist';
function get(doc: string, conf: { explicit?: boolean } = {}) {
let cur = doc.indexOf('|');
doc = doc.slice(0, cur) + doc.slice(cur + 1);
let state = EditorState.create({
doc,
selection: { anchor: cur },
extensions: [html()],
});
let result = state.languageDataAt<CompletionSource>('autocomplete', cur)[0](
new CompletionContext(state, cur, !!conf.explicit),
);
return result as CompletionResult | null;
}
describe('HTML completion', () => {
it('completes tag names', () => {
let c = get('<|')!.options;
ist(c.length, 100, '>');
ist(!c.some((o) => /\W/.test(o.label)));
});
it("doesn't complete from nothing unless explicit", () => {
ist(!get('|'));
});
it('completes at top level', () => {
let c = get('|', { explicit: true })!.options;
ist(c.length, 100, '>');
ist(c.every((o) => /^<\w+$/.test(o.label) && o.type == 'type'));
});
it('completes inside an element', () => {
let c = get('<body>|', { explicit: true })!.options;
ist(c.length, 100, '>');
ist(c.some((o) => o.label == '</body>'));
ist(c.every((o) => /^<(\/body>|\w+)$/.test(o.label)));
});
it('completes attribute names', () => {
let c = get('<body f|')!.options;
ist(c.length);
ist(c.every((o) => o.type == 'property'));
});
it('completes attribute names explicitly', () => {
let c = get('<body |', { explicit: true })!.options;
ist(c.length);
ist(c.every((o) => o.type == 'property'));
});
it('completes attribute values', () => {
let c = get('<form method=|')!.options;
ist(
c
.map((o) => o.label)
.sort()
.join(','),
'delete,get,post,put',
);
});
it("completes the 2nd attribute's values", () => {
let c = get('<form lang=en method=|')!.options;
ist(
c
.map((o) => o.label)
.sort()
.join(','),
'delete,get,post,put',
);
});
it('keeps quotes for attribute values', () => {
let c = get('<form method="|')!.options;
ist(
c
.map((o) => o.apply)
.sort()
.join(','),
'delete",get",post",put"',
);
});
it('omits already closed quotes', () => {
let c = get('<form method="|"')!;
ist(c.to, 14);
ist(
c.options
.map((o) => o.apply)
.sort()
.join(','),
'delete,get,post,put',
);
});
it('can handle single quotes', () => {
let c = get("<form method='|'")!;
ist(c.to, 14);
ist(
c.options
.map((o) => o.apply)
.sort()
.join(','),
'delete,get,post,put',
);
});
it('completes close tags', () => {
let c = get('<body></|')!.options;
ist(c.length, 1);
ist(c[0].apply, 'body>');
});
it('completes partial close tags', () => {
let c = get('<body></b|')!.options;
ist(c.length, 1);
ist(c[0].apply, 'body>');
});
it("only completes close tags that haven't already been closed", () => {
let c = get('<html><body><div><p></|</body></html>')!.options;
ist(c.length, 2);
ist(c.map((o) => o.apply).join(','), 'p>,div>');
});
it('includes close tag in completion after less-than', () => {
let c = get('<html><|')!.options;
ist(c.some((o) => o.apply == '/html>'));
});
});
@@ -0,0 +1,3 @@
import { createVitestConfig } from '@n8n/vitest-config/node';
export default createVitestConfig({ include: ['test/**/test-*.ts', 'test/**/test-*.js'] });