@excom/quark-formatter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/printer.ts ADDED
@@ -0,0 +1,625 @@
1
+ /**
2
+ * Printer for the Quark AST.
3
+ *
4
+ * Output conventions match prettier's CSS/SCSS style: two-space indent,
5
+ * one selector per line in multi-selector rules, a single preserved blank
6
+ * line between statement groups, comments kept where they were written
7
+ * (including trailing same-line comments), and an 80-column print width.
8
+ * Statements are built as documents (`doc.ts`): parenthesised groups —
9
+ * maps, call arguments, `if()` arms, `@on` options — break one item per
10
+ * line when they overflow, operator chains and space lists wrap like text,
11
+ * a `key: value` inside them breaks after the colon when even
12
+ * `key: value(` will not fit, and a declaration whose value is a comma
13
+ * list of multi-word items breaks one item per line the way prettier
14
+ * prints `transition` / `grid-template-columns`. Strings, selectors and
15
+ * interpolations never wrap.
16
+ *
17
+ * The two Quark deviations from CSS are printed compactly, exactly as
18
+ * written: dot accessors (`$obj.field`) and bracket accessors
19
+ * (`$obj["field"]`) never receive surrounding whitespace.
20
+ */
21
+ import {
22
+ type Doc,
23
+ fill,
24
+ group,
25
+ hardline,
26
+ indent,
27
+ indentIfBreak,
28
+ join,
29
+ line,
30
+ printDoc,
31
+ softline,
32
+ } from "./doc";
33
+ import type {
34
+ ActionRule,
35
+ Argument,
36
+ AtRule,
37
+ Binary,
38
+ Block,
39
+ CommentNode,
40
+ Declaration,
41
+ DelayRule,
42
+ EventName,
43
+ Expression,
44
+ Interpolation,
45
+ ListenerOption,
46
+ ListenerRule,
47
+ QuarkAtRuleName,
48
+ ScopeRule,
49
+ Selector,
50
+ SelectorList,
51
+ Statement,
52
+ Stylesheet,
53
+ TransitionRule,
54
+ UseRule,
55
+ ValueAtRule,
56
+ } from "@excom/quark-parser";
57
+ import { parse } from "@excom/quark-parser";
58
+
59
+ export interface FormatOptions {
60
+ /** Indentation unit. Defaults to two spaces. */
61
+ indent?: string;
62
+ }
63
+
64
+ /** Format Quark source. Throws `QuarkParseError` on invalid input. */
65
+ export function format(source: string, options: FormatOptions = {}): string {
66
+ const ast = parse(source);
67
+ const lines = new Printer(source, options.indent ?? " ").stylesheet(ast);
68
+ return (
69
+ lines
70
+ .join("\n")
71
+ .replace(/\n{3,}/g, "\n\n")
72
+ .trim() + "\n"
73
+ );
74
+ }
75
+
76
+ /** Columns per line before a breakable construct wraps (prettier's default). */
77
+ const PRINT_WIDTH = 80;
78
+
79
+ /* Binding powers mirror the parser (`BINARY_BP`); used to re-insert
80
+ * the parentheses the AST no longer carries. */
81
+ const BINARY_BP: Record<string, number> = {
82
+ or: 1,
83
+ and: 2,
84
+ "==": 4,
85
+ "!=": 4,
86
+ "<": 5,
87
+ ">": 5,
88
+ "<=": 5,
89
+ ">=": 5,
90
+ "+": 6,
91
+ "-": 6,
92
+ "*": 7,
93
+ "/": 7,
94
+ "%": 7,
95
+ };
96
+ const NOT_BP = 3;
97
+ const UNARY_BP = 8;
98
+ const POSTFIX_BP = 9;
99
+
100
+ type AtRulePrinter<N extends QuarkAtRuleName> = (
101
+ printer: Printer,
102
+ node: Extract<AtRule, { name: N }>,
103
+ depth: number
104
+ ) => string[];
105
+
106
+ /**
107
+ * One printer per Quark at-rule, keyed by the parser's `QuarkAtRuleName`
108
+ * (`QUARK_AT_RULES`) so the two sets cannot drift: a name the parser adds
109
+ * or drops breaks this table at compile time.
110
+ */
111
+ const AT_RULE_PRINTERS: { readonly [N in QuarkAtRuleName]: AtRulePrinter<N> } =
112
+ {
113
+ use: (p, node, depth) => p.useRule(node, depth),
114
+ scope: (p, node, depth) => p.scopeRule(node, depth),
115
+ on: (p, node, depth) => p.listenerRule(node, depth),
116
+ dispatch: (p, node, depth) => p.actionRule(node, depth),
117
+ command: (p, node, depth) => p.actionRule(node, depth),
118
+ "view-transition": (p, node, depth) => p.transitionRule(node, depth),
119
+ delay: (p, node, depth) => p.delayRule(node, depth),
120
+ warn: (p, node, depth) => p.valueAtRule(node, depth),
121
+ debug: (p, node, depth) => p.valueAtRule(node, depth),
122
+ error: (p, node, depth) => p.valueAtRule(node, depth),
123
+ };
124
+
125
+ /** The table's entries seen through the widest node type they accept. */
126
+ type AtRulePrint = (printer: Printer, node: AtRule, depth: number) => string[];
127
+
128
+ class Printer {
129
+ constructor(
130
+ private source: string,
131
+ private indentUnit: string
132
+ ) {}
133
+
134
+ private pad(depth: number): string {
135
+ return this.indentUnit.repeat(depth);
136
+ }
137
+
138
+ stylesheet(ast: Stylesheet): string[] {
139
+ return this.body(ast.body, 0);
140
+ }
141
+
142
+ /** Lay a statement document out at `depth`, wrapping at the print width. */
143
+ private layout(doc: Doc, depth: number): string[] {
144
+ return printDoc(doc, {
145
+ width: PRINT_WIDTH,
146
+ indentUnit: this.indentUnit,
147
+ rootIndent: this.pad(depth),
148
+ }).split("\n");
149
+ }
150
+
151
+ /** Render a document on one line (for contexts that never wrap). */
152
+ private flat(doc: Doc): string {
153
+ return printDoc(doc, {
154
+ width: Infinity,
155
+ indentUnit: this.indentUnit,
156
+ rootIndent: "",
157
+ });
158
+ }
159
+
160
+ /*
161
+ * ---------------------------------------------------------------------
162
+ * Statements
163
+ * ---------------------------------------------------------------------
164
+ */
165
+
166
+ /**
167
+ * Print a statement list, preserving single blank lines between groups
168
+ * and re-attaching comments that trailed a statement on the same line.
169
+ */
170
+ private body(statements: Statement[], depth: number): string[] {
171
+ const lines: string[] = [];
172
+ let prevEnd = -1;
173
+ for (const node of statements) {
174
+ const gap =
175
+ prevEnd >= 0 ? this.source.slice(prevEnd, node.start) : undefined;
176
+ if (
177
+ node.type === "comment" &&
178
+ gap !== undefined &&
179
+ !gap.includes("\n") &&
180
+ lines.length
181
+ ) {
182
+ // Trailing comment on the same line as the previous statement.
183
+ lines[lines.length - 1] += " " + this.comment(node, depth, true);
184
+ prevEnd = node.end;
185
+ continue;
186
+ }
187
+ if (gap !== undefined && countNewlines(gap) >= 2) {
188
+ lines.push("");
189
+ }
190
+ lines.push(...this.statement(node, depth));
191
+ prevEnd = node.end;
192
+ }
193
+ return lines;
194
+ }
195
+
196
+ private statement(node: Statement, depth: number): string[] {
197
+ switch (node.type) {
198
+ case "comment":
199
+ return [this.pad(depth) + this.comment(node, depth, false)];
200
+ case "declaration":
201
+ return this.declaration(node, depth);
202
+ case "rule":
203
+ return this.withBlock(
204
+ this.selectorHead(node.selector),
205
+ node.block,
206
+ depth
207
+ );
208
+ case "atrule":
209
+ return (AT_RULE_PRINTERS[node.name] as AtRulePrint)(this, node, depth);
210
+ }
211
+ }
212
+
213
+ /** `head { ...body }` with the head's last line receiving the brace. */
214
+ private withBlock(head: Doc, block: Block, depth: number): string[] {
215
+ const headLines = this.layout([head, " {"], depth);
216
+ const inner = this.body(block.body, depth + 1);
217
+ return [...headLines, ...inner, this.pad(depth) + "}"];
218
+ }
219
+
220
+ private declaration(node: Declaration, depth: number): string[] {
221
+ const prop =
222
+ node.property.type === "variable"
223
+ ? "$" + node.property.name
224
+ : node.property.name;
225
+ return this.layout(
226
+ [prop, ": ", this.declarationValue(node.value, prop), ";"],
227
+ depth
228
+ );
229
+ }
230
+
231
+ /**
232
+ * prettier's multi-value rule: a top-level comma list whose items are
233
+ * themselves multi-word (`transition: opacity 1s, color 1s`) breaks one
234
+ * item per line; custom properties (`--x`) are exempt.
235
+ */
236
+ private declarationValue(value: Expression, prop: string): Doc {
237
+ if (
238
+ value.type === "list" &&
239
+ value.separator === "," &&
240
+ !value.parens &&
241
+ !value.brackets &&
242
+ !prop.startsWith("--") &&
243
+ value.items.some(isMultiWord)
244
+ ) {
245
+ const items = value.items.map((item) => this.expr(item));
246
+ return indent([hardline, join([",", hardline], items)]);
247
+ }
248
+ return this.expr(value);
249
+ }
250
+
251
+ private comment(node: CommentNode, depth: number, trailing: boolean): string {
252
+ if (!node.text.includes("\n")) {
253
+ return "/*" + node.text + "*/";
254
+ }
255
+ if (trailing) return "/*" + node.text + "*/";
256
+ /* Multi-line block comment: shift interior lines from the
257
+ * comment's original column to the current indent. */
258
+ const lineStart = this.source.lastIndexOf("\n", node.start) + 1;
259
+ const originalIndent = this.source
260
+ .slice(lineStart, node.start)
261
+ .match(/^\s*/)![0];
262
+ const shifted = node.text
263
+ .split("\n")
264
+ .map((line, i) => {
265
+ if (i === 0) return line;
266
+ return line.startsWith(originalIndent)
267
+ ? this.pad(depth) + line.slice(originalIndent.length)
268
+ : line;
269
+ })
270
+ .join("\n");
271
+ return "/*" + shifted + "*/";
272
+ }
273
+
274
+ /*
275
+ * ---------------------------------------------------------------------
276
+ * At-rules (dispatched through `AT_RULE_PRINTERS`)
277
+ * ---------------------------------------------------------------------
278
+ */
279
+
280
+ /** `@use "/x" as ns;` — a `with (…)` configuration is not Quark. */
281
+ useRule(node: UseRule, depth: number): string[] {
282
+ const ns =
283
+ node.namespace === null
284
+ ? ""
285
+ : node.namespace === "*"
286
+ ? " as *"
287
+ : ` as ${node.namespace}`;
288
+ return this.layout(`@use "${node.url}"${ns};`, depth);
289
+ }
290
+
291
+ /** `@scope { … }` — block only, no prelude. */
292
+ scopeRule(node: ScopeRule, depth: number): string[] {
293
+ return this.withBlock("@scope", node.block, depth);
294
+ }
295
+
296
+ /**
297
+ * `@on input, change (debounce: 300, handle: save) { … }` /
298
+ * `@on submit (prevent-default);`: names as written, options as
299
+ * expressions, then a block like a rule or the statement's `;`.
300
+ */
301
+ listenerRule(node: ListenerRule, depth: number): string[] {
302
+ const head: Doc = [
303
+ "@on ",
304
+ this.nameList(node.events),
305
+ this.optionsGroup(node.options),
306
+ ];
307
+ return node.block
308
+ ? this.withBlock(head, node.block, depth)
309
+ : this.layout([head, ";"], depth);
310
+ }
311
+
312
+ /** `@dispatch cart-add (detail: $d, target: "#x");`, statements only. */
313
+ actionRule(node: ActionRule, depth: number): string[] {
314
+ return this.layout(
315
+ [
316
+ `@${node.name} `,
317
+ this.nameList(node.names),
318
+ this.optionsGroup(node.options),
319
+ ";",
320
+ ],
321
+ depth
322
+ );
323
+ }
324
+
325
+ /** `@view-transition (types: "t", timeout: 500) { … }`, always a block. */
326
+ transitionRule(node: TransitionRule, depth: number): string[] {
327
+ return this.withBlock(
328
+ ["@view-transition", this.optionsGroup(node.options)],
329
+ node.block,
330
+ depth
331
+ );
332
+ }
333
+
334
+ /** `@delay 2000 { … }`: one duration expression, always a block. */
335
+ delayRule(node: DelayRule, depth: number): string[] {
336
+ return this.withBlock(
337
+ ["@delay ", this.expr(node.duration)],
338
+ node.block,
339
+ depth
340
+ );
341
+ }
342
+
343
+ /** `@debug` / `@warn` / `@error`, one expression each. */
344
+ valueAtRule(node: ValueAtRule, depth: number): string[] {
345
+ return this.layout([`@${node.name} `, this.expr(node.value), ";"], depth);
346
+ }
347
+
348
+ private argument(a: Argument): Doc {
349
+ return [
350
+ a.name ? `$${a.name}: ` : "",
351
+ this.expr(a.value),
352
+ a.spread ? "..." : "",
353
+ ];
354
+ }
355
+
356
+ /*
357
+ * ---------------------------------------------------------------------
358
+ * Selectors
359
+ * ---------------------------------------------------------------------
360
+ */
361
+
362
+ /** Rule heads put each selector on its own line. */
363
+ private selectorHead(list: SelectorList): Doc {
364
+ return join(
365
+ [",", hardline],
366
+ list.selectors.map((s) => this.selector(s))
367
+ );
368
+ }
369
+
370
+ /** `, `-joined, for `:not(...)` and the other selector pseudos. */
371
+ private selectorsInline(list: SelectorList): string {
372
+ return list.selectors.map((s) => this.selector(s)).join(", ");
373
+ }
374
+
375
+ private selector(selector: Selector): string {
376
+ let out = "";
377
+ for (const part of selector.parts) {
378
+ switch (part.type) {
379
+ case "combinator":
380
+ out = out === "" ? "" : out + " ";
381
+ if (part.value !== " ") out += part.value + " ";
382
+ break;
383
+ case "type_selector":
384
+ out += part.name;
385
+ break;
386
+ case "class_selector":
387
+ out += "." + part.name;
388
+ break;
389
+ case "id_selector":
390
+ out += "#" + part.name;
391
+ break;
392
+ case "parent_selector":
393
+ out += "&" + (part.suffix ?? "");
394
+ break;
395
+ case "attribute_selector": {
396
+ const value =
397
+ part.operator && part.value
398
+ ? part.operator + this.flat(this.expr(part.value))
399
+ : "";
400
+ const modifier = part.modifier ? " " + part.modifier : "";
401
+ out += `[${part.name}${value}${modifier}]`;
402
+ break;
403
+ }
404
+ case "pseudo_class_selector": {
405
+ let argument = "";
406
+ if (part.argument) {
407
+ argument =
408
+ part.argument.type === "selector_list"
409
+ ? `(${this.selectorsInline(part.argument)})`
410
+ : `(${collapseWs(part.argument.value)})`;
411
+ }
412
+ out += `:${part.name}${argument}`;
413
+ break;
414
+ }
415
+ case "pseudo_element_selector":
416
+ out += `::${part.name}${
417
+ part.argument ? `(${collapseWs(part.argument.value)})` : ""
418
+ }`;
419
+ break;
420
+ }
421
+ }
422
+ return out;
423
+ }
424
+
425
+ /*
426
+ * ---------------------------------------------------------------------
427
+ * Expressions
428
+ * ---------------------------------------------------------------------
429
+ */
430
+
431
+ private interpolation(node: Interpolation): string {
432
+ return `#{${this.flat(this.expr(node.expression))}}`;
433
+ }
434
+
435
+ /** Event / command names as authored: `click, "my:evt", --refresh`. */
436
+ private nameList(names: EventName[]): Doc {
437
+ return join(
438
+ ", ",
439
+ names.map((n) => (n.quoted ? `"${n.name}"` : n.name))
440
+ );
441
+ }
442
+
443
+ /**
444
+ * ` (once, target: "li", debounce: 300)` after `@on <event>` or
445
+ * `@view-transition`: flags bare, values as expressions, one per line
446
+ * when the group overflows; an empty group prints nothing.
447
+ */
448
+ private optionsGroup(options: ListenerOption[]): Doc {
449
+ return options.length
450
+ ? [
451
+ " ",
452
+ this.parenGroup(
453
+ options.map((o) =>
454
+ o.value ? this.pair(o.name, this.expr(o.value)) : o.name
455
+ ),
456
+ ","
457
+ ),
458
+ ]
459
+ : "";
460
+ }
461
+
462
+ /**
463
+ * `(a, b, c)`: flat when it fits, otherwise one item per line with the
464
+ * delimiters on their own lines. `separator` is `","` / `";"` / `""`.
465
+ */
466
+ private parenGroup(
467
+ items: Doc[],
468
+ separator: string,
469
+ open = "(",
470
+ close = ")"
471
+ ): Doc {
472
+ return group([
473
+ open,
474
+ indent([softline, join([separator, line], items)]),
475
+ softline,
476
+ close,
477
+ ]);
478
+ }
479
+
480
+ /**
481
+ * `key: value` inside a paren group (`if()` arms, map entries, `@on`
482
+ * options). prettier's "fluid" assignment layout: the value hugs the key
483
+ * when `key: value(` fits on the line, otherwise the line breaks after the
484
+ * colon and the value is indented.
485
+ */
486
+ private pair(key: Doc, value: Doc): Doc {
487
+ const id = Symbol("pair");
488
+ return group([key, ":", group(indent(line), id), indentIfBreak(value, id)]);
489
+ }
490
+
491
+ /**
492
+ * Print an expression. `parentBp`/`side` re-create the parentheses that
493
+ * precedence made necessary in the original source.
494
+ */
495
+ private expr(node: Expression, parentBp = 0, side?: "right"): Doc {
496
+ switch (node.type) {
497
+ case "string": {
498
+ const inner =
499
+ node.value !== null
500
+ ? node.value
501
+ : node.parts
502
+ .map((part) =>
503
+ typeof part === "string" ? part : this.interpolation(part)
504
+ )
505
+ .join("");
506
+ return node.quote + inner + node.quote;
507
+ }
508
+ case "number":
509
+ return `${node.value}${node.unit ?? ""}`;
510
+ case "color":
511
+ return node.value;
512
+ case "boolean":
513
+ return String(node.value);
514
+ case "null":
515
+ return "null";
516
+ case "identifier":
517
+ return node.name;
518
+ case "variable":
519
+ return "$" + node.name;
520
+ case "parent_reference":
521
+ return "&";
522
+ case "interpolation":
523
+ return this.interpolation(node);
524
+ case "url":
525
+ return `url(${node.parts
526
+ .map((part) =>
527
+ typeof part === "string" ? part : this.interpolation(part)
528
+ )
529
+ .join("")})`;
530
+ case "member":
531
+ return [
532
+ this.expr(node.object, POSTFIX_BP),
533
+ `.${node.variable ? "$" : ""}${node.property}`,
534
+ ];
535
+ case "index":
536
+ return [
537
+ this.expr(node.object, POSTFIX_BP),
538
+ "[",
539
+ this.expr(node.index),
540
+ "]",
541
+ ];
542
+ case "function":
543
+ return [
544
+ this.expr(node.callee),
545
+ node.args.length
546
+ ? this.parenGroup(
547
+ node.args.map((a) => this.argument(a)),
548
+ ","
549
+ )
550
+ : "()",
551
+ ];
552
+ case "if": {
553
+ const arms = node.arms.map((arm) =>
554
+ this.pair(
555
+ arm.condition === null ? "else" : this.expr(arm.condition),
556
+ this.expr(arm.value)
557
+ )
558
+ );
559
+ return ["if", this.parenGroup(arms, ";")];
560
+ }
561
+ case "unary": {
562
+ if (node.operator === "not") {
563
+ const doc: Doc = ["not ", this.expr(node.argument, NOT_BP)];
564
+ return parentBp > NOT_BP ? ["(", doc, ")"] : doc;
565
+ }
566
+ const doc: Doc = [node.operator, this.expr(node.argument, UNARY_BP)];
567
+ // `(-$a).b`: a signed operand of a postfix accessor keeps its parens.
568
+ return parentBp > UNARY_BP ? ["(", doc, ")"] : doc;
569
+ }
570
+ case "binary":
571
+ // `$a and $b or $c` wraps like text, breaking after an operator.
572
+ return group(indent(fill(this.binaryParts(node, parentBp, side))));
573
+ case "list": {
574
+ const items = node.items.map((item) => this.expr(item));
575
+ const separator = node.separator === "," ? "," : "";
576
+ if (node.brackets) return this.parenGroup(items, separator, "[", "]");
577
+ /* A bare comma/space list used as an operand needs parens to
578
+ * keep its grouping against surrounding operators. */
579
+ if (node.parens || parentBp > 0)
580
+ return this.parenGroup(items, separator);
581
+ return group(indent(fill(join([separator, line], items))));
582
+ }
583
+ case "map": {
584
+ const entries = node.entries.map((entry) =>
585
+ this.pair(this.expr(entry.key), this.expr(entry.value))
586
+ );
587
+ return this.parenGroup(entries, ",");
588
+ }
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Flatten an operator chain into `fill` parts (`[a and, line, b or, line, c]`).
594
+ * A child that precedence parenthesised in the source becomes one part.
595
+ */
596
+ private binaryParts(node: Binary, parentBp: number, side?: "right"): Doc[] {
597
+ const bp = BINARY_BP[node.operator];
598
+ const parts = this.operandParts(node.left, bp);
599
+ parts[parts.length - 1] = [parts[parts.length - 1], " ", node.operator];
600
+ parts.push(line, ...this.operandParts(node.right, bp, "right"));
601
+ /* Lower-precedence child, or equal precedence on the right of
602
+ * a left-associative operator, was parenthesized in the source. */
603
+ return bp < parentBp || (bp === parentBp && side === "right")
604
+ ? [group(["(", indent([softline, fill(parts)]), softline, ")"])]
605
+ : parts;
606
+ }
607
+
608
+ private operandParts(node: Expression, bp: number, side?: "right"): Doc[] {
609
+ return node.type === "binary"
610
+ ? this.binaryParts(node, bp, side)
611
+ : [this.expr(node, bp, side)];
612
+ }
613
+ }
614
+
615
+ /** Space list or operator chain — prettier's "comma group" of several words. */
616
+ const isMultiWord = (node: Expression): boolean =>
617
+ (node.type === "list" &&
618
+ node.separator === " " &&
619
+ !node.parens &&
620
+ !node.brackets) ||
621
+ node.type === "binary";
622
+
623
+ const countNewlines = (text: string): number => text.split("\n").length - 1;
624
+
625
+ const collapseWs = (text: string): string => text.replace(/\s+/g, " ").trim();
@@ -0,0 +1,70 @@
1
+ # quark-formatter
2
+
3
+ Prettier-style formatting for Quark sheets — one call, one canonical style, nothing to configure.
4
+
5
+ ```js
6
+ import { format } from "@excom/quark-formatter";
7
+
8
+ format(`provider-fetch[is-success]{$todos:prop("provision").body;ul{content:iterate($todos)}}`);
9
+ // provider-fetch[is-success] {
10
+ // $todos: prop("provision").body;
11
+ // ul {
12
+ // content: iterate($todos);
13
+ // }
14
+ // }
15
+ ```
16
+
17
+ ## Features
18
+
19
+ - **One style** Two-space indent, 80-column wrapping, one selector per line, single blank lines between groups
20
+ - **Safe** Invalid Quark throws instead of rewriting; comments stay where they were written; formatting is idempotent
21
+ - **Editor / CLI ready** Powers Format Document in the [Nucleus & Quark extension](/nucleus/packages/nucleus-quark-highlighter) and the monorepo `format` script
22
+ - **Self-contained** Parser bundled in; runs in Node, bundlers and browsers
23
+
24
+ ## Installation
25
+
26
+ <include-content is-active template-ref="/views/install-section/install-section.html"></include-content>
27
+
28
+ ## Usage
29
+
30
+ `format(source, options?)` returns the formatted sheet as a string. The only option is `indent` (default two spaces).
31
+
32
+ <include-content data-language="js"><template>import { format } from "@excom/quark-formatter";
33
+
34
+ const pretty = format(source, { indent: "\t" });</template></include-content>
35
+
36
+ Invalid input throws `QuarkParseError` (from `@excom/quark-parser`) with the line and column, so callers leave the original file untouched:
37
+
38
+ <include-content data-language="js"><template>try {
39
+ fs.writeFileSync(file, format(fs.readFileSync(file, "utf8")));
40
+ } catch (error) {
41
+ console.error(`${file}: ${error.message}`);
42
+ }</template></include-content>
43
+
44
+ ### What gets normalized
45
+
46
+ - Whitespace, indentation and blank lines (at most one preserved between statements)
47
+ - Selector lists: one per line at rule heads, `, `-joined inside `:not()` / `:is()`
48
+ - Operator spacing, with only the parentheses the expression needs: `($a or $b) and $c`
49
+ - Accessors and call chains print compactly: `$todo.title`, `$row["id"]`, `closest("li").getAttribute("id")`
50
+ - Comments (`/* … */` only) keep their position, including trailing same-line comments
51
+ - Lines wrap at 80 columns the way prettier wraps CSS: maps, call arguments, `if()` arms, `@on` / `@dispatch` / `@command` / `@view-transition` options and `@delay` durations that overflow break one item per line, operator chains wrap like text, a comma list of multi-word values breaks one value per line
52
+
53
+ ```quark
54
+ dataset: (
55
+ trip: event.target.form.elements["data-trip"].value,
56
+ outbound: event.target.form.elements["data-outbound"].value
57
+ );
58
+ data-mode: if(
59
+ event.target.name == "data-mode": event.target.value;
60
+ else: preserve
61
+ );
62
+ ```
63
+
64
+ Strings, selectors and interpolations never wrap, so a long `content: "…#{…}…"` stays on one line. Listener at-rules print as `@on input, change (debounce: 300, handle: save) {` — the event list as written, the options group like a map — and `@dispatch` / `@command` statements the same way; `@view-transition (types: "todo-change") {` prints its options like `@on`'s and always opens a block, as does `@scope {`.
65
+
66
+ Quark is a derivative of CSS, not a superset: the formatter prints Quark's own at-rules — `@use`, `@scope`, `@on`, `@dispatch`, `@command`, `@view-transition`, `@delay`, `@warn`, `@debug`, `@error` — and nothing else. Anything the parser rejects (`@media` and the other CSS at-rules, SCSS's `@if` / `@each` / `@mixin`, `%placeholder` selectors, `#{…}` outside a string, `!important` / `!default`, nested property blocks, `@use … with (…)`) throws rather than being reformatted.
67
+
68
+ ### In the editor
69
+
70
+ Install the [Nucleus & Quark Syntax Highlighter](/nucleus/packages/nucleus-quark-highlighter) to format `.quark` files with Format Document and inline `<quark-sheet>` blocks with a command.