@backtickjs/prettier-plugin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jean-François Bisson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,63 @@
1
- # Temporary Holding Version
1
+ # @backtickjs/prettier-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [Prettier](https://prettier.io) plugin that formats the code inside [Backtick](https://backtickjs.com) client scripts. Without it, Prettier leaves a `` cs`…` `` script as an opaque template string; with it, the script is formatted as TypeScript or JavaScript, like the rest of the file, with its splices kept in place.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install --save-dev prettier typescript @backtickjs/prettier-plugin
9
+ ```
10
+
11
+ Prettier 3 and TypeScript `^6.0.3` are peer dependencies: the plugin uses your project's TypeScript to find scripts. Install it in the package that runs Prettier, which resolves plugins from there.
12
+
13
+ ## Setup
14
+
15
+ Name the plugin in `.prettierrc`:
16
+
17
+ ```json
18
+ {
19
+ "plugins": ["@backtickjs/prettier-plugin"]
20
+ }
21
+ ```
22
+
23
+ Projects made with `create-backtick-app` already include this file. In VS Code, the [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) reads the same config, so format-on-save formats scripts too.
24
+
25
+ The plugin applies to `.ts`, `.tsx`, `.js` and `.jsx` files. Other embedded templates, such as CSS or GraphQL, are formatted by Prettier as usual.
26
+
27
+ ## Example
28
+
29
+ Before:
30
+
31
+ ```tsx
32
+ const name = "Ada";
33
+ const sum = cs`() => {const a=1;return a+${name.length}}`;
34
+ export const Greeting = cs`<$View style={{ padding: 24 }}><$Text>Hi {${name}}</$Text><$Text>A second line long enough to break</$Text></$View>`;
35
+ ```
36
+
37
+ After:
38
+
39
+ ```tsx
40
+ const name = "Ada";
41
+ const sum = cs`() => {
42
+ const a = 1;
43
+ return a + ${name.length};
44
+ }`;
45
+ export const Greeting = cs`(
46
+ <$View style={{ padding: 24 }}>
47
+ <$Text>Hi {$name}</$Text>
48
+ <$Text>A second line long enough to break</$Text>
49
+ </$View>
50
+ )`;
51
+ ```
52
+
53
+ A splice of a bare identifier in code prints in its short form (`${name}` becomes `$name`); any other splice keeps its braces and is formatted as host code. A script that is a single JSX element gets parentheses only when it breaks across lines, and a trailing semicolon after a script's final expression is dropped. Formatting is idempotent.
54
+
55
+ ## Related
56
+
57
+ - [`@backtickjs/core`](https://github.com/backtickjs/backtick/tree/main/packages/core): the `cs` tag
58
+ - [Backtick for VS Code](https://marketplace.visualstudio.com/items?itemName=backtickjs.backtick-vscode): highlighting and type-checking inside scripts
59
+ - [Documentation](https://backtickjs.com/docs)
60
+
61
+ ## License
62
+
63
+ MIT
@@ -0,0 +1,6 @@
1
+ import { type Printer } from "prettier";
2
+ export declare const printers: Record<string, Printer>;
3
+ declare const _default: {
4
+ printers: Record<string, Printer<any>>;
5
+ };
6
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,137 @@
1
+ import { flattenScripts, parseSourceText, } from "@backtickjs/compiler";
2
+ import { doc as prettierDoc, } from "prettier";
3
+ import { printers as builtinPrinters } from "prettier/plugins/estree";
4
+ import ts from "typescript";
5
+ const estree = builtinPrinters.estree;
6
+ const { mapDoc } = prettierDoc.utils;
7
+ const { group, ifBreak, indent, softline } = prettierDoc.builders;
8
+ const embed = (path, options) => {
9
+ const node = path.node;
10
+ if (node?.type === "TaggedTemplateExpression") {
11
+ const script = scriptsByStart(options).get(startOf(node));
12
+ if (script) {
13
+ const fileName = filepathOf(options) ?? "input.tsx";
14
+ const expressions = node.quasi.expressions;
15
+ return async (textToDoc, print) => printScript(script, fileName, expressions, textToDoc, print);
16
+ }
17
+ }
18
+ // Not ours — defer to Prettier's built-in embedding so it can still
19
+ // format css/graphql/styled-components and the like.
20
+ return estree.embed?.call(estree, path, options) ?? null;
21
+ };
22
+ async function printScript(script, fileName, expressions, textToDoc, print) {
23
+ const parser = parserForFile(fileName);
24
+ const docWithPlaceholders = await textToDoc(script.textWithPlaceholders, {
25
+ parser,
26
+ });
27
+ const body = reinjectSplices(stripTrailingSemicolon(docWithPlaceholders), script, expressions, print);
28
+ return ["cs", "`", isJsxRoot(script) ? wrapInParens(body) : body, "`"];
29
+ }
30
+ // A script that is one JSX element, parenthesized or not.
31
+ function isJsxRoot(script) {
32
+ const [statement, ...rest] = script.fileWithPlaceholders.statements;
33
+ if (statement === undefined ||
34
+ rest.length > 0 ||
35
+ !ts.isExpressionStatement(statement)) {
36
+ return false;
37
+ }
38
+ let root = statement.expression;
39
+ while (ts.isParenthesizedExpression(root)) {
40
+ root = root.expression;
41
+ }
42
+ return (ts.isJsxElement(root) ||
43
+ ts.isJsxFragment(root) ||
44
+ ts.isJsxSelfClosingElement(root));
45
+ }
46
+ // As Prettier wraps JSX in a `return`: parens only when it breaks.
47
+ function wrapInParens(doc) {
48
+ return group([ifBreak("("), indent([softline, doc]), softline, ifBreak(")")]);
49
+ }
50
+ function reinjectSplices(formatted, script, expressions, print) {
51
+ return mapDoc(formatted, (current) => {
52
+ if (typeof current !== "string" || !current.includes("$0splice")) {
53
+ return current;
54
+ }
55
+ return replacePlaceholders(current, script, expressions, print);
56
+ });
57
+ }
58
+ // Rebuild one formatted string leaf, swapping each `$0splice<n>` placeholder for
59
+ // the real host expression `${...}` printed from the original AST. We walk the
60
+ // matches in order, emitting the literal text before each placeholder and then
61
+ // the spliced-in expression, finishing with whatever text trails the last one.
62
+ function replacePlaceholders(text, script, expressions, print) {
63
+ const parts = [];
64
+ let textStart = 0;
65
+ for (const match of text.matchAll(/\$0splice(\d+)/g)) {
66
+ const spliceIndex = Number(match[1]);
67
+ parts.push(text.slice(textStart, match.index));
68
+ const expression = expressions[spliceIndex];
69
+ // A braced splice holding a bare identifier prints as its unbraced
70
+ // shorthand: `${x}` reads as `$x`. A `$`-led name keeps its braces, and so
71
+ // does a `${…}` in text (a string, a comment, JSX text), which isn't a
72
+ // splice: there `$x` would be the text itself.
73
+ if (script.splices[match[0]] !== undefined &&
74
+ expression?.type === "Identifier" &&
75
+ !expression.name.startsWith("$")) {
76
+ parts.push(`$${expression.name}`);
77
+ }
78
+ else {
79
+ parts.push(["${", print(["quasi", "expressions", spliceIndex]), "}"]);
80
+ }
81
+ textStart = match.index + match[0].length;
82
+ }
83
+ parts.push(text.slice(textStart));
84
+ return parts;
85
+ }
86
+ // Formatting the body as a TypeScript program adds a trailing `;` to a bare
87
+ // final expression. It reads as noise inside a client script, so drop one if it
88
+ // is the very last thing printed. Anything else (internal `;`, multi-statement
89
+ // bodies) is left untouched.
90
+ function stripTrailingSemicolon(doc) {
91
+ if (typeof doc === "string") {
92
+ return doc.replace(/;$/, "");
93
+ }
94
+ if (Array.isArray(doc) && doc.length > 0) {
95
+ const last = doc[doc.length - 1];
96
+ return [...doc.slice(0, -1), stripTrailingSemicolon(last)];
97
+ }
98
+ return doc;
99
+ }
100
+ let cache = null;
101
+ function scriptsByStart(options) {
102
+ const { originalText } = options;
103
+ if (cache?.text === originalText) {
104
+ return cache.byStart;
105
+ }
106
+ const { sourceFile, scripts } = parseSourceText(ts, filepathOf(options) ?? "input.tsx", originalText);
107
+ const byStart = new Map();
108
+ for (const script of flattenScripts(scripts)) {
109
+ const start = script.sourceNode.getStart(sourceFile);
110
+ byStart.set(start, script);
111
+ }
112
+ cache = { text: originalText, byStart };
113
+ return byStart;
114
+ }
115
+ function filepathOf(options) {
116
+ return options.filepath;
117
+ }
118
+ function parserForFile(fileName) {
119
+ if (fileName?.endsWith(".js") || fileName?.endsWith(".jsx")) {
120
+ return "babel";
121
+ }
122
+ return "typescript";
123
+ }
124
+ function startOf(node) {
125
+ // Prettier attaches `range` for the typescript/babel parsers; fall back to the
126
+ // babel-style `start` field just in case.
127
+ const withLoc = node;
128
+ return withLoc.range?.[0] ?? withLoc.start ?? 0;
129
+ }
130
+ export const printers = {
131
+ estree: {
132
+ ...estree,
133
+ embed,
134
+ },
135
+ };
136
+ export default { printers };
137
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@backtickjs/prettier-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "A Prettier plugin that formats the code inside Backtick client scripts",
5
+ "keywords": [
6
+ "backtick",
7
+ "react-native",
8
+ "server-driven-ui",
9
+ "server-components",
10
+ "prettier",
11
+ "prettier-plugin",
12
+ "formatter"
13
+ ],
14
+ "homepage": "https://backtickjs.com",
15
+ "bugs": {
16
+ "url": "https://github.com/backtickjs/backtick/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/backtickjs/backtick.git",
21
+ "directory": "packages/prettier-plugin"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Jean-François Bisson <jf@backtickjs.com>",
25
+ "type": "module",
26
+ "main": "./dist/index.js",
27
+ "files": [
28
+ "dist",
29
+ "!dist/._cache",
30
+ "src"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "devDependencies": {
36
+ "@types/estree": "^1.0.9",
37
+ "prettier": "^3.6.2",
38
+ "tsx": "^4.22.4",
39
+ "typescript": "^6.0.3"
40
+ },
41
+ "dependencies": {
42
+ "@backtickjs/compiler": "0.1.0"
43
+ },
44
+ "peerDependencies": {
45
+ "prettier": "^3.0.0",
46
+ "typescript": "^6.0.3"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -b",
50
+ "dev": "tsc -b --watch",
51
+ "format": "prettier --write --ignore-unknown .",
52
+ "format:check": "prettier --check --ignore-unknown .",
53
+ "test": "node --import tsx --test \"test/**/*.test.ts\"",
54
+ "test:update": "UPDATE_SNAPSHOTS=1 node --import tsx --test \"test/**/*.test.ts\"",
55
+ "clean": "rm -rf node_modules dist .turbo"
56
+ }
6
57
  }
package/src/index.ts ADDED
@@ -0,0 +1,206 @@
1
+ import {
2
+ type ClientScript,
3
+ flattenScripts,
4
+ parseSourceText,
5
+ } from "@backtickjs/compiler";
6
+ import type { Expression, Node } from "estree";
7
+ import {
8
+ type AstPath,
9
+ type Doc,
10
+ type Options,
11
+ type Printer,
12
+ doc as prettierDoc,
13
+ } from "prettier";
14
+ import { printers as builtinPrinters } from "prettier/plugins/estree";
15
+ import ts from "typescript";
16
+
17
+ const estree: Printer = builtinPrinters.estree;
18
+ const { mapDoc } = prettierDoc.utils;
19
+ const { group, ifBreak, indent, softline } = prettierDoc.builders;
20
+
21
+ type Print = (selector: Array<string | number>) => Doc;
22
+ type TextToDoc = (text: string, options: Options) => Promise<Doc>;
23
+
24
+ const embed: NonNullable<Printer["embed"]> = (
25
+ path: AstPath<Node>,
26
+ options: Options,
27
+ ) => {
28
+ const node = path.node;
29
+ if (node?.type === "TaggedTemplateExpression") {
30
+ const script = scriptsByStart(options).get(startOf(node));
31
+ if (script) {
32
+ const fileName = filepathOf(options) ?? "input.tsx";
33
+ const expressions = node.quasi.expressions;
34
+ return async (textToDoc, print) =>
35
+ printScript(script, fileName, expressions, textToDoc, print);
36
+ }
37
+ }
38
+
39
+ // Not ours — defer to Prettier's built-in embedding so it can still
40
+ // format css/graphql/styled-components and the like.
41
+ return estree.embed?.call(estree, path, options) ?? null;
42
+ };
43
+
44
+ async function printScript(
45
+ script: ClientScript,
46
+ fileName: string,
47
+ expressions: readonly Expression[],
48
+ textToDoc: TextToDoc,
49
+ print: Print,
50
+ ): Promise<Doc> {
51
+ const parser = parserForFile(fileName);
52
+ const docWithPlaceholders = await textToDoc(script.textWithPlaceholders, {
53
+ parser,
54
+ });
55
+ const body = reinjectSplices(
56
+ stripTrailingSemicolon(docWithPlaceholders),
57
+ script,
58
+ expressions,
59
+ print,
60
+ );
61
+ return ["cs", "`", isJsxRoot(script) ? wrapInParens(body) : body, "`"];
62
+ }
63
+
64
+ // A script that is one JSX element, parenthesized or not.
65
+ function isJsxRoot(script: ClientScript): boolean {
66
+ const [statement, ...rest] = script.fileWithPlaceholders.statements;
67
+ if (
68
+ statement === undefined ||
69
+ rest.length > 0 ||
70
+ !ts.isExpressionStatement(statement)
71
+ ) {
72
+ return false;
73
+ }
74
+ let root = statement.expression;
75
+ while (ts.isParenthesizedExpression(root)) {
76
+ root = root.expression;
77
+ }
78
+ return (
79
+ ts.isJsxElement(root) ||
80
+ ts.isJsxFragment(root) ||
81
+ ts.isJsxSelfClosingElement(root)
82
+ );
83
+ }
84
+
85
+ // As Prettier wraps JSX in a `return`: parens only when it breaks.
86
+ function wrapInParens(doc: Doc): Doc {
87
+ return group([ifBreak("("), indent([softline, doc]), softline, ifBreak(")")]);
88
+ }
89
+
90
+ function reinjectSplices(
91
+ formatted: Doc,
92
+ script: ClientScript,
93
+ expressions: readonly Expression[],
94
+ print: Print,
95
+ ): Doc {
96
+ return mapDoc(formatted, (current) => {
97
+ if (typeof current !== "string" || !current.includes("$0splice")) {
98
+ return current;
99
+ }
100
+ return replacePlaceholders(current, script, expressions, print);
101
+ });
102
+ }
103
+
104
+ // Rebuild one formatted string leaf, swapping each `$0splice<n>` placeholder for
105
+ // the real host expression `${...}` printed from the original AST. We walk the
106
+ // matches in order, emitting the literal text before each placeholder and then
107
+ // the spliced-in expression, finishing with whatever text trails the last one.
108
+ function replacePlaceholders(
109
+ text: string,
110
+ script: ClientScript,
111
+ expressions: readonly Expression[],
112
+ print: Print,
113
+ ): Doc {
114
+ const parts: Doc[] = [];
115
+ let textStart = 0;
116
+
117
+ for (const match of text.matchAll(/\$0splice(\d+)/g)) {
118
+ const spliceIndex = Number(match[1]);
119
+ parts.push(text.slice(textStart, match.index));
120
+ const expression = expressions[spliceIndex];
121
+ // A braced splice holding a bare identifier prints as its unbraced
122
+ // shorthand: `${x}` reads as `$x`. A `$`-led name keeps its braces, and so
123
+ // does a `${…}` in text (a string, a comment, JSX text), which isn't a
124
+ // splice: there `$x` would be the text itself.
125
+ if (
126
+ script.splices[match[0]] !== undefined &&
127
+ expression?.type === "Identifier" &&
128
+ !expression.name.startsWith("$")
129
+ ) {
130
+ parts.push(`$${expression.name}`);
131
+ } else {
132
+ parts.push(["${", print(["quasi", "expressions", spliceIndex]), "}"]);
133
+ }
134
+ textStart = match.index + match[0].length;
135
+ }
136
+
137
+ parts.push(text.slice(textStart));
138
+ return parts;
139
+ }
140
+
141
+ // Formatting the body as a TypeScript program adds a trailing `;` to a bare
142
+ // final expression. It reads as noise inside a client script, so drop one if it
143
+ // is the very last thing printed. Anything else (internal `;`, multi-statement
144
+ // bodies) is left untouched.
145
+ function stripTrailingSemicolon(doc: Doc): Doc {
146
+ if (typeof doc === "string") {
147
+ return doc.replace(/;$/, "");
148
+ }
149
+ if (Array.isArray(doc) && doc.length > 0) {
150
+ const last = doc[doc.length - 1];
151
+ return [...doc.slice(0, -1), stripTrailingSemicolon(last)];
152
+ }
153
+ return doc;
154
+ }
155
+
156
+ let cache: { text: string; byStart: Map<number, ClientScript> } | null = null;
157
+
158
+ function scriptsByStart(options: Options): Map<number, ClientScript> {
159
+ const { originalText } = options as { originalText: string };
160
+
161
+ if (cache?.text === originalText) {
162
+ return cache.byStart;
163
+ }
164
+
165
+ const { sourceFile, scripts } = parseSourceText(
166
+ ts,
167
+ filepathOf(options) ?? "input.tsx",
168
+ originalText,
169
+ );
170
+
171
+ const byStart = new Map<number, ClientScript>();
172
+ for (const script of flattenScripts(scripts)) {
173
+ const start = script.sourceNode.getStart(sourceFile);
174
+ byStart.set(start, script);
175
+ }
176
+
177
+ cache = { text: originalText, byStart };
178
+ return byStart;
179
+ }
180
+
181
+ function filepathOf(options: Options): string | undefined {
182
+ return (options as { filepath?: string }).filepath;
183
+ }
184
+
185
+ function parserForFile(fileName: string): "babel" | "typescript" {
186
+ if (fileName?.endsWith(".js") || fileName?.endsWith(".jsx")) {
187
+ return "babel";
188
+ }
189
+ return "typescript";
190
+ }
191
+
192
+ function startOf(node: Node): number {
193
+ // Prettier attaches `range` for the typescript/babel parsers; fall back to the
194
+ // babel-style `start` field just in case.
195
+ const withLoc = node as { range?: [number, number]; start?: number };
196
+ return withLoc.range?.[0] ?? withLoc.start ?? 0;
197
+ }
198
+
199
+ export const printers: Record<string, Printer> = {
200
+ estree: {
201
+ ...estree,
202
+ embed,
203
+ },
204
+ };
205
+
206
+ export default { printers };