diff --git a/changelog_unreleased/api/10183.md b/changelog_unreleased/api/10183.md new file mode 100644 index 000000000..9807e453e --- /dev/null +++ b/changelog_unreleased/api/10183.md @@ -0,0 +1,3 @@ +#### "Doc Explorer" mode for the Playground (#10183 by @thorn0) + +Switch the `parser` option to the special `doc-explorer` value to play with Prettier's intermediate representation and see how it's printed with different options. diff --git a/docs/technical-details.md b/docs/technical-details.md index 1a6b44ae5..249b4f39b 100644 --- a/docs/technical-details.md +++ b/docs/technical-details.md @@ -9,4 +9,6 @@ The basic idea is that the printer takes an AST and returns an intermediate repr This means that most of the logic of printing an AST involves generating an abstract representation of the output involving certain commands. For example, `["(", line, arg, line, ")"]` would represent a concatenation of opening parens, an argument, and closing parens. But if that doesn’t fit on one line, the printer can break where `line` is specified. +The [Playground](https://prettier.io/playground) has a special mode for exploring how Prettier’s intermediate representation is printed. To get there, open the sidebar (the "Show options" button) and set the `parser` option to the special value `doc-explorer`. + More (rough) details can be found in [commands.md](https://github.com/prettier/prettier/blob/main/commands.md). diff --git a/netlify.toml b/netlify.toml index ffcd928e0..fb951ce95 100644 --- a/netlify.toml +++ b/netlify.toml @@ -4,3 +4,7 @@ [build.environment] NODE_VERSION = "lts/*" YARN_VERSION = "1.22.15" +[[headers]] + for = "/worker.js" + [headers.values] + Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-eval'" diff --git a/src/main/parser.js b/src/main/parser.js index a5c9606b9..8ae740862 100644 --- a/src/main/parser.js +++ b/src/main/parser.js @@ -96,7 +96,7 @@ function parse(text, opts) { } /* istanbul ignore next */ - throw error.stack; + throw error; } } diff --git a/website/playground/Playground.js b/website/playground/Playground.js index 222896080..c76e724fb 100644 --- a/website/playground/Playground.js +++ b/website/playground/Playground.js @@ -67,10 +67,13 @@ class Playground extends React.Component { options.parser = "babel"; } - const content = original.content || getCodeSample(options.parser); + const codeSample = getCodeSample(options.parser); + const content = original.content || codeSample; + const needsClickForFirstRun = + options.parser === "doc-explorer" && content !== codeSample; const selection = {}; - this.state = { content, options, selection }; + this.state = { content, options, selection, needsClickForFirstRun }; this.handleOptionValueChange = this.handleOptionValueChange.bind(this); @@ -80,14 +83,10 @@ class Playground extends React.Component { this.setSelection = (selection) => this.setState({ selection }); this.setSelectionAsRange = () => { const { selection, content, options } = this.state; - const { head, anchor } = selection; - const range = [head, anchor].map( - ({ ch, line }) => - content.split("\n").slice(0, line).join("\n").length + - ch + - (line ? 1 : 0) + const [rangeStart, rangeEnd] = util.convertSelectionToRange( + selection, + content ); - const [rangeStart, rangeEnd] = range.sort((a, b) => a - b); const updatedOptions = { ...options, rangeStart, rangeEnd }; if (rangeStart === rangeEnd) { delete updatedOptions.rangeStart; @@ -103,6 +102,8 @@ class Playground extends React.Component { this.rangeEndOption = props.availableOptions.find( (opt) => opt.name === "rangeEnd" ); + + this.handleInputPanelFormat = this.handleInputPanelFormat.bind(this); } componentDidUpdate(_, prevState) { @@ -131,6 +132,10 @@ class Playground extends React.Component { ? getCodeSample(options.parser) : state.content; + if (option.name === "parser") { + state.needsClickForFirstRun = false; + } + return { options, content }; }); } @@ -158,6 +163,30 @@ class Playground extends React.Component { }); } + handleInputPanelFormat() { + if (this.state.options.parser !== "doc-explorer") { + return; + } + + const { content, selection } = this.state; + + return this.props.worker + .format(content, { + parser: "__js_expression", + cursorOffset: util.convertSelectionToRange(selection, content)[0], + }) + .then(({ error, formatted, cursorOffset }) => { + if (error) { + return; + } + + return { + value: formatted, + cursor: util.convertOffsetToPosition(cursorOffset, formatted), + }; + }); + } + render() { const { worker, version } = this.props; const { content, options } = this.state; @@ -171,6 +200,7 @@ class Playground extends React.Component { {(editorState) => ( - {editorState.showDoc && debug.doc && ( + {editorState.showDoc && ( this.getMarkdown({ doc: debug.doc })} + disabled={!debug.doc} > Copy doc @@ -284,6 +315,7 @@ class Playground extends React.Component { overlayEnd={options.rangeEnd} onChange={this.setContent} onSelectionChange={this.setSelection} + onFormat={this.handleInputPanelFormat} /> ) : null} {editorState.showAst ? ( @@ -302,11 +334,36 @@ class Playground extends React.Component { /> ) : null} {editorState.showOutput ? ( - + this.state.needsClickForFirstRun ? ( +
+
+ doc-explorer involves running code + provided by users. +
+
+ To stay on the safe side and prevent abuse, an + explicit user action is required when a direct + link to a doc-explorer playground is + opened. +
+
+ Click the button below to start the playground. +
+ +
+ ) : ( + + ) ) : null} {editorState.showSecondFormat ? ( this.setState(result)); diff --git a/website/playground/WorkerApi.js b/website/playground/WorkerApi.js index c251741f2..23928ffeb 100644 --- a/website/playground/WorkerApi.js +++ b/website/playground/WorkerApi.js @@ -32,7 +32,7 @@ export default function WorkerApi(source) { getMetadata() { return postMessage({ type: "meta" }); }, - format(code, options, debug) { + format(code, options, debug = {}) { return postMessage({ type: "format", code, options, debug }); }, postMessage, diff --git a/website/playground/codeSamples.js b/website/playground/codeSamples.js index 096383fdf..3038fbca3 100644 --- a/website/playground/codeSamples.js +++ b/website/playground/codeSamples.js @@ -308,6 +308,24 @@ export default function getCodeSamples(parser) { " ", "", ].join("\n"); + case "doc-explorer": + return [ + "group([", + ' "(*",', + " indent([", + " line,", + ' "Prettier:",', + " line,", + ' "Opinionated,",', + " line,", + ' "Code,",', + " line,", + ' "Formatter",', + " ]),", + " line,", + ' "*)",', + "])", + ].join("\n"); default: return ""; } diff --git a/website/playground/markdown.js b/website/playground/markdown.js index 60e14dd25..ec2c412e3 100644 --- a/website/playground/markdown.js +++ b/website/playground/markdown.js @@ -41,6 +41,7 @@ function getMarkdownSyntax(options) { case "acorn": case "espree": case "meriyah": + case "doc-explorer": return "jsx"; case "babel-ts": case "typescript": diff --git a/website/playground/panels.js b/website/playground/panels.js index aed97d5ca..41b32693f 100644 --- a/website/playground/panels.js +++ b/website/playground/panels.js @@ -19,6 +19,7 @@ class CodeMirrorPanel extends React.Component { delete options.rulerColor; delete options.value; delete options.onChange; + delete options.onFormat; options.rulers = [makeRuler(this.props)]; @@ -26,6 +27,12 @@ class CodeMirrorPanel extends React.Component { options.gutters = ["CodeMirror-linenumbers", "CodeMirror-foldgutter"]; } + if (this.props.onFormat) { + options.extraKeys = { + "Shift-Alt-F": this.handleFormat.bind(this), + }; + } + this._codeMirror = CodeMirror.fromTextArea( this._textareaRef.current, options @@ -112,6 +119,17 @@ class CodeMirrorPanel extends React.Component { } } + handleFormat() { + const result = this.props.onFormat(); + if (!result) { + return; + } + Promise.resolve(result).then(({ value, cursor }) => { + this.props.onChange(value); + this._codeMirror.setCursor(cursor); + }); + } + render() { return (
diff --git a/website/playground/util.js b/website/playground/util.js index 587a54910..592336c28 100644 --- a/website/playground/util.js +++ b/website/playground/util.js @@ -95,6 +95,30 @@ export function getAstAutoFold(parser) { } } +export function convertSelectionToRange({ head, anchor }, content) { + const lines = content.split("\n"); + return [head, anchor] + .map( + ({ ch, line }) => + lines.slice(0, line).join("\n").length + ch + (line ? 1 : 0) + ) + .sort((a, b) => a - b); +} + +export function convertOffsetToPosition(offset, content) { + let line = 0; + let ch = 0; + for (let i = 0; i < offset && i <= content.length; i++) { + if (content[i] === "\n") { + line++; + ch = 0; + } else { + ch++; + } + } + return { line, ch }; +} + /** * Copied from https://github.com/prettier/prettier/blob/6fe21780115cf5f74f83876d64b03a727fbab220/src/cli/utils.js#L6-L27 * @template Obj diff --git a/website/static/separate-css/playground.css b/website/static/separate-css/playground.css index 8507dfb93..030b1bace 100644 --- a/website/static/separate-css/playground.css +++ b/website/static/separate-css/playground.css @@ -112,6 +112,25 @@ header h1 { border-bottom: 1px solid #ddd; } +.disabled-output-panel { + flex-direction: column; + align-items: center; + justify-content: center; + gap: 15px; + text-align: center; +} + +.disabled-output-panel .explanation { + font-size: 16px; + max-width: 400px; + color: darkred; +} + +.disabled-output-panel .btn { + font-size: 22px; + padding: 10px 20px; +} + /* display as 2x2 grid */ @media (min-width: 800px) { .options-container { @@ -258,7 +277,7 @@ input[type="number"] { border-color: #c8e1ff; } -.btn:hover { +.btn:hover:enabled { background-color: #e6ebf1; background-image: linear-gradient(to bottom, #f0f3f6, #dce3ec); border-color: #afb1b2; @@ -271,6 +290,10 @@ input[type="number"] { box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); } +.btn:disabled { + color: #afb1b2; +} + .tooltip { position: absolute; z-index: 6; diff --git a/website/static/worker.js b/website/static/worker.js index 18e423b56..0b115053e 100644 --- a/website/static/worker.js +++ b/website/static/worker.js @@ -30,6 +30,25 @@ for (const file in parsersLocation) { } } +const docExplorerPlugin = { + parsers: { + "doc-explorer": { + parse: (text) => + new Function( + `{ ${Object.keys(prettier.doc.builders)} }`, + `const result = (${text || "''"}\n); return result;` + )(prettier.doc.builders), + astFormat: "doc-explorer", + }, + }, + printers: { + "doc-explorer": { + print: (path) => path.getValue(), + }, + }, + languages: [{ name: "doc-explorer", parsers: ["doc-explorer"] }], +}; + self.onmessage = function (event) { self.postMessage({ uid: event.data.uid, @@ -45,6 +64,8 @@ function serializeAst(ast) { ? { name: value.name, message: value.message, ...value } : typeof value === "bigint" ? `BigInt('${String(value)}')` + : typeof value === "symbol" + ? String(value) : value, 2 ); @@ -58,6 +79,7 @@ function handleMessage(message) { JSON.stringify( prettier.getSupportInfo({ showUnreleased: true, + plugins: [docExplorerPlugin], }) ) ), @@ -72,13 +94,15 @@ function handleMessage(message) { delete options.doc; delete options.output2; - const plugins = [{ parsers }]; + const plugins = [{ parsers }, docExplorerPlugin]; options.plugins = plugins; const formatResult = formatCode(message.code, options); const response = { formatted: formatResult.formatted, + cursorOffset: formatResult.cursorOffset, + error: formatResult.error, debug: { ast: null, doc: null, @@ -111,10 +135,10 @@ function handleMessage(message) { try { response.debug.doc = prettier.__debug.formatDoc( prettier.__debug.printToDoc(message.code, options), - { parser: "babel", plugins } + { plugins } ); - } catch (e) { - response.debug.doc = String(e); + } catch { + response.debug.doc = ""; } } @@ -142,10 +166,23 @@ function formatCode(text, options) { } catch (e) { if (e.constructor && e.constructor.name === "SyntaxError") { // Likely something wrong with the user's code - return { formatted: String(e) }; + return { formatted: String(e), error: true }; } // Likely a bug in Prettier // Provide the whole stack for debugging - return { formatted: e.stack || String(e) }; + return { formatted: stringifyError(e), error: true }; } } + +function stringifyError(e) { + const stringified = String(e); + if (typeof e.stack !== "string") { + return stringified; + } + if (e.stack.includes(stringified)) { + // Chrome + return e.stack; + } + // Firefox + return stringified + "\n" + e.stack; +}