From 1adfd4a8c07ee91cb482681aec0c3295d7d39806 Mon Sep 17 00:00:00 2001 From: Erika <3019731+Princesseuh@users.noreply.github.com> Date: Tue, 2 Aug 2022 18:32:57 -0400 Subject: [PATCH] Refactor codebase (#245) * Refactor codebase * Rename to utils * Sprinkle some comments * Move elements to printer folder --- src/index.ts | 17 +- src/nodes.ts | 313 ------------------ src/options.ts | 34 +- src/printer.ts | 700 --------------------------------------- src/printer/elements.ts | 78 +++++ src/printer/embed.ts | 168 ++++++++++ src/printer/index.ts | 313 ++++++++++++++++++ src/printer/nodes.ts | 46 +++ src/printer/utils.ts | 255 +++++++++++++++ src/utils.ts | 701 ---------------------------------------- 10 files changed, 877 insertions(+), 1748 deletions(-) delete mode 100644 src/nodes.ts delete mode 100644 src/printer.ts create mode 100644 src/printer/elements.ts create mode 100644 src/printer/embed.ts create mode 100644 src/printer/index.ts create mode 100644 src/printer/nodes.ts create mode 100644 src/printer/utils.ts delete mode 100644 src/utils.ts diff --git a/src/index.ts b/src/index.ts index a9c5865..1f6bb5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,15 @@ -import printer from './printer'; -import { options } from './options'; +import { createRequire } from 'node:module'; import { Parser, Printer, SupportLanguage } from 'prettier'; import { createSyncFn } from 'synckit'; -import { createRequire } from 'node:module'; -const require = createRequire(import.meta.url); +import { options } from './options'; +import { print } from './printer'; +import { embed } from './printer/embed'; +const require = createRequire(import.meta.url); // the worker path must be absolute const parse = createSyncFn(require.resolve('../workers/parse-worker.js')); +// https://prettier.io/docs/en/plugins.html#languages export const languages: Partial[] = [ { name: 'astro', @@ -17,6 +19,7 @@ export const languages: Partial[] = [ }, ]; +// https://prettier.io/docs/en/plugins.html#parsers export const parsers: Record = { astro: { parse: (source) => parse(source), @@ -26,8 +29,12 @@ export const parsers: Record = { }, }; +// https://prettier.io/docs/en/plugins.html#printers export const printers: Record = { - astro: printer, + astro: { + print, + embed, + }, }; const defaultOptions = { diff --git a/src/nodes.ts b/src/nodes.ts deleted file mode 100644 index b1170db..0000000 --- a/src/nodes.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { - Node, - AttributeNode, - RootNode, - ElementNode, - ComponentNode, - CustomElementNode, - ExpressionNode, - TextNode, - FrontmatterNode, - DoctypeNode, - CommentNode, - FragmentNode, -} from '@astrojs/compiler/types'; - -// MISSING ATTRIBUTE NODE FROM THE NODE TYPE - -export interface NodeWithText { - value: string; -} - -// export interface Ast { -// html: anyNode; -// css: StyleNode[]; -// module: ScriptNode; -// meta: { -// features: number; -// }; -// } - -// export interface BaseNode { -// start: number; -// end: number; -// type: string; -// children?: anyNode[]; -// // TODO: ADD BETTER TYPE -// [prop_name: string]: any; -// } - -// export type attributeValue = TextNode[] | AttributeShorthandNode[] | MustacheTagNode[] | true; - -export interface NodeWithChildren { - // children: anyNode[]; - children: Node[]; -} - -// export interface NodeWithText { -// data: string; -// raw?: string; -// } - -// export interface FragmentNode extends BaseNode { -// type: 'Fragment'; -// children: anyNode[]; -// } - -// export interface TextNode extends BaseNode { -// type: 'Text'; -// data: string; -// raw: string; -// } - -// export interface CodeFenceNode extends BaseNode { -// type: 'CodeFence'; -// metadata: string; -// data: string; -// raw: string; -// } - -// export interface CodeSpanNode extends BaseNode { -// type: 'CodeSpan'; -// metadata: string; -// data: string; -// raw: string; -// } - -// export interface SpreadNode extends BaseNode { -// type: 'Spread'; -// expression: ExpressionNode; -// } - -// export interface ExpressionNode { -// type: 'Expression'; -// start: number; -// end: number; -// codeChunks: string[]; -// children: anyNode[]; -// } - -// export interface ScriptNode extends BaseNode { -// type: 'Script'; -// context: 'runtime' | 'setup'; -// content: string; -// } - -// export interface StyleNode extends BaseNode { -// type: 'Style'; -// // TODO: ADD BETTER TYPE -// attributes: any[]; -// content: { -// start: number; -// end: number; -// styles: string; -// }; -// } - -// export interface AttributeNode extends BaseNode { -// type: 'Attribute'; -// name: string; -// value: attributeValue; -// } - -// export interface AttributeShorthandNode extends BaseNode { -// type: 'AttributeShorthand'; -// expression: IdentifierNode; -// } - -// export interface IdentifierNode extends BaseNode { -// type: 'Identifier'; -// name: string; -// } - -// export interface MustacheTagNode extends BaseNode { -// type: 'MustacheTag'; -// expression: ExpressionNode; -// } - -// export interface SlotNode extends BaseNode { -// type: 'Slot'; -// name: string; -// attributes: AttributeNode[]; -// } - -// export interface CommentNode extends BaseNode { -// type: 'Comment'; -// data: string; -// name?: string; -// leading?: boolean; -// trailing?: boolean; -// printed?: boolean; -// nodeDescription?: string; -// } - -// export interface ElementNode extends BaseNode { -// type: 'Element'; -// name: string; -// attributes: AttributeNode[]; -// } - -// export interface InlineComponentNode extends BaseNode { -// type: 'InlineComponent'; -// name: string; -// attributes: AttributeNode[]; -// } - -export interface BlockElementNode extends ElementNode { - name: typeof blockElementsT[number]; -} - -export interface InlineElementNode extends ElementNode { - name: typeof inlineElementsT[number]; -} - -// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements#Elements -const blockElementsT = [ - 'address', - 'article', - 'aside', - 'blockquote', - 'details', - 'dialog', - 'dd', - 'div', - 'dl', - 'dt', - 'fieldset', - 'figcaption', - 'figure', - 'footer', - 'form', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'header', - 'hgroup', - 'hr', - 'li', - 'main', - 'nav', - 'ol', - 'p', - 'pre', - 'section', - 'table', - 'ul', - // TODO: WIP - 'title', - 'html', -] as const; -// https://github.com/microsoft/TypeScript/issues/31018 -export const blockElements: string[] = [...blockElementsT]; - -// https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elements -const inlineElementsT = [ - 'a', - 'abbr', - 'acronym', - 'audio', - 'b', - 'bdi', - 'bdo', - 'big', - 'br', - 'button', - 'canvas', - 'cite', - 'code', - 'data', - 'datalist', - 'del', - 'dfn', - 'em', - 'embed', - 'i', - 'iframe', - 'img', - 'input', - 'ins', - 'kbd', - 'label', - 'map', - 'mark', - 'meter', - 'noscript', - 'object', - 'output', - 'picture', - 'progress', - 'q', - 'ruby', - 's', - 'samp', - 'script', - 'select', - 'slot', - 'small', - 'span', - 'strong', - 'sub', - 'sup', - 'svg', - 'template', - 'textarea', - 'time', - 'u', - 'tt', - 'var', - 'video', - 'wbr', -] as const; -// https://github.com/microsoft/TypeScript/issues/31018 -export const inlineElements: string[] = [...inlineElementsT]; - -// @see http://xahlee.info/js/html5_non-closing_tag.html -export const selfClosingTags = [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'slot', - 'source', - 'track', - 'wbr', -]; - -export type anyNode = - | RootNode - | AttributeNode - | ElementNode - | ComponentNode - | CustomElementNode - | ExpressionNode - | TextNode - | DoctypeNode - | CommentNode - | FragmentNode - | FrontmatterNode; - -export type { - AttributeNode, - Node, - RootNode, - ElementNode, - ComponentNode, - CustomElementNode, - ExpressionNode, - TextNode, - FrontmatterNode, - DoctypeNode, - CommentNode, - FragmentNode, - TagLikeNode, -} from '@astrojs/compiler/types'; diff --git a/src/options.ts b/src/options.ts index 6dd4001..6eef8fb 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,33 +1,16 @@ import { SupportOption } from 'prettier'; +interface PluginOptions { + astroAllowShorthand: boolean; +} + declare module 'prettier' { // eslint-disable-next-line @typescript-eslint/no-empty-interface interface RequiredOptions extends PluginOptions {} } -export interface PluginOptions { - astroSortOrder: SortOrder; - astroAllowShorthand: boolean; -} - +// https://prettier.io/docs/en/plugins.html#options export const options: Record = { - astroSortOrder: { - since: '0.0.1', - category: 'Astro', - type: 'choice', - default: 'markup | styles', - description: 'Sort order for markup, scripts, and styles', - choices: [ - { - value: 'markup | styles', - description: 'markup | styles', - }, - { - value: 'styles | markup', - description: 'styles | markup', - }, - ], - }, astroAllowShorthand: { since: '0.0.10', category: 'Astro', @@ -36,10 +19,3 @@ export const options: Record = { description: 'Enable/disable attribute shorthand if attribute name and expression are the same', }, }; - -export const parseSortOrder = (sortOrder: SortOrder): SortOrderPart[] => - sortOrder.split(' | ') as SortOrderPart[]; - -export type SortOrder = 'markup | styles' | 'styles | markup'; - -export type SortOrderPart = 'markup' | 'styles'; diff --git a/src/printer.ts b/src/printer.ts deleted file mode 100644 index 7ebeefb..0000000 --- a/src/printer.ts +++ /dev/null @@ -1,700 +0,0 @@ -import { - AstPath as AstP, - BuiltInParsers, - Doc, - ParserOptions as ParserOpts, - Printer, -} from 'prettier'; -import _doc from 'prettier/doc'; -const { - builders: { - breakParent, - dedent, - fill, - group, - hardline, - indent, - join, - line, - literalline, - softline, - }, - utils: { removeLines, stripTrailingHardline }, -} = _doc; -import { SassFormatter, SassFormatterConfig } from 'sass-formatter'; - -import { parseSortOrder } from './options'; - -import { - RootNode, - Node, - AttributeNode, - CommentNode, - NodeWithText, - selfClosingTags, - TextNode, - anyNode, -} from './nodes'; - -type ParserOptions = ParserOpts; -type AstPath = AstP; - -import { - // attachCommentsHTML, - canOmitSoftlineBeforeClosingTag, - manualDedent, - endsWithLinebreak, - forceIntoExpression, - formattableAttributes, - getText, - getUnencodedText, - isRootNode, - // isDocCommand, - // isEmptyDoc, - isEmptyTextNode, - isInlineElement, - isInsideQuotedAttribute, - // isLine, - isLoneMustacheTag, - // isNodeWithChildren, - isOrCanBeConvertedToShorthand, - isPreTagContent, - isShorthandAndMustBeConvertedToBinaryExpression, - isTextNode, - isTextNodeEndingWithWhitespace, - isTextNodeStartingWithLinebreak, - isTextNodeStartingWithWhitespace, - printRaw, - // replaceEndOfLineWith, - shouldHugEnd, - shouldHugStart, - startsWithLinebreak, - // trim, - // trimChildren, - trimTextNodeLeft, - trimTextNodeRight, - getNextNode, - isTagLikeNode, -} from './utils'; - -// function printTopLevelParts(node: RootNode, path: AstPath, opts: ParserOptions, print: printFn): Doc { -// let docs = []; - -// const normalize = (doc: Doc) => [stripTrailingHardline(doc), hardline]; - -// // frontmatter always comes first -// if (node.module) { -// const subDoc = normalize(path.call(print, 'module')); -// docs.push(subDoc); -// } - -// // markup and styles follow, whichever the user prefers (default: markup, styles) -// for (const section of parseSortOrder(opts.astroSortOrder)) { -// switch (section) { -// case 'markup': { -// const subDoc = path.call(print, 'html'); -// if (!isEmptyDoc(subDoc)) docs.push(normalize(subDoc)); -// break; -// } -// case 'styles': { -// const subDoc = path.call(print, 'css'); -// if (!isEmptyDoc(subDoc)) docs.push(normalize(subDoc)); -// break; -// } -// } -// } - -// return join(softline, docs); -// } - -// function printAttributeNodeValue(path: AstPath, print: printFn, quotes: boolean, node: AttributeNode): Doc[] | _doc.builders.Indent { -// const valueDocs = path.map((childPath) => childPath.call(print), 'value'); - -// if (!quotes || !formattableAttributes.includes(node.name)) { -// return valueDocs; -// } else { -// return indent(group(trim(valueDocs, isLine))); -// } -// } - -// TODO: USE ASTPATH GENERIC -// function printJS(path: AstP, print: printFn, name: string, { forceSingleQuote, forceSingleLine }: { forceSingleQuote: boolean; forceSingleLine: boolean }) { -// path.getValue()[name].isJS = true; -// path.getValue()[name].forceSingleQuote = forceSingleQuote; -// path.getValue()[name].forceSingleLine = forceSingleLine; -// return path.call(print, name); -// } - -// TODO: MAYBE USE THIS TO HANDLE COMMENTS -function printComment(commentPath: AstPath, options: ParserOptions): Doc { - // note(drew): this isn’t doing anything currently, but Prettier requires it anyway - // @ts-ignore - return commentPath; -} - -export type printFn = (path: AstPath) => Doc; - -// eslint-disable-next-line @typescript-eslint/no-shadow -function print(path: AstPath, opts: ParserOptions, print: printFn): Doc { - const node = path.getValue(); - // const isMarkdownSubDoc = opts.parentParser === 'markdown'; // is this a code block within .md? - - // 1. handle special node types - if (!node) { - return ''; - } - - if (typeof node === 'string') { - return node; - } - - // if (Array.isArray(node)) { - // return path.map((childPath) => childPath.call(print)); - // } - - // if (isASTNode(node)) { - // return printTopLevelParts(node, path, opts, print); - // } - - // 2. attach comments shallowly to children, if any (https://prettier.io/docs/en/plugins.html#manually-attaching-a-comment) - // if (!isPreTagContent(path) && !isMarkdownSubDoc && node.type === 'Fragment') { - // attachCommentsHTML(node); - // } - - // 3. handle printing - switch (node.type) { - case 'root': { - return [stripTrailingHardline(path.map(print, 'children')), hardline]; - } - - // case 'Fragment': { - // const text = getText(node, opts); - // if (text.length === 0) { - // return ''; - // } - - // if (!isNodeWithChildren(node) || node.children.every(isEmptyTextNode)) return ''; - - // if (!isPreTagContent(path)) { - // trimChildren(node.children); - // const output = trim( - // [path.map(print, 'children')], - // (n) => - // isLine(n) || - // (typeof n === 'string' && n.trim() === '') || - // // Because printChildren may append this at the end and - // // may hide other lines before it - // n === breakParent - // ); - // if (output.every((doc) => isEmptyDoc(doc))) { - // return ''; - // } - // return group([...output, hardline]); - // } else { - // return group(path.map(print, 'children')); - // } - // } - case 'text': { - const rawText = getUnencodedText(node); - - // TODO: TEST PRE TAGS - // if (isPreTagContent(path)) { - // if (path.getParentNode()?.type === 'Attribute') { - // // Direct child of attribute value -> add literallines at end of lines - // // so that other things don't break in unexpected places - // return replaceEndOfLineWith(rawText, literalline); - // } - // return rawText; - // } - - if (isEmptyTextNode(node)) { - const hasWhiteSpace = rawText.trim().length < getUnencodedText(node).length; - const hasOneOrMoreNewlines = /\n/.test(getUnencodedText(node)); - const hasTwoOrMoreNewlines = /\n\r?\s*\n\r?/.test(getUnencodedText(node)); - if (hasTwoOrMoreNewlines) { - return [hardline, hardline]; - } - if (hasOneOrMoreNewlines) { - return hardline; - } - if (hasWhiteSpace) { - return line; - } - return ''; - } - - /** - * For non-empty text nodes each sequence of non-whitespace characters (effectively, - * each "word") is joined by a single `line`, which will be rendered as a single space - * until this node's current line is out of room, at which `fill` will break at the - * most convenient instance of `line`. - */ - return fill(splitTextToDocs(node)); - } - - // case 'InlineComponent': - // case 'Slot': - case 'component': - case 'fragment': - case 'element': { - // const isEmpty = node.children?.every((child) => isEmptyTextNode(child)); - let isEmpty: boolean; - if (!node.children) { - isEmpty = true; - } else { - isEmpty = node.children.every((child) => isEmptyTextNode(child)); - } - const isSelfClosingTag = - isEmpty && (node.type !== 'element' || selfClosingTags.indexOf(node.name) !== -1); - - const attributeLine = - opts.singleAttributePerLine && node.attributes.length > 1 ? breakParent : ''; - const attributes = join(attributeLine, path.map(print, 'attributes')); - - if (isSelfClosingTag) { - return group(['<', node.name, indent(group(attributes)), line, `/>`]); - // return group(['<', node.name, indent(group([...attributes, opts.jsxBracketNewLine ? dedent(line) : ''])), ...[opts.jsxBracketNewLine ? '' : ' ', `/>`]]); - } - - if (node.children) { - const children = node.children; - const firstChild = children[0]; - const lastChild = children[children.length - 1]; - - // No hugging of content means it's either a block element and/or there's whitespace at the start/end - let noHugSeparatorStart: - | _doc.builders.Concat - | _doc.builders.Line - | _doc.builders.Softline - | string = softline; - let noHugSeparatorEnd: - | _doc.builders.Concat - | _doc.builders.Line - | _doc.builders.Softline - | string = softline; - const hugStart = shouldHugStart(node, opts); - const hugEnd = shouldHugEnd(node, opts); - - let body; - - if (isEmpty) { - body = - isInlineElement(path, opts, node) && - node.children.length && - isTextNodeStartingWithWhitespace(node.children[0]) && - !isPreTagContent(path) - ? () => line - : // () => (opts.jsxBracketNewLine ? '' : softline); - () => softline; - } else if (isPreTagContent(path)) { - body = () => printRaw(node); - } else if (isInlineElement(path, opts, node) && !isPreTagContent(path)) { - body = () => path.map(print, 'children'); - } else { - body = () => path.map(print, 'children'); - } - - const openingTag = [ - '<', - node.name, - indent( - group([ - attributes, - hugStart - ? '' - : !isPreTagContent(path) && !opts.bracketSameLine - ? dedent(softline) - : '', - ]) - ), - ]; - // const openingTag = ['<', node.name, indent(group([...attributes, hugStart ? '' : opts.jsxBracketNewLine && !isPreTagContent(path) ? dedent(softline) : '']))]; - - if (hugStart && hugEnd) { - const huggedContent = [softline, group(['>', body(), `', - ]); - } - - if (isPreTagContent(path)) { - noHugSeparatorStart = ''; - noHugSeparatorEnd = ''; - } else { - let didSetEndSeparator = false; - - if (!hugStart && firstChild && isTextNode(firstChild)) { - if ( - isTextNodeStartingWithLinebreak(firstChild) && - firstChild !== lastChild && - (!isInlineElement(path, opts, node) || isTextNodeEndingWithWhitespace(lastChild)) - ) { - noHugSeparatorStart = hardline; - noHugSeparatorEnd = hardline; - didSetEndSeparator = true; - } else if (isInlineElement(path, opts, node)) { - noHugSeparatorStart = line; - } - trimTextNodeLeft(firstChild); - } - if (!hugEnd && lastChild && isTextNode(lastChild)) { - if (isInlineElement(path, opts, node) && !didSetEndSeparator) { - noHugSeparatorEnd = softline; - } - trimTextNodeRight(lastChild); - } - } - - if (hugStart) { - return group([ - ...openingTag, - indent([softline, group(['>', body()])]), - noHugSeparatorEnd, - ``, - ]); - } - - if (hugEnd) { - return group([ - ...openingTag, - '>', - indent([noHugSeparatorStart, group([body(), `', - ]); - } - - if (isEmpty) { - return group([...openingTag, '>', body(), ``]); - } - - return group([ - ...openingTag, - '>', - indent([noHugSeparatorStart, body()]), - noHugSeparatorEnd, - ``, - ]); - } - // TODO: WIP - return ''; - } - // case 'AttributeShorthand': { - // return node.expression.name; - // } - case 'attribute': { - const name = node.name.trim(); - const quote = opts.jsxSingleQuote ? "'" : '"'; - switch (node.kind) { - case 'empty': - return [line, name]; - case 'expression': - // HANDLED IN EMBED FUNCION - return ''; - case 'quoted': - return [line, name, '=', quote, node.value, quote]; - case 'shorthand': - return [line, '{', name, '}']; - case 'spread': - return [line, '{...', name, '}']; - case 'template-literal': - return [line, name, '=', '`', node.value, '`']; - default: - break; - } - return ''; - } - - case 'doctype': { - // https://www.w3.org/wiki/Doctypes_and_markup_styles - return ['', hardline]; - } - // case 'Expression': - // // missing test ? - // return []; - // case 'MustacheTag': - // return [ - // '{', - // printJS(path, print, 'expression', { - // forceSingleLine: isInsideQuotedAttribute(path), - // forceSingleQuote: opts.jsxSingleQuote, - // }), - // '}', - // ]; - // case 'Spread': - // return [ - // line, - // '{...', - // printJS(path, print, 'expression', { - // forceSingleQuote: true, - // forceSingleLine: false, - // }), - // '}', - // ]; - case 'comment': - const nextNode = getNextNode(path); - let trailingLine: _doc.builders.Concat | string = ''; - if (nextNode && isTagLikeNode(nextNode)) { - trailingLine = hardline; - } - return ['', trailingLine]; - // case 'CodeSpan': - // return getUnencodedText(node); - // case 'CodeFence': { - // console.debug(node); - // // const lang = node.metadata.slice(3); - // return [node.metadata, hardline, /** somehow call textToDoc(lang), */ node.data, hardline, '```', hardline]; - - // // We should use `node.metadata` to select a parser to embed with... something like return [node.metadata, hardline textToDoc(node.getMetadataLanguage()), hardline, `\`\`\``]; - // } - default: { - throw new Error(`Unhandled node type "${node.type}"!`); - } - } -} - -/** - * Split the text into words separated by whitespace. Replace the whitespaces by lines, - * collapsing multiple whitespaces into a single line. - * - * If the text starts or ends with multiple newlines, two of those should be kept. - */ -function splitTextToDocs(node: NodeWithText): Doc[] { - const text = getUnencodedText(node); - - const textLines = text.split(/[\t\n\f\r ]+/); - - let docs = join(line, textLines).parts.filter((s) => s !== ''); - - if (startsWithLinebreak(text)) { - docs[0] = hardline; - } - if (startsWithLinebreak(text, 2)) { - docs = [hardline, ...docs]; - } - - if (endsWithLinebreak(text)) { - docs[docs.length - 1] = hardline; - } - if (endsWithLinebreak(text, 2)) { - docs = [...docs, hardline]; - } - - return docs; -} - -function expressionParser(text: string, parsers: BuiltInParsers, opts: ParserOptions) { - const ast = parsers.babel(text, opts); - // const ast = parsers.babel(text, parsers, opts); - - return { - ...ast, - program: ast.program.body[0].expression.children[0].expression, - }; -} - -function embed( - path: AstPath, - // eslint-disable-next-line @typescript-eslint/no-shadow - print: printFn, - textToDoc: (text: string, options: object) => Doc, - opts: ParserOptions -) { - // TODO: ADD TYPES OR FIND ANOTHER WAY TO ACHIVE THIS - // @ts-ignore - if (!opts.__astro) opts.__astro = {}; - - const node = path.getValue(); - - if (!node) return null; - - if (node.type === 'expression') { - const textContent = printRaw(node); - - let content: Doc; - - content = textToDoc(forceIntoExpression(textContent), { - ...opts, - parser: expressionParser, - }); - content = stripTrailingHardline(content); - - // if (node.children[0].value) { - // content = textToDoc(forceIntoExpression(textContent), { parser: expressionParser }); - // } else { - // content = textToDoc(forceIntoExpression(node.children[0].value), { parser: expressionParser }); - // } - return [ - '{', - // printJS(path, print, 'expression', { - // forceSingleLine: isInsideQuotedAttribute(path), - // forceSingleQuote: opts.jsxSingleQuote, - // }), - content, - '}', - ]; - } - - // ATTRIBUTE WITH EXPRESSION AS VALUE - if (node.type === 'attribute' && node.kind === 'expression') { - const value = node.value.trim(); - const name = node.name.trim(); - let attrNodeValue = textToDoc(forceIntoExpression(value), { - ...opts, - parser: expressionParser, - }); - attrNodeValue = stripTrailingHardline(attrNodeValue); - // if (Array.isArray(attrNodeValue) && attrNodeValue[0] === ';') { - // attrNodeValue = attrNodeValue.slice(1); - // } - if (name === value && opts.astroAllowShorthand) { - return [line, '{', attrNodeValue, '}']; - } - return [line, name, '=', '{', attrNodeValue, '}']; - } - - // TODO: ADD TYPES OR FIND ANOTHER WAY TO ACHIVE THIS - // @ts-ignore - // if (node.isJS) { - // try { - // const embeddedopts = { - // parser: expressionParser, - // }; - // // TODO: ADD TYPES OR FIND ANOTHER WAY TO ACHIVE THIS - // // @ts-ignore - // if (node.forceSingleQuote) { - // // TODO: ADD TYPES OR FIND ANOTHER WAY TO ACHIVE THIS - // // @ts-ignore - // embeddedopts.singleQuote = true; - // } - - // const docs = textToDoc(forceIntoExpression(getText(node, opts)), embeddedopts); - // // TODO: ADD TYPES OR FIND ANOTHER WAY TO ACHIVE THIS - // // @ts-ignore - // return node.forceSingleLine ? removeLines(docs) : docs; - // } catch (e) { - // return getText(node, opts); - // } - // } - - if (node.type === 'frontmatter') { - return [ - group([ - '---', - hardline, - textToDoc(node.value, { ...opts, parser: 'typescript' }), - '---', - hardline, - ]), - hardline, - ]; - } - - // format script element - if (node.type === 'element' && node.name === 'script') { - const scriptContent = printRaw(node); - let formatttedScript = textToDoc(scriptContent, { - ...opts, - parser: 'typescript', - }); - formatttedScript = stripTrailingHardline(formatttedScript); - - // print - const attributes = path.map(print, 'attributes'); - const openingTag = group(['']); - return [openingTag, indent([hardline, formatttedScript]), hardline, '']; - } - // if (isTextNode(node)) { - // const parent = path.getParentNode(); - - // if (parent && parent.type === 'Element' && parent.name === 'script') { - // const formatttedScript = textToDoc(node.data, { ...opts, parser: 'typescript' }); - // return stripTrailingHardline(formatttedScript); - // } - // } - - // format style element - if (node.type === 'element' && node.name === 'style') { - const styleTagContent = printRaw(node); - - const supportedStyleLangValues = ['css', 'scss', 'sass']; - let parserLang = 'css'; - - if (node.attributes) { - const langAttribute = node.attributes.filter((x) => x.name === 'lang'); - if (langAttribute.length) { - const styleLang = langAttribute[0].value.toLowerCase(); - if (supportedStyleLangValues.includes(styleLang)) parserLang = styleLang; - } - } - - switch (parserLang) { - case 'css': - case 'scss': { - // the css parser appends an extra indented hardline, which we want outside of the `indent()`, - // so we remove the last element of the array - let formattedStyles = textToDoc(styleTagContent, { - ...opts, - parser: parserLang, - }); - - formattedStyles = stripTrailingHardline(formattedStyles); - - // print - const attributes = path.map(print, 'attributes'); - const openingTag = group(['']); - return [openingTag, indent([hardline, formattedStyles]), hardline, '']; - } - case 'sass': { - const lineEnding = opts.endOfLine.toUpperCase() === 'CRLF' ? 'CRLF' : 'LF'; - const sassOptions: Partial = { - tabSize: opts.tabWidth, - insertSpaces: !opts.useTabs, - lineEnding, - }; - - // dedent the .sass, otherwise SassFormatter gets indentation wrong - const { result: raw } = manualDedent(styleTagContent); - - // format - const formattedSassIndented = SassFormatter.Format(raw, sassOptions).trim(); - - // print - const formattedSass = join(hardline, formattedSassIndented.split('\n')); - const attributes = path.map(print, 'attributes'); - const openingTag = group(['']); - return [openingTag, indent(group([hardline, formattedSass])), hardline, '']; - } - } - } - - return null; -} - -function hasPrettierIgnore(path: AstP) { - // const node = path.getNode(); - - // if (!node || !Array.isArray(node.comments)) return false; - - // const hasIgnore = node.comments.some( - // (comment: any) => comment.data.includes('prettier-ignore') && !comment.data.includes('prettier-ignore-start') && !comment.data.includes('prettier-ignore-end') - // ); - // return hasIgnore; - return false; -} - -const printer: Printer = { - print, - printComment, - embed, - hasPrettierIgnore, -}; - -export default printer; diff --git a/src/printer/elements.ts b/src/printer/elements.ts new file mode 100644 index 0000000..041e09c --- /dev/null +++ b/src/printer/elements.ts @@ -0,0 +1,78 @@ +export type TagName = keyof HTMLElementTagNameMap | 'svg'; + +// https://github.com/prettier/prettier/blob/main/vendors/html-void-elements.json +export const selfClosingTags = [ + 'area', + 'base', + 'basefont', + 'bgsound', + 'br', + 'col', + 'command', + 'embed', + 'frame', + 'hr', + 'image', + 'img', + 'input', + 'isindex', + 'keygen', + 'link', + 'menuitem', + 'meta', + 'nextid', + 'param', + 'slot', + 'source', + 'track', + 'wbr', +]; + +// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements#Elements +export const blockElements: TagName[] = [ + 'address', + 'article', + 'aside', + 'blockquote', + 'details', + 'dialog', + 'dd', + 'div', + 'dl', + 'dt', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'header', + 'hgroup', + 'hr', + 'li', + 'main', + 'nav', + 'ol', + 'p', + 'pre', + 'section', + 'table', + 'ul', + // TODO: WIP + 'title', + 'html', +]; + +/** + * HTML attributes that we may safely reformat (trim whitespace, add or remove newlines) + */ +export const formattableAttributes: string[] = [ + // None at the moment + // Prettier HTML does not format attributes at all + // and to be consistent we leave this array empty for now +]; diff --git a/src/printer/embed.ts b/src/printer/embed.ts new file mode 100644 index 0000000..d765889 --- /dev/null +++ b/src/printer/embed.ts @@ -0,0 +1,168 @@ +import { BuiltInParsers, Doc, ParserOptions } from 'prettier'; +import _doc from 'prettier/doc'; +import { SassFormatter, SassFormatterConfig } from 'sass-formatter'; +import { AstPath, manualDedent, printFn, printRaw } from './utils'; +const { + builders: { group, indent, join, line, softline, hardline }, + utils: { stripTrailingHardline }, +} = _doc; + +type supportedStyleLang = 'css' | 'scss' | 'sass'; + +// https://prettier.io/docs/en/plugins.html#optional-embed +export function embed( + path: AstPath, + print: printFn, + textToDoc: (text: string, options: object) => Doc, + opts: ParserOptions +) { + const node = path.getValue(); + + if (!node) return null; + + if (node.type === 'expression') { + const textContent = printRaw(node); + + let content: Doc; + + content = textToDoc(forceIntoExpression(textContent), { + ...opts, + parser: expressionParser, + }); + content = stripTrailingHardline(content); + + return ['{', content, '}']; + } + + // Attribute using an expression as value + if (node.type === 'attribute' && node.kind === 'expression') { + const value = node.value.trim(); + const name = node.name.trim(); + let attrNodeValue = textToDoc(forceIntoExpression(value), { + ...opts, + parser: expressionParser, + }); + attrNodeValue = stripTrailingHardline(attrNodeValue); + + if (name === value && opts.astroAllowShorthand) { + return [line, '{', attrNodeValue, '}']; + } + + return [line, name, '=', '{', attrNodeValue, '}']; + } + + // Frontmatter + if (node.type === 'frontmatter') { + return [ + group([ + '---', + hardline, + textToDoc(node.value, { ...opts, parser: 'typescript' }), + '---', + hardline, + ]), + hardline, + ]; + } + + // Script tags + if (node.type === 'element' && node.name === 'script') { + const scriptContent = printRaw(node); + let formatttedScript = textToDoc(scriptContent, { + ...opts, + parser: 'typescript', + }); + formatttedScript = stripTrailingHardline(formatttedScript); + + // print + const attributes = path.map(print, 'attributes'); + const openingTag = group(['']); + return [openingTag, indent([hardline, formatttedScript]), hardline, '']; + } + + // Style tags + if (node.type === 'element' && node.name === 'style') { + const content = printRaw(node); + const supportedStyleLangValues = ['css', 'scss', 'sass']; + let parserLang: supportedStyleLang = 'css'; + + if (node.attributes) { + const langAttribute = node.attributes.filter((x) => x.name === 'lang'); + if (langAttribute.length) { + const styleLang = langAttribute[0].value.toLowerCase(); + if (supportedStyleLangValues.includes(styleLang)) + parserLang = styleLang as supportedStyleLang; + } + } + + return embedStyle(parserLang, content, path, print, textToDoc, opts); + } + + return null; +} + +function expressionParser(text: string, parsers: BuiltInParsers, opts: ParserOptions) { + const ast = parsers.babel(text, opts); + + return { + ...ast, + program: ast.program.body[0].expression.children[0].expression, + }; +} + +function forceIntoExpression(statement: string): string { + // note the trailing newline: if the statement ends in a // comment, + // we can't add the closing bracket right afterwards + return `<>{${statement}\n}`; +} + +/** + * Format the content of a style tag and print the entire element + */ +function embedStyle( + lang: supportedStyleLang, + content: string, + path: AstPath, + print: printFn, + textToDoc: (text: string, options: object) => Doc, + options: ParserOptions +) { + switch (lang) { + case 'css': + case 'scss': { + let formattedStyles = textToDoc(content, { + ...options, + parser: lang, + }); + + // The css parser appends an extra indented hardline, which we want outside of the `indent()`, + // so we remove the last element of the array + formattedStyles = stripTrailingHardline(formattedStyles); + + // print + const attributes = path.map(print, 'attributes'); + const openingTag = group(['']); + return [openingTag, indent([hardline, formattedStyles]), hardline, '']; + } + case 'sass': { + const lineEnding = options.endOfLine.toUpperCase() === 'CRLF' ? 'CRLF' : 'LF'; + const sassOptions: Partial = { + tabSize: options.tabWidth, + insertSpaces: !options.useTabs, + lineEnding, + }; + + // dedent the .sass, otherwise SassFormatter gets indentation wrong + const { result: raw } = manualDedent(content); + + // format + const formattedSassIndented = SassFormatter.Format(raw, sassOptions).trim(); + + // print + const formattedSass = join(hardline, formattedSassIndented.split('\n')); + const attributes = path.map(print, 'attributes'); + const openingTag = group(['']); + return [openingTag, indent(group([hardline, formattedSass])), hardline, '']; + } + } +} diff --git a/src/printer/index.ts b/src/printer/index.ts new file mode 100644 index 0000000..09f6c59 --- /dev/null +++ b/src/printer/index.ts @@ -0,0 +1,313 @@ +import { Doc } from 'prettier'; +import { selfClosingTags } from './elements'; +import { + AstPath, + canOmitSoftlineBeforeClosingTag, + endsWithLinebreak, + getNextNode, + getUnencodedText, + isEmptyTextNode, + isInlineElement, + isPreTagContent, + isTagLikeNode, + isTextNode, + isTextNodeEndingWithWhitespace, + isTextNodeStartingWithLinebreak, + isTextNodeStartingWithWhitespace, + ParserOptions, + printFn, + printRaw, + shouldHugEnd, + shouldHugStart, + startsWithLinebreak, + trimTextNodeLeft, + trimTextNodeRight, +} from './utils'; +import { TextNode } from './nodes'; + +import _doc from 'prettier/doc'; +const { + builders: { breakParent, dedent, fill, group, indent, join, line, softline, hardline }, + utils: { stripTrailingHardline }, +} = _doc; + +// https://prettier.io/docs/en/plugins.html#print +// eslint-disable-next-line @typescript-eslint/no-shadow +export function print(path: AstPath, opts: ParserOptions, print: printFn): Doc { + const node = path.getValue(); + + // 1. handle special node types + if (!node) { + return ''; + } + + if (typeof node === 'string') { + return node; + } + + // 2. handle printing + switch (node.type) { + case 'root': { + return [stripTrailingHardline(path.map(print, 'children')), hardline]; + } + + case 'text': { + const rawText = getUnencodedText(node); + + // TODO: TEST PRE TAGS + // if (isPreTagContent(path)) { + // if (path.getParentNode()?.type === 'Attribute') { + // // Direct child of attribute value -> add literallines at end of lines + // // so that other things don't break in unexpected places + // return replaceEndOfLineWith(rawText, literalline); + // } + // return rawText; + // } + + if (isEmptyTextNode(node)) { + const hasWhiteSpace = rawText.trim().length < getUnencodedText(node).length; + const hasOneOrMoreNewlines = /\n/.test(getUnencodedText(node)); + const hasTwoOrMoreNewlines = /\n\r?\s*\n\r?/.test(getUnencodedText(node)); + if (hasTwoOrMoreNewlines) { + return [hardline, hardline]; + } + if (hasOneOrMoreNewlines) { + return hardline; + } + if (hasWhiteSpace) { + return line; + } + return ''; + } + + /** + * For non-empty text nodes each sequence of non-whitespace characters (effectively, + * each "word") is joined by a single `line`, which will be rendered as a single space + * until this node's current line is out of room, at which `fill` will break at the + * most convenient instance of `line`. + */ + return fill(splitTextToDocs(node)); + } + + case 'component': + case 'fragment': + case 'element': { + let isEmpty: boolean; + if (!node.children) { + isEmpty = true; + } else { + isEmpty = node.children.every((child) => isEmptyTextNode(child)); + } + const isSelfClosingTag = + isEmpty && (node.type !== 'element' || selfClosingTags.includes(node.name)); + + const attributeLine = + opts.singleAttributePerLine && node.attributes.length > 1 ? breakParent : ''; + const attributes = join(attributeLine, path.map(print, 'attributes')); + + if (isSelfClosingTag) { + return group(['<', node.name, indent(group(attributes)), line, `/>`]); + } + + if (node.children) { + const children = node.children; + const firstChild = children[0]; + const lastChild = children[children.length - 1]; + + // No hugging of content means it's either a block element and/or there's whitespace at the start/end + let noHugSeparatorStart: + | _doc.builders.Concat + | _doc.builders.Line + | _doc.builders.Softline + | string = softline; + let noHugSeparatorEnd: + | _doc.builders.Concat + | _doc.builders.Line + | _doc.builders.Softline + | string = softline; + const hugStart = shouldHugStart(node, opts); + const hugEnd = shouldHugEnd(node, opts); + + let body; + + if (isEmpty) { + body = + isInlineElement(path, opts, node) && + node.children.length && + isTextNodeStartingWithWhitespace(node.children[0]) && + !isPreTagContent(path) + ? () => line + : // () => (opts.jsxBracketNewLine ? '' : softline); + () => softline; + } else if (isPreTagContent(path)) { + body = () => printRaw(node); + } else if (isInlineElement(path, opts, node) && !isPreTagContent(path)) { + body = () => path.map(print, 'children'); + } else { + body = () => path.map(print, 'children'); + } + + const openingTag = [ + '<', + node.name, + indent( + group([ + attributes, + hugStart + ? '' + : !isPreTagContent(path) && !opts.bracketSameLine + ? dedent(softline) + : '', + ]) + ), + ]; + + if (hugStart && hugEnd) { + const huggedContent = [softline, group(['>', body(), `', + ]); + } + + if (isPreTagContent(path)) { + noHugSeparatorStart = ''; + noHugSeparatorEnd = ''; + } else { + let didSetEndSeparator = false; + + if (!hugStart && firstChild && isTextNode(firstChild)) { + if ( + isTextNodeStartingWithLinebreak(firstChild) && + firstChild !== lastChild && + (!isInlineElement(path, opts, node) || isTextNodeEndingWithWhitespace(lastChild)) + ) { + noHugSeparatorStart = hardline; + noHugSeparatorEnd = hardline; + didSetEndSeparator = true; + } else if (isInlineElement(path, opts, node)) { + noHugSeparatorStart = line; + } + trimTextNodeLeft(firstChild); + } + if (!hugEnd && lastChild && isTextNode(lastChild)) { + if (isInlineElement(path, opts, node) && !didSetEndSeparator) { + noHugSeparatorEnd = softline; + } + trimTextNodeRight(lastChild); + } + } + + if (hugStart) { + return group([ + ...openingTag, + indent([softline, group(['>', body()])]), + noHugSeparatorEnd, + ``, + ]); + } + + if (hugEnd) { + return group([ + ...openingTag, + '>', + indent([noHugSeparatorStart, group([body(), `', + ]); + } + + if (isEmpty) { + return group([...openingTag, '>', body(), ``]); + } + + return group([ + ...openingTag, + '>', + indent([noHugSeparatorStart, body()]), + noHugSeparatorEnd, + ``, + ]); + } + + // TODO: WIP + return ''; + } + + case 'attribute': { + const name = node.name.trim(); + const quote = opts.jsxSingleQuote ? "'" : '"'; + switch (node.kind) { + case 'empty': + return [line, name]; + case 'expression': + // Handled in the `embed` function + // See embed.ts at the root of the src folder + return ''; + case 'quoted': + return [line, name, '=', quote, node.value, quote]; + case 'shorthand': + return [line, '{', name, '}']; + case 'spread': + return [line, '{...', name, '}']; + case 'template-literal': + return [line, name, '=', '`', node.value, '`']; + default: + break; + } + return ''; + } + + case 'doctype': { + // https://www.w3.org/wiki/Doctypes_and_markup_styles + return ['', hardline]; + } + + case 'comment': + const nextNode = getNextNode(path); + let trailingLine: _doc.builders.Concat | string = ''; + if (nextNode && isTagLikeNode(nextNode)) { + trailingLine = hardline; + } + return ['', trailingLine]; + + default: { + throw new Error(`Unhandled node type "${node.type}"!`); + } + } +} + +/** + * Split the text into words separated by whitespace. Replace the whitespaces by lines, + * collapsing multiple whitespaces into a single line. + * + * If the text starts or ends with multiple newlines, two of those should be kept. + */ +function splitTextToDocs(node: TextNode): Doc[] { + const text = getUnencodedText(node); + + const textLines = text.split(/[\t\n\f\r ]+/); + + let docs = join(line, textLines).parts.filter((s) => s !== ''); + + if (startsWithLinebreak(text)) { + docs[0] = hardline; + } + if (startsWithLinebreak(text, 2)) { + docs = [hardline, ...docs]; + } + + if (endsWithLinebreak(text)) { + docs[docs.length - 1] = hardline; + } + if (endsWithLinebreak(text, 2)) { + docs = [...docs, hardline]; + } + + return docs; +} diff --git a/src/printer/nodes.ts b/src/printer/nodes.ts new file mode 100644 index 0000000..57ed9aa --- /dev/null +++ b/src/printer/nodes.ts @@ -0,0 +1,46 @@ +import { + AttributeNode, + CommentNode, + ComponentNode, + CustomElementNode, + DoctypeNode, + ElementNode, + ExpressionNode, + FragmentNode, + FrontmatterNode, + Node, + ParentLikeNode, + RootNode, + TagLikeNode, + TextNode, +} from '@astrojs/compiler/types'; + +export type anyNode = + | RootNode + | AttributeNode + | ElementNode + | ComponentNode + | CustomElementNode + | ExpressionNode + | TextNode + | DoctypeNode + | CommentNode + | FragmentNode + | FrontmatterNode; + +export type { + AttributeNode, + CommentNode, + ComponentNode, + CustomElementNode, + DoctypeNode, + ElementNode, + ExpressionNode, + FragmentNode, + FrontmatterNode, + Node, + ParentLikeNode, + RootNode, + TagLikeNode, + TextNode, +}; diff --git a/src/printer/utils.ts b/src/printer/utils.ts new file mode 100644 index 0000000..8a5abf7 --- /dev/null +++ b/src/printer/utils.ts @@ -0,0 +1,255 @@ +import { createRequire } from 'node:module'; +import { AstPath as AstP, Doc, ParserOptions as ParserOpts } from 'prettier'; +import { createSyncFn } from 'synckit'; +import { blockElements, formattableAttributes, TagName } from './elements'; +import { anyNode, CommentNode, Node, ParentLikeNode, TagLikeNode, TextNode } from './nodes'; + +export type printFn = (path: AstPath) => Doc; +export type ParserOptions = ParserOpts; +export type AstPath = AstP; + +const require = createRequire(import.meta.url); +const serialize = createSyncFn(require.resolve('../workers/serialize-worker.js')); + +export function isInlineElement(path: AstPath, opts: ParserOptions, node: anyNode): boolean { + return node && node.type === 'element' && !isBlockElement(node, opts) && !isPreTagContent(path); +} + +export function isBlockElement(node: anyNode, opts: ParserOptions): boolean { + return ( + node && + node.type === 'element' && + opts.htmlWhitespaceSensitivity !== 'strict' && + (opts.htmlWhitespaceSensitivity === 'ignore' || blockElements.includes(node.name as TagName)) + ); +} + +/** + * Returns the content of the node + */ +export function printRaw(node: anyNode, stripLeadingAndTrailingNewline = false): string { + if (!isNodeWithChildren(node)) { + return ''; + } + + if (node.children.length === 0) { + return ''; + } + + let raw = node.children.reduce((prev: string, curr: Node) => prev + serialize(curr), ''); + + if (!stripLeadingAndTrailingNewline) { + return raw; + } + + if (startsWithLinebreak(raw)) { + raw = raw.substring(raw.indexOf('\n') + 1); + } + if (endsWithLinebreak(raw)) { + raw = raw.substring(0, raw.lastIndexOf('\n')); + if (raw.charAt(raw.length - 1) === '\r') { + raw = raw.substring(0, raw.length - 1); + } + } + + return raw; +} + +export function isNodeWithChildren(node: anyNode): node is anyNode & ParentLikeNode { + return node && 'children' in node && Array.isArray(node.children); +} + +export const isEmptyTextNode = (node: Node): boolean => { + return !!node && node.type === 'text' && getUnencodedText(node).trim() === ''; +}; + +export function getUnencodedText(node: TextNode | CommentNode): string { + return node.value; +} + +export function isTextNodeStartingWithLinebreak(node: TextNode, nrLines = 1): node is TextNode { + return startsWithLinebreak(getUnencodedText(node), nrLines); +} + +export function startsWithLinebreak(text: string, nrLines = 1): boolean { + return new RegExp(`^([\\t\\f\\r ]*\\n){${nrLines}}`).test(text); +} + +export function endsWithLinebreak(text: string, nrLines = 1): boolean { + return new RegExp(`(\\n[\\t\\f\\r ]*){${nrLines}}$`).test(text); +} + +export function isTextNodeStartingWithWhitespace(node: Node): node is TextNode { + return node.type === 'text' && /^\s/.test(getUnencodedText(node)); +} + +export function isTextNodeEndingWithWhitespace(node: Node): node is TextNode { + return node.type === 'text' && /\s$/.test(getUnencodedText(node)); +} + +/** + * Check if given node's start tag should hug its first child. This is the case for inline elements when there's + * no whitespace between the `>` and the first child. + */ +export function shouldHugStart(node: anyNode, opts: ParserOptions): boolean { + if (isBlockElement(node, opts)) { + return false; + } + + if (!isNodeWithChildren(node)) { + return false; + } + + const children = node.children; + if (children.length === 0) { + return true; + } + + const firstChild = children[0]; + return !isTextNodeStartingWithWhitespace(firstChild); +} + +/** + * Check if given node's end tag should hug its last child. This is the case for inline elements when there's + * no whitespace between the last child and the `` can be omitted. + */ +export function canOmitSoftlineBeforeClosingTag(path: AstPath, opts: ParserOptions): boolean { + return isLastChildWithinParentBlockElement(path, opts); +} + +function getChildren(node: anyNode): Node[] { + return isNodeWithChildren(node) ? node.children : []; +} + +function isLastChildWithinParentBlockElement(path: AstPath, opts: ParserOptions): boolean { + const parent = path.getParentNode(); + if (!parent || !isBlockElement(parent, opts)) { + return false; + } + + const children = getChildren(parent); + const lastChild = children[children.length - 1]; + return lastChild === path.getNode(); +} + +export function trimTextNodeLeft(node: TextNode): void { + node.value = node.value && node.value.trimStart(); +} + +export function trimTextNodeRight(node: TextNode): void { + node.value = node.value && node.value.trimEnd(); +} + +/** dedent string & return tabSize (the last part is what we need) */ +export function manualDedent(input: string): { + tabSize: number; + char: string; + result: string; +} { + let minTabSize = Infinity; + let result = input; + // 1. normalize + result = result.replace(/\r\n/g, '\n'); + + // 2. count tabSize + let char = ''; + for (const line of result.split('\n')) { + if (!line) continue; + // if any line begins with a non-whitespace char, minTabSize is 0 + if (line[0] && /^[^\s]/.test(line[0])) { + minTabSize = 0; + break; + } + const match = line.match(/^(\s+)\S+/); // \S ensures we don’t count lines of pure whitespace + if (match) { + if (match[1] && !char) char = match[1][0]; + if (match[1].length < minTabSize) minTabSize = match[1].length; + } + } + + // 3. reformat string + if (minTabSize > 0 && Number.isFinite(minTabSize)) { + result = result.replace(new RegExp(`^${new Array(minTabSize + 1).join(char)}`, 'gm'), ''); + } + + return { + tabSize: minTabSize === Infinity ? 0 : minTabSize, + char, + result, + }; +} + +/** True if the node is of type text */ +export function isTextNode(node: anyNode): node is TextNode { + return node.type === 'text'; +} + +/** True if the node is TagLikeNode: + * + * ElementNode | ComponentNode | CustomElementNode | FragmentNode */ +export function isTagLikeNode(node: anyNode): node is TagLikeNode { + return ( + node.type === 'element' || + node.type === 'component' || + node.type === 'custom-element' || + node.type === 'fragment' + ); +} + +/** + * Returns siblings, that is, the children of the parent. + */ +export function getSiblings(path: AstPath): anyNode[] { + const parent = path.getParentNode(); + if (!parent) return []; + + return getChildren(parent); +} + +export function getNextNode(path: AstPath): anyNode | null { + const node = path.getNode(); + if (node) { + const siblings = getSiblings(path); + if (node.position?.start === siblings[siblings.length - 1].position?.start) return null; + for (let i = 0; i < siblings.length; i++) { + const sibling = siblings[i]; + if (sibling.position?.start === node.position?.start && i !== siblings.length - 1) { + return siblings[i + 1]; + } + } + } + return null; +} + +export const isPreTagContent = (path: AstPath): boolean => { + if (!path || !path.stack || !Array.isArray(path.stack)) return false; + return path.stack.some( + (node: anyNode) => + (node.type === 'element' && node.name.toLowerCase() === 'pre') || + (node.type === 'attribute' && !formattableAttributes.includes(node.name)) + ); +}; diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index a0ac692..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,701 +0,0 @@ -import { AstPath as AstP, doc, Doc, ParserOptions as ParserOpts, util } from 'prettier'; - -import { - anyNode, - Node, - RootNode, - AttributeNode, - ElementNode, - ComponentNode, - CustomElementNode, - ExpressionNode, - TextNode, - FrontmatterNode, - DoctypeNode, - CommentNode, - NodeWithText, - blockElements, - // attributeValue, - BlockElementNode, - InlineElementNode, - // MustacheTagNode, - NodeWithChildren, - TagLikeNode, - // NodeWithText, - // TextNode, -} from './nodes'; - -import { createSyncFn } from 'synckit'; -import { createRequire } from 'node:module'; -const require = createRequire(import.meta.url); - -// the worker path must be absolute -const serialize = createSyncFn(require.resolve('../workers/serialize-worker.js')); - -type ParserOptions = ParserOpts; -type AstPath = AstP; - -/** - * HTML attributes that we may safely reformat (trim whitespace, add or remove newlines) - */ -export const formattableAttributes: string[] = [ - // None at the moment - // Prettier HTML does not format attributes at all - // and to be consistent we leave this array empty for now -]; - -// const rootNodeKeys = new Set(['html', 'css', 'module']); - -// const isSync = makeSynchronous(async (node: anyNode) => { -// const dynamicImport = new Function('file', 'return import(file)'); -// const { is } = await dynamicImport('@astrojs/compiler/utils'); -// try { -// return await is(node); -// } catch (e) { -// console.error(e); -// } -// }); - -// export const is = (node: anyNode) => isSync(node); - -export const isRootNode = (node: anyNode): node is RootNode => node.type === 'root'; - -export const isEmptyTextNode = (node: Node): boolean => { - return !!node && node.type === 'text' && getUnencodedText(node).trim() === ''; -}; - -export const isPreTagContent = (path: AstPath): boolean => { - if (!path || !path.stack || !Array.isArray(path.stack)) return false; - return path.stack.some( - (node: anyNode) => - (node.type === 'element' && node.name.toLowerCase() === 'pre') || - (node.type === 'attribute' && !formattableAttributes.includes(node.name)) - ); -}; - -export function isLoneMustacheTag(node: AttributeNode): boolean { - // export function isLoneMustacheTag(node: AttributeNode): node is [MustacheTagNode] { - return node.kind === 'expression'; - // return node !== true && node.length === 1 && node[0].type === 'MustacheTag'; -} - -// function isAttributeShorthand(node: attributeValue): node is [AttributeShorthandNode] { -// return node !== true && node.length === 1 && node[0].type === 'AttributeShorthand'; -// } - -/** - * True if node is of type `{a}` or `a={a}` - */ -export function isOrCanBeConvertedToShorthand(node: AttributeNode, opts: ParserOptions): boolean { - if (!opts.astroAllowShorthand) return false; - if (node.kind === 'shorthand') { - return true; - } - // if (isAttributeShorthand(node.value)) { - // return true; - // } - - if (node.value.trim() === node.name.trim()) { - return true; - } - - // if (isLoneMustacheTag(node.value)) { - // const expression = node.value[0].expression; - // return expression.codeChunks[0].trim() === node.name; - // // return (expression.type === 'Identifier' && expression.name === node.name) || (expression.type === 'Expression' && expression.codeChunks[0] === node.name); - // } - - return false; -} - -/** - * True if node is of type `{a}` and astroAllowShorthand is false - */ -export function isShorthandAndMustBeConvertedToBinaryExpression( - node: AttributeNode, - opts: ParserOptions -): boolean { - if (opts.astroAllowShorthand) return false; - if (node.type === 'attribute' && node.kind === 'shorthand') { - return true; - } - // if (isAttributeShorthand(node.value)) { - // return true; - // } - return false; -} - -// export function flatten(arrays: T[][]): T[] { -// return ([] as T[]).concat.apply([], arrays); -// } - -// TODO: TEST IF IT'S GETTING THE CORRECT TEXT -export function getText(node: anyNode, opts: ParserOptions): string { - if (!node.position) return ''; - return opts.originalText.slice(node.position.start.offset + 1, node.position.end?.offset); - // return opts.originalText.slice(opts.locStart(node), opts.locEnd(node)); -} - -export function getUnencodedText(node: NodeWithText): string { - return node.value; -} - -// export function replaceEndOfLineWith(text: string, replacement: doc.builders.DocCommand): Doc[] { -// const parts = []; -// for (const part of text.split('\n')) { -// if (parts.length > 0) { -// parts.push(replacement); -// } -// if (part.endsWith('\r')) { -// parts.push(part.slice(0, -1)); -// } else { -// parts.push(part); -// } -// } -// return parts; -// } - -/** - * Returns the content of the node - */ -export function printRaw(node: anyNode, stripLeadingAndTrailingNewline = false): string { - if (!isNodeWithChildren(node)) { - return ''; - } - - if (node.children.length === 0) { - return ''; - } - - let raw = node.children.reduce((prev: string, curr: Node) => prev + serialize(curr), ''); - - if (!stripLeadingAndTrailingNewline) { - return raw; - } - - if (startsWithLinebreak(raw)) { - raw = raw.substring(raw.indexOf('\n') + 1); - } - if (endsWithLinebreak(raw)) { - raw = raw.substring(0, raw.lastIndexOf('\n')); - if (raw.charAt(raw.length - 1) === '\r') { - raw = raw.substring(0, raw.length - 1); - } - } - - return raw; -} - -export function isNodeWithChildren(node: anyNode): node is anyNode & NodeWithChildren { - return node && 'children' in node && Array.isArray(node.children); -} - -export function isInlineElement( - path: AstPath, - opts: ParserOptions, - node: anyNode -): node is InlineElementNode { - return node && node.type === 'element' && !isBlockElement(node, opts) && !isPreTagContent(path); -} - -export function isBlockElement(node: anyNode, opts: ParserOptions): node is BlockElementNode { - return ( - node && - node.type === 'element' && - opts.htmlWhitespaceSensitivity !== 'strict' && - (opts.htmlWhitespaceSensitivity === 'ignore' || blockElements.includes(node.name)) - ); -} - -export function isTextNodeStartingWithLinebreak(node: TextNode, nrLines = 1): node is TextNode { - return startsWithLinebreak(getUnencodedText(node), nrLines); - // return node.type === 'Text' && startsWithLinebreak(getUnencodedText(node), nrLines); -} - -export function startsWithLinebreak(text: string, nrLines = 1): boolean { - return new RegExp(`^([\\t\\f\\r ]*\\n){${nrLines}}`).test(text); -} - -// export function isTextNodeEndingWithLinebreak(node: TextNode, nrLines: number = 1) { -// return node.type === 'text' && endsWithLinebreak(getUnencodedText(node), nrLines); -// } - -export function endsWithLinebreak(text: string, nrLines = 1): boolean { - return new RegExp(`(\\n[\\t\\f\\r ]*){${nrLines}}$`).test(text); -} - -export function isTextNodeStartingWithWhitespace(node: Node): node is TextNode { - return node.type === 'text' && /^\s/.test(getUnencodedText(node)); -} - -export function isTextNodeEndingWithWhitespace(node: Node): node is TextNode { - return node.type === 'text' && /\s$/.test(getUnencodedText(node)); -} - -export function forceIntoExpression(statement: string): string { - // note the trailing newline: if the statement ends in a // comment, - // we can't add the closing bracket right afterwards - return `<>{${statement}\n}`; -} - -/** - * Check if given node's starg tag should hug its first child. This is the case for inline elements when there's - * no whitespace between the `>` and the first child. - */ -export function shouldHugStart(node: anyNode, opts: ParserOptions): boolean { - if (isBlockElement(node, opts)) { - return false; - } - - if (!isNodeWithChildren(node)) { - return false; - } - - const children = node.children; - if (children.length === 0) { - return true; - } - - const firstChild = children[0]; - return !isTextNodeStartingWithWhitespace(firstChild); -} - -/** - * Check if given node's end tag should hug its last child. This is the case for inline elements when there's - * no whitespace between the last child and the `` can be omitted. - */ -export function canOmitSoftlineBeforeClosingTag(path: AstPath, opts: ParserOptions): boolean { - return isLastChildWithinParentBlockElement(path, opts); - // return !hugsStartOfNextNode(node, options) || isLastChildWithinParentBlockElement(path, options); - // return !options.svelteBracketNewLine && (!hugsStartOfNextNode(node, options) || isLastChildWithinParentBlockElement(path, options)); -} - -/** - * Return true if given node does not hug the next node, meaning there's whitespace - * or the end of the doc afterwards. - */ -// function hugsStartOfNextNode(node: anyNode, opts: ParserOptions): boolean { -// if (node.end === opts.originalText.length) { -// // end of document -// return false; -// } - -// return !opts.originalText.substring(node.end).match(/^\s/); -// } - -function getChildren(node: anyNode): Node[] { - return isNodeWithChildren(node) ? node.children : []; -} - -function isLastChildWithinParentBlockElement(path: AstPath, opts: ParserOptions): boolean { - const parent = path.getParentNode(); - if (!parent || !isBlockElement(parent, opts)) { - return false; - } - - const children = getChildren(parent); - const lastChild = children[children.length - 1]; - return lastChild === path.getNode(); -} - -export function trimTextNodeLeft(node: TextNode): void { - node.value = node.value && node.value.trimStart(); -} - -export function trimTextNodeRight(node: TextNode): void { - node.value = node.value && node.value.trimEnd(); -} - -// export function findLastIndex(isMatch: (item: T, idx: number) => boolean, items: T[]) { -// for (let i = items.length - 1; i >= 0; i--) { -// if (isMatch(items[i], i)) { -// return i; -// } -// } - -// return -1; -// } - -/** - * Remove all leading whitespace up until the first non-empty text node, - * and all trailing whitepsace from the last non-empty text node onwards. - */ -// export function trimChildren(children: anyNode[]) { -// // export function trimChildren(children: anyNode[], path: AstPath) { -// let firstNonEmptyNode = children.findIndex((n) => !isEmptyTextNode(n)); -// // let firstNonEmptyNode = children.findIndex((n) => !isEmptyTextNode(n) && !doesEmbedStartAfterNode(n, path)); -// firstNonEmptyNode = firstNonEmptyNode === -1 ? children.length - 1 : firstNonEmptyNode; - -// let lastNonEmptyNode = findLastIndex((n, idx) => { -// // Last node is ok to end at the start of an embedded region, -// // if it's not a comment (which should stick to the region) -// return !isEmptyTextNode(n); -// // return !isEmptyTextNode(n) && ((idx === children.length - 1 && n.type !== 'Comment') || !doesEmbedStartAfterNode(n, path)); -// }, children); -// lastNonEmptyNode = lastNonEmptyNode === -1 ? 0 : lastNonEmptyNode; - -// for (let i = 0; i <= firstNonEmptyNode; i++) { -// const n = children[i]; -// if (isTextNode(n)) { -// trimTextNodeLeft(n); -// } -// } - -// for (let i = children.length - 1; i >= lastNonEmptyNode; i--) { -// const n = children[i]; -// if (isTextNode(n)) { -// trimTextNodeRight(n); -// } -// } -// } - -/** - * Returns siblings, that is, the children of the parent. - */ -// export function getSiblings(path: AstPath): anyNode[] { -// let parent = path.getParentNode(); -// if (!parent) return []; - -// if (isRootNode(parent)) { -// parent = parent.html; -// } - -// return getChildren(parent); -// } - -/** - * Did there use to be any embedded object (that has been snipped out of the AST to be moved) - * at the specified position? - */ -// function doesEmbedStartAfterNode(node: anyNode, path: AstPath, siblings = getSiblings(path)): boolean { -// // If node is not at the top level of html, an embed cannot start after it, -// // because embeds are only at the top level -// if (!isNodeTopLevelHTML(node, path)) { -// return false; -// } - -// const position = node.end; -// const root = path.stack[0]; - -// const embeds = [root.module, root.html, root.css]; - -// const nextNode = siblings[siblings.indexOf(node) + 1]; -// return embeds.find((n) => n && n.start >= position && (!nextNode || n.end <= nextNode.start)); -// } - -// function isNodeTopLevelHTML(node: anyNode, path: AstPath) { -// const root = path.stack[0]; -// return !!root.html && !!root.html.children && root.html.children.includes(node); -// } - -/** - * Check if doc is a hardline. - * We can't just rely on a simple equality check because the doc could be created with another - * runtime version of prettier than what we import, making a reference check fail. - */ - -// function isHardline(docToCheck: Doc): boolean { -// return docToCheck === doc.builders.hardline || deepEqual(docToCheck, doc.builders.hardline); -// } - -/** - * Simple deep equal function which suits our needs. Only works properly on POJOs without cyclic deps. - */ -// function deepEqual(x: any, y: any): boolean { -// if (x === y) { -// return true; -// } else if (typeof x == 'object' && x != null && typeof y == 'object' && y != null) { -// if (Object.keys(x).length != Object.keys(y).length) return false; - -// for (var prop in x) { -// if (Object.prototype.hasOwnProperty.call(y, prop)) { -// if (!deepEqual(x[prop], y[prop])) return false; -// } else { -// return false; -// } -// } - -// return true; -// } else { -// return false; -// } -// } - -// export function isLine(docToCheck: Doc): boolean { -// return ( -// isHardline(docToCheck) || -// (typeof docToCheck === 'object' && isDocCommand(docToCheck) && docToCheck.type === 'line') || -// (typeof docToCheck === 'object' && isDocCommand(docToCheck) && docToCheck.type === 'concat' && docToCheck.parts.every(isLine)) -// ); -// } - -/** - * Check if the doc is empty, i.e. consists of nothing more than empty strings (possibly nested). - */ -// export function isEmptyDoc(doc: Doc): boolean { -// if (typeof doc === 'string') { -// return doc.length === 0; -// } - -// // if (doc.type === 'line') { -// // return !doc.keepIfLonely; -// // } - -// // Since Prettier 2.3.0, concats are represented as flat arrays -// if (Array.isArray(doc)) { -// return doc.length === 0; -// } - -// // const { contents } = doc; - -// // if (contents) { -// // return isEmptyDoc(contents); -// // } - -// // const { parts } = doc; - -// // if (parts) { -// // return isEmptyGroup(parts); -// // } - -// return false; -// } - -// function isEmptyGroup(group: any) { -// return !group.find((doc: any) => !isEmptyDoc(doc)); -// } - -/** - * Trims both leading and trailing nodes matching `isWhitespace` independent of nesting level - * (though all trimmed adjacent nodes need to be a the same level). Modifies the `docs` array. - */ -// export function trim(docs: Doc[], isWhitespace: (doc: Doc) => boolean): Doc[] { -// trimLeft(docs, isWhitespace); -// trimRight(docs, isWhitespace); - -// return docs; -// } - -/** - * Trims the leading nodes matching `isWhitespace` independent of nesting level (though all nodes need to be a the same level). - * If there are empty docs before the first whitespace, they are removed, too. - */ -// function trimLeft(group: Doc[], isWhitespace: (doc: Doc) => boolean): void { -// let firstNonWhitespace = group.findIndex((doc) => !isEmptyDoc(doc) && !isWhitespace(doc)); - -// if (firstNonWhitespace < 0 && group.length) { -// firstNonWhitespace = group.length; -// } - -// if (firstNonWhitespace > 0) { -// const removed = group.splice(0, firstNonWhitespace); -// if (removed.every(isEmptyDoc)) { -// return trimLeft(group, isWhitespace); -// } -// } else { -// const parts = getParts(group[0]); - -// if (parts) { -// return trimLeft(parts, isWhitespace); -// } -// } -// } - -/** - * Trims the trailing nodes matching `isWhitespace` independent of nesting level (though all nodes need to be a the same level). - * If there are empty docs after the last whitespace, they are removed, too. - */ -// function trimRight(group: Doc[], isWhitespace: (doc: Doc) => boolean): void { -// let lastNonWhitespace = group.length ? findLastIndex((doc: any) => !isEmptyDoc(doc) && !isWhitespace(doc), group) : 0; - -// if (lastNonWhitespace < group.length - 1) { -// const removed = group.splice(lastNonWhitespace + 1); -// if (removed.every(isEmptyDoc)) { -// return trimRight(group, isWhitespace); -// } -// } else { -// const parts = getParts(group[group.length - 1]); - -// if (parts) { -// return trimRight(parts, isWhitespace); -// } -// } -// } - -// function getParts(doc: Doc): Doc[] | undefined { -// if (typeof doc === 'object') { -// // Since Prettier 2.3.0, concats are represented as flat arrays -// if (Array.isArray(doc)) { -// return doc; -// } -// if (doc.type === 'fill' || doc.type === 'concat') { -// return doc.parts; -// } -// if (doc.type === 'group') { -// return getParts(doc.contents); -// } -// } -// } - -// export const isObjEmpty = (obj: object): boolean => { -// for (let i in obj) return false; -// return true; -// }; - -/** Shallowly attach comments to children */ -// export function attachCommentsHTML(node: anyNode): void { -// if (!isNodeWithChildren(node) || !node.children.some(({ type }) => type === 'Comment')) return; - -// const nodesToRemove = []; - -// // note: the .length - 1 is because we don’t need to read the last node -// for (let n = 0; n < node.children.length - 1; n++) { -// if (!node.children[n]) continue; - -// // attach comment to the next non-whitespace node -// if (node.children[n].type === 'Comment') { -// let next = n + 1; -// while (isEmptyTextNode(node.children[next])) { -// nodesToRemove.push(next); // if arbitrary whitespace between comment and node, remove -// next++; // skip to the next non-whitespace node -// } -// const commentNode = node.children[next]; -// if (commentNode) { -// const comment = node.children[n]; -// util.addLeadingComment(commentNode, comment); -// } -// } -// } - -// // remove arbitrary whitespace nodes -// nodesToRemove.reverse(); // start at back so we aren’t changing indices -// nodesToRemove.forEach((index) => { -// node.children.splice(index, 1); -// }); -// } - -/** dedent string & return tabSize (the last part is what we need) */ -export function manualDedent(input: string): { - tabSize: number; - char: string; - result: string; -} { - let minTabSize = Infinity; - let result = input; - // 1. normalize - result = result.replace(/\r\n/g, '\n'); - - // 2. count tabSize - let char = ''; - for (const line of result.split('\n')) { - if (!line) continue; - // if any line begins with a non-whitespace char, minTabSize is 0 - if (line[0] && /^[^\s]/.test(line[0])) { - minTabSize = 0; - break; - } - const match = line.match(/^(\s+)\S+/); // \S ensures we don’t count lines of pure whitespace - if (match) { - if (match[1] && !char) char = match[1][0]; - if (match[1].length < minTabSize) minTabSize = match[1].length; - } - } - - // 3. reformat string - if (minTabSize > 0 && Number.isFinite(minTabSize)) { - result = result.replace(new RegExp(`^${new Array(minTabSize + 1).join(char)}`, 'gm'), ''); - } - - return { - tabSize: minTabSize === Infinity ? 0 : minTabSize, - char, - result, - }; -} - -/** re-indent string by chars */ -// export function indent(input: string, char: string = ' '): string { -// return input.replace(/^(.)/gm, `${char}$1`); -// } - -// TODO: USE THE COMPILER -/** True if the node is of type text */ -export function isTextNode(node: anyNode): node is TextNode { - return node.type === 'text'; -} - -// export function isMustacheNode(node: anyNode): node is MustacheTagNode { -// return node.type === 'MustacheTag'; -// } - -// export function isDocCommand(doc: Doc): doc is doc.builders.DocCommand { -// if (typeof doc === 'string') return false; -// if (Array.isArray(doc)) return false; -// return true; -// } - -export function isInsideQuotedAttribute(path: AstPath): boolean { - const stack = path.stack as anyNode[]; - return stack.some((node) => node.type === 'attribute' && !isLoneMustacheTag(node)); -} - -/** True if the node is TagLikeNode: - * - * ElementNode | ComponentNode | CustomElementNode | FragmentNode */ -export function isTagLikeNode(node: anyNode): node is TagLikeNode { - return ( - node.type === 'element' || - node.type === 'component' || - node.type === 'custom-element' || - node.type === 'fragment' - ); -} - -/** - * Returns siblings, that is, the children of the parent. - */ -export function getSiblings(path: AstPath): anyNode[] { - const parent = path.getParentNode(); - if (!parent) return []; - - return getChildren(parent); -} - -export function getNextNode(path: AstPath): anyNode | null { - const node = path.getNode(); - if (node) { - const siblings = getSiblings(path); - if (node.position?.start === siblings[siblings.length - 1].position?.start) return null; - for (let i = 0; i < siblings.length; i++) { - const sibling = siblings[i]; - if (sibling.position?.start === node.position?.start && i !== siblings.length - 1) { - return siblings[i + 1]; - } - } - } - return null; -}