perf(templates): load Emmet when an HTML or stylesheet editor opens

The Emmet engine (~70 KB minified) was part of the initial bundle; it now loads
with the first editor that can expand abbreviations, or with Update Tag with Emmet.
Цей коміт міститься в:
l0ng-ai committed 2026-11-06 08:11:35 +08:00
1 джерело b91025a48e
коміт 4ceae47d1f
4 files changed
+35 -7

No files matched your search

+4 -2
Переглянути файл
@@ -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);
+2
Переглянути файл
@@ -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,
+27 -4
Переглянути файл
@@ -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<Emmet> | null = null;
/** Loads the Emmet engine, kept out of start-up; expansion finds nothing until it has. */
export function loadEmmet(): Promise<Emmet> {
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<string, string> = { 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;
}
+2 -1
Переглянути файл
@@ -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({