import {
getLanguageService,
HTMLDocument,
LanguageService,
Node as HtmlNode,
TextDocument as HtmlTextDocument,
} from 'vscode-html-languageservice';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { Position } from 'vscode-languageserver/node';
import { matchDirective, getAllDirectiveNames, getPatterns } from './directive-registry';
let htmlService: LanguageService;
function getHtmlService(): LanguageService {
if (!htmlService) {
htmlService = getLanguageService();
}
return htmlService;
}
export interface ParsedAttribute {
name: string;
value: string | null;
nameStart: number;
nameEnd: number;
valueStart: number;
valueEnd: number;
}
export interface ElementInfo {
tag: string;
attributes: ParsedAttribute[];
start: number;
end: number;
node: HtmlNode;
}
/** Parses the HTML document using vscode-html-languageservice */
export function parseHtmlDocument(document: TextDocument): HTMLDocument {
const htmlDoc = HtmlTextDocument.create(
document.uri,
document.languageId,
document.version,
document.getText()
);
return getHtmlService().parseHTMLDocument(htmlDoc);
}
/** Extract attributes from an HTML node */
function getNodeAttributes(node: HtmlNode, text: string): ParsedAttribute[] {
const attrs: ParsedAttribute[] = [];
if (!node.attributes) return attrs;
for (const [name, value] of Object.entries(node.attributes)) {
// Find the attribute position in the text
const tagContent = text.substring(node.start, node.startTagEnd ?? node.end);
const nameIdx = findAttributePosition(tagContent, name);
if (nameIdx === -1) continue;
const absoluteNameStart = node.start + nameIdx;
const absoluteNameEnd = absoluteNameStart + name.length;
let valueStart = -1;
let valueEnd = -1;
let attrValue: string | null = null;
if (value !== null) {
// Strip surrounding quotes for the value
attrValue = typeof value === 'string' ? value.replace(/^["']|["']$/g, '') : null;
const eqIdx = tagContent.indexOf('=', nameIdx + name.length);
if (eqIdx !== -1) {
const quoteStart = tagContent.indexOf('"', eqIdx) !== -1
? tagContent.indexOf('"', eqIdx) + 1
: tagContent.indexOf("'", eqIdx) !== -1
? tagContent.indexOf("'", eqIdx) + 1
: eqIdx + 1;
valueStart = node.start + quoteStart;
valueEnd = valueStart + (attrValue?.length ?? 0);
}
}
attrs.push({
name,
value: attrValue,
nameStart: absoluteNameStart,
nameEnd: absoluteNameEnd,
valueStart,
valueEnd,
});
}
return attrs;
}
function findAttributePosition(tagContent: string, attrName: string): number {
// Search for the attribute name as a whole word in the tag
const regex = new RegExp(`\\b${escapeRegex(attrName)}\\b`);
const match = regex.exec(tagContent);
return match ? match.index : -1;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Walk all nodes in the HTML document and collect element info */
export function getAllElements(htmlDoc: HTMLDocument, text: string): ElementInfo[] {
const elements: ElementInfo[] = [];
function walk(node: HtmlNode) {
if (node.tag) {
elements.push({
tag: node.tag,
attributes: getNodeAttributes(node, text),
start: node.start,
end: node.end,
node,
});
}
if (node.children) {
for (const child of node.children) {
walk(child);
}
}
}
for (const root of htmlDoc.roots) {
walk(root);
}
return elements;
}
/** Get the element containing the given offset */
export function getElementAtOffset(htmlDoc: HTMLDocument, offset: number, text: string): ElementInfo | undefined {
function walk(node: HtmlNode): ElementInfo | undefined {
if (node.start <= offset && offset <= node.end) {
// Check children first (deepest match)
if (node.children) {
for (const child of node.children) {
const found = walk(child);
if (found) return found;
}
}
if (node.tag && node.startTagEnd && offset <= node.startTagEnd) {
return {
tag: node.tag,
attributes: getNodeAttributes(node, text),
start: node.start,
end: node.end,
node,
};
}
}
return undefined;
}
for (const root of htmlDoc.roots) {
const found = walk(root);
if (found) return found;
}
return undefined;
}
export type CursorContext =
| { type: 'attributeName'; partial: string; element: ElementInfo }
| { type: 'attributeValue'; attrName: string; partial: string; element: ElementInfo }
| { type: 'none' };
/** Determine the cursor context at a given position */
export function getCursorContext(document: TextDocument, position: Position, htmlDoc: HTMLDocument): CursorContext {
const offset = document.offsetAt(position);
const text = document.getText();
const element = getElementAtOffset(htmlDoc, offset, text);
if (!element || !element.node.startTagEnd || offset > element.node.startTagEnd) {
return { type: 'none' };
}
// Get the tag content up to cursor
const tagStart = element.start;
const beforeCursor = text.substring(tagStart, offset);
// Check if we're inside an attribute value (inside quotes)
const lastEq = beforeCursor.lastIndexOf('=');
if (lastEq !== -1) {
const afterEq = beforeCursor.substring(lastEq + 1).trimStart();
if (afterEq.startsWith('"') || afterEq.startsWith("'")) {
const quote = afterEq[0];
const valueContent = afterEq.substring(1);
// Check if the quote is closed
if (!valueContent.includes(quote)) {
// We're inside an attribute value
// Find the attribute name before the =
const beforeEq = beforeCursor.substring(0, lastEq).trimEnd();
const nameMatch = beforeEq.match(/(\S+)$/);
const attrName = nameMatch ? nameMatch[1] : '';
return {
type: 'attributeValue',
attrName,
partial: valueContent,
element,
};
}
}
}
// Check if we're in an attribute name position
// After tag name and space, or after a completed attribute
const afterLastSpace = beforeCursor.match(/\s(\S*)$/);
if (afterLastSpace) {
const partial = afterLastSpace[1];
// Make sure we're not after an = sign that's part of an unclosed attribute
if (!partial.includes('=')) {
return {
type: 'attributeName',
partial,
element,
};
}
}
return { type: 'none' };
}
/** Check if the document likely contains No.JS directives */
export function hasNoJsDirectives(text: string): boolean {
const directiveNames = getAllDirectiveNames();
const patterns = getPatterns();
// Quick scan: look for any known directive attribute in the text
for (const name of directiveNames) {
// Simple heuristic: look for the directive as an HTML attribute
if (text.includes(` ${name}=`) || text.includes(` ${name} `) || text.includes(` ${name}>`)) {
return true;
}
}
// Check patterns
for (const p of patterns) {
if (text.includes(` ${p.prefix}`)) {
return true;
}
}
// Check for CDN script tag
if (text.includes('no-js') || text.includes('nojs') || text.includes('NoJS')) {
return true;
}
return false;
}
/** Find all template definitions in the document */
export function findTemplates(htmlDoc: HTMLDocument, text: string): Map {
const templates = new Map();
function walk(node: HtmlNode) {
if (node.tag === 'template') {
const attrs = getNodeAttributes(node, text);
const idAttr = attrs.find(a => a.name === 'id');
if (idAttr?.value) {
templates.set(idAttr.value, { start: node.start, end: node.end });
}
}
if (node.children) {
for (const child of node.children) {
walk(child);
}
}
}
for (const root of htmlDoc.roots) {
walk(root);
}
return templates;
}
/** Find all refs in the document */
export function findRefs(htmlDoc: HTMLDocument, text: string): Map {
const refs = new Map();
function walk(node: HtmlNode) {
if (node.tag) {
const attrs = getNodeAttributes(node, text);
const refAttr = attrs.find(a => a.name === 'ref');
if (refAttr?.value) {
refs.set(refAttr.value, { start: node.start, end: node.end });
}
}
if (node.children) {
for (const child of node.children) {
walk(child);
}
}
}
for (const root of htmlDoc.roots) {
walk(root);
}
return refs;
}
/** Find all store declarations in the document */
export function findStores(htmlDoc: HTMLDocument, text: string): Map {
const stores = new Map();
function walk(node: HtmlNode) {
if (node.tag) {
const attrs = getNodeAttributes(node, text);
const storeAttr = attrs.find(a => a.name === 'store');
if (storeAttr?.value) {
stores.set(storeAttr.value, { start: node.start, end: node.end });
}
}
if (node.children) {
for (const child of node.children) {
walk(child);
}
}
}
for (const root of htmlDoc.roots) {
walk(root);
}
// Also detect stores declared in NoJS.config({ stores: { name: { ... } } })
const configIdx = text.indexOf('NoJS.config(');
const storesIdx = configIdx !== -1 ? text.indexOf('stores', configIdx) : -1;
if (storesIdx !== -1) {
const colonIdx = text.indexOf('{', text.indexOf(':', storesIdx));
if (colonIdx !== -1) {
let depth = 0;
let blockEnd = -1;
for (let i = colonIdx; i < text.length; i++) {
if (text[i] === '{') depth++;
else if (text[i] === '}') { depth--; if (depth === 0) { blockEnd = i; break; } }
}
if (blockEnd !== -1) {
const storesBlock = text.substring(colonIdx + 1, blockEnd);
const entryRegex = /([a-zA-Z_$][\w$]*)\s*:\s*\{/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(storesBlock)) !== null) {
const storeName = entryMatch[1];
if (!stores.has(storeName)) {
const nameStart = colonIdx + 1 + entryMatch.index;
const nameEnd = nameStart + storeName.length;
stores.set(storeName, { start: nameStart, end: nameEnd });
}
}
}
}
}
return stores;
}
/** Get siblings of a node (nodes sharing the same parent) */
export function getSiblings(node: HtmlNode): HtmlNode[] {
const parent = node.parent;
if (!parent || !parent.children) return [];
return parent.children;
}
/** Get the previous sibling element */
export function getPreviousSibling(node: HtmlNode): HtmlNode | undefined {
const siblings = getSiblings(node);
const idx = siblings.indexOf(node);
if (idx <= 0) return undefined;
// Walk backwards to find previous element (skip text nodes)
for (let i = idx - 1; i >= 0; i--) {
if (siblings[i].tag) return siblings[i];
}
return undefined;
}