Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/green-lizards-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/language-server': patch
---

Keep generated `AstroComponent` suffixes in language-server output while rewriting `.astro` auto-import suggestions and edits back to the expected component name.
9 changes: 6 additions & 3 deletions packages/language-tools/language-server/src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,18 @@ export function classNameFromFilename(filename: string): string {

// TODO: Patch the upstream packages with these changes
export function patchTSX(code: string, filePath: string) {
const basename = filePath.split('/').pop()!;
const url = URI.parse(filePath);
const basename = Utils.basename(url).slice(0, -Utils.extname(url).length);
const isDynamic = basename.startsWith('[') && basename.endsWith(']');

return code.replace(/\b(\S*)__AstroComponent_/g, (fullMatch, m1: string) => {
// If we don't have a match here, it usually means the file has a weird name that couldn't be expressed with valid identifier characters
if (!m1) {
if (basename === '404') return 'FourOhFour';
if (basename === '404') return 'FourOhFourAstroComponent';
return fullMatch;
}
return isDynamic ? `_${m1}_` : m1[0].toUpperCase() + m1.slice(1);

const componentName = isDynamic ? `_${m1}_` : m1[0].toUpperCase() + m1.slice(1);
return `${componentName}AstroComponent`;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import type {
import { CompletionItemKind } from '@volar/language-server';
import { URI } from 'vscode-uri';
import { AstroVirtualCode } from '../../core/index.js';
import { mapEdit } from './utils.js';
import {
isAstroComponentImportSource,
mapEdit,
rewriteAstroImportText,
stripAstroComponentSuffix,
} from './utils.js';

export function enhancedProvideCompletionItems(completions: CompletionList): CompletionList {
completions.items = completions.items.filter(isValidCompletion).map((completion) => {
Expand All @@ -23,6 +28,10 @@ export function enhancedProvideCompletionItems(completions: CompletionList): Com
completion.detail = completion.detail + '\n\n' + source;
completion.sortText = '\u0001' + (completion.sortText ?? completion.label);
completion.data.isComponent = true;

if (isAstroComponentImportSource(source)) {
rewriteAstroComponentCompletion(completion);
}
}
}

Expand All @@ -44,6 +53,10 @@ export function enhancedResolveCompletionItem(
);
}

if (isAstroComponentImportSource(resolvedCompletion.data.originalItem.source)) {
rewriteAstroComponentCompletion(resolvedCompletion);
}

if (resolvedCompletion.additionalTextEdits) {
const decoded = context.decodeEmbeddedDocumentUri(URI.parse(resolvedCompletion.data.uri));
const sourceScript = decoded && context.language.scripts.get(decoded[0]);
Expand All @@ -59,6 +72,27 @@ export function enhancedResolveCompletionItem(
return resolvedCompletion;
}

function rewriteAstroComponentCompletion(completion: CompletionItem) {
completion.label = stripAstroComponentSuffix(String(completion.label));
completion.filterText = completion.filterText
? stripAstroComponentSuffix(completion.filterText)
: completion.filterText;
completion.insertText = completion.insertText
? stripAstroComponentSuffix(completion.insertText)
: completion.insertText;

if (completion.textEdit && 'newText' in completion.textEdit) {
completion.textEdit.newText = stripAstroComponentSuffix(completion.textEdit.newText);
}

if (completion.additionalTextEdits) {
completion.additionalTextEdits = completion.additionalTextEdits.map((edit) => ({
...edit,
newText: rewriteAstroImportText(edit.newText),
}));
}
}

function getDetailForFileCompletion(detail: string, source: string): string {
return `${detail}\n\n${source}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,40 @@ import type { TextEdit } from 'vscode-html-languageservice';
import type { AstroVirtualCode } from '../../core/index.js';
import { editShouldBeInFrontmatter, ensureProperEditForFrontmatter } from '../utils.js';

const ASTRO_COMPONENT_SUFFIX = 'AstroComponent';
const ASTRO_IMPORT_FROM_PATTERN = /\bfrom\s+['"][^'"]+\.astro['"]/;
const ASTRO_DEFAULT_IMPORT_PATTERN =
/^(\s*import(?:\s+type)?\s+)([A-Za-z_$][\w$]*)AstroComponent(?=\s*,|\s+from\b)/;
const ASTRO_DEFAULT_ALIAS_PATTERN =
/(default\s+as\s+)([A-Za-z_$][\w$]*)AstroComponent(?=\s*\})/;

export function isAstroComponentImportSource(source: string | undefined): source is string {
return !!source && source.endsWith('.astro');
}

export function stripAstroComponentSuffix(name: string) {
if (!name.endsWith(ASTRO_COMPONENT_SUFFIX)) {
return name;
}

return name.slice(0, -ASTRO_COMPONENT_SUFFIX.length);
}

export function rewriteAstroImportText(text: string) {
return text
.split('\n')
.map((line) => {
if (!ASTRO_IMPORT_FROM_PATTERN.test(line)) {
return line;
}

return line
.replace(ASTRO_DEFAULT_IMPORT_PATTERN, '$1$2')
.replace(ASTRO_DEFAULT_ALIAS_PATTERN, '$1$2');
})
.join('\n');
}

export function mapEdit(edit: TextEdit, code: AstroVirtualCode, languageId: string) {
// Don't attempt to move the edit to the frontmatter if the file isn't the root TSX file, it means it's a script tag
if (languageId === 'typescriptreact') {
Expand All @@ -15,5 +49,7 @@ export function mapEdit(edit: TextEdit, code: AstroVirtualCode, languageId: stri
}
}

edit.newText = rewriteAstroImportText(edit.newText);

return edit;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
import { Image } from 'astro:assets';
---
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div>Image component</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

<Ima
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,36 @@ describe('TypeScript - Completions', async () => {
assert.strictEqual(edits?.additionalTextEdits?.[0].range.start.line, 0);
});

it('strips AstroComponent suffixes from Astro component auto-import completions', async () => {
const document = await languageServer.handle.openTextDocument(
path.join(fixtureDir, 'src/pages/componentAutoImport.astro'),
'astro',
);
const completions = await languageServer.handle.sendCompletionRequest(
document.uri,
Position.create(3, 4),
);

const imageCompletion = completions?.items.find(
(item) =>
item.label === 'Image' &&
item.labelDetails?.description === '../components/Image.astro',
);
assert.notStrictEqual(imageCompletion, undefined);
assert.ok(!imageCompletion?.filterText?.includes('AstroComponent'));
assert.ok(!imageCompletion?.insertText?.includes('AstroComponent'));

const edits = await languageServer.handle.sendCompletionResolveRequest(imageCompletion!);
assert.ok(edits);
assert.strictEqual(
edits?.additionalTextEdits?.[0].newText.includes(
`import Image from "../components/Image.astro";`,
),
true,
);
assert.strictEqual(edits?.additionalTextEdits?.[0].newText.includes('AstroComponent'), false);
});

it('Can get completions inside HTML events', async () => {
const document = await languageServer.openFakeDocument('<div onload="a"></div>', 'astro');
const completions = await languageServer.handle.sendCompletionRequest(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,23 @@ describe('TypeScript - Diagnostics', async () => {
)) as FullDocumentDiagnosticReport;
assert.strictEqual(diagnostics.items.length, 1);
});

it('does not report a local declaration conflict when importing Image from astro:assets', async () => {
const document = await languageServer.handle.openTextDocument(
path.join(fixtureDir, 'image.astro'),
'astro',
);
const diagnostics = (await languageServer.handle.sendDocumentDiagnosticRequest(
document.uri,
)) as FullDocumentDiagnosticReport;

assert.deepStrictEqual(
diagnostics.items.filter(
(diagnostic) =>
diagnostic.code === 2440 &&
diagnostic.message.includes("Import declaration conflicts with local declaration of 'Image'"),
),
[],
);
});
});
72 changes: 72 additions & 0 deletions packages/language-tools/language-server/test/units/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { Node } from 'vscode-html-languageservice';
import * as html from 'vscode-html-languageservice';
import { getTSXRangesAsLSPRanges, safeConvertToTSX } from '../../dist/core/astro2tsx.js';
import { getAstroMetadata } from '../../dist/core/parseAstro.js';
import { patchTSX } from '../../dist/core/utils.js';
import { rewriteAstroImportText } from '../../dist/plugins/typescript/utils.js';
import * as utils from '../../dist/plugins/utils.js';

describe('Utilities', async () => {
Expand Down Expand Up @@ -119,4 +121,74 @@ describe('Utilities', async () => {
newText: '\nfoo---',
});
});

it('rewriteAstroImportText - strips AstroComponent suffixes from default Astro imports', () => {
assert.strictEqual(
rewriteAstroImportText(`import ImageAstroComponent from "../components/Image.astro";\n`),
`import Image from "../components/Image.astro";\n`,
);
});

it('rewriteAstroImportText - only rewrites Astro imports', () => {
assert.strictEqual(
rewriteAstroImportText(`import ImageAstroComponent from "astro:assets";\n`),
`import ImageAstroComponent from "astro:assets";\n`,
);
});

it('rewriteAstroImportText - preserves named imports on Astro component imports', () => {
assert.strictEqual(
rewriteAstroImportText(
`import ImageAstroComponent, { type Props } from "../components/Image.astro";\n`,
),
`import Image, { type Props } from "../components/Image.astro";\n`,
);
});

it('rewriteAstroImportText - strips AstroComponent suffixes from default aliases', () => {
assert.strictEqual(
rewriteAstroImportText(
`import { default as ImageAstroComponent } from "../components/Image.astro";\n`,
),
`import { default as Image } from "../components/Image.astro";\n`,
);
});

it('patchTSX - keeps AstroComponent suffixes when import names conflict', () => {
const input = `/* @jsxImportSource astro */

import { Image } from 'astro:assets';

export default function Image__AstroComponent_(_props: Record<string, any>): any {}
`;

assert.match(
patchTSX(input, 'file:///src/pages/image.astro'),
/export default function ImageAstroComponent\(/,
);
});

it('patchTSX - keeps filename-based component names for plain references', () => {
const input = `/* @jsxImportSource astro */

<Fragment>
<div>{Image}</div>
</Fragment>
export default function Image__AstroComponent_(_props: Record<string, any>): any {}
`;

assert.match(
patchTSX(input, 'file:///src/pages/image.astro'),
/export default function ImageAstroComponent\(/,
);
});

it('patchTSX - preserves dynamic route component names', () => {
const input = `export default function slug__AstroComponent_(_props: Record<string, any>): any {}`;

assert.match(
patchTSX(input, 'file:///src/pages/[slug].astro'),
/export default function _slug_AstroComponent\(/,
);
});
});
Loading