@lapis-notes/language-service 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # @lapis-notes/language-service
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - First public npm release of the provider-neutral Markdown language-service
8
+ and native Markdownlint runtime contracts.
package/LICENSE.md ADDED
@@ -0,0 +1,4 @@
1
+ AGPL-3.0-or-later
2
+
3
+ This package is licensed under the GNU Affero General Public License v3.0 or later.
4
+ See <https://www.gnu.org/licenses/agpl-3.0.en.html>.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @lapis-notes/language-service
2
+
3
+ Provider-neutral Markdown language services for Lapis applications.
4
+
5
+ The package exposes browser-worker and native-host adapters without owning
6
+ workspace presentation, vault navigation, or application policy.
7
+
8
+ ## Public surface
9
+
10
+ - `@lapis-notes/language-service`
11
+ - `@lapis-notes/language-service/markdown`
12
+ - `@lapis-notes/language-service/markdownlint/runtime`
13
+
14
+ ## Usage
15
+
16
+ ```ts
17
+ import { createMarkdownLanguageServiceProvider } from "@lapis-notes/language-service/markdown";
18
+ ```
19
+
20
+ Install `@lapis-notes/api` from npm with a compatible version.
21
+
22
+ ## Common scripts
23
+
24
+ ```sh
25
+ pnpm --filter @lapis-notes/language-service build
26
+ pnpm --filter @lapis-notes/language-service check
27
+ pnpm --filter @lapis-notes/language-service test
28
+ ```
@@ -0,0 +1,3 @@
1
+ export { createMarkdownLanguageServiceProvider, createMarkdownLanguageServiceProvider as createMarkdownLintProvider, createNativeMarkdownLanguageServiceProvider, probeNativeMarkdownLanguageService, } from "./markdown";
2
+ export type { MarkdownLanguageServiceProviderOptions } from "./markdown";
3
+ export { createMarkdownlintLintOptions } from "./markdownlint/options";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createMarkdownLanguageServiceProvider, createMarkdownLanguageServiceProvider as createMarkdownLintProvider, createNativeMarkdownLanguageServiceProvider, probeNativeMarkdownLanguageService, } from "./markdown";
2
+ export { createMarkdownlintLintOptions } from "./markdownlint/options";
@@ -0,0 +1,8 @@
1
+ import { type LanguageServiceProvider } from "@lapis-notes/api/language-service";
2
+ export interface MarkdownLanguageServiceProviderOptions {
3
+ rules?: Record<string, unknown>;
4
+ getRules?: () => Record<string, unknown> | undefined;
5
+ }
6
+ export declare function createMarkdownLanguageServiceProvider(options?: MarkdownLanguageServiceProviderOptions): LanguageServiceProvider;
7
+ export declare function createNativeMarkdownLanguageServiceProvider(invoke: (command: string, payload?: Record<string, unknown>) => Promise<unknown>, options?: MarkdownLanguageServiceProviderOptions): LanguageServiceProvider;
8
+ export declare function probeNativeMarkdownLanguageService(invoke: (command: string, payload?: Record<string, unknown>) => Promise<unknown>): Promise<boolean>;
@@ -0,0 +1,120 @@
1
+ /// <reference path="./vite-env.d.ts" />
2
+ import { LanguageServiceWorkerClient } from "@lapis-notes/api/language-service/worker";
3
+ import MarkdownLintWorker from "./workers/markdownlint.worker.js?worker&inline";
4
+ const DESKTOP_LANGUAGE_SERVICE_PROTOCOL_VERSION = 1;
5
+ function resolveMarkdownRules(options) {
6
+ return options.getRules?.() ?? options.rules;
7
+ }
8
+ export function createMarkdownLanguageServiceProvider(options = {}) {
9
+ const client = new LanguageServiceWorkerClient(new MarkdownLintWorker());
10
+ return {
11
+ metadata: {
12
+ id: "markdownlint-worker",
13
+ languages: ["markdown"],
14
+ runtime: "worker",
15
+ priority: 0,
16
+ capabilities: { diagnostics: true, codeActions: true },
17
+ },
18
+ async updateDocument(update) {
19
+ await client.request({
20
+ type: "document/update",
21
+ document: update.document,
22
+ });
23
+ },
24
+ async provideDiagnostics(context) {
25
+ const response = await client.request({
26
+ type: "diagnostics",
27
+ document: context.document,
28
+ globals: context.globals,
29
+ rules: resolveMarkdownRules(options),
30
+ });
31
+ if (response.type !== "diagnostics")
32
+ return [];
33
+ return response.diagnostics.map((diagnostic) => ({
34
+ ...diagnostic,
35
+ source: diagnostic.source ?? "markdownlint",
36
+ }));
37
+ },
38
+ async provideCodeActions(context, range) {
39
+ const response = await client.request({
40
+ type: "code-actions",
41
+ document: context.document,
42
+ range,
43
+ globals: context.globals,
44
+ rules: resolveMarkdownRules(options),
45
+ });
46
+ if (response.type !== "code-actions") {
47
+ return [];
48
+ }
49
+ return response.actions;
50
+ },
51
+ dispose() {
52
+ client.dispose();
53
+ },
54
+ };
55
+ }
56
+ export function createNativeMarkdownLanguageServiceProvider(invoke, options = {}) {
57
+ return {
58
+ metadata: {
59
+ id: "markdownlint-native-sidecar",
60
+ languages: ["markdown"],
61
+ runtime: "native",
62
+ priority: 100,
63
+ capabilities: { diagnostics: true, codeActions: true },
64
+ },
65
+ async updateDocument(update) {
66
+ await invoke("desktop_ls_update_document", {
67
+ protocolVersion: DESKTOP_LANGUAGE_SERVICE_PROTOCOL_VERSION,
68
+ document: update.document,
69
+ globals: [],
70
+ });
71
+ },
72
+ async provideDiagnostics(context) {
73
+ const raw = await invoke("desktop_ls_diagnostics", {
74
+ protocolVersion: DESKTOP_LANGUAGE_SERVICE_PROTOCOL_VERSION,
75
+ document: context.document,
76
+ rules: resolveMarkdownRules(options),
77
+ globals: context.globals.map((declaration) => ({
78
+ uri: declaration.uri,
79
+ text: declaration.text,
80
+ version: declaration.version,
81
+ })),
82
+ });
83
+ if (!Array.isArray(raw)) {
84
+ return [];
85
+ }
86
+ return raw;
87
+ },
88
+ async provideCodeActions(context, range) {
89
+ const raw = await invoke("desktop_ls_code_actions", {
90
+ protocolVersion: DESKTOP_LANGUAGE_SERVICE_PROTOCOL_VERSION,
91
+ document: context.document,
92
+ range,
93
+ rules: resolveMarkdownRules(options),
94
+ globals: context.globals.map((declaration) => ({
95
+ uri: declaration.uri,
96
+ text: declaration.text,
97
+ version: declaration.version,
98
+ })),
99
+ });
100
+ if (!Array.isArray(raw)) {
101
+ return [];
102
+ }
103
+ return raw;
104
+ },
105
+ dispose() {
106
+ //
107
+ },
108
+ };
109
+ }
110
+ export async function probeNativeMarkdownLanguageService(invoke) {
111
+ try {
112
+ await invoke("desktop_ls_capabilities", {
113
+ protocolVersion: DESKTOP_LANGUAGE_SERVICE_PROTOCOL_VERSION,
114
+ });
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Lowercase-first Lapis tag at line start (whole line or prefix before
3
+ * whitespace).
4
+ */
5
+ export declare const TAG_PREFIX_PATTERN: RegExp;
6
+ /** ATX heading missing a space after the hash run (same shape as stock MD018). */
7
+ export declare const BAD_ATX_HEADING_PATTERN: RegExp;
8
+ export declare const MD018_RULE_ALIASES: string[];
9
+ declare const md018AllowTagsRule: {
10
+ names: string[];
11
+ description: string;
12
+ tags: string[];
13
+ parser: "none";
14
+ function: (params: {
15
+ lines: readonly string[];
16
+ }, onError: (error: {
17
+ lineNumber: number;
18
+ detail?: string;
19
+ context?: string;
20
+ fixInfo?: {
21
+ editColumn?: number;
22
+ insertText?: string;
23
+ };
24
+ }) => void) => void;
25
+ };
26
+ export declare const LAPIS_MARKDOWNLINT_DEFAULT_CONFIG: {
27
+ readonly default: true;
28
+ readonly MD018: false;
29
+ };
30
+ export declare function createMarkdownlintLintOptions(rules?: Record<string, unknown>): {
31
+ config: Record<string, unknown>;
32
+ customRules: [typeof md018AllowTagsRule];
33
+ };
34
+ export {};
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Lowercase-first Lapis tag at line start (whole line or prefix before
3
+ * whitespace).
4
+ */
5
+ export const TAG_PREFIX_PATTERN = /^( {0,3})#([a-z][a-z0-9_/-]*)(?:$|\s)/u;
6
+ /** ATX heading missing a space after the hash run (same shape as stock MD018). */
7
+ export const BAD_ATX_HEADING_PATTERN = /^( {0,3})(#{1,6})([^#\s].*)$/u;
8
+ export const MD018_RULE_ALIASES = [
9
+ "MD018-lapis",
10
+ "no-missing-space-atx-except-tags",
11
+ ];
12
+ const md018AllowTagsRule = {
13
+ names: MD018_RULE_ALIASES,
14
+ description: "No space after hash on atx style heading, except lowercase Lapis tag lines",
15
+ tags: ["atx", "headings", "spaces"],
16
+ parser: "none",
17
+ function: (params, onError) => {
18
+ params.lines.forEach((line, index) => {
19
+ if (TAG_PREFIX_PATTERN.test(line)) {
20
+ return;
21
+ }
22
+ const match = BAD_ATX_HEADING_PATTERN.exec(line);
23
+ if (!match) {
24
+ return;
25
+ }
26
+ const indent = match[1] ?? "";
27
+ const hashes = match[2] ?? "";
28
+ onError({
29
+ lineNumber: index + 1,
30
+ detail: "Expected: space; Actual: no space",
31
+ context: line,
32
+ fixInfo: {
33
+ editColumn: indent.length + hashes.length + 1,
34
+ insertText: " ",
35
+ },
36
+ });
37
+ });
38
+ },
39
+ };
40
+ export const LAPIS_MARKDOWNLINT_DEFAULT_CONFIG = {
41
+ default: true,
42
+ MD018: false,
43
+ };
44
+ export function createMarkdownlintLintOptions(rules) {
45
+ const config = {
46
+ ...LAPIS_MARKDOWNLINT_DEFAULT_CONFIG,
47
+ ...(rules ?? {}),
48
+ };
49
+ if (rules?.MD018 === false) {
50
+ config["MD018-lapis"] = false;
51
+ config["no-missing-space-atx-except-tags"] = false;
52
+ }
53
+ return {
54
+ config,
55
+ customRules: [md018AllowTagsRule],
56
+ };
57
+ }
@@ -0,0 +1,32 @@
1
+ import type { LanguageServiceCodeAction, LanguageServiceDiagnostic, LanguageServiceRange, VirtualDocument } from "@lapis-notes/api/language-service";
2
+ type MarkdownLintIssue = {
3
+ lineNumber?: number;
4
+ errorRange?: [number, number] | null;
5
+ ruleNames?: string[];
6
+ ruleName?: string;
7
+ ruleDescription?: string;
8
+ errorDetail?: string | null;
9
+ errorContext?: string | null;
10
+ fixInfo?: {
11
+ lineNumber?: number;
12
+ editColumn?: number;
13
+ deleteCount?: number;
14
+ insertText?: string;
15
+ } | null;
16
+ };
17
+ export declare const MARKDOWN_LINT_DISABLE_RULE_COMMAND = "markdown-lint:disable-rule";
18
+ export declare function markdownlintActionTitle(kind: "fixThis" | "fixAll" | "disableLine" | "disableFile" | "disableVault", rulePath: string): string;
19
+ export declare function markdownCodeActionsFromIssues(document: VirtualDocument, requestedRange: LanguageServiceRange, issues: MarkdownLintIssue[], applyFixesForIssues: (issues: MarkdownLintIssue[]) => string, md018RuleAliases?: string[]): LanguageServiceCodeAction[];
20
+ export declare function markdownDiagnosticsFromIssues(issues: MarkdownLintIssue[]): LanguageServiceDiagnostic[];
21
+ export declare function markdownlintDiagnosticCode(ruleNames: string[] | undefined, ruleName: string | undefined): string | undefined;
22
+ export declare function formatMarkdownlintMessage(issue: MarkdownLintIssue): string;
23
+ export declare function isLineInsideLeadingYamlFrontmatter(text: string, lineIndex: number): boolean;
24
+ export declare function firstContentLineIndex(text: string): number | null;
25
+ export declare function leadingYamlFrontmatterEndLine(text: string): number | null;
26
+ export declare function normalizeMarkdownlintDirectiveAliases(text: string, md018RuleAliases?: string[]): string;
27
+ export declare function toSingleReplacement(before: string, after: string): {
28
+ from: number;
29
+ to: number;
30
+ insert: string;
31
+ } | null;
32
+ export {};
@@ -0,0 +1,312 @@
1
+ export const MARKDOWN_LINT_DISABLE_RULE_COMMAND = "markdown-lint:disable-rule";
2
+ export function markdownlintActionTitle(kind, rulePath) {
3
+ switch (kind) {
4
+ case "fixThis":
5
+ return `Fix this violation of \`${rulePath}\``;
6
+ case "fixAll":
7
+ return `Fix all violations of \`${rulePath}\` in the document`;
8
+ case "disableLine":
9
+ return `Disable ${rulePath} for this line`;
10
+ case "disableFile":
11
+ return `Disable ${rulePath} for this file`;
12
+ case "disableVault":
13
+ return `Disable ${rulePath} in this vault`;
14
+ }
15
+ }
16
+ export function markdownCodeActionsFromIssues(document, requestedRange, issues, applyFixesForIssues, md018RuleAliases = []) {
17
+ const intersecting = [];
18
+ for (const issue of issues) {
19
+ const diagnostic = toDiagnostic(issue);
20
+ if (!rangesIntersect(diagnostic.range, requestedRange)) {
21
+ continue;
22
+ }
23
+ intersecting.push({
24
+ issue,
25
+ diagnostic,
26
+ code: diagnostic.code != null ? String(diagnostic.code) : "issue",
27
+ });
28
+ }
29
+ const groups = new Map();
30
+ for (const entry of intersecting) {
31
+ const group = groups.get(entry.code) ?? [];
32
+ group.push(entry);
33
+ groups.set(entry.code, group);
34
+ }
35
+ const actions = [];
36
+ for (const [code, group] of groups) {
37
+ const diagnostics = group.map((entry) => entry.diagnostic);
38
+ const rulePath = markdownlintRulePath(group[0].issue);
39
+ for (const entry of group) {
40
+ if (!entry.issue.fixInfo)
41
+ continue;
42
+ const updatedText = applyFixesForIssues([entry.issue]);
43
+ const change = toSingleReplacement(document.text, updatedText);
44
+ if (change) {
45
+ actions.push({
46
+ title: markdownlintActionTitle("fixThis", rulePath),
47
+ kind: "quickfix",
48
+ diagnostics: [entry.diagnostic],
49
+ edit: { changes: [change] },
50
+ });
51
+ }
52
+ }
53
+ const documentFixable = issues.filter((issue) => (markdownlintDiagnosticCode(issue.ruleNames, issue.ruleName) ??
54
+ "issue") === code && issue.fixInfo);
55
+ if (documentFixable.length > 1) {
56
+ const updatedText = applyFixesForIssues(documentFixable);
57
+ const change = toSingleReplacement(document.text, updatedText);
58
+ if (change) {
59
+ actions.push({
60
+ title: markdownlintActionTitle("fixAll", rulePath),
61
+ kind: "quickfix",
62
+ diagnostics,
63
+ edit: { changes: [change] },
64
+ });
65
+ }
66
+ }
67
+ if (code === "issue") {
68
+ continue;
69
+ }
70
+ const ignoreNextLineChange = createIgnoreNextLineChange(document.text, group[0].diagnostic.range.start.line, code);
71
+ if (ignoreNextLineChange) {
72
+ actions.push({
73
+ title: markdownlintActionTitle("disableLine", rulePath),
74
+ kind: "quickfix",
75
+ diagnostics,
76
+ edit: { changes: [ignoreNextLineChange] },
77
+ });
78
+ }
79
+ const ignoreFileChange = createIgnoreFileChange(document.text, code, md018RuleAliases);
80
+ if (ignoreFileChange) {
81
+ actions.push({
82
+ title: markdownlintActionTitle("disableFile", rulePath),
83
+ kind: "quickfix",
84
+ diagnostics,
85
+ edit: { changes: [ignoreFileChange] },
86
+ });
87
+ }
88
+ actions.push({
89
+ title: markdownlintActionTitle("disableVault", rulePath),
90
+ kind: "quickfix",
91
+ diagnostics,
92
+ command: {
93
+ id: MARKDOWN_LINT_DISABLE_RULE_COMMAND,
94
+ arguments: [code],
95
+ },
96
+ });
97
+ }
98
+ return actions;
99
+ }
100
+ export function markdownDiagnosticsFromIssues(issues) {
101
+ return issues.map((issue) => toDiagnostic(issue));
102
+ }
103
+ export function markdownlintDiagnosticCode(ruleNames, ruleName) {
104
+ const code = ruleNames?.[0] ?? ruleName;
105
+ if (code === "MD018-lapis" || code === "no-missing-space-atx-except-tags") {
106
+ return "MD018";
107
+ }
108
+ return code;
109
+ }
110
+ function toDiagnostic(issue) {
111
+ const startLine = Math.max(0, (issue.lineNumber ?? 1) - 1);
112
+ const range = issue.errorRange;
113
+ const startColumn = Math.max(0, range ? range[0] - 1 : 0);
114
+ const length = Math.max(1, range ? range[1] : 1);
115
+ return {
116
+ range: {
117
+ start: { line: startLine, character: startColumn },
118
+ end: { line: startLine, character: startColumn + length },
119
+ },
120
+ source: "markdownlint",
121
+ code: markdownlintDiagnosticCode(issue.ruleNames, issue.ruleName),
122
+ severity: "warning",
123
+ message: formatMarkdownlintMessage(issue),
124
+ };
125
+ }
126
+ function markdownlintRulePath(issue) {
127
+ const names = (issue.ruleNames ?? (issue.ruleName ? [issue.ruleName] : [])).map((name) => (name === "MD018-lapis" ? "MD018" : name));
128
+ const unique = [...new Set(names)];
129
+ if (unique.length === 0) {
130
+ return (markdownlintDiagnosticCode(issue.ruleNames, issue.ruleName) ?? "issue");
131
+ }
132
+ return unique.join("/");
133
+ }
134
+ export function formatMarkdownlintMessage(issue) {
135
+ const description = issue.ruleDescription?.trim();
136
+ if (!description) {
137
+ return issue.errorDetail ?? "Markdown lint issue";
138
+ }
139
+ const path = markdownlintRulePath(issue);
140
+ const detail = issue.errorDetail?.trim();
141
+ return detail
142
+ ? `${path}: ${description} [${detail}]`
143
+ : `${path}: ${description}`;
144
+ }
145
+ function createIgnoreNextLineChange(text, targetLine, code) {
146
+ const lines = parseLines(text);
147
+ if (targetLine < 0 ||
148
+ targetLine >= lines.length ||
149
+ isLineInsideLeadingYamlFrontmatter(text, targetLine)) {
150
+ return null;
151
+ }
152
+ const lineBreak = detectLineBreak(lines);
153
+ const disableCodes = [code];
154
+ if (firstContentLineIndex(text) === targetLine &&
155
+ !disableCodes.includes("MD041")) {
156
+ disableCodes.push("MD041");
157
+ }
158
+ return {
159
+ from: lines[targetLine].start,
160
+ to: lines[targetLine].start,
161
+ insert: `<!-- markdownlint-disable-next-line ${disableCodes.join(" ")} -->${lineBreak}`,
162
+ };
163
+ }
164
+ function createIgnoreFileChange(text, code, md018RuleAliases = []) {
165
+ const lines = parseLines(text);
166
+ const lineBreak = detectLineBreak(lines);
167
+ const targetLine = firstContentLineIndex(text);
168
+ const disableCodes = [code];
169
+ if (targetLine === 0 && !disableCodes.includes("MD041")) {
170
+ disableCodes.push("MD041");
171
+ }
172
+ if (code === "MD018") {
173
+ for (const alias of md018RuleAliases) {
174
+ if (!disableCodes.includes(alias)) {
175
+ disableCodes.push(alias);
176
+ }
177
+ }
178
+ }
179
+ if (targetLine === null) {
180
+ const prefix = text.length === 0 || hasTrailingLineBreak(text) ? "" : lineBreak;
181
+ return {
182
+ from: text.length,
183
+ to: text.length,
184
+ insert: `${prefix}<!-- markdownlint-disable-file ${disableCodes.join(" ")} -->`,
185
+ };
186
+ }
187
+ return {
188
+ from: lines[targetLine].start,
189
+ to: lines[targetLine].start,
190
+ insert: `<!-- markdownlint-disable-file ${disableCodes.join(" ")} -->${lineBreak}`,
191
+ };
192
+ }
193
+ export function isLineInsideLeadingYamlFrontmatter(text, lineIndex) {
194
+ const endLine = leadingYamlFrontmatterEndLine(text);
195
+ return endLine !== null && lineIndex <= endLine;
196
+ }
197
+ export function firstContentLineIndex(text) {
198
+ const lines = parseLines(text);
199
+ const frontmatterEndLine = leadingYamlFrontmatterEndLine(text);
200
+ let index = frontmatterEndLine === null ? 0 : frontmatterEndLine + 1;
201
+ while (index < lines.length && lines[index].text.trim().length === 0) {
202
+ index += 1;
203
+ }
204
+ return index < lines.length ? index : null;
205
+ }
206
+ export function leadingYamlFrontmatterEndLine(text) {
207
+ const lines = parseLines(text);
208
+ if (!lines.length || lines[0].text.trim() !== "---") {
209
+ return null;
210
+ }
211
+ for (let index = 1; index < lines.length; index += 1) {
212
+ const trimmed = lines[index].text.trim();
213
+ if (trimmed === "---" || trimmed === "...") {
214
+ return index;
215
+ }
216
+ }
217
+ return lines.length - 1;
218
+ }
219
+ function parseLines(text) {
220
+ if (text.length === 0) {
221
+ return [{ text: "", start: 0, end: 0, fullEnd: 0, newline: "" }];
222
+ }
223
+ const lines = [];
224
+ let start = 0;
225
+ while (start <= text.length) {
226
+ let end = start;
227
+ while (end < text.length && text[end] !== "\n" && text[end] !== "\r") {
228
+ end += 1;
229
+ }
230
+ let fullEnd = end;
231
+ if (fullEnd < text.length) {
232
+ if (text[fullEnd] === "\r" && text[fullEnd + 1] === "\n") {
233
+ fullEnd += 2;
234
+ }
235
+ else {
236
+ fullEnd += 1;
237
+ }
238
+ }
239
+ lines.push({
240
+ text: text.slice(start, end),
241
+ start,
242
+ end,
243
+ fullEnd,
244
+ newline: text.slice(end, fullEnd),
245
+ });
246
+ if (fullEnd >= text.length) {
247
+ break;
248
+ }
249
+ start = fullEnd;
250
+ }
251
+ return lines;
252
+ }
253
+ function detectLineBreak(lines) {
254
+ for (const line of lines) {
255
+ if (line.newline) {
256
+ return line.newline;
257
+ }
258
+ }
259
+ return "\n";
260
+ }
261
+ function hasTrailingLineBreak(text) {
262
+ return /(?:\r\n|\n|\r)$/u.test(text);
263
+ }
264
+ export function normalizeMarkdownlintDirectiveAliases(text, md018RuleAliases = []) {
265
+ return text.replace(/<!--\s*(markdownlint-[a-z-]+)(\s+[^>]*)?-->/gu, (match, directive, rawRules = "") => {
266
+ const rules = rawRules.trim().split(/\s+/u).filter(Boolean);
267
+ if (!rules.includes("MD018")) {
268
+ return match;
269
+ }
270
+ const expandedRules = [...rules];
271
+ for (const alias of md018RuleAliases) {
272
+ if (!expandedRules.includes(alias)) {
273
+ expandedRules.push(alias);
274
+ }
275
+ }
276
+ return `<!-- ${directive} ${expandedRules.join(" ")} -->`;
277
+ });
278
+ }
279
+ function rangesIntersect(left, right) {
280
+ return (comparePositions(left.start, right.end) <= 0 &&
281
+ comparePositions(right.start, left.end) <= 0);
282
+ }
283
+ function comparePositions(left, right) {
284
+ if (left.line !== right.line) {
285
+ return left.line - right.line;
286
+ }
287
+ return left.character - right.character;
288
+ }
289
+ export function toSingleReplacement(before, after) {
290
+ if (before === after) {
291
+ return null;
292
+ }
293
+ let start = 0;
294
+ while (start < before.length &&
295
+ start < after.length &&
296
+ before[start] === after[start]) {
297
+ start += 1;
298
+ }
299
+ let beforeEnd = before.length;
300
+ let afterEnd = after.length;
301
+ while (beforeEnd > start &&
302
+ afterEnd > start &&
303
+ before[beforeEnd - 1] === after[afterEnd - 1]) {
304
+ beforeEnd -= 1;
305
+ afterEnd -= 1;
306
+ }
307
+ return {
308
+ from: start,
309
+ to: beforeEnd,
310
+ insert: after.slice(start, afterEnd),
311
+ };
312
+ }
@@ -0,0 +1,5 @@
1
+ import type { LanguageServiceCodeAction, LanguageServiceDiagnostic, LanguageServiceRange, VirtualDocument } from "@lapis-notes/api/language-service";
2
+ import { markdownlintActionTitle, MARKDOWN_LINT_DISABLE_RULE_COMMAND } from "./runtime-core";
3
+ export { markdownlintActionTitle, MARKDOWN_LINT_DISABLE_RULE_COMMAND, };
4
+ export declare function markdownDiagnosticsForDocument(document: VirtualDocument, rules?: Record<string, unknown>): LanguageServiceDiagnostic[];
5
+ export declare function markdownCodeActionsForDocument(document: VirtualDocument, requestedRange: LanguageServiceRange, rules?: Record<string, unknown>): LanguageServiceCodeAction[];
@@ -0,0 +1,20 @@
1
+ import { applyFixes } from "markdownlint";
2
+ import { lint } from "markdownlint/sync";
3
+ import { createMarkdownlintLintOptions, MD018_RULE_ALIASES } from "./options";
4
+ import { markdownCodeActionsFromIssues, markdownDiagnosticsFromIssues, markdownlintActionTitle, MARKDOWN_LINT_DISABLE_RULE_COMMAND, normalizeMarkdownlintDirectiveAliases, } from "./runtime-core";
5
+ export { markdownlintActionTitle, MARKDOWN_LINT_DISABLE_RULE_COMMAND, };
6
+ function getMarkdownLintIssues(document, rules) {
7
+ const text = normalizeMarkdownlintDirectiveAliases(document.text, MD018_RULE_ALIASES);
8
+ const result = lint({
9
+ strings: { [document.uri]: text },
10
+ ...createMarkdownlintLintOptions(rules),
11
+ });
12
+ return (result[document.uri] ?? []);
13
+ }
14
+ export function markdownDiagnosticsForDocument(document, rules) {
15
+ return markdownDiagnosticsFromIssues(getMarkdownLintIssues(document, rules));
16
+ }
17
+ export function markdownCodeActionsForDocument(document, requestedRange, rules) {
18
+ const issues = getMarkdownLintIssues(document, rules);
19
+ return markdownCodeActionsFromIssues(document, requestedRange, issues, (fixable) => applyFixes(document.text, fixable), MD018_RULE_ALIASES);
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { registerLanguageServiceWorker } from "./protocol";
2
+ import { installWorkerEntityDecoderDocumentShim } from "./worker-entity-decoder";
3
+ installWorkerEntityDecoderDocumentShim();
4
+ const markdownlintRuntime = import("../markdownlint/runtime");
5
+ const documents = new Map();
6
+ registerLanguageServiceWorker({
7
+ "document/update"(request) {
8
+ documents.set(request.document.uri, request.document);
9
+ return { type: "document/update", ok: true };
10
+ },
11
+ async diagnostics(request) {
12
+ documents.set(request.document.uri, request.document);
13
+ return {
14
+ type: "diagnostics",
15
+ diagnostics: await lintMarkdown(request.document, request.rules),
16
+ };
17
+ },
18
+ async "code-actions"(request) {
19
+ documents.set(request.document.uri, request.document);
20
+ return {
21
+ type: "code-actions",
22
+ actions: await codeActionsForMarkdown(request.document, request.range, request.rules),
23
+ };
24
+ },
25
+ });
26
+ async function lintMarkdown(document, rules) {
27
+ const { markdownDiagnosticsForDocument } = await markdownlintRuntime;
28
+ return markdownDiagnosticsForDocument(document, rules);
29
+ }
30
+ async function codeActionsForMarkdown(document, requestedRange, rules) {
31
+ const { markdownCodeActionsForDocument } = await markdownlintRuntime;
32
+ return markdownCodeActionsForDocument(document, requestedRange, rules);
33
+ }
@@ -0,0 +1,12 @@
1
+ import type { LanguageServiceWorkerRequest, LanguageServiceWorkerResponse } from "@lapis-notes/api/language-service";
2
+ type WorkerHandlers = {
3
+ [K in LanguageServiceWorkerRequest["type"]]: (request: Extract<LanguageServiceWorkerRequest, {
4
+ type: K;
5
+ }>) => Promise<Extract<LanguageServiceWorkerResponse, {
6
+ type: K;
7
+ }>> | Extract<LanguageServiceWorkerResponse, {
8
+ type: K;
9
+ }>;
10
+ };
11
+ export declare function registerLanguageServiceWorker(handlers: Partial<WorkerHandlers>): void;
12
+ export {};
@@ -0,0 +1,23 @@
1
+ export function registerLanguageServiceWorker(handlers) {
2
+ self.onmessage = async (event) => {
3
+ const { id, payload } = event.data;
4
+ try {
5
+ const handler = handlers[payload.type];
6
+ if (!handler)
7
+ throw new Error(`Unknown language service worker request: ${payload.type}`);
8
+ const workerHandler = handler;
9
+ postMessage({
10
+ id,
11
+ ok: true,
12
+ payload: await workerHandler(payload),
13
+ });
14
+ }
15
+ catch (error) {
16
+ postMessage({
17
+ id,
18
+ ok: false,
19
+ error: error instanceof Error ? error.message : String(error),
20
+ });
21
+ }
22
+ };
23
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Unified's entity decoder selects its DOM implementation under a browser
3
+ * export condition, even when Vite is compiling a Web Worker. Provide the
4
+ * single element contract that implementation needs before markdownlint is
5
+ * loaded, without introducing a real DOM dependency into the worker.
6
+ */
7
+ export declare function installWorkerEntityDecoderDocumentShim(): void;
@@ -0,0 +1,37 @@
1
+ import { characterEntities } from "character-entities";
2
+ /**
3
+ * Unified's entity decoder selects its DOM implementation under a browser
4
+ * export condition, even when Vite is compiling a Web Worker. Provide the
5
+ * single element contract that implementation needs before markdownlint is
6
+ * loaded, without introducing a real DOM dependency into the worker.
7
+ */
8
+ export function installWorkerEntityDecoderDocumentShim() {
9
+ const workerGlobal = globalThis;
10
+ if (workerGlobal.document)
11
+ return;
12
+ const documentShim = {
13
+ compatMode: "CSS1Compat",
14
+ createElement() {
15
+ let decodedText = "";
16
+ return {
17
+ set innerHTML(value) {
18
+ const match = /^&([^;]+);$/u.exec(value);
19
+ decodedText = match
20
+ ? (characterEntities[match[1]] ??
21
+ value)
22
+ : value;
23
+ },
24
+ get innerHTML() {
25
+ return decodedText;
26
+ },
27
+ get textContent() {
28
+ return decodedText;
29
+ },
30
+ };
31
+ },
32
+ };
33
+ Object.defineProperty(workerGlobal, "document", {
34
+ configurable: true,
35
+ value: documentShim,
36
+ });
37
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@lapis-notes/language-service",
3
+ "version": "0.1.0",
4
+ "description": "Provider-neutral Markdown language services for Lapis applications",
5
+ "license": "AGPL-3.0-or-later",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/lapismd/lapis-notes.git",
9
+ "directory": "packages/language-service"
10
+ },
11
+ "homepage": "https://github.com/lapismd/lapis-notes/tree/main/packages/language-service#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/lapismd/lapis-notes/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "files": [
20
+ "README.md",
21
+ "CHANGELOG.md",
22
+ "LICENSE.md",
23
+ "dist",
24
+ "!dist/**/*.test.*",
25
+ "!dist/**/*.spec.*"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./markdown": {
34
+ "types": "./dist/markdown.d.ts",
35
+ "import": "./dist/markdown.js",
36
+ "default": "./dist/markdown.js"
37
+ },
38
+ "./markdownlint/runtime": {
39
+ "types": "./dist/markdownlint/runtime.d.ts",
40
+ "import": "./dist/markdownlint/runtime.js",
41
+ "default": "./dist/markdownlint/runtime.js"
42
+ }
43
+ },
44
+ "typesVersions": {
45
+ "*": {
46
+ "markdownlint/runtime": [
47
+ "dist/markdownlint/runtime.d.ts"
48
+ ]
49
+ }
50
+ },
51
+ "dependencies": {
52
+ "@lapis-notes/api": "^0.1.0",
53
+ "character-entities": "^2.0.2",
54
+ "markdownlint": "^0.40.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^22.10.5",
58
+ "publint": "^0.3.12",
59
+ "typescript": "~5.9.3",
60
+ "vite": "^6.4.2",
61
+ "vitest": "^4.1.8"
62
+ },
63
+ "scripts": {
64
+ "build": "tsc -p tsconfig.json",
65
+ "check": "pnpm check:types",
66
+ "check:types": "tsc -p tsconfig.json --noEmit",
67
+ "test": "vitest run"
68
+ }
69
+ }