Run oxfmt across repo, add format script and docs

Add .oxfmtignore to skip generated bindings and wasm-pack output.
Add npm format script, update DEVELOPMENT.md for Vite+ toolchain,
and format all non-generated files with oxfmt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-03-13 10:15:49 -07:00
parent 45262edfbd
commit b4a1c418bb
664 changed files with 13638 additions and 13492 deletions

View File

@@ -1,20 +1,20 @@
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
import { startCompletion } from '@codemirror/autocomplete';
import type { TemplateFunction } from '@yaakapp-internal/plugins';
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import { startCompletion } from "@codemirror/autocomplete";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
const openTag = '${[ ';
const closeTag = ' ]}';
const openTag = "${[ ";
const closeTag = " ]}";
export type TwigCompletionOptionVariable = {
type: 'variable';
type: "variable";
};
export type TwigCompletionOptionNamespace = {
type: 'namespace';
type: "namespace";
};
export type TwigCompletionOptionFunction = TemplateFunction & {
type: 'function';
type: "function";
};
export type TwigCompletionOption = (
@@ -50,17 +50,17 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
const completions: Completion[] = options
.flatMap((o): Completion[] => {
const matchSegments = toMatch.text.replace(/^\$/, '').split('.');
const optionSegments = o.name.split('.');
const matchSegments = toMatch.text.replace(/^\$/, "").split(".");
const optionSegments = o.name.split(".");
// If not on the last segment, only complete the namespace
if (matchSegments.length < optionSegments.length) {
const prefix = optionSegments.slice(0, matchSegments.length).join('.');
const prefix = optionSegments.slice(0, matchSegments.length).join(".");
return [
{
label: `${prefix}.*`,
type: 'namespace',
detail: 'namespace',
type: "namespace",
detail: "namespace",
apply: (view, _completion, from, to) => {
const insert = `${prefix}.`;
view.dispatch({
@@ -75,13 +75,13 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
}
// If on the last segment, wrap the entire tag
const inner = o.type === 'function' ? `${o.name}()` : o.name;
const inner = o.type === "function" ? `${o.name}()` : o.name;
return [
{
label: o.name,
info: o.description,
detail: o.type,
type: o.type === 'variable' ? 'variable' : 'function',
type: o.type === "variable" ? "variable" : "function",
apply: (view, _completion, from, to) => {
const insert = openTag + inner + closeTag;
view.dispatch({
@@ -94,7 +94,7 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
})
.filter((v) => v != null);
const uniqueCompletions = uniqueBy(completions, 'label');
const uniqueCompletions = uniqueBy(completions, "label");
const sortedCompletions = uniqueCompletions.sort((a, b) => {
const boostDiff = defaultBoost(b) - defaultBoost(a);
if (boostDiff !== 0) return boostDiff;
@@ -119,9 +119,9 @@ export function uniqueBy<T, K extends keyof T>(arr: T[], key: K): T[] {
}
export function defaultBoost(o: Completion) {
if (o.type === 'variable') return 4;
if (o.type === 'constant') return 3;
if (o.type === 'function') return 2;
if (o.type === 'namespace') return 1;
if (o.type === "variable") return 4;
if (o.type === "constant") return 3;
if (o.type === "function") return 2;
if (o.type === "namespace") return 1;
return 0;
}

View File

@@ -1,15 +1,15 @@
import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language';
import type { Extension } from '@codemirror/state';
import { parseMixed } from '@lezer/common';
import type { WrappedEnvironmentVariable } from '../../../../hooks/useEnvironmentVariables';
import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion';
import { textLanguage } from '../text/extension';
import type { TwigCompletionOption } from './completion';
import { twigCompletion } from './completion';
import { templateTagsPlugin } from './templateTags';
import { parser as twigParser } from './twig';
import type { LanguageSupport } from "@codemirror/language";
import { LRLanguage } from "@codemirror/language";
import type { Extension } from "@codemirror/state";
import { parseMixed } from "@lezer/common";
import type { WrappedEnvironmentVariable } from "../../../../hooks/useEnvironmentVariables";
import type { GenericCompletionConfig } from "../genericCompletion";
import { genericCompletion } from "../genericCompletion";
import { textLanguage } from "../text/extension";
import type { TwigCompletionOption } from "./completion";
import { twigCompletion } from "./completion";
import { templateTagsPlugin } from "./templateTags";
import { parser as twigParser } from "./twig";
export function twig({
base,
@@ -35,7 +35,7 @@ export function twig({
environmentVariables.map((v) => ({
name: v.variable.name,
value: v.variable.value,
type: 'variable',
type: "variable",
label: v.variable.name,
description: `Inherited from ${v.source}`,
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
@@ -74,12 +74,12 @@ function mixLanguage(base: LanguageSupport): LRLanguage {
return {
parser: base.language.parser,
overlay: (node) => node.type.name === 'Text',
overlay: (node) => node.type.name === "Text",
};
}),
});
const language = LRLanguage.define({ name: 'twig', parser });
const language = LRLanguage.define({ name: "twig", parser });
mixedLanguagesCache[base.language.name] = language;
return language;
}

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
TagOpen: t.bracket,

View File

@@ -1,7 +1,7 @@
import { syntaxTree } from '@codemirror/language';
import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import { syntaxTree } from "@codemirror/language";
import type { Range } from "@codemirror/state";
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
class PathPlaceholderWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
@@ -22,15 +22,15 @@ class PathPlaceholderWidget extends WidgetType {
}
toDOM() {
const elt = document.createElement('span');
elt.className = 'x-theme-templateTag x-theme-templateTag--secondary template-tag';
const elt = document.createElement("span");
elt.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
elt.textContent = this.rawText;
elt.addEventListener('click', this.#clickListenerCallback);
elt.addEventListener("click", this.#clickListenerCallback);
return elt;
}
destroy(dom: HTMLElement) {
dom.removeEventListener('click', this.#clickListenerCallback);
dom.removeEventListener("click", this.#clickListenerCallback);
super.destroy(dom);
}
@@ -50,14 +50,14 @@ function pathParameters(
from,
to,
enter(node) {
if (node.name === 'Text') {
if (node.name === "Text") {
// Find the `url` node and then jump into it to find the placeholders
for (let i = node.from; i < node.to; i++) {
const innerTree = syntaxTree(view.state).resolveInner(i);
if (innerTree.node.name === 'url') {
if (innerTree.node.name === "url") {
innerTree.toTree().iterate({
enter(node) {
if (node.name !== 'Placeholder') return;
if (node.name !== "Placeholder") return;
const globalFrom = innerTree.node.from + node.from;
const globalTo = innerTree.node.from + node.to;
const rawText = view.state.doc.sliceString(globalFrom, globalTo);

View File

@@ -1,13 +1,13 @@
import { syntaxTree } from '@codemirror/language';
import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common';
import { applyFormInputDefaults, validateTemplateFunctionArgs } from '@yaakapp-internal/lib';
import type { FormInput, JsonPrimitive, TemplateFunction } from '@yaakapp-internal/plugins';
import { parseTemplate } from '@yaakapp-internal/templates';
import type { TwigCompletionOption } from './completion';
import { collectArgumentValues } from './util';
import { syntaxTree } from "@codemirror/language";
import type { Range } from "@codemirror/state";
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
import type { SyntaxNodeRef } from "@lezer/common";
import { applyFormInputDefaults, validateTemplateFunctionArgs } from "@yaakapp-internal/lib";
import type { FormInput, JsonPrimitive, TemplateFunction } from "@yaakapp-internal/plugins";
import { parseTemplate } from "@yaakapp-internal/templates";
import type { TwigCompletionOption } from "./completion";
import { collectArgumentValues } from "./util";
class TemplateTagWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
@@ -34,24 +34,24 @@ class TemplateTagWidget extends WidgetType {
}
toDOM() {
const elt = document.createElement('span');
const elt = document.createElement("span");
elt.className = `x-theme-templateTag template-tag ${
this.option.invalid
? 'x-theme-templateTag--danger'
: this.option.type === 'variable'
? 'x-theme-templateTag--primary'
: 'x-theme-templateTag--info'
? "x-theme-templateTag--danger"
: this.option.type === "variable"
? "x-theme-templateTag--primary"
: "x-theme-templateTag--info"
}`;
elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? '');
elt.setAttribute('data-tag-type', this.option.type);
if (typeof this.option.label === 'string') elt.textContent = this.option.label;
elt.title = this.option.invalid ? "Not Found" : (this.option.value ?? "");
elt.setAttribute("data-tag-type", this.option.type);
if (typeof this.option.label === "string") elt.textContent = this.option.label;
else elt.appendChild(this.option.label);
elt.addEventListener('click', this.#clickListenerCallback);
elt.addEventListener("click", this.#clickListenerCallback);
return elt;
}
destroy(dom: HTMLElement) {
dom.removeEventListener('click', this.#clickListenerCallback);
dom.removeEventListener("click", this.#clickListenerCallback);
super.destroy(dom);
}
@@ -72,34 +72,34 @@ function templateTags(
from,
to,
enter(node) {
if (node.name === 'Tag') {
if (node.name === "Tag") {
// Don't decorate if the cursor is inside the match
if (isSelectionInsideNode(view, node)) return;
const rawTag = view.state.doc.sliceString(node.from, node.to);
// TODO: Search `node.tree` instead of using Regex here
const inner = rawTag.replace(/^\$\{\[\s*/, '').replace(/\s*]}$/, '');
const inner = rawTag.replace(/^\$\{\[\s*/, "").replace(/\s*]}$/, "");
let name = inner.match(/([\w.]+)[(]/)?.[1] ?? inner;
if (inner.includes('\n')) {
if (inner.includes("\n")) {
return;
}
// The beta named the function `Response` but was changed in stable.
// Keep this here for a while because there's no easy way to migrate
if (name === 'Response') {
name = 'response';
if (name === "Response") {
name = "response";
}
let option = options.find(
(o) => o.name === name || (o.type === 'function' && o.aliases?.includes(name)),
(o) => o.name === name || (o.type === "function" && o.aliases?.includes(name)),
);
if (option == null) {
const from = node.from; // Cache here so the reference doesn't change
option = {
type: 'variable',
type: "variable",
invalid: true,
name: inner,
value: null,
@@ -110,7 +110,7 @@ function templateTags(
};
}
if (option.type === 'function') {
if (option.type === "function") {
const tokens = parseTemplate(rawTag);
const rawValues = collectArgumentValues(tokens, option);
const values = applyFormInputDefaults(option.args, rawValues);
@@ -175,49 +175,49 @@ function makeFunctionLabel(
): HTMLElement | string {
if (fn.args.length === 0) return fn.name;
const $outer = document.createElement('span');
$outer.className = 'fn';
const $bOpen = document.createElement('span');
$bOpen.className = 'fn-bracket';
$bOpen.textContent = '(';
const $outer = document.createElement("span");
$outer.className = "fn";
const $bOpen = document.createElement("span");
$bOpen.className = "fn-bracket";
$bOpen.textContent = "(";
$outer.appendChild(document.createTextNode(fn.name));
$outer.appendChild($bOpen);
const $inner = document.createElement('span');
$inner.className = 'fn-inner';
$inner.title = '';
const $inner = document.createElement("span");
$inner.className = "fn-inner";
$inner.title = "";
fn.previewArgs?.forEach((name: string, i: number, all: string[]) => {
const v = String(values[name] || '');
const v = String(values[name] || "");
if (!v) return;
if (all.length > 1) {
const $c = document.createElement('span');
$c.className = 'fn-arg-name';
const $c = document.createElement("span");
$c.className = "fn-arg-name";
$c.textContent = i > 0 ? `, ${name}=` : `${name}=`;
$inner.appendChild($c);
}
const $v = document.createElement('span');
$v.className = 'fn-arg-value';
$v.textContent = v.includes(' ') ? `'${v}'` : v;
const $v = document.createElement("span");
$v.className = "fn-arg-value";
$v.textContent = v.includes(" ") ? `'${v}'` : v;
$inner.appendChild($v);
});
fn.args.forEach((a: FormInput, i: number) => {
if (!('name' in a)) return;
if (!("name" in a)) return;
const v = values[a.name];
if (v == null) return;
if (i > 0) $inner.title += '\n';
if (i > 0) $inner.title += "\n";
$inner.title += `${a.name} = ${JSON.stringify(v)}`;
});
if ($inner.childNodes.length === 0) {
$inner.appendChild(document.createTextNode('…'));
$inner.appendChild(document.createTextNode("…"));
}
$outer.appendChild($inner);
const $bClose = document.createElement('span');
$bClose.className = 'fn-bracket';
$bClose.textContent = ')';
const $bClose = document.createElement("span");
$bClose.className = "fn-bracket";
$bClose.textContent = ")";
$outer.appendChild($bClose);
return $outer;

View File

@@ -1,14 +1,14 @@
/* oxlint-disable no-template-curly-in-string */
import { describe, expect, test } from 'vite-plus/test';
import { parser } from './twig';
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./twig";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== 'Template') {
if (cursor.name !== "Template") {
nodes.push(cursor.name);
}
} while (cursor.next());
@@ -16,93 +16,93 @@ function getNodeNames(input: string): string[] {
}
function hasTag(input: string): boolean {
return getNodeNames(input).includes('Tag');
return getNodeNames(input).includes("Tag");
}
function hasError(input: string): boolean {
return getNodeNames(input).includes('⚠');
return getNodeNames(input).includes("⚠");
}
describe('twig grammar', () => {
describe('${[var]} format (valid template tags)', () => {
test('parses simple variable as Tag', () => {
expect(hasTag('${[var]}')).toBe(true);
expect(hasError('${[var]}')).toBe(false);
describe("twig grammar", () => {
describe("${[var]} format (valid template tags)", () => {
test("parses simple variable as Tag", () => {
expect(hasTag("${[var]}")).toBe(true);
expect(hasError("${[var]}")).toBe(false);
});
test('parses variable with whitespace as Tag', () => {
expect(hasTag('${[ var ]}')).toBe(true);
expect(hasError('${[ var ]}')).toBe(false);
test("parses variable with whitespace as Tag", () => {
expect(hasTag("${[ var ]}")).toBe(true);
expect(hasError("${[ var ]}")).toBe(false);
});
test('parses embedded variable as Tag', () => {
expect(hasTag('hello ${[name]} world')).toBe(true);
expect(hasError('hello ${[name]} world')).toBe(false);
test("parses embedded variable as Tag", () => {
expect(hasTag("hello ${[name]} world")).toBe(true);
expect(hasError("hello ${[name]} world")).toBe(false);
});
test('parses function call as Tag', () => {
expect(hasTag('${[fn()]}')).toBe(true);
expect(hasError('${[fn()]}')).toBe(false);
test("parses function call as Tag", () => {
expect(hasTag("${[fn()]}")).toBe(true);
expect(hasError("${[fn()]}")).toBe(false);
});
});
describe('${var} format (should be plain text, not tags)', () => {
test('parses ${var} as plain Text without errors', () => {
expect(hasTag('${var}')).toBe(false);
expect(hasError('${var}')).toBe(false);
describe("${var} format (should be plain text, not tags)", () => {
test("parses ${var} as plain Text without errors", () => {
expect(hasTag("${var}")).toBe(false);
expect(hasError("${var}")).toBe(false);
});
test('parses embedded ${var} as plain Text', () => {
expect(hasTag('hello ${name} world')).toBe(false);
expect(hasError('hello ${name} world')).toBe(false);
test("parses embedded ${var} as plain Text", () => {
expect(hasTag("hello ${name} world")).toBe(false);
expect(hasError("hello ${name} world")).toBe(false);
});
test('parses JSON with ${var} as plain Text', () => {
test("parses JSON with ${var} as plain Text", () => {
const json = '{"key": "${value}"}';
expect(hasTag(json)).toBe(false);
expect(hasError(json)).toBe(false);
});
test('parses multiple ${var} as plain Text', () => {
expect(hasTag('${a} and ${b}')).toBe(false);
expect(hasError('${a} and ${b}')).toBe(false);
test("parses multiple ${var} as plain Text", () => {
expect(hasTag("${a} and ${b}")).toBe(false);
expect(hasError("${a} and ${b}")).toBe(false);
});
});
describe('mixed content', () => {
test('distinguishes ${var} from ${[var]} in same string', () => {
const input = '${plain} and ${[tag]}';
describe("mixed content", () => {
test("distinguishes ${var} from ${[var]} in same string", () => {
const input = "${plain} and ${[tag]}";
expect(hasTag(input)).toBe(true);
expect(hasError(input)).toBe(false);
});
test('parses JSON with ${[var]} as having Tag', () => {
test("parses JSON with ${[var]} as having Tag", () => {
const json = '{"key": "${[value]}"}';
expect(hasTag(json)).toBe(true);
expect(hasError(json)).toBe(false);
});
});
describe('edge cases', () => {
test('handles $ at end of string', () => {
expect(hasError('hello$')).toBe(false);
expect(hasTag('hello$')).toBe(false);
describe("edge cases", () => {
test("handles $ at end of string", () => {
expect(hasError("hello$")).toBe(false);
expect(hasTag("hello$")).toBe(false);
});
test('handles ${ at end of string without crash', () => {
test("handles ${ at end of string without crash", () => {
// Incomplete syntax may produce errors, but should not crash
expect(() => parser.parse('hello${')).not.toThrow();
expect(() => parser.parse("hello${")).not.toThrow();
});
test('handles ${[ without closing without crash', () => {
test("handles ${[ without closing without crash", () => {
// Unclosed tag may produce partial match, but should not crash
expect(() => parser.parse('${[unclosed')).not.toThrow();
expect(() => parser.parse("${[unclosed")).not.toThrow();
});
test('handles empty ${[]}', () => {
test("handles empty ${[]}", () => {
// Empty tags may or may not be valid depending on grammar
// Just ensure no crash
expect(() => parser.parse('${[]}')).not.toThrow();
expect(() => parser.parse("${[]}")).not.toThrow();
});
});
});

View File

@@ -1,20 +1,20 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LocalTokenGroup, LRParser } from '@lezer/lr';
import { highlight } from './highlight';
import { LocalTokenGroup, LRParser } from "@lezer/lr";
import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states:
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
stateData: 'g~OUROYPO~OSTO~OSTOTXO~O',
goto: 'nXPPY^PPPbhTROSTQOSQSORVSQUQRWU',
nodeNames: '⚠ Template Tag TagOpen TagContent TagClose Text',
stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text",
maxTerm: 10,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData:
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~",
tokenizers: [1, new LocalTokenGroup('b~RP#P#QU~XP#q#r[~aOT~~', 17, 4)],
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
topRules: { Template: [0, 1] },
tokenPrec: 0,
});

View File

@@ -1,5 +1,5 @@
import type { FormInput, TemplateFunction } from '@yaakapp-internal/plugins';
import type { Tokens } from '@yaakapp-internal/templates';
import type { FormInput, TemplateFunction } from "@yaakapp-internal/plugins";
import type { Tokens } from "@yaakapp-internal/templates";
/**
* Process the initial tokens from the template and merge those with the default values pulled from
@@ -8,21 +8,21 @@ import type { Tokens } from '@yaakapp-internal/templates';
export function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {
const initial: Record<string, string | boolean> = {};
const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
initialTokens.tokens[0]?.type === "tag" && initialTokens.tokens[0]?.val.type === "fn"
? initialTokens.tokens[0]?.val.args
: [];
const processArg = (arg: FormInput) => {
if ('inputs' in arg && arg.inputs) {
if ("inputs" in arg && arg.inputs) {
arg.inputs.forEach(processArg);
}
if (!('name' in arg)) return;
if (!("name" in arg)) return;
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue =
initialArg?.value.type === 'str'
initialArg?.value.type === "str"
? initialArg?.value.text
: initialArg?.value.type === 'bool'
: initialArg?.value.type === "bool"
? initialArg.value.value
: undefined;
const value = initialArgValue ?? arg.defaultValue;