Skip to content
Closed
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
18 changes: 18 additions & 0 deletions .changeset/custom-namespaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'svelte2tsx': minor
'svelte-check': minor
'svelte-language-server': minor
---

feat: add `customNamespaces` option to exempt custom namespaced attributes from prop/attribute type validation

Frameworks built on top of Svelte sometimes attach custom namespaced attributes to elements and components (e.g. `<Counter mochi:hydrate />`). These previously caused a `Type 'true' is not assignable to type 'never'` error. You can now list the namespaces to treat as opaque framework metadata via `customNamespaces` in `svelte.config.js`:

```js
// svelte.config.js
export default {
customNamespaces: ['mochi']
};
```

An entry `'mochi'` matches an attribute named exactly `mochi` or any attribute starting with `mochi:`. Value expressions inside matched attributes are still type-checked. The option is honored by both `svelte-check` (CLI) and the language server (editor). `svelte2tsx` exposes the underlying `customNamespaces?: string[]` option directly.
6 changes: 6 additions & 0 deletions packages/language-server/src/lib/documents/configLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export interface SvelteConfig {
isFallbackConfig?: boolean;
configSource?: 'svelte' | 'vite';
kit?: any;
/**
* Attribute namespaces (e.g. `'mochi'`) that frameworks built on top of Svelte attach to
* elements and components as opaque metadata. Matching attributes (`mochi` or `mochi:*`)
* are exempt from prop/attribute type validation. See svelte2tsx' `customNamespaces` option.
*/
customNamespaces?: string[];
}

type LoadConfigFromDirectoryFn = typeof loadConfigFromDirectory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions
})
: document.config?.compilerOptions?.customElement),
emitJsDoc: options.emitJsDoc,
rewriteExternalImports: options.rewriteExternalImports
rewriteExternalImports: options.rewriteExternalImports,
customNamespaces: document.config?.customNamespaces
});
text = tsx.code;
tsxMap = tsx.map as EncodedSourceMap;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script lang="ts">
// A component with no props.
</script>

<button>clicked</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[
{
"range": { "start": { "line": 6, "character": 9 }, "end": { "line": 6, "character": 22 } },
"severity": 1,
"source": "ts",
"message": "Type 'true' is not assignable to type 'never'.",
"code": 2322,
"tags": []
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script lang="ts">
import Counter from './Counter.svelte';
</script>

<!-- No `customNamespaces` configured, so the namespaced attribute is validated as a prop
and must error (the behavior this feature opts out of). -->
<Counter mochi:hydrate />
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script lang="ts">
// A component with no props.
</script>

<button>clicked</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[
{
"range": { "start": { "line": 6, "character": 22 }, "end": { "line": 6, "character": 29 } },
"severity": 1,
"source": "ts",
"message": "Cannot find name 'typoVar'.",
"code": 2304,
"tags": []
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script lang="ts">
import Counter from './Counter.svelte';
</script>

<!-- The namespace is exempt from prop validation, but the value expression is still
type-checked: `typoVar` is undefined, so this must still error. -->
<Counter mochi:defer={typoVar} />
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
customNamespaces: ['mochi']
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script lang="ts">
// A component with no props.
</script>

<button>clicked</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
import Counter from './Counter.svelte';

let count = 5;
</script>

<Counter mochi:hydrate />
<Counter mochi:defer={count} />
<Counter mochi />

<div mochi:foo>x</div>
<div mochi:bar={count}></div>
<div mochi></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
customNamespaces: ['mochi']
};
32 changes: 32 additions & 0 deletions packages/svelte-check/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,38 @@ Usage:
| `--incremental` | Opts into TypeScript's incremental build cache, which speeds up subsequent runs. Saved within `.svelte-kit` or if not available within `.svelte-check`. This might result in slightly different type check outcomes, and certain patterns are not supported. Specifically, anything that is not in the root dir of your tsconfig.json and is a Svelte file will not be properly loaded and type-checked. |
| `--tsgo` | Use TypeScript's Go implementation. Needs to have `@typescript/native-preview` installed. Subject to the same limitations as `--incremental` |

### Custom attribute namespaces

Frameworks built on top of Svelte sometimes attach custom namespaced attributes to elements and
components to carry framework-level metadata, e.g. `<Counter mochi:hydrate />`. At runtime Svelte
passes these through, but at type-check time `svelte-check` reports
`Type 'true' is not assignable to type 'never'` because the component/element has no such prop.

To exempt one or more namespaces from prop/attribute validation, list them under
`customNamespaces` in your `svelte.config.js`:

```js
// svelte.config.js
export default {
preprocess: [
/* ... */
],
// Attributes named exactly `mochi` or starting with `mochi:` are treated as opaque metadata.
customNamespaces: ['mochi']
};
```

This option is read from the same `svelte.config.js` by both the CLI and the editor (the Svelte
language server / VS Code extension), so the editor experience matches `svelte-check`. Matching
attributes are still emitted such that any value expression is type-checked
(`mochi:defer={someVar}` still errors if `someVar` is undefined), but the attribute name itself is
no longer validated against the component's props.

Note that the Svelte compiler also emits an `attribute_illegal_colon` warning for namespaced
attributes. `customNamespaces` only addresses the TypeScript prop/attribute error; to silence the
compiler warning as well, combine it with
`--compiler-warnings "attribute_illegal_colon:ignore"`. Frameworks typically need both.

### FAQ

#### Why is there no option to only check specific files (for example only staged files)?
Expand Down
36 changes: 27 additions & 9 deletions packages/svelte-check/src/incremental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,21 +107,38 @@ function kitFilesSettingsFromConfig(config: any): InternalHelpers.KitFilesSettin
}

/**
* Loads the svelte.config.js file and extracts SvelteKit file path settings.
* Reads the `customNamespaces` option from a loaded Svelte config, normalizing it to a
* string array (or undefined when absent/invalid).
*/
function customNamespacesFromConfig(config: any): string[] | undefined {
const namespaces = config?.customNamespaces;
if (!Array.isArray(namespaces)) {
return undefined;
}
return namespaces.filter((ns): ns is string => typeof ns === 'string');
}

/**
* Loads the svelte.config.js file and extracts the settings svelte2tsx needs:
* SvelteKit file path settings and the `customNamespaces` option.
* Falls back to default paths when no Svelte config can be loaded.
*
* @param workspacePath - Root directory of the project
* @returns KitFilesSettings with paths for params, hooks files
* @returns Kit file path settings and custom namespaces
*/
async function loadKitFilesSettings(
workspacePath: string
): Promise<InternalHelpers.KitFilesSettings> {
async function loadSvelteConfigSettings(workspacePath: string): Promise<{
kitFilesSettings: InternalHelpers.KitFilesSettings;
customNamespaces: string[] | undefined;
}> {
const result = await loadConfig(workspacePath, { traverse: false });
if (!result || !('config' in result)) {
return defaultKitFilesSettings;
return { kitFilesSettings: defaultKitFilesSettings, customNamespaces: undefined };
}

return kitFilesSettingsFromConfig(result.config.kit);
return {
kitFilesSettings: kitFilesSettingsFromConfig(result.config.kit),
customNamespaces: customNamespacesFromConfig(result.config)
};
}

/**
Expand All @@ -146,7 +163,7 @@ export async function emitSvelteFiles(
const manifest = incremental
? loadManifest(manifestPath, workspacePath)
: { version: MANIFEST_VERSION, entries: {} as Record<string, ManifestEntry> };
const kitFilesSettings = await loadKitFilesSettings(workspacePath);
const { kitFilesSettings, customNamespaces } = await loadSvelteConfigSettings(workspacePath);
const isJsOrTsFile = (filePath: string) => filePath.endsWith('.ts') || filePath.endsWith('.js');
const allRelevantFiles = await findFiles(
workspacePath,
Expand Down Expand Up @@ -232,7 +249,8 @@ export async function emitSvelteFiles(
rewriteExternalImports: {
workspacePath,
generatedPath: outPath
}
},
customNamespaces
});

fs.writeFileSync(outPath, tsx.code, 'utf-8');
Expand Down
9 changes: 9 additions & 0 deletions packages/svelte2tsx/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ export function svelte2tsx(
* from the generated file location.
*/
rewriteExternalImports?: InternalHelpers.RewriteExternalImportsConfig;
/**
* Attribute namespaces that should be treated as opaque framework metadata rather than
* typed props/attributes. An entry `'mochi'` matches an attribute named exactly `mochi`
* or any attribute starting with `mochi:`. Matching attributes are emitted so their
* value expressions are still type-checked, but the attribute name itself is exempt from
* prop/attribute validation. A trailing `:` in an entry is ignored, so both `'mochi'`
* and `'mochi:'` work.
*/
customNamespaces?: string[];
}
): SvelteCompiledToTsx

Expand Down
5 changes: 4 additions & 1 deletion packages/svelte2tsx/src/htmlxtojsx_v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export function convertHtmlxToJsx(
emitJsDoc?: boolean;
isTsFile?: boolean;
rewriteExternalImports?: RewriteExternalImportsOptions;
customNamespaces?: string[];
} = { svelte5Plus: false }
): TemplateProcessResult {
options.typingsNamespace = options.typingsNamespace || 'svelteHTML';
Expand Down Expand Up @@ -498,7 +499,8 @@ export function convertHtmlxToJsx(
parent,
preserveAttributeCase,
options.svelte5Plus,
element
element,
options.customNamespaces
);
break;
case 'Spread':
Expand Down Expand Up @@ -675,6 +677,7 @@ export function htmlx2jsx(
preserveAttributeCase: boolean;
typingsNamespace: string;
svelte5Plus: boolean;
customNamespaces?: string[];
}
) {
const { htmlxAst, tags } = parseHtmlx(htmlx, parse, { ...options });
Expand Down
35 changes: 34 additions & 1 deletion packages/svelte2tsx/src/htmlxtojsx_v2/nodes/Attribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ const numberOnlyAttributes = new Set([
'tabindex'
]);

/**
* Returns whether the given attribute name belongs to one of the configured
* custom namespaces. A namespace entry `'mochi'` matches an attribute named
* exactly `mochi` or any attribute starting with `mochi:`.
*
* Entries are expected to be normalized (no trailing `:`) by the caller.
*/
function matchesCustomNamespace(name: string, customNamespaces: string[] | undefined): boolean {
if (!customNamespaces?.length) {
return false;
}
return customNamespaces.some((ns) => name === ns || name.startsWith(ns + ':'));
}

/**
* Handle various kinds of attributes and make them conform to being valid in context of a object definition
* - {x} ---> x
Expand All @@ -57,7 +71,8 @@ export function handleAttribute(
parent: BaseNode,
preserveCase: boolean,
svelte5Plus: boolean,
element: Element | InlineComponent
element: Element | InlineComponent,
customNamespaces?: string[]
): void {
if (
parent.name === '!DOCTYPE' ||
Expand Down Expand Up @@ -91,6 +106,15 @@ export function handleAttribute(
value = ['__sveltets_2_any()'];
}
value.push('})');
} else if (matchesCustomNamespace(attr.name, customNamespaces)) {
// Attributes in a configured custom namespace are opaque framework
// metadata, not typed attributes. Wrap them so the attribute name is
// exempt from validation while any value expression stays type-checked.
name.unshift('...__sveltets_2_empty({');
if (!value) {
value = ['__sveltets_2_any()'];
}
value.push('})');
}
element.addAttribute(name, value);
}
Expand All @@ -103,6 +127,15 @@ export function handleAttribute(
value = ['""'];
}
value.push('})');
} else if (matchesCustomNamespace(attr.name, customNamespaces)) {
// Attributes in a configured custom namespace are opaque framework
// metadata, not typed props. Wrap them so the attribute name is
// exempt from validation while any value expression stays type-checked.
name.unshift('...__sveltets_2_empty({');
if (!value) {
value = ['__sveltets_2_any()'];
}
value.push('})');
}
element.addProp(name, value);
};
Expand Down
16 changes: 15 additions & 1 deletion packages/svelte2tsx/src/svelte2tsx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function processSvelteTemplate(
emitJsDoc?: boolean;
isTsFile?: boolean;
rewriteExternalImports?: RewriteExternalImportsOptions;
customNamespaces?: string[];
}
): TemplateProcessResult {
const { htmlxAst, tags } = parseHtmlx(str.original, parse, options);
Expand Down Expand Up @@ -55,10 +56,22 @@ export function svelte2tsx(
workspacePath: string;
generatedPath: string;
};
/**
* Attribute namespaces that should be treated as opaque framework metadata rather than
* typed props/attributes. An entry `'mochi'` matches an attribute named exactly `mochi`
* or any attribute starting with `mochi:`. Matching attributes are emitted so their
* value expressions are still type-checked, but the attribute name itself is exempt from
* prop/attribute validation.
*/
customNamespaces?: string[];
} = { parse }
) {
options.mode = options.mode || 'ts';
options.version = options.version || VERSION;
// Normalize custom namespaces by stripping a trailing `:` so both `'mochi'` and `'mochi:'` work.
const customNamespaces = options.customNamespaces
?.map((ns) => (ns.endsWith(':') ? ns.slice(0, -1) : ns))
.filter((ns) => ns.length > 0);

const str = new MagicString(svelte);

Expand Down Expand Up @@ -93,7 +106,8 @@ export function svelte2tsx(
} = processSvelteTemplate(str, options.parse || parse, {
...options,
svelte5Plus,
rewriteExternalImports: rewriteExternalImportsOptions
rewriteExternalImports: rewriteExternalImportsOptions,
customNamespaces
});

/* Rearrange the script tags so that module is first, and instance second followed finally by the template
Expand Down
4 changes: 3 additions & 1 deletion packages/svelte2tsx/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ type BaseConfig = {
emitOnTemplateError?: boolean;
filename?: string;
rewriteExternalImports?: Svelte2TsxConfig['rewriteExternalImports'];
customNamespaces?: string[];
};
type Svelte2TsxConfig = Required<Parameters<typeof svelte2tsx>[1]>;

Expand All @@ -386,7 +387,8 @@ export function get_svelte2tsx_config(base: BaseConfig, sampleName: string): Sve
accessors: sampleName.startsWith('accessors-config'),
emitJsDoc: sampleName.startsWith('jsdoc-'),
version: VERSION,
rewriteExternalImports: base.rewriteExternalImports
rewriteExternalImports: base.rewriteExternalImports,
customNamespaces: base.customNamespaces
};
}

Expand Down
Loading
Loading