diff --git a/.changeset/custom-namespaces.md b/.changeset/custom-namespaces.md
new file mode 100644
index 000000000..8b17a5091
--- /dev/null
+++ b/.changeset/custom-namespaces.md
@@ -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. ``). 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.
diff --git a/packages/language-server/src/lib/documents/configLoader.ts b/packages/language-server/src/lib/documents/configLoader.ts
index 2678ed0a1..237c18ce9 100644
--- a/packages/language-server/src/lib/documents/configLoader.ts
+++ b/packages/language-server/src/lib/documents/configLoader.ts
@@ -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;
diff --git a/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts b/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts
index 0cd520bf4..031cd22a0 100644
--- a/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts
+++ b/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts
@@ -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;
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-disabled/Counter.svelte b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-disabled/Counter.svelte
new file mode 100644
index 000000000..ff7dee16e
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-disabled/Counter.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-disabled/expectedv2.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-disabled/expectedv2.json
new file mode 100644
index 000000000..634459031
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-disabled/expectedv2.json
@@ -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": []
+ }
+]
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-disabled/input.svelte b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-disabled/input.svelte
new file mode 100644
index 000000000..f53f2e246
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-disabled/input.svelte
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/Counter.svelte b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/Counter.svelte
new file mode 100644
index 000000000..ff7dee16e
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/Counter.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/expectedv2.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/expectedv2.json
new file mode 100644
index 000000000..596d35c3e
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/expectedv2.json
@@ -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": []
+ }
+]
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/input.svelte b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/input.svelte
new file mode 100644
index 000000000..ec5a5f4c5
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/input.svelte
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/svelte.config.js b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/svelte.config.js
new file mode 100644
index 000000000..dbf6fb002
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces-value-checked/svelte.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ customNamespaces: ['mochi']
+};
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/Counter.svelte b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/Counter.svelte
new file mode 100644
index 000000000..ff7dee16e
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/Counter.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/expectedv2.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/expectedv2.json
new file mode 100644
index 000000000..fe51488c7
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/expectedv2.json
@@ -0,0 +1 @@
+[]
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/input.svelte b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/input.svelte
new file mode 100644
index 000000000..22f32213c
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/input.svelte
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
x
+
+
diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/svelte.config.js b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/svelte.config.js
new file mode 100644
index 000000000..dbf6fb002
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/custom-namespaces/svelte.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ customNamespaces: ['mochi']
+};
diff --git a/packages/svelte-check/README.md b/packages/svelte-check/README.md
index a05eff83b..2a1813ce4 100644
--- a/packages/svelte-check/README.md
+++ b/packages/svelte-check/README.md
@@ -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. ``. 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)?
diff --git a/packages/svelte-check/src/incremental.ts b/packages/svelte-check/src/incremental.ts
index aec4c83b7..8fcb85be9 100644
--- a/packages/svelte-check/src/incremental.ts
+++ b/packages/svelte-check/src/incremental.ts
@@ -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 {
+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)
+ };
}
/**
@@ -146,7 +163,7 @@ export async function emitSvelteFiles(
const manifest = incremental
? loadManifest(manifestPath, workspacePath)
: { version: MANIFEST_VERSION, entries: {} as Record };
- 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,
@@ -232,7 +249,8 @@ export async function emitSvelteFiles(
rewriteExternalImports: {
workspacePath,
generatedPath: outPath
- }
+ },
+ customNamespaces
});
fs.writeFileSync(outPath, tsx.code, 'utf-8');
diff --git a/packages/svelte2tsx/index.d.ts b/packages/svelte2tsx/index.d.ts
index 271e210f2..e3acb5c1f 100644
--- a/packages/svelte2tsx/index.d.ts
+++ b/packages/svelte2tsx/index.d.ts
@@ -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
diff --git a/packages/svelte2tsx/src/htmlxtojsx_v2/index.ts b/packages/svelte2tsx/src/htmlxtojsx_v2/index.ts
index f15d624d9..b7f74f34a 100644
--- a/packages/svelte2tsx/src/htmlxtojsx_v2/index.ts
+++ b/packages/svelte2tsx/src/htmlxtojsx_v2/index.ts
@@ -103,6 +103,7 @@ export function convertHtmlxToJsx(
emitJsDoc?: boolean;
isTsFile?: boolean;
rewriteExternalImports?: RewriteExternalImportsOptions;
+ customNamespaces?: string[];
} = { svelte5Plus: false }
): TemplateProcessResult {
options.typingsNamespace = options.typingsNamespace || 'svelteHTML';
@@ -498,7 +499,8 @@ export function convertHtmlxToJsx(
parent,
preserveAttributeCase,
options.svelte5Plus,
- element
+ element,
+ options.customNamespaces
);
break;
case 'Spread':
@@ -675,6 +677,7 @@ export function htmlx2jsx(
preserveAttributeCase: boolean;
typingsNamespace: string;
svelte5Plus: boolean;
+ customNamespaces?: string[];
}
) {
const { htmlxAst, tags } = parseHtmlx(htmlx, parse, { ...options });
diff --git a/packages/svelte2tsx/src/htmlxtojsx_v2/nodes/Attribute.ts b/packages/svelte2tsx/src/htmlxtojsx_v2/nodes/Attribute.ts
index bda3288c1..70b710f6a 100644
--- a/packages/svelte2tsx/src/htmlxtojsx_v2/nodes/Attribute.ts
+++ b/packages/svelte2tsx/src/htmlxtojsx_v2/nodes/Attribute.ts
@@ -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
@@ -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' ||
@@ -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);
}
@@ -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);
};
diff --git a/packages/svelte2tsx/src/svelte2tsx/index.ts b/packages/svelte2tsx/src/svelte2tsx/index.ts
index fb0ee2c03..f26d62565 100644
--- a/packages/svelte2tsx/src/svelte2tsx/index.ts
+++ b/packages/svelte2tsx/src/svelte2tsx/index.ts
@@ -26,6 +26,7 @@ function processSvelteTemplate(
emitJsDoc?: boolean;
isTsFile?: boolean;
rewriteExternalImports?: RewriteExternalImportsOptions;
+ customNamespaces?: string[];
}
): TemplateProcessResult {
const { htmlxAst, tags } = parseHtmlx(str.original, parse, options);
@@ -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);
@@ -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
diff --git a/packages/svelte2tsx/test/helpers.ts b/packages/svelte2tsx/test/helpers.ts
index 44f161342..3ea216044 100644
--- a/packages/svelte2tsx/test/helpers.ts
+++ b/packages/svelte2tsx/test/helpers.ts
@@ -372,6 +372,7 @@ type BaseConfig = {
emitOnTemplateError?: boolean;
filename?: string;
rewriteExternalImports?: Svelte2TsxConfig['rewriteExternalImports'];
+ customNamespaces?: string[];
};
type Svelte2TsxConfig = Required[1]>;
@@ -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
};
}
diff --git a/packages/svelte2tsx/test/svelte2tsx/samples/custom-namespaces-disabled/expectedv2.ts b/packages/svelte2tsx/test/svelte2tsx/samples/custom-namespaces-disabled/expectedv2.ts
new file mode 100644
index 000000000..1595fc308
--- /dev/null
+++ b/packages/svelte2tsx/test/svelte2tsx/samples/custom-namespaces-disabled/expectedv2.ts
@@ -0,0 +1,22 @@
+///
+;
+import Counter from './Counter.svelte';
+function $$render() {
+
+
+ let count = 5;
+;
+async () => {
+
+
+ { const $$_retnuoC0C = __sveltets_2_ensureComponent(Counter); new $$_retnuoC0C({ target: __sveltets_2_any(), props: {"mochi:hydrate":true,}});}
+ { const $$_retnuoC0C = __sveltets_2_ensureComponent(Counter); new $$_retnuoC0C({ target: __sveltets_2_any(), props: { "mochi:defer":count,}});}
+ { const $$_retnuoC0C = __sveltets_2_ensureComponent(Counter); new $$_retnuoC0C({ target: __sveltets_2_any(), props: {"mochi":true,}});}
+
+ { svelteHTML.createElement("div", {"mochi:foo":true,}); }
+ { svelteHTML.createElement("div", { "mochi:bar":count,}); }
+ { svelteHTML.createElement("div", {"mochi":true,}); }};
+return { props: /** @type {Record} */ ({}), slots: {}, events: {} }}
+
+export default class Input__SvelteComponent_ extends __sveltets_2_createSvelte2TsxComponent(__sveltets_2_partial(__sveltets_2_with_any_event($$render()))) {
+}
\ No newline at end of file
diff --git a/packages/svelte2tsx/test/svelte2tsx/samples/custom-namespaces-disabled/input.svelte b/packages/svelte2tsx/test/svelte2tsx/samples/custom-namespaces-disabled/input.svelte
new file mode 100644
index 000000000..714bbab6d
--- /dev/null
+++ b/packages/svelte2tsx/test/svelte2tsx/samples/custom-namespaces-disabled/input.svelte
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+