Skip to content

Commit 14ccc7a

Browse files
Peter Stengerclaude
andcommitted
Add configurable indentation for custom code tags, bump v0.0.33
Custom code tags (e.g. <pl-code>) can now have their content dedented and re-indented at the current nesting level. Three modes via the new `indent` setting: "never" (default, preserves as-is), "always" (always dedent+indent), and "attribute" (indent only when a specified attribute has a truthy value). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dcbcc5c commit 14ccc7a

6 files changed

Lines changed: 321 additions & 13 deletions

File tree

lsp/package.json

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lsp/server/src/customCodeTags.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type { Node as SyntaxNode } from 'web-tree-sitter';
22

3+
export type CustomCodeTagIndentMode = 'never' | 'always' | 'attribute';
4+
35
export interface CustomCodeTagConfig {
46
name: string;
57
languageAttribute?: string;
68
languageMap?: Record<string, string>;
79
languageDefault?: string;
10+
indent?: CustomCodeTagIndentMode;
11+
indentAttribute?: string;
812
}
913

1014
export interface CustomCodeTagContent {
@@ -17,14 +21,24 @@ export interface CustomCodeTagContent {
1721
/**
1822
* Parse customCodeTags settings, extracting tag names and full configs.
1923
*/
24+
const VALID_INDENT_MODES = new Set<string>(['never', 'always', 'attribute']);
25+
2026
export function parseCustomCodeTagSettings(tags: unknown[]): { tagNames: string[]; configs: CustomCodeTagConfig[] } {
2127
const tagNames: string[] = [];
2228
const configs: CustomCodeTagConfig[] = [];
2329
for (const tag of tags) {
2430
if (tag && typeof tag === 'object' && 'name' in tag && typeof (tag as { name: unknown }).name === 'string') {
25-
const t = tag as CustomCodeTagConfig;
26-
tagNames.push(t.name);
27-
configs.push(t);
31+
const t = tag as Record<string, unknown>;
32+
const config: CustomCodeTagConfig = { name: t.name as string };
33+
if (typeof t.languageAttribute === 'string') config.languageAttribute = t.languageAttribute;
34+
if (t.languageMap && typeof t.languageMap === 'object') config.languageMap = t.languageMap as Record<string, string>;
35+
if (typeof t.languageDefault === 'string') config.languageDefault = t.languageDefault;
36+
if (typeof t.indent === 'string' && VALID_INDENT_MODES.has(t.indent)) {
37+
config.indent = t.indent as CustomCodeTagIndentMode;
38+
}
39+
if (typeof t.indentAttribute === 'string') config.indentAttribute = t.indentAttribute;
40+
tagNames.push(config.name);
41+
configs.push(config);
2842
}
2943
}
3044
return { tagNames, configs };
@@ -33,7 +47,7 @@ export function parseCustomCodeTagSettings(tags: unknown[]): { tagNames: string[
3347
/**
3448
* Get the attribute value for a given attribute name from an element's start tag.
3549
*/
36-
function getAttributeValue(node: SyntaxNode, attrName: string): string | null {
50+
export function getAttributeValue(node: SyntaxNode, attrName: string): string | null {
3751
for (let i = 0; i < node.childCount; i++) {
3852
const child = node.child(i);
3953
if (child?.type === 'html_start_tag') {

lsp/server/src/formatting/formatters.ts

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,84 @@ import {
3333
isWhitespaceInsensitive,
3434
} from './classifier';
3535
import { normalizeText, getVisibleChildren, normalizeMustacheWhitespace, normalizeMustacheWhitespaceAll } from './utils';
36+
import type { CustomCodeTagConfig } from '../customCodeTags';
37+
import { getAttributeValue } from '../customCodeTags';
3638

3739
export interface FormatterContext {
3840
document: TextDocument;
3941
customCodeTags?: Set<string>;
42+
customCodeTagConfigs?: Map<string, CustomCodeTagConfig>;
4043
embeddedFormatted?: Map<number, string>;
4144
mustacheSpaces?: boolean;
4245
}
4346

47+
/**
48+
* Check if an attribute value is truthy (not null, empty, "false", or "0").
49+
*/
50+
export function isAttributeTruthy(value: string | null): boolean {
51+
if (value === null || value === '' || value === 'false' || value === '0') {
52+
return false;
53+
}
54+
return true;
55+
}
56+
57+
/**
58+
* Dedent content by stripping leading/trailing empty lines and removing the
59+
* minimum common indentation from all non-empty lines.
60+
*/
61+
export function dedentContent(rawContent: string): string {
62+
const lines = rawContent.split('\n');
63+
64+
// Strip leading empty lines
65+
while (lines.length > 0 && lines[0].trim() === '') {
66+
lines.shift();
67+
}
68+
// Strip trailing empty lines
69+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
70+
lines.pop();
71+
}
72+
73+
if (lines.length === 0) return '';
74+
75+
// Find minimum indentation across non-empty lines
76+
let minIndent = Infinity;
77+
for (const l of lines) {
78+
if (l.trim() === '') continue;
79+
const match = l.match(/^(\s*)/);
80+
if (match && match[1].length < minIndent) {
81+
minIndent = match[1].length;
82+
}
83+
}
84+
if (minIndent === Infinity) minIndent = 0;
85+
86+
// Strip common indent
87+
return lines.map(l => l.trim() === '' ? '' : l.slice(minIndent)).join('\n');
88+
}
89+
90+
/**
91+
* Resolve whether a custom code tag's content should be indented.
92+
*/
93+
function resolveIndentMode(
94+
node: SyntaxNode,
95+
config: CustomCodeTagConfig
96+
): boolean {
97+
const mode = config.indent ?? 'never';
98+
if (mode === 'never') return false;
99+
if (mode === 'always') return true;
100+
// mode === 'attribute'
101+
if (!config.indentAttribute) return false;
102+
const value = getAttributeValue(node, config.indentAttribute);
103+
return isAttributeTruthy(value);
104+
}
105+
106+
function getTagNameFromStartTag(startTag: SyntaxNode): string | null {
107+
for (let i = 0; i < startTag.childCount; i++) {
108+
const child = startTag.child(i);
109+
if (child?.type === 'html_tag_name') return child.text.toLowerCase();
110+
}
111+
return null;
112+
}
113+
44114
function mustacheText(raw: string, context: FormatterContext): string {
45115
if (context.mustacheSpaces !== undefined) {
46116
return normalizeMustacheWhitespace(raw, context.mustacheSpaces);
@@ -175,9 +245,39 @@ export function formatHtmlElement(node: SyntaxNode, context: FormatterContext):
175245

176246
// Handle content
177247
if (preserveContent) {
178-
// Use raw document text to preserve all whitespace, since tree-sitter
179-
// text nodes strip boundary whitespace from regular html_element children
180-
if (startTag && endTag) {
248+
// Check if this custom code tag should be indented
249+
const tagNameLower = startTag ? getTagNameFromStartTag(startTag) : null;
250+
const tagConfig = tagNameLower ? context.customCodeTagConfigs?.get(tagNameLower) : undefined;
251+
const shouldIndent = tagConfig ? resolveIndentMode(node, tagConfig) : false;
252+
253+
if (shouldIndent && startTag && endTag) {
254+
const rawContent = context.document.getText().slice(
255+
startTag.endIndex,
256+
endTag.startIndex
257+
);
258+
const dedented = dedentContent(rawContent);
259+
if (dedented.length > 0) {
260+
const contentLines = dedented.split('\n');
261+
const lineDocs: Doc[] = [];
262+
for (let j = 0; j < contentLines.length; j++) {
263+
if (j > 0) {
264+
if (contentLines[j] === '') {
265+
// Empty line: literal \n avoids indentation from the printer
266+
lineDocs.push('\n');
267+
} else {
268+
lineDocs.push(hardline);
269+
}
270+
}
271+
if (contentLines[j] !== '') {
272+
lineDocs.push(text(contentLines[j]));
273+
}
274+
}
275+
parts.push(indent(concat([hardline, ...lineDocs])));
276+
parts.push(hardline);
277+
}
278+
} else if (startTag && endTag) {
279+
// Use raw document text to preserve all whitespace, since tree-sitter
280+
// text nodes strip boundary whitespace from regular html_element children
181281
const rawContent = context.document.getText().slice(
182282
startTag.endIndex,
183283
endTag.startIndex
@@ -306,7 +406,37 @@ export function formatScriptStyleElement(
306406
}
307407
} else {
308408
// Fallback: preserve raw content as-is (also used for html_raw_element)
309-
parts.push(text(child.text));
409+
// Check if this is a custom code tag that should be indented
410+
if (node.type === 'html_raw_element') {
411+
const startTagNode = node.child(0);
412+
const tagNameLower = startTagNode?.type === 'html_start_tag' ? getTagNameFromStartTag(startTagNode) : null;
413+
const tagConfig = tagNameLower ? context.customCodeTagConfigs?.get(tagNameLower) : undefined;
414+
if (tagConfig && resolveIndentMode(node, tagConfig)) {
415+
const dedented = dedentContent(child.text);
416+
if (dedented.length > 0) {
417+
const contentLines = dedented.split('\n');
418+
const lineDocs: Doc[] = [];
419+
for (let j = 0; j < contentLines.length; j++) {
420+
if (j > 0) {
421+
if (contentLines[j] === '') {
422+
lineDocs.push('\n');
423+
} else {
424+
lineDocs.push(hardline);
425+
}
426+
}
427+
if (contentLines[j] !== '') {
428+
lineDocs.push(text(contentLines[j]));
429+
}
430+
}
431+
parts.push(indent(concat([hardline, ...lineDocs])));
432+
parts.push(hardline);
433+
}
434+
} else {
435+
parts.push(text(child.text));
436+
}
437+
} else {
438+
parts.push(text(child.text));
439+
}
310440
}
311441
}
312442
}

lsp/server/src/formatting/index.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { formatDocument as formatDocumentToDoc, FormatterContext } from './forma
1919
import { mergeOptions, createIndentUnit } from './editorconfig';
2020
import { findContainingNode, calculateIndentLevel } from './utils';
2121
import { isBlockLevel, getContentNodes, hasImplicitEndTags, setCustomCodeTags } from './classifier';
22+
import type { CustomCodeTagConfig } from '../customCodeTags';
2223

2324
/**
2425
* Format an entire document.
@@ -30,7 +31,8 @@ export function formatDocument(
3031
customCodeTags?: string[],
3132
printWidth = 80,
3233
embeddedFormatted?: Map<number, string>,
33-
mustacheSpaces?: boolean
34+
mustacheSpaces?: boolean,
35+
customCodeTagConfigs?: CustomCodeTagConfig[]
3436
): TextEdit[] {
3537
const mergedOptions = mergeOptions(options, document.uri);
3638
const indentUnit = createIndentUnit(mergedOptions);
@@ -39,9 +41,11 @@ export function formatDocument(
3941
setCustomCodeTags(customCodeTags);
4042
}
4143

44+
const configMap = buildConfigMap(customCodeTagConfigs);
4245
const context: FormatterContext = {
4346
document,
4447
customCodeTags: customCodeTags ? new Set(customCodeTags.map(t => t.toLowerCase())) : undefined,
48+
customCodeTagConfigs: configMap,
4549
embeddedFormatted,
4650
mustacheSpaces,
4751
};
@@ -68,7 +72,8 @@ export function formatDocumentRange(
6872
customCodeTags?: string[],
6973
printWidth = 80,
7074
embeddedFormatted?: Map<number, string>,
71-
mustacheSpaces?: boolean
75+
mustacheSpaces?: boolean,
76+
customCodeTagConfigs?: CustomCodeTagConfig[]
7277
): TextEdit[] {
7378
const mergedOptions = mergeOptions(options, document.uri);
7479
const indentUnit = createIndentUnit(mergedOptions);
@@ -104,9 +109,11 @@ export function formatDocumentRange(
104109
getContentNodes
105110
);
106111

112+
const configMap = buildConfigMap(customCodeTagConfigs);
107113
const context: FormatterContext = {
108114
document,
109115
customCodeTags: customCodeTags ? new Set(customCodeTags.map(t => t.toLowerCase())) : undefined,
116+
customCodeTagConfigs: configMap,
110117
embeddedFormatted,
111118
mustacheSpaces,
112119
};
@@ -145,6 +152,15 @@ function formatNodeForRange(
145152
/**
146153
* Apply base indentation to each line of formatted output.
147154
*/
155+
function buildConfigMap(configs?: CustomCodeTagConfig[]): Map<string, CustomCodeTagConfig> | undefined {
156+
if (!configs || configs.length === 0) return undefined;
157+
const map = new Map<string, CustomCodeTagConfig>();
158+
for (const config of configs) {
159+
map.set(config.name.toLowerCase(), config);
160+
}
161+
return map;
162+
}
163+
148164
function applyBaseIndent(
149165
formatted: string,
150166
indentLevel: number,

lsp/server/src/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ connection.onDocumentFormatting(async (params) => {
466466
}
467467

468468
const embeddedFormatted = await formatEmbeddedRegions(tree.rootNode, params.options);
469-
return formatDocument(tree, document, params.options, customCodeTags, printWidth, embeddedFormatted, mustacheSpaces);
469+
return formatDocument(tree, document, params.options, customCodeTags, printWidth, embeddedFormatted, mustacheSpaces, customCodeTagConfigs);
470470
});
471471

472472
// Document range formatting handler
@@ -482,7 +482,7 @@ connection.onDocumentRangeFormatting(async (params) => {
482482
}
483483

484484
const embeddedFormatted = await formatEmbeddedRegions(tree.rootNode, params.options);
485-
return formatDocumentRange(tree, document, params.range, params.options, customCodeTags, printWidth, embeddedFormatted, mustacheSpaces);
485+
return formatDocumentRange(tree, document, params.range, params.options, customCodeTags, printWidth, embeddedFormatted, mustacheSpaces, customCodeTagConfigs);
486486
});
487487

488488
// Listen on the documents and connection

0 commit comments

Comments
 (0)