initial commit

This commit is contained in:
davidodenwald 2022-11-18 20:05:06 +01:00
commit 417b3d96ec
51 changed files with 7285 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.vscode
node_modules
lib

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
/test/cases

3
.prettierrc Normal file
View file

@ -0,0 +1,3 @@
{
"useTabs": true
}

26
README.md Normal file
View file

@ -0,0 +1,26 @@
# prettier-plugin-jinja-template
Formatter plugin for jinja2 template files.
## Install
```bash
npm install --save-dev prettier-plugin-jinja-template
```
## Use
To use it with basic .html files, you'll have to override the used parser inside your prettier config:
```json
{
"overrides": [
{
"files": ["*.html"],
"options": {
"parser": "jinja-template"
}
}
]
}
```

3
babel.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
presets: ["@babel/preset-typescript"],
};

4
jest.config.js Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = () => ({
preset: "ts-jest",
});

6347
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "prettier-plugin-jinja-template",
"version": "0.0.1",
"description": "Prettier plugin for formatting jinja templates.",
"author": "David Odenwald",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/davidodenwald/prettier-plugin-jinja-template.git"
},
"bugs": {
"url": "https://github.com/davidodenwald/prettier-plugin-jinja-template/issues"
},
"homepage": "https://github.com/davidodenwald/prettier-plugin-jinja-template#readme",
"keywords": [
"prettier",
"plugin",
"template",
"html",
"jinja",
"jinja2",
"flask"
],
"main": "lib/index.js",
"scripts": {
"build": "tsc",
"watch": "npm run build -- --watch",
"test": "jest --verbose",
"test:watch": "jest --watch --verbose",
"publish": "npm run test && npm run build && npm publish",
"publish:beta": "npm run test && npm run build && npm publish --tag beta"
},
"devDependencies": {
"@babel/preset-typescript": "^7.18.6",
"@types/jest": "^29.2.2",
"jest": "^29.3.1",
"prettier": "^2.7.1",
"ts-jest": "^29.0.3",
"typescript": "^4.8.4"
}
}

31
src/index.ts Normal file
View file

@ -0,0 +1,31 @@
import { Node } from "./jinja";
import { parse } from "./parser";
import { print, embed } from "./printer";
import { Parser, Printer, SupportLanguage } from "prettier";
const PLUGIN_KEY = "jinja-template";
export const languages: SupportLanguage[] = [
{
name: "JinjaTemplate",
parsers: [PLUGIN_KEY],
extensions: [".jinja", ".jinja2", ".j2", ".html"],
vscodeLanguageIds: ["jinja"],
},
];
export const parsers = {
[PLUGIN_KEY]: <Parser<Node>>{
astFormat: PLUGIN_KEY,
parse,
locStart: (node) => node.index,
locEnd: (node) => node.index + node.length,
},
};
export const printers = {
[PLUGIN_KEY]: <Printer<Node>>{
print,
embed,
},
};

68
src/jinja.ts Normal file
View file

@ -0,0 +1,68 @@
export const Placeholder = {
startToken: "#~",
endToken: "~#",
};
export interface Node {
id: string;
type: "root" | "expression" | "statement" | "block" | "ignore";
content: string;
originalText: string;
index: number;
length: number;
nodes: { [id: string]: Node };
}
export interface Expression extends Node {
type: "expression";
ownLine: boolean;
}
export type Delimiter = "" | "-" | "+";
export interface Statement extends Node {
type: "statement";
keyword: Keyword;
startDelimiter: Delimiter;
endDelimiter: Delimiter;
}
export interface Block extends Node {
type: "block";
start: Statement;
end: Statement;
}
export interface IgnoreBlock extends Node {
type: "ignore";
ownLine: boolean;
}
export type Keyword =
| "for"
| "endfor"
| "if"
| "else"
| "endif"
| "macro"
| "endmacro"
| "call"
| "endcall"
| "filter"
| "endfilter"
| "set"
| "endset"
| "include"
| "import"
| "from"
| "extends"
| "block"
| "endblock";
export const nonClosingStatements = [
"else",
"include",
"import",
"from",
"extends",
];

222
src/parser.ts Normal file
View file

@ -0,0 +1,222 @@
import { Parser } from "prettier";
import {
Delimiter,
Keyword,
Node,
Placeholder,
Statement,
Block,
nonClosingStatements,
Expression,
IgnoreBlock,
} from "./jinja";
const regex =
/(?<pre>(?<newline>\n)?(\s*?))(?<node>{{\s*(?<expression>'([^']|\\')*'|"([^"]|\\")*"|[\S\s]*?)\s*}}|{%(?<startDelimiter>[-\+]?)\s*(?<statement>(?<keyword>for|endfor|if|else|endif|macro|endmacro|call|endcall|filter|endfilter|set|endset|include|import|from|extends|block|endblock)('([^']|\\')*'|"([^"]|\\")*"|[\S\s])*?)\s*(?<endDelimiter>[-\+]?)%}|(?<comment>{#[\S\s]*?#})|(?<scriptBlock><(script)((?!<)[\s\S])*>((?!<\/script)[\s\S])*?{{[\s\S]*?<\/(script)>)|(?<styleBlock><(style)((?!<)[\s\S])*>((?!<\/style)[\s\S])*?{{[\s\S]*?<\/(style)>)|(?<ignoreBlock><!-- prettier-ignore-start -->[\s\S]*<!-- prettier-ignore-end -->))/;
export const parse: Parser<Node>["parse"] = (text) => {
const statementStack: Statement[] = [];
const root: Node = {
id: "0",
type: "root" as const,
content: text,
originalText: text,
index: 0,
length: 0,
nodes: {},
};
const generatePlaceholder = placeholderGenerator(text);
let match;
let i = 0;
while ((match = root.content.slice(i).match(regex)) !== null) {
if (!match.groups || match.index === undefined) {
continue;
}
if (match.groups.scriptBlock || match.groups.styleBlock) {
i += match.index + match[0].length;
continue;
}
const pre = match.groups.pre || "";
const newline = !!match.groups.newline;
const node = match.groups.node;
const expression = match.groups.expression;
const statement = match.groups.statement;
const ignoreBlock = match.groups.ignoreBlock;
const comment = match.groups.comment;
if (!node && !expression && !statement && !ignoreBlock && !comment) {
continue;
}
const matchText = node;
if (ignoreBlock || comment) {
const placeholder = generatePlaceholder();
root.content = root.content.replace(matchText, placeholder);
root.nodes[placeholder] = {
id: placeholder,
type: "ignore",
content: ignoreBlock || comment,
ownLine: newline,
originalText: matchText,
index: match.index + i + pre.length,
length: matchText.length,
nodes: root.nodes,
} as IgnoreBlock;
i += match.index;
}
if (expression) {
const placeholder = generatePlaceholder();
root.content = root.content.replace(matchText, placeholder);
root.nodes[placeholder] = {
id: placeholder,
type: "expression",
content: expression,
ownLine: newline,
originalText: matchText,
index: match.index + i + pre.length,
length: matchText.length,
nodes: root.nodes,
} as Expression;
i += match.index;
}
if (statement) {
const keyword = match.groups.keyword as Keyword;
const startDelimiter = match.groups.startDelimiter as Delimiter;
const endDelimiter = match.groups.endDelimiter as Delimiter;
if (nonClosingStatements.includes(keyword)) {
const placeholder = generatePlaceholder();
root.content = root.content.replace(matchText, placeholder);
root.nodes[placeholder] = {
id: placeholder,
type: "statement",
content: statement,
originalText: matchText,
index: match.index + i + pre.length,
length: matchText.length,
keyword,
startDelimiter,
endDelimiter,
nodes: root.nodes,
} as Statement;
i += match.index;
} else if (!keyword.startsWith("end")) {
statementStack.push({
id: generatePlaceholder(),
type: "statement" as const,
content: statement,
originalText: matchText,
index: match.index + i + pre.length,
length: matchText.length,
keyword,
startDelimiter,
endDelimiter,
nodes: root.nodes,
});
i += match.index + matchText.length;
} else {
let start: Statement | undefined;
while (!start) {
start = statementStack.pop();
if (!start) {
throw new Error(
`No opening statement found for closing statement "${statement}".`
);
}
const startKeyword = keyword.replace("end", "");
if (startKeyword !== start.keyword) {
if (start.keyword === "set") {
start = undefined;
continue;
}
throw new Error(
`Closung statement "${statement}" doesn't match Opening Statement "${start.content}".`
);
}
}
const end = {
id: generatePlaceholder(),
type: "statement" as const,
content: statement,
originalText: matchText,
index: match.index + i + pre.length,
length: matchText.length,
keyword,
startDelimiter,
endDelimiter,
nodes: root.nodes,
};
const placeholder = generatePlaceholder();
const content = root.content.slice(
start.index + start.length,
end.index
);
root.nodes[placeholder] = {
id: placeholder,
type: "block",
start: start,
end: end,
content,
originalText: matchText,
index: start.index,
length: end.index + end.length - start.index,
nodes: root.nodes,
} as Block;
root.nodes[start.id] = start;
root.nodes[end.id] = end;
root.content =
root.content.slice(0, start.index) +
placeholder +
root.content.slice(end.index + end.length, root.content.length);
i = start.index + placeholder.length;
}
}
}
const remainingStatement = statementStack.find(
(stmt) => stmt.keyword !== "set"
);
if (remainingStatement) {
throw new Error(
`No closing statement found for opening statement "${remainingStatement.content}".`
);
}
return root;
};
const placeholderGenerator = (text: string) => {
let id = 0;
return (): string => {
while (true) {
id++;
const placeholder = Placeholder.startToken + id + Placeholder.endToken;
if (!text.includes(placeholder)) {
return placeholder;
}
}
};
};

181
src/printer.ts Normal file
View file

@ -0,0 +1,181 @@
import { Printer } from "prettier";
import { builders, utils } from "prettier/doc";
import {
Placeholder,
Node,
Expression,
Statement,
Block,
IgnoreBlock,
} from "./jinja";
const NOT_FOUND = -1;
process.env.PRETTIER_DEBUG = "true";
export const print: Printer<Node>["print"] = (path) => {
const node = path.getNode();
if (!node) {
return [];
}
switch (node.type) {
case "expression":
return printExpression(node as Expression);
case "statement":
return printStatement(node as Statement);
case "ignore":
return printIgnoreBlock(node as IgnoreBlock);
}
return [];
};
const printExpression = (node: Expression): builders.Doc => {
return builders.group(["{{", " ", node.content, " ", "}}"], {
shouldBreak: node.ownLine || node.content.includes("\n"),
});
};
const printStatement = (node: Statement): builders.Doc => {
const statemnt = builders.group(
[
"{%",
node.startDelimiter,
" ",
node.content,
" ",
node.endDelimiter,
"%}",
],
{ shouldBreak: true }
);
return node.keyword === "else"
? [builders.dedent(builders.hardline), statemnt, builders.hardline]
: statemnt;
};
const printIgnoreBlock = (node: IgnoreBlock): builders.Doc => {
return builders.group(node.content, { shouldBreak: node.ownLine });
};
export const embed: Printer<Node>["embed"] = (
path,
print,
textToDoc,
options
) => {
const node = path.getNode();
if (!node || !["root", "block"].includes(node.type)) {
return null;
}
const mapped = splitAtElse(node).map((content) => {
let doc;
if (content in node.nodes) {
doc = content;
} else {
doc = utils.stripTrailingHardline(
textToDoc(content, {
...options,
parser: "html",
})
);
}
let ignoreDoc = false;
return utils.mapDoc(doc, (currentDoc) => {
if (typeof currentDoc !== "string") {
return currentDoc;
}
if (currentDoc === "<!-- prettier-ignore -->") {
ignoreDoc = true;
return currentDoc;
}
const idxs = findPlaceholders(currentDoc).filter(
([start, end]) => currentDoc.slice(start, end + 1) in node.nodes
);
if (!idxs.length) {
return currentDoc;
}
const res: builders.Doc = [];
let lastEnd = 0;
for (const [start, end] of idxs) {
if (lastEnd < start) {
res.push(currentDoc.slice(lastEnd, start));
}
const p = currentDoc.slice(start, end + 1) as string;
if (ignoreDoc) {
res.push(node.nodes[p].originalText);
} else {
res.push(path.call(print, "nodes", p));
}
lastEnd = end + 1;
}
if (lastEnd > 0 && currentDoc.length > lastEnd) {
res.push(currentDoc.slice(lastEnd));
}
ignoreDoc = false;
return res;
});
});
if (node.type === "block") {
return builders.group([
path.call(print, "nodes", (node as Block).start.id),
builders.indent([builders.softline, utils.stripTrailingHardline(mapped)]),
builders.hardline,
path.call(print, "nodes", (node as Block).end.id),
]);
}
return [...mapped, builders.hardline];
};
const splitAtElse = (node: Node): string[] => {
const elseNodes = Object.values(node.nodes).filter(
(n) =>
n.type === "statement" &&
(n as Statement).keyword === "else" &&
node.content.search(n.id) !== NOT_FOUND
);
if (!elseNodes.length) {
return [node.content];
}
const re = new RegExp(`(${elseNodes.map((e) => e.id).join(")|(")})`);
return node.content.split(re).filter(Boolean);
};
/**
* Returns the indexs of the first and the last character of any placeholder
* occuring in a string.
*/
export const findPlaceholders = (text: string): [number, number][] => {
const res = [];
let i = 0;
while (true) {
const start = text.slice(i).search(Placeholder.startToken);
if (start === NOT_FOUND) break;
const end = text
.slice(start + i + Placeholder.startToken.length)
.search(Placeholder.endToken);
if (end === NOT_FOUND) break;
res.push([
start + i,
end + start + i + Placeholder.startToken.length + 1,
] as [number, number]);
i += start + Placeholder.startToken.length;
}
return res;
};

View file

@ -0,0 +1 @@
#~1~# {{ i }}

View file

@ -0,0 +1 @@
#~1~# {{i}}

View file

@ -0,0 +1,14 @@
{#
Language will be used as ab_variant shortcut
tracking callback is our own patched callback
Integration note:
window.emos3 = {
stored: [],
send: function(p){this.stored.push(p);}
};
This script tag must be moved to the head tag so
that onclick events can already use the send method.
#}

View file

@ -0,0 +1,14 @@
{#
Language will be used as ab_variant shortcut
tracking callback is our own patched callback
Integration note:
window.emos3 = {
stored: [],
send: function(p){this.stored.push(p);}
};
This script tag must be moved to the head tag so
that onclick events can already use the send method.
#}

View file

@ -0,0 +1,5 @@
<div>
{{ user.name }}
</div>
<div>{{ user.age }}</div>

View file

@ -0,0 +1,5 @@
<div>
{{user.name}}
</div>
<div>{{user.age}}</div>

View file

@ -0,0 +1 @@
<div>~{{ user.id }}~{{ user.name }}~</div>

View file

@ -0,0 +1,3 @@
<div>
~{{user.id}}~{{user.name}}~
</div>

View file

@ -0,0 +1 @@
<div id="test" {{ attr }}></div>

View file

@ -0,0 +1 @@
<div id="test" {{attr}}></div>

View file

@ -0,0 +1,2 @@
{{ '{{' }}
{{ '}}' }}

View file

@ -0,0 +1,2 @@
{{'{{'}}
{{'}}'}}

View file

@ -0,0 +1,3 @@
<div>
{{ really really looooooooooooooooooooooooooooooooooooooooong expression that should get a own line }}
</div>

View file

@ -0,0 +1 @@
<div>{{really really looooooooooooooooooooooooooooooooooooooooong expression that should get a own line}}</div>

View file

@ -0,0 +1 @@
<div>{{ user.id }}{{ user.name }}</div>

View file

@ -0,0 +1 @@
<div>{{ user.id }}{{user.name}}</div>

View file

@ -0,0 +1,7 @@
<div>
{{ {
'dict': 'of',
'key': 'and',
'value': 'pairs'
} }}
</div>

View file

@ -0,0 +1,9 @@
<div>
{{
{
'dict': 'of',
'key': 'and',
'value': 'pairs'
}
}}
</div>

View file

@ -0,0 +1,34 @@
<html>
<script>
{
{
js;
}
}
</script>
<style>
/* {{ css }} */
</style>
<!-- prettier-ignore-start -->
<ul>
{%for item in seq%}
<li>
{{item}}
</li>
{%endfor%}
</ul>
<!-- prettier-ignore-end -->
<!-- prettier-ignore -->
<div class="{{class}}" >hello world</div >
<div class="{{ class }}">hello world</div>
{% if foo %}
<p>
<!-- prettier-ignore -->
{{item}}
</p>
{% endif %}
</html>

View file

@ -0,0 +1,30 @@
<html>
<script>
{{js}}
</script>
<style>
/* {{ css }} */
</style>
<!-- prettier-ignore-start -->
<ul>
{%for item in seq%}
<li>
{{item}}
</li>
{%endfor%}
</ul>
<!-- prettier-ignore-end -->
<!-- prettier-ignore -->
<div class="{{class}}" >hello world</div >
<div class="{{class}}" >hello world</div >
{%if foo%}
<p>
<!-- prettier-ignore -->
{{item}}
</p>
{%endif%}
</html>

View file

@ -0,0 +1,5 @@
<ul>
{% for item in seq %}
<li>{{ item }}</li>
{% endfor %}
</ul>

View file

@ -0,0 +1,5 @@
<ul>
{% for item in seq %}
<li> {{ item }} </li>
{% endfor %}
</ul>

View file

@ -0,0 +1 @@
Error('Error: No opening statement found for closing statement "endif"')

View file

@ -0,0 +1,8 @@
<body>
<h1>{{title}}</h1>
{%else if href == "random.html" %}
<h2>{{title}}</h2>
{%else%}
<h3>{{title}}</h3>
{% endif %}
</body>

View file

@ -0,0 +1 @@
Error('Error: No closing statement found for opening statement "if title".')

View file

@ -0,0 +1,4 @@
<body>
{% if title %}
<h1>{{title}}</h1>
</body>

View file

@ -0,0 +1,9 @@
<body>
{% if href in ['layout.html', 'index.html, 'about.html', 'user.html'] %}
<h1>{{ title }}</h1>
{% else if href == "random.html" %}
<h2>{{ title }}</h2>
{% else %}
<h3>{{ title }}</h3>
{% endif %}
</body>

View file

@ -0,0 +1,9 @@
<body>
{% if href in ['layout.html', 'index.html, 'about.html', 'user.html'] %}
<h1>{{title}}</h1>
{%else if href == "random.html" %}
<h2>{{title}}</h2>
{%else%}
<h3>{{title}}</h3>
{% endif %}
</body>

View file

@ -0,0 +1,19 @@
<div>
{% for row in database %}
<ul>
{% for element in row %}
<li>
{% if element.active %}
{{ element.description }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endfor %}
{% for row in res %}
<p>{{ row[0] }}</p>
<p>{{ row[1] }}</p>
<p>{{ row[2] }}</p>
<p>{{ row[3] }}</p>
{% endfor %}
</div>

View file

@ -0,0 +1,14 @@
<div>
{% for row in database %}
<ul>
{% for element in row %}
<li>
{%if element.active%}
{{ element.description }}
{% endif%}
</li>
{% endfor%}</ul>{%endfor %}
{% for row in res %}
<p>{{row[0]}}</p><p>{{row[1]}}</p><p>{{row[2]}}</p><p>{{row[3]}}</p>
{% endfor %}
</div>

View file

@ -0,0 +1,7 @@
<html>
{% for item in seq %}
{% include 'header.html' %}
{% include 'body.html' %}
{% include 'footer.html' %}
{% endfor %}
</html>

View file

@ -0,0 +1,7 @@
<html>
{% for item in seq %}
{% include 'header.html' %}
{% include 'body.html' %}
{% include 'footer.html' %}
{% endfor %}
</html>

View file

@ -0,0 +1,14 @@
<head>
{% for item in seq %}
<!-- meta for {{ item }} -->
{% set key, value = call(item) %}
<meta name="{{ key }}" content="{{ value }}" />
{% endfor %}
</head>
<body>
{% set navigation = [('index.html', 'Index'), ('about.html', 'About')] %}
{% set navigation %}
<li><a href="/">Index</a></li>
<li><a href="/downloads">Downloads</a></li>
{% endset %}
</body>

View file

@ -0,0 +1,14 @@
<head>
{% for item in seq %}
<!-- meta for {{item}} -->
{% set key, value = call(item) %}
<meta name="{{key}}" content="{{value}}">
{% endfor %}
</head>
<body>
{% set navigation = [('index.html', 'Index'), ('about.html', 'About')] %}
{% set navigation %}
<li><a href="/">Index</a></li>
<li><a href="/downloads">Downloads</a></li>
{% endset %}
</body>

View file

@ -0,0 +1,5 @@
<ul>
{%- for item in seq -%}
<li>{{ item }}</li>
{%- endfor %}
</ul>

View file

@ -0,0 +1,5 @@
<ul>
{%-for item in seq -%}
<li>{{ item}}</li>
{%- endfor%}
</ul>

44
test/plugin.test.ts Normal file
View file

@ -0,0 +1,44 @@
import { existsSync, readdirSync, readFileSync } from "fs";
import { join } from "path";
import { format, Options } from "prettier";
import * as jinjaPlugin from "../src/index";
const prettify = (code: string, options: Options) =>
format(code, {
parser: "jinja-template",
plugins: [jinjaPlugin],
...options,
});
const testFolder = join(__dirname, "cases");
const tests = readdirSync(testFolder);
tests.forEach((test) => {
if (test.startsWith("_")) {
return;
}
return it(test, () => {
const path = join(testFolder, test);
const input = readFileSync(join(path, "input.html")).toString();
const expected = readFileSync(join(path, "expected.html")).toString();
const configPath = join(path, "config.json");
const configString =
existsSync(configPath) && readFileSync(configPath)?.toString();
const configObject = configString ? JSON.parse(configString) : {};
const format = () => prettify(input, configObject);
const expectedError = expected.match(/Error\(["'`](?<message>.*)["'`]\)/)
?.groups?.message;
if (expectedError) {
jest.spyOn(console, "error").mockImplementation(() => {});
expect(format).toThrow(expectedError);
} else {
const result = format();
expect(result).toEqual(expected);
expect(prettify(result, configObject)).toEqual(expected);
}
});
});

34
test/printer.test.ts Normal file
View file

@ -0,0 +1,34 @@
import { findPlaceholders } from "../src/printer";
test("findPlaceholders should find placeholder", () => {
expect(findPlaceholders("#~1~#")).toEqual([[0, 4]]);
expect(findPlaceholders("XX#~1~#XX")).toEqual([[2, 6]]);
});
test("findPlaceholders should find multiple placeholders", () => {
expect(findPlaceholders("#~1~##~99~#")).toEqual([
[0, 4],
[5, 10],
]);
expect(findPlaceholders("#~1~#X#~99~#")).toEqual([
[0, 4],
[6, 11],
]);
expect(findPlaceholders("#~1~##~X#~99~#")).toEqual([
[0, 4],
[5, 13],
[8, 13],
]);
expect(findPlaceholders("#~1~##~99~#~#")).toEqual([
[0, 4],
[5, 10],
]);
});
test("findPlaceholders should find no placeholders", () => {
expect(findPlaceholders("")).toEqual([]);
expect(findPlaceholders("#~#")).toEqual([]);
expect(findPlaceholders("#~ #")).toEqual([]);
expect(findPlaceholders("# ~#")).toEqual([]);
expect(findPlaceholders("#~#~")).toEqual([]);
});

14
tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"outDir": "./lib",
"target": "ES6",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["esnext"],
"declaration": true,
"strictNullChecks": true,
"strict": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "test"]
}