Prettier support for .astro files (#106)

* docs: fix readme

* chore: scaffold prettier plugin

* chore(prettier): switch to cjs

* test(prettier): scaffold prettier tests

* test(prettier): add simple prettier tests

* feat(prettier): first pass

* refactor: expose parser as CJS export

* test(prettier): add long expression

* refactor(prettier): use Astro parser + built-in prettier doc for prettier plugin

* chore: remove parser from git

* chore: add prettier-plugin-astro `build` to workflow

* chore: update package-lock

* chore: do not build prettier-plugin-astro

* fix: update engines

* chore: remove NPM restriction

* chore: fix workflow paths

* chore: update build script

* test: fix prettier expr test

* chore: fix parser build on windows

* refactor: add parser tsconfig, extending base config

* chore: relax ban-ts-comment

* chore: fix lint issue

Co-authored-by: Nate Moore <nate@skypack.dev>
This commit is contained in:
Nate Moore 2021-04-21 11:14:44 -05:00 committed by GitHub
parent 76932822b8
commit 54409a0702
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 5980 additions and 83 deletions

View file

@ -3,7 +3,7 @@ module.exports = {
extends: ['plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['@typescript-eslint', 'prettier'],
rules: {
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/camelcase': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',

View file

@ -25,7 +25,10 @@ jobs:
npm ci
npm run build
cd examples/kitchen-sink
cd prettier-plugin-astro
npm ci
cd ../examples/kitchen-sink
npm ci
npm run build
cd ../..

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules/
lib/
parser/
dist/
*.tsbuildinfo
.DS_Store

View file

@ -1900,9 +1900,9 @@
"integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ=="
},
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"bcrypt-pbkdf": {
"version": "1.0.2",
@ -4850,9 +4850,9 @@
"integrity": "sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw=="
},
"snowpack": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/snowpack/-/snowpack-3.3.1.tgz",
"integrity": "sha512-93utJcqKkvQ9StnliIKqDp02laf+7MnPVULacuvNEVg8nHdmON0jKVuG5ezGAMtFRUGBfeQ+kO417tifG1lQUw==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/snowpack/-/snowpack-3.3.4.tgz",
"integrity": "sha512-Yj4WlVwLHH9LRb0MwTklIWPtE4L20RMFDpuWr75kS6wvHtIJWUf0/rma8PcoLMKYtSJirsDmrglI9rceFIx+Zg==",
"requires": {
"cli-spinners": "^2.5.0",
"default-browser-id": "^2.0.0",
@ -4890,9 +4890,9 @@
}
},
"socks": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.6.0.tgz",
"integrity": "sha512-mNmr9owlinMplev0Wd7UHFlqI4ofnBnNzFuzrm63PPaHgbkqCFe4T5LzwKmtQ/f2tX0NTpcdVLyD/FHxFBstYw==",
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.6.1.tgz",
"integrity": "sha512-kLQ9N5ucj8uIcxrDwjm0Jsqk06xdpBjGNQtpXy4Q8/QY2k+fY7nZH8CARy+hkbG+SGAovmzzuauCpBlb8FrnBA==",
"requires": {
"ip": "^1.1.5",
"smart-buffer": "^4.1.0"

View file

@ -7,6 +7,7 @@
"exports": {
".": "./astro.mjs",
"./snowpack-plugin": "./snowpack-plugin.cjs",
"./parser": "./parser.cjs",
"./components/*.astro": "./components/*.astro"
},
"bin": {
@ -20,7 +21,10 @@
"astro.mjs"
],
"scripts": {
"build": "tsc",
"build": "npm run build:core && npm run build:parser",
"build:core": "tsc -p tsconfig.json",
"build:parser": "tsc -p tsconfig.parser.json",
"postbuild:parser": "echo '{ \"type\": \"commonjs\" }' > parser/package.json",
"dev": "tsc --watch",
"lint": "eslint 'src/**/*.{js,ts}'",
"format": "prettier -w '{src,test}/**/*.{js,ts}'",
@ -99,7 +103,7 @@
"uvu": "^0.5.1"
},
"engines": {
"node": "~14.0.0",
"npm": ">=6.14.0 <7.0.0"
"node": ">=14.0.0",
"npm": ">=6.14.0"
}
}

1
parser.cjs Normal file
View file

@ -0,0 +1 @@
module.exports = require('./parser/index.js');

View file

@ -0,0 +1,154 @@
const {
doc: {
builders: { concat, hardline },
},
} = require('prettier');
const { parse } = require('astro/parser');
/** @type {Partial<import('prettier').SupportLanguage>[]} */
module.exports.languages = [
{
name: 'astro',
parsers: ['astro'],
extensions: ['.astro'],
vscodeLanguageIds: ['astro'],
},
];
/** @type {Record<string, import('prettier').Parser>} */
module.exports.parsers = {
astro: {
parse: (text) => {
let { html, css, module: frontmatter } = parse(text);
html = html ? { ...html, text: text.slice(html.start, html.end), isRoot: true } : null;
return [frontmatter, html, css].filter((v) => v);
},
locStart(node) {
return node.start;
},
locEnd(node) {
return node.end;
},
astFormat: 'astro-ast',
},
'astro-expression': {
parse: (text, parsers) => {
return { text };
},
locStart(node) {
return node.start;
},
locEnd(node) {
return node.end;
},
astFormat: 'astro-expression',
}
};
const findExpressionsInAST = (node, collect = []) => {
if (node.type === 'MustacheTag') {
return collect.concat(node);
}
if (node.children) {
collect.push(...[].concat(...node.children.map(child => findExpressionsInAST(child))));
}
return collect;
}
const formatExpression = ({ expression: { codeStart, codeEnd, children }}, text, options) => {
if (children.length === 0) {
if ([`'`, `"`].includes(codeStart[0])) {
return `<script $ lang="ts">${codeStart}${codeEnd}</script>`
}
return `{${codeStart}${codeEnd}}`;
}
return `<script $ lang="ts">${text}</script>`;
}
const isAstroScript = (node) => node.type === 'concat' && node.parts[0] === '<script' && node.parts[1].type === 'indent' && node.parts[1].contents.parts.find(v => v === '$');
const walkDoc = (doc) => {
let inAstroScript = false;
const recurse = (node, { parent }) => {
if (node.type === 'concat') {
if (isAstroScript(node)) {
inAstroScript = true;
parent.contents = { type: 'concat', parts: ['{'] };
}
return node.parts.map(part => recurse(part, { parent: node }));
}
if (inAstroScript) {
if (node.type === 'break-parent') {
parent.parts = parent.parts.filter(part => !['break-parent', 'line'].includes(part.type));
}
if (node.type === 'indent') {
parent.parts = parent.parts.map(part => {
if (part.type !== 'indent') return part;
return {
type: 'concat',
parts: [part.contents]
}
})
}
if (typeof node === 'string' && node.endsWith(';')) {
parent.parts = parent.parts.map(part => {
if (typeof part === 'string' && part.endsWith(';')) return part.slice(0, -1);
return part;
});
}
if (node === '</script>') {
parent.parts = parent.parts.map(part => part === '</script>' ? '}' : part);
inAstroScript = false;
}
}
if (['group', 'indent'].includes(node.type)) {
return recurse(node.contents, { parent: node });
}
}
recurse(doc, { parent: null });
}
/** @type {Record<string, import('prettier').Printer>} */
module.exports.printers = {
'astro-ast': {
print(path, opts, print) {
const node = path.getValue();
if (Array.isArray(node)) return concat(path.map(print));
if (node.type === 'Fragment') return concat(path.map(print, 'children'));
return node;
},
embed(path, print, textToDoc, options) {
const node = path.getValue();
if (node.type === 'Script' && node.context === 'setup') {
return concat(['---', hardline, textToDoc(node.content, { ...options, parser: 'typescript' }), '---', hardline, hardline]);
}
if (node.type === 'Fragment' && node.isRoot) {
const expressions = findExpressionsInAST(node);
if (expressions.length > 0) {
const parts = [].concat(...expressions.map((expr, i, all) => {
const prev = all[i - 1];
const start = node.text.slice((prev?.end ?? node.start) - node.start, expr.start - node.start);
const exprText = formatExpression(expr, node.text.slice(expr.start - node.start + 1, expr.end - node.start - 1), options);
if (i === all.length - 1) {
const end = node.text.slice(expr.end - node.start);
return [start, exprText, end]
}
return [start, exprText]
}));
const html = parts.join('\n');
const doc = textToDoc(html, { parser: 'html' });
walkDoc(doc);
return doc;
}
return textToDoc(node.text, { parser: 'html' });
}
return null;
},
},
};

5594
prettier-plugin-astro/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"name": "prettier-plugin-astro",
"version": "0.0.1",
"main": "index.js",
"type": "commonjs",
"private": true,
"devDependencies": {
"prettier": "^2.2.1"
},
"dependencies": {
"astro": "file:.."
}
}

View file

@ -229,13 +229,13 @@ interface RuntimeOptions {
logging: LogOptions;
}
/** Create a new Snowpack instance to power Astro */
interface CreateSnowpackOptions {
env: Record<string, any>;
mode: RuntimeMode;
resolvePackageUrl?: (pkgName: string) => Promise<string>;
}
/** Create a new Snowpack instance to power Astro */
async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackOptions) {
const { projectRoot, astroRoot, extensions } = astroConfig;
const { env, mode, resolvePackageUrl } = options;

View file

@ -0,0 +1,44 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import { format } from './test-utils.js';
import { setup } from './helpers.js';
const Prettier = suite('Prettier formatting');
setup(Prettier, './fixtures/astro-prettier');
/**
* Utility to get `[src, out]` files
* @param name {string}
* @param ctx {any}
*/
const getFiles = async (name, { readFile }) => {
const [src, out] = await Promise.all([readFile(`/in/${name}.astro`), readFile(`/out/${name}.astro`)]);
return [src, out];
}
Prettier('can format a basic Astro file', async (ctx) => {
const [src, out] = await getFiles('basic', ctx);
assert.not.equal(src, out);
const formatted = format(src);
assert.equal(formatted, out);
});
Prettier('can format an Astro file with frontmatter', async (ctx) => {
const [src, out] = await getFiles('frontmatter', ctx);
assert.not.equal(src, out);
const formatted = format(src);
assert.equal(formatted, out);
});
Prettier('can format an Astro file with embedded JSX expressions', async (ctx) => {
const [src, out] = await getFiles('embedded-expr', ctx);
assert.not.equal(src, out);
const formatted = format(src);
assert.equal(formatted, out);
});
Prettier.run();

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>
Hello world!</h1>
</body>
</html>

View file

@ -0,0 +1,26 @@
---
import Color from '../components/Color.jsx';
let title =
'My Site';
const colors = ['red', 'yellow', 'blue',];
---
<html lang="en">
<head>
<title>My site</title>
</head>
<body>
<h1>{title}</h1>
{"I'm some super long text and oh boy I sure do hope this formatter doesn't break me!"}
{colors.map(color => (
<div><Color name={color} /></div>))}
</body>
</html>

View file

@ -0,0 +1,18 @@
---
import Color from '../components/Color.jsx';
let title =
'My Site';
const colors = ['red', 'yellow', 'blue',];
---
<html lang="en">
<head>
<title>My site</title>
</head>
<body>
<h1>{title}</h1>
</body>
</html>

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h1>Hello world!</h1>
</body>
</html>

View file

@ -0,0 +1,24 @@
---
import Color from "../components/Color.jsx";
let title = "My Site";
const colors = ["red", "yellow", "blue"];
---
<html lang="en">
<head>
<title>My site</title>
</head>
<body>
<h1>{title}</h1>
{"I'm some super long text and oh boy I sure do hope this formatter doesn't break me!"}
{colors.map((color) => (
<div>
<Color name={color} />
</div>
))}
</body>
</html>

View file

@ -0,0 +1,16 @@
---
import Color from "../components/Color.jsx";
let title = "My Site";
const colors = ["red", "yellow", "blue"];
---
<html lang="en">
<head>
<title>My site</title>
</head>
<body>
<h1>{title}</h1>
</body>
</html>

View file

@ -1,7 +1,8 @@
import { fileURLToPath } from 'url';
import { build as astroBuild } from '../lib/build.js';
import { loadConfig } from '../lib/config.js';
import { readFile } from 'fs/promises';
import { createRuntime } from '../lib/runtime.js';
import { loadConfig } from '../lib/config.js';
import * as assert from 'uvu/assert';
/** setup fixtures for tests */
export function setup(Suite, fixturePath) {
@ -23,6 +24,10 @@ export function setup(Suite, fixturePath) {
}
context.runtime = runtime;
context.readFile = async (path) => {
const resolved = fileURLToPath(new URL(`${fixturePath}${path}`, import.meta.url));
return readFile(resolved).then(r => r.toString('utf-8'));
}
});
Suite.after(async () => {

View file

@ -1,5 +1,18 @@
import cheerio from 'cheerio';
import prettier from 'prettier';
import { fileURLToPath } from 'url';
/** load html */
export function doc(html) {
return cheerio.load(html);
}
/**
* format the contents of an astro file
* @param contents {string}
*/
export function format(contents) {
return prettier.format(contents, {
parser: 'astro',
plugins: [fileURLToPath(new URL('../prettier-plugin-astro', import.meta.url))]
})
}

10
tsconfig.base.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"declaration": true,
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

View file

@ -1,72 +1,9 @@
{
"extends": "./tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "ES2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
"declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./lib", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
"target": "ES2020",
"module": "ES2020",
"outDir": "./lib"
}
}

9
tsconfig.parser.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.base.json",
"include": ["src/parser"],
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "./parser"
}
}