diff --git a/.gitignore b/.gitignore index 5f34e42..08863dc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules .history plugin.js plugin.js.map +browser.js \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2750f54..ba77dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # prettier-plugin-svelte changelog +## 3.2.3 + +- (fix) don't force-self-close `` tags + +## 3.2.2 + +- (fix) handle updated `@render` tag AST shape + +## 3.2.1 + +- (fix) handle updated `@render` tag AST shape + +## 3.2.0 + +- (feat) format JSON script tags +- (feat) introduce separate entry point using `prettier/standalone` +- (fix) don't duplicate comments of nested script/style tags +- (fix) handle updated `Snippet` block AST shape + ## 3.1.2 - (fix) handle `>` tags in attributes diff --git a/README.md b/README.md index d4dbc82..a799ac1 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,10 @@ Since we are using configuration overrides to handle svelte files, you might als } ``` +## Usage in the browser + +Usage in the browser is semi-supported. You can import the plugin from `prettier-plugin-svelte/browser` to get a version that depends on `prettier/standalone` and therefore doesn't use any node APIs. What isn't supported in a good way yet is using this without a build step - you still need a bundler like Vite to build everything together as one self-contained package in advance. + ## Migration ```diff diff --git a/package-lock.json b/package-lock.json index 9403662..dec8fe9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "prettier-plugin-svelte", - "version": "3.1.2", + "version": "3.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prettier-plugin-svelte", - "version": "3.1.2", + "version": "3.2.3", "license": "MIT", "devDependencies": { + "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "14.0.0", "@rollup/plugin-node-resolve": "11.0.1", "@types/node": "^14.0.0", @@ -152,9 +153,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.4.tgz", + "integrity": "sha512-Oud2QPM5dHviZNn4y/WhhYKSXksv+1xLEIsNrAbGcFzUN3ubqWRFT5gwPchNc5NuzILOU4tPBDTZ4VwhL8Y7cw==", "dev": true, "dependencies": { "@jridgewell/set-array": "^1.0.1", @@ -234,6 +235,38 @@ "node": ">= 8" } }, + "node_modules/@rollup/plugin-alias": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.0.tgz", + "integrity": "sha512-lpA3RZ9PdIG7qqhEfv79tBffNaoDuukFDrmhLqg9ifv99u/ehn+lOg30x2zmhf8AQqQUZaMk/B9fZraQ6/acDQ==", + "dev": true, + "dependencies": { + "slash": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-alias/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-14.0.0.tgz", @@ -669,9 +702,9 @@ } }, "node_modules/axobject-query": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", - "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", + "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==", "dev": true, "dependencies": { "dequal": "^2.0.3" @@ -3416,17 +3449,18 @@ } }, "node_modules/svelte": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.7.tgz", - "integrity": "sha512-UExR1KS7raTdycsUrKLtStayu4hpdV3VZQgM0akX8XbXgLBlosdE/Sf3crOgyh9xIjqSYB3UEBuUlIQKRQX2hg==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.12.tgz", + "integrity": "sha512-d8+wsh5TfPwqVzbm4/HCXC783/KPHV60NvwitJnyTA5lWn1elhXMNWhXGCJ7PwPa8qFUnyJNIyuIRt2mT0WMug==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/trace-mapping": "^0.3.18", + "@types/estree": "^1.0.1", "acorn": "^8.9.0", "aria-query": "^5.3.0", - "axobject-query": "^3.2.1", + "axobject-query": "^4.0.0", "code-red": "^1.0.3", "css-tree": "^2.3.1", "estree-walker": "^3.0.3", @@ -3440,9 +3474,9 @@ } }, "node_modules/svelte/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.23.tgz", + "integrity": "sha512-9/4foRoUKp8s96tSkh8DlAAc5A0Ty8vLXld+l9gjKKY6ckwI8G15f0hskGmuLZu78ZlGa1vtsfOa+lnB4vG6Jg==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3474,9 +3508,9 @@ } }, "node_modules/svelte/node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", + "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" diff --git a/package.json b/package.json index 4f147b7..2cb2957 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "prettier-plugin-svelte", - "version": "3.1.2", + "version": "3.2.3", "description": "Svelte plugin for prettier", "main": "plugin.js", "files": [ "plugin.js", "plugin.js.map", + "browser.js", "index.d.ts" ], "types": "./index.d.ts", @@ -14,6 +15,7 @@ "types": "./index.d.ts", "default": "./plugin.js" }, + "./browser": "./browser.js", "./package.json": "./package.json" }, "scripts": { @@ -37,6 +39,7 @@ }, "homepage": "https://github.com/sveltejs/prettier-plugin-svelte#readme", "devDependencies": { + "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "14.0.0", "@rollup/plugin-node-resolve": "11.0.1", "@types/node": "^14.0.0", diff --git a/rollup.config.js b/rollup.config.js index edcd9ff..fd12ae5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,14 +1,38 @@ +import alias from '@rollup/plugin-alias'; import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import typescript from 'rollup-plugin-typescript'; -export default { - input: 'src/index.ts', - plugins: [resolve(), commonjs(), typescript()], - external: ['prettier', 'svelte'], - output: { - file: 'plugin.js', - format: 'cjs', - sourcemap: true, +export default [ + // CommonJS build + { + input: 'src/index.ts', + plugins: [resolve(), commonjs(), typescript()], + external: ['prettier', 'svelte/compiler'], + output: { + file: 'plugin.js', + format: 'cjs', + sourcemap: true, + }, }, -}; + // Browser build + // Supported use case: importing the plugin from a bundler like Vite or Webpack + // Semi-supported use case: importing the plugin directly in the browser through using import maps. + // (semi-supported because it requires a svelte/compiler.cjs import map and the .cjs ending has the wrong mime type on CDNs) + { + input: 'src/index.ts', + plugins: [ + alias({ + entries: [{ find: 'prettier', replacement: 'prettier/standalone' }], + }), + resolve(), + commonjs(), + typescript(), + ], + external: ['prettier/standalone', 'prettier/plugins/babel', 'svelte/compiler'], + output: { + file: 'browser.js', + format: 'esm', + }, + }, +]; diff --git a/src/base64-string.ts b/src/base64-string.ts new file mode 100644 index 0000000..887084f --- /dev/null +++ b/src/base64-string.ts @@ -0,0 +1,20 @@ +// Base64 string encoding and decoding module. +// Uses Buffer for Node.js and btoa/atob for browser environments. +// We use TextEncoder/TextDecoder for browser environments because +// they can handle non-ASCII characters, unlike btoa/atob. + +export const stringToBase64 = + typeof Buffer !== 'undefined' + ? (str: string) => Buffer.from(str).toString('base64') + : (str: string) => + btoa( + new TextEncoder() + .encode(str) + .reduce((acc, byte) => acc + String.fromCharCode(byte), ''), + ); + +export const base64ToString = + typeof Buffer !== 'undefined' + ? (str: string) => Buffer.from(str, 'base64').toString() + : (str: string) => + new TextDecoder().decode(Uint8Array.from(atob(str), (c) => c.charCodeAt(0))); diff --git a/src/embed.ts b/src/embed.ts index 4c433b5..03bdaa5 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -11,6 +11,7 @@ import { getLeadingComment, isIgnoreDirective, isInsideQuotedAttribute, + isJSON, isLess, isNodeSupportedLanguage, isPugTemplate, @@ -18,8 +19,9 @@ import { isTypeScript, printRaw, } from './print/node-helpers'; -import { CommentNode, ElementNode, Node, ScriptNode, StyleNode } from './print/nodes'; +import { BaseNode, CommentNode, ElementNode, Node, ScriptNode, StyleNode } from './print/nodes'; import { extractAttributes } from './lib/extractAttributes'; +import { base64ToString } from './base64-string'; const { builders: { group, hardline, softline, indent, dedent, literalline }, @@ -73,7 +75,7 @@ export function embed(path: FastPath, _options: Options) { // embed does depth first traversal with deepest node called first, therefore we need to // check the parent to see if we are inside an expression that should be embedded. - const parent = path.getParentNode(); + const parent: Node = path.getParentNode(); const printJsExpression = () => (parent as any).expression ? printJS(parent, options.svelteStrictMode ?? false, false, false, 'expression') @@ -97,10 +99,14 @@ export function embed(path: FastPath, _options: Options) { parent.expression.end = options.originalText.indexOf( ')', - parent.context?.end ?? parent.expression.end, + parent.context?.end ?? // TODO: remove at some point, snippet API changed in .next-.. + parent.parameters?.[parent.parameters.length - 1]?.end ?? + parent.expression.end, ) + 1; parent.context = null; - printSvelteBlockJS('expression'); + parent.parameters = null; + node.isJS = true; + node.asFunction = true; } break; case 'Element': @@ -119,14 +125,19 @@ export function embed(path: FastPath, _options: Options) { printJS(parent, false, false, true, 'expression'); break; case 'RenderTag': - // We merge the two parts into one expression, which future-proofs this for template TS support if (node === parent.expression) { - parent.expression.end = - options.originalText.indexOf( - ')', - parent.argument?.end ?? parent.expression.end, - ) + 1; - parent.argument = null; + // TODO: remove this if block at some point, snippet API changed in .next-.. + if ('argument' in parent || 'arguments' in parent) { + parent.expression.end = + options.originalText.indexOf( + ')', + parent.argument?.end ?? + parent.arguments?.[parent.arguments.length - 1]?.end ?? + parent.expression.end, + ) + 1; + parent.argument = null; + parent.arguments = null; + } printJS(parent, false, false, false, 'expression'); } break; @@ -152,14 +163,14 @@ export function embed(path: FastPath, _options: Options) { // so we need to have another public parser and defer to that parser: 'svelteExpressionParser', singleQuote: node.forceSingleQuote ? true : options.singleQuote, + _svelte_asFunction: node.asFunction, }; + // If we have snipped content, it was done wrongly and we need to unsnip it. + // This happens for example for {@html ``} + const text = getText(node, options, true); let docs = await textToDoc( - forceIntoExpression( - // If we have snipped content, it was done wrongly and we need to unsnip it. - // This happens for example for {@html ``} - getText(node, options, true), - ), + node.asFunction ? forceIntoFunction(text) : forceIntoExpression(text), embeddedOptions, ); if (node.forceSingleLine) { @@ -168,6 +179,14 @@ export function embed(path: FastPath, _options: Options) { if (node.removeParentheses) { docs = removeParentheses(docs); } + if (node.asFunction) { + if (Array.isArray(docs) && typeof docs[0] === 'string') { + docs[0] = docs[0].replace('function ', ''); + docs.splice(-1, 1); + } else { + throw new Error('Prettier AST changed, asFunction logic needs to change'); + } + } return docs; } catch (e) { return getText(node, options, true); @@ -177,7 +196,7 @@ export function embed(path: FastPath, _options: Options) { const embedType = ( tag: 'script' | 'style' | 'template', - parser: 'typescript' | 'babel-ts' | 'css' | 'scss' | 'less' | 'pug', + parser: 'typescript' | 'babel-ts' | 'css' | 'scss' | 'less' | 'pug' | 'json', isTopLevel: boolean, ) => { return async ( @@ -203,7 +222,7 @@ export function embed(path: FastPath, _options: Options) { // the user could have set the default language. babel-ts will format things a little // bit different though, especially preserving parentheses around dot notation which // fixes https://github.com/sveltejs/prettier-plugin-svelte/issues/218 - isTypeScript(node) ? 'typescript' : 'babel-ts', + isTypeScript(node) ? 'typescript' : isJSON(node) ? 'json' : 'babel-ts', isTopLevel, ); const embedStyle = (isTopLevel: boolean) => @@ -235,6 +254,10 @@ function forceIntoExpression(statement: string) { return `(${statement}\n)`; } +function forceIntoFunction(statement: string) { + return `function ${statement} {}`; +} + function preformattedBody(str: string): Doc { if (!str) { return ''; @@ -252,7 +275,7 @@ function getSnippedContent(node: Node) { const encodedContent = getAttributeTextValue(snippedTagContentAttribute, node); if (encodedContent) { - return Buffer.from(encodedContent, 'base64').toString('utf-8'); + return base64ToString(encodedContent); } else { return ''; } @@ -260,7 +283,7 @@ function getSnippedContent(node: Node) { async function formatBodyContent( content: string, - parser: 'typescript' | 'babel-ts' | 'css' | 'scss' | 'less' | 'pug', + parser: 'typescript' | 'babel-ts' | 'css' | 'scss' | 'less' | 'pug' | 'json', textToDoc: (text: string, options: object) => Promise, options: ParserOptions & { pugTabWidth?: number }, ) { @@ -365,7 +388,8 @@ async function embedTag( // the new position. return [...comments, result, hardline]; } else { - return comments.length ? [...comments, result] : result; + // Only comments at the top level get the special "move comment" treatment. + return isTopLevel && comments.length ? [...comments, result] : result; } } @@ -376,11 +400,12 @@ function printJS( removeParentheses: boolean, name: string, ) { - if (!node[name] || typeof node[name] !== 'object') { + const part = node[name] as BaseNode | undefined; + if (!part || typeof part !== 'object') { return; } - node[name].isJS = true; - node[name].forceSingleQuote = forceSingleQuote; - node[name].forceSingleLine = forceSingleLine; - node[name].removeParentheses = removeParentheses; + part.isJS = true; + part.forceSingleQuote = forceSingleQuote; + part.forceSingleLine = forceSingleLine; + part.removeParentheses = removeParentheses; } diff --git a/src/index.ts b/src/index.ts index 40d15d3..336a6f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { hasPragma, print } from './print'; import { ASTNode } from './print/nodes'; import { embed, getVisitorKeys } from './embed'; import { snipScriptAndStyleTagContent } from './lib/snipTagContent'; +import { parse } from 'svelte/compiler'; const babelParser = prettierPluginBabel.parsers.babel; @@ -29,7 +30,7 @@ export const parsers: Record = { hasPragma, parse: (text) => { try { - return { ...require(`svelte/compiler`).parse(text), __isRoot: true }; + return { ...parse(text), __isRoot: true }; } catch (err: any) { if (err.start != null && err.end != null) { // Prettier expects error objects to have loc.start and loc.end fields. @@ -63,7 +64,12 @@ export const parsers: Record = { parse: (text: string, options: any) => { const ast = babelParser.parse(text, options); - return { ...ast, program: ast.program.body[0].expression }; + let program = ast.program.body[0]; + if (!options._svelte_asFunction) { + program = program.expression; + } + + return { ...ast, program }; }, }, }; diff --git a/src/lib/snipTagContent.ts b/src/lib/snipTagContent.ts index 8933726..cb02d8d 100644 --- a/src/lib/snipTagContent.ts +++ b/src/lib/snipTagContent.ts @@ -1,3 +1,5 @@ +import { base64ToString, stringToBase64 } from '../base64-string'; + export const snippedTagContentAttribute = '✂prettier:content✂'; const scriptRegex = @@ -42,7 +44,7 @@ export function snipScriptAndStyleTagContent(source: string): string { if (match.startsWith(' + + + + + + diff --git a/test/printer/samples/self-closing-tags-lenient.html b/test/printer/samples/self-closing-tags-lenient.html new file mode 100644 index 0000000..23ef5f1 --- /dev/null +++ b/test/printer/samples/self-closing-tags-lenient.html @@ -0,0 +1,2 @@ + + diff --git a/test/printer/samples/self-closing-tags.html b/test/printer/samples/self-closing-tags.html index 662774b..c9c7c0f 100644 --- a/test/printer/samples/self-closing-tags.html +++ b/test/printer/samples/self-closing-tags.html @@ -6,6 +6,10 @@ + + + + diff --git a/test/printer/samples/snippet.html.skip b/test/printer/samples/snippet.html.skip index f0f2547..765cf60 100644 --- a/test/printer/samples/snippet.html.skip +++ b/test/printer/samples/snippet.html.skip @@ -6,5 +6,30 @@

bar

{/snippet} +{#snippet baz(a, b, c = 1)} +

baz

+{/snippet} + +
+ {#snippet loooongFunction( + a, + lot, + _of, + parameters, + that, + make, + the, + lines, + _break, + )} +

baz

+ {/snippet} +
+ {@render foo()} +{@render foo?.()} {@render bar(x)} +{@render bar.baz[buzz](x)} +{@render (why ? not : like ?? thiss)(x)} +{@render test((() => "a")())} +{@render test(t())}