@backtickjs/prettier-plugin 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +62 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +137 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -3
- package/src/index.ts +206 -0
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
|
-
#
|
|
1
|
+
# @backtickjs/prettier-plugin
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/index.d.ts
ADDED
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
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.1"
|
|
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 };
|