diff --git a/src/index.ts b/src/index.ts index 9d576781..f6920167 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,11 +15,13 @@ import { options as pugOptions } from './options'; import { convergeOptions } from './options/converge'; import type { PugPrinterOptions } from './printer'; import { PugPrinter } from './printer'; +import parseFrontmatter from './utils/frontmatter/parse'; /** Ast path stack entry. */ interface AstPathStackEntry { content: string; tokens: Token[]; + frontmatter: string; } /** The plugin object that is picked up by prettier. */ @@ -43,6 +45,12 @@ export const plugin: Plugin = { parse(text, options) { logger.debug('[parsers:pug:parse]:', { text }); + const parts: FrontMatter = parseFrontmatter(text); + const frontmatter: string = parts.frontMatter + ? parts.frontMatter.value + : undefined; + text = parts.content; + let trimmedAndAlignedContent: string = text.replace(/^\s*\n/, ''); const contentIndentation: RegExpExecArray | null = /^\s*/.exec( trimmedAndAlignedContent, @@ -64,7 +72,7 @@ export const plugin: Plugin = { // logger.debug('[parsers:pug:parse]: tokens', JSON.stringify(tokens, undefined, 2)); // const ast: AST = parse(tokens, {}); // logger.debug('[parsers:pug:parse]: ast', JSON.stringify(ast, undefined, 2)); - return { content, tokens }; + return { content, tokens, frontmatter }; }, astFormat: 'pug-ast', @@ -94,11 +102,20 @@ export const plugin: Plugin = { printers: { 'pug-ast': { // @ts-expect-error: Prettier allow it to be async if we don't do recursively print - async print(path, options: ParserOptions & PugParserOptions) { - const entry: AstPathStackEntry = path.stack[0]!; - const { content, tokens } = entry; + async print( + path: AstPath, + options: ParserOptions & PugParserOptions, + print: (path: AstPath) => Doc, + ): Doc { + const entry: AstPathStackEntry = path.stack[0]; + const { content, tokens, frontmatter } = entry; const pugOptions: PugPrinterOptions = convergeOptions(options); - const printer: PugPrinter = new PugPrinter(content, tokens, pugOptions); + const printer: PugPrinter = new PugPrinter( + content, + tokens, + pugOptions, + frontmatter, + ); const result: string = await printer.build(); logger.debug('[printers:pug-ast:print]:', result); return result; diff --git a/src/printer.ts b/src/printer.ts index 20183240..2297acfb 100644 --- a/src/printer.ts +++ b/src/printer.ts @@ -237,12 +237,15 @@ export class PugPrinter { * @param content The pug content string. * @param tokens The pug token array. * @param options Options for the printer. + * @param frontmatter The extracted frontmatter part. */ public constructor( private readonly content: string, private tokens: Token[], private readonly options: PugPrinterOptions, + private readonly frontmatter: string, ) { + this.frontmatter = frontmatter; this.indentString = options.pugUseTabs ? '\t' : ' '.repeat(options.pugTabWidth); @@ -390,6 +393,18 @@ export class PugPrinter { token = this.getNextToken(); } + if (this.frontmatter) { + try { + results.unshift( + '---\n', + await this.frontmatterFormat(this.frontmatter), + '---\n', + ); + } catch (error: any) { + logger.warn(error); + } + } + return results.join(''); } @@ -478,6 +493,17 @@ export class PugPrinter { } } + private async frontmatterFormat(frontmatter: string): Promise { + const options: Options = { + parser: 'yaml', + collectionStyle: 'block', + }; + + const result: string = await format(frontmatter, options); + + return result; + } + private async frameworkFormat(code: string): Promise { const options: Options = { ...this.codeInterpolationOptions, diff --git a/src/utils/frontmatter/constants.js b/src/utils/frontmatter/constants.js new file mode 100644 index 00000000..6b83a539 --- /dev/null +++ b/src/utils/frontmatter/constants.js @@ -0,0 +1,3 @@ +/** @type {unique symbol} */ +export const FRONT_MATTER_MARK = Symbol.for("PRETTIER_IS_FRONT_MATTER"); +export const FRONT_MATTER_VISITOR_KEYS = []; diff --git a/src/utils/frontmatter/parse.js b/src/utils/frontmatter/parse.js new file mode 100644 index 00000000..f87823f7 --- /dev/null +++ b/src/utils/frontmatter/parse.js @@ -0,0 +1,121 @@ +import replaceNonLineBreaksWithSpace from './replace-non-line-breaks-with-space.js'; +import { FRONT_MATTER_MARK } from './constants.js'; + +const DELIMITER_LENGTH = 3; + +/** +@typedef {{ + index: number, + // 1-based line number + line: number, + // 0-based column number + column: number, +}} Position +@typedef {{ + language: string, + explicitLanguage: string | null, + value: string, + startDelimiter: string, + endDelimiter: string, + raw: string, + start: Position, + end: Position, + [FRONT_MATTER_MARK]: true, +}} FrontMatter +*/ + +/** +@param {string} text +@returns {FrontMatter | undefined} +*/ +function getFrontMatter(text) { + const startDelimiter = text.slice(0, DELIMITER_LENGTH); + + if (startDelimiter !== '---' && startDelimiter !== '+++') { + return; + } + + const firstLineBreakIndex = text.indexOf('\n', DELIMITER_LENGTH); + if (firstLineBreakIndex === -1) { + return; + } + + const explicitLanguage = text + .slice(DELIMITER_LENGTH, firstLineBreakIndex) + .trim(); + + let endDelimiterIndex = text.indexOf( + `\n${startDelimiter}`, + firstLineBreakIndex, + ); + + let language = explicitLanguage; + if (!language) { + language = startDelimiter === '+++' ? 'toml' : 'yaml'; + } + + if ( + endDelimiterIndex === -1 && + startDelimiter === '---' && + language === 'yaml' + ) { + // In some markdown processors such as pandoc, + // "..." can be used as the end delimiter for YAML front-matter. + endDelimiterIndex = text.indexOf('\n...', firstLineBreakIndex); + } + + if (endDelimiterIndex === -1) { + return; + } + + const frontMatterEndIndex = endDelimiterIndex + 1 + DELIMITER_LENGTH; + + const nextCharacter = text.charAt(frontMatterEndIndex + 1); + if (!/\s?/.test(nextCharacter)) { + return; + } + + const raw = text.slice(0, frontMatterEndIndex); + /** @type {string[]} */ + let lines; + + return { + language, + explicitLanguage: explicitLanguage || null, + value: text.slice(firstLineBreakIndex + 1, endDelimiterIndex), + startDelimiter, + endDelimiter: raw.slice(-DELIMITER_LENGTH), + raw, + start: { line: 1, column: 0, index: 0 }, + end: { + index: raw.length, + get line() { + lines ??= raw.split('\n'); + return lines.length; + }, + get column() { + lines ??= raw.split('\n'); + return lines.at(-1).length; + }, + }, + [FRONT_MATTER_MARK]: true, + }; +} + +function parse(text) { + const frontMatter = getFrontMatter(text); + + if (!frontMatter) { + return { content: text }; + } + + return { + frontMatter, + get content() { + const { raw } = frontMatter; + return replaceNonLineBreaksWithSpace(raw) + text.slice(raw.length); + }, + }; +} + +export default parse; diff --git a/src/utils/frontmatter/replace-non-line-breaks-with-space.js b/src/utils/frontmatter/replace-non-line-breaks-with-space.js new file mode 100644 index 00000000..3c1e2d64 --- /dev/null +++ b/src/utils/frontmatter/replace-non-line-breaks-with-space.js @@ -0,0 +1,19 @@ +// import * as assert from "#universal/assert"; + +/** +Replaces all characters in the input string except line breaks `\n` with a space. + +@param {string} string - The input string to process. +@returns {string} +*/ +function replaceNonLineBreaksWithSpace(string) { + const replaced = string.replaceAll(/[^\n]/g, ' '); + + if (process.env.NODE_ENV !== 'production') { + // assert.equal(replaced.length, string.length); + } + + return replaced; +} + +export default replaceNonLineBreaksWithSpace; diff --git a/tests/frontmatter/formatted.pug b/tests/frontmatter/formatted.pug new file mode 100644 index 00000000..f16f2441 --- /dev/null +++ b/tests/frontmatter/formatted.pug @@ -0,0 +1,33 @@ +--- +name: YAML test +tags: + - this + - squence + - should + - be + - indented +lets: + have: + us: + some: nesting + and: some more + flow style not supported: + key: value + bar: baz + not even arrays: + - 1 + - 2 + - 3 + - 4 + - 5 +--- +doctype +html + head + title This is a !{ name }! + + body + h1 Hello world! + h2= name + + #example diff --git a/tests/frontmatter/frontmatter.test.ts b/tests/frontmatter/frontmatter.test.ts new file mode 100644 index 00000000..e1be230b --- /dev/null +++ b/tests/frontmatter/frontmatter.test.ts @@ -0,0 +1,9 @@ +import { compareFiles } from 'tests/common'; +import { describe, expect, it } from 'vitest'; + +describe('Frontmatter', () => { + it('should format yaml frontmatter', async () => { + const { expected, actual } = await compareFiles(import.meta.url); + expect(actual).toBe(expected); + }); +}); diff --git a/tests/frontmatter/unformatted.pug b/tests/frontmatter/unformatted.pug new file mode 100644 index 00000000..0c9a90a9 --- /dev/null +++ b/tests/frontmatter/unformatted.pug @@ -0,0 +1,27 @@ +--- +name: YAML test +tags: +- this +- squence +- should +- be +- indented +lets: + have: + us: + some: nesting + and: some more + flow style not supported: + {key: value, bar: baz} + not even arrays: [1,2,3,4,5] +--- +doctype +html + head + title This is a !{name}! + + body + h1 Hello world! + h2= name + + div( id='example' )