diff --git a/src/features/templates/__tests__/emmet.test.ts b/src/features/templates/__tests__/emmet.test.ts index 1ce65d33e..09bf8ad9e 100644 --- a/src/features/templates/__tests__/emmet.test.ts +++ b/src/features/templates/__tests__/emmet.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; -import { enclosingTag, expandAt, syntaxAt, updateTagEdits, type TextEdit } from "../emmet/model"; +import { beforeAll, describe, expect, it } from "vitest"; +import { enclosingTag, expandAt, loadEmmet, syntaxAt, updateTagEdits, type TextEdit } from "../emmet/model"; + +beforeAll(() => loadEmmet()); const apply = (text: string, edits: TextEdit[]) => [...edits].sort((a, b) => b.from - a.from).reduce((t, e) => t.slice(0, e.from) + e.insert + t.slice(e.to), text); diff --git a/src/features/templates/editor.ts b/src/features/templates/editor.ts index d4e432a59..1e2d27026 100644 --- a/src/features/templates/editor.ts +++ b/src/features/templates/editor.ts @@ -48,6 +48,7 @@ import { import { usePostfixSettings } from "./postfix/store"; import { showChooser, type ChooserItem } from "./popup/store"; import { expandEmmetAtCaret } from "./emmet/editor"; +import { hasEmmet, loadEmmet } from "./emmet/model"; export interface TemplateDocInfo { path: string; @@ -557,6 +558,7 @@ export const templateCompletionSource: CompletionSource = (ctx: CompletionContex // Extension export function templateEditorExtension(ctx: EditorDocContext): Extension { + if (hasEmmet(ctx.language)) void loadEmmet().catch(() => {}); return [ templateDoc.of({ path: ctx.path, language: ctx.language }), fieldCompletion, diff --git a/src/features/templates/emmet/model.ts b/src/features/templates/emmet/model.ts index 19e409a1b..5619d8d0c 100644 --- a/src/features/templates/emmet/model.ts +++ b/src/features/templates/emmet/model.ts @@ -4,7 +4,22 @@ * with Emmet" on the enclosing tag. Pure functions over document text; the * editor glue lives in `./editor`. */ -import expand, { extract, type UserConfig } from "emmet"; +import type { UserConfig } from "emmet"; + +type Emmet = typeof import("emmet"); +let emmet: Emmet | null = null; +let loading: Promise | null = null; + +/** Loads the Emmet engine, kept out of start-up; expansion finds nothing until it has. */ +export function loadEmmet(): Promise { + return (loading ??= import("emmet").then( + (m) => (emmet = m), + (e: unknown) => { + loading = null; + throw e; + }, + )); +} export interface EmmetSyntax { type: "markup" | "stylesheet"; @@ -15,6 +30,9 @@ export interface EmmetSyntax { const MARKUP_LANGUAGES = new Set(["html", "vue", "svelte"]); const STYLESHEET_LANGUAGES: Record = { css: "css", scss: "scss", less: "less", sass: "sass" }; +/** Whether `language` files expand Emmet abbreviations. */ +export const hasEmmet = (language: string) => MARKUP_LANGUAGES.has(language) || language in STYLESHEET_LANGUAGES; + /** Text before the caret that is scanned for context. */ const CONTEXT_LIMIT = 200_000; @@ -96,14 +114,18 @@ export interface Expansion { /** The abbreviation ending at `col` of `line` expanded, or null when there is none. */ export function expandAt(line: string, col: number, syntax: EmmetSyntax): Expansion | null { - const found = extract(line, col, { type: syntax.type, lookAhead: true }); + if (!emmet) { + void loadEmmet().catch(() => {}); + return null; + } + const found = emmet.extract(line, col, { type: syntax.type, lookAhead: true }); if (!found || !found.abbreviation.trim()) return null; const abbr = found.abbreviation; // A bare number or a closing-tag fragment is never meant as an abbreviation. if (/^\d/.test(abbr) || abbr.startsWith("/")) return null; let snippet: string; try { - snippet = expand(abbr, config(syntax, true)); + snippet = emmet.default(abbr, config(syntax, true)); } catch { return null; } @@ -194,9 +216,10 @@ export interface TextEdit { export function updateTagEdits(tag: TagRange, abbreviation: string): TextEdit[] | null { const abbr = abbreviation.trim(); if (!abbr || /[>+^*{]/.test(abbr.replace(/\[[^\]]*\]/g, ""))) return null; + if (!emmet) return null; let html: string; try { - html = expand(abbr, config({ type: "markup", syntax: "html" }, false)); + html = emmet.default(abbr, config({ type: "markup", syntax: "html" }, false)); } catch { return null; } diff --git a/src/features/templates/index.ts b/src/features/templates/index.ts index de5b3ca05..257819ae6 100644 --- a/src/features/templates/index.ts +++ b/src/features/templates/index.ts @@ -21,6 +21,7 @@ import { fileTemplates, useFileTemplates, watchTemplateProject } from "./files/s import type { FileTemplate } from "./files/model"; import { syncTemplatesFromStorage } from "./sync"; import { tagAtCaret, updateTag } from "./emmet/editor"; +import { loadEmmet } from "./emmet/model"; import { promptAbbreviation, UpdateTagDialogHost } from "./emmet/UpdateTagDialog"; import { tx } from "@/platform/i18n"; @@ -35,7 +36,7 @@ async function updateTagWithEmmet() { notifications().notify({ level: "info", title: "No tag at the caret", key: "emmet-update-tag" }); return; } - const abbreviation = await promptAbbreviation(tag); + const [abbreviation] = await Promise.all([promptAbbreviation(tag), loadEmmet()]); editor.view.focus(); if (abbreviation && !updateTag(editor.view, abbreviation)) notifications().notify({