@terrazzo/parser 0.0.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/lint/index.js ADDED
@@ -0,0 +1,59 @@
1
+ import { pluralize } from '@terrazzo/token-tools';
2
+
3
+ const listFormat = new Intl.ListFormat('en-us');
4
+
5
+ export default async function lintRunner({ tokens, ast, config = {}, logger }) {
6
+ const { plugins = [], lint = { rules: {} } } = config;
7
+ const unusedLintRules = Object.keys(lint?.rules ?? {});
8
+
9
+ for (const plugin of plugins) {
10
+ if (typeof plugin.lint === 'function') {
11
+ const s = performance.now();
12
+ logger.debug({ group: 'plugin', task: plugin.name, message: 'Start linting' });
13
+
14
+ const rules = plugin.lint();
15
+ const errorEntries = [];
16
+ const warnEntries = [];
17
+ await Promise.all(
18
+ Object.entries(rules).map(async ([id, linter]) => {
19
+ const { severity = 'warn' } = lint.rules[id] || {};
20
+ const results = await linter({ tokens, ast, rule: { id, severity } });
21
+ for (const result of results ?? []) {
22
+ const noticeList = severity === 'error' ? errorEntries : warnEntries;
23
+ noticeList.push({
24
+ message: result.message,
25
+ ast,
26
+ node: result.node,
27
+ continueOnError: true,
28
+ });
29
+ }
30
+
31
+ // tick off used rule
32
+ const unusedLintRuleI = unusedLintRules.indexOf(id);
33
+ if (unusedLintRuleI !== -1) {
34
+ unusedLintRules.splice(unusedLintRuleI, 1);
35
+ }
36
+ }),
37
+ );
38
+ for (const entry of errorEntries) {
39
+ logger.error(entry);
40
+ }
41
+ for (const entry of warnEntries) {
42
+ logger.warn(entry);
43
+ }
44
+ logger.debug({ group: 'plugin', task: plugin.name, message: 'Finish linting', timing: performance.now() - s });
45
+ if (errorEntries.length) {
46
+ const counts = [pluralize(errorEntries.length, 'error', 'errors')];
47
+ if (warnEntries.length) {
48
+ counts.push(pluralize(warnEntries.length, 'warning', 'warnings'));
49
+ }
50
+ logger.error({ message: `Lint failed with ${listFormat.format(counts)}`, label: plugin.name });
51
+ }
52
+ }
53
+ }
54
+
55
+ // warn user if they have unused lint rules (they might have meant to configure something!)
56
+ for (const unusedRule of unusedLintRules) {
57
+ logger.warn({ group: 'parser', task: 'lint', message: `Unknown lint rule "${unusedRule}"` });
58
+ }
59
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from '../../config.js';
2
+
3
+ export default function coreLintPlugin(): Plugin;
@@ -0,0 +1,12 @@
1
+ import ruleDuplicateValues from './rules/duplicate-values.js';
2
+
3
+ export default function coreLintPlugin() {
4
+ return {
5
+ name: '@terrazzo/plugin-lint-core',
6
+ lint() {
7
+ return {
8
+ duplicateValues: ruleDuplicateValues,
9
+ };
10
+ },
11
+ };
12
+ }
@@ -0,0 +1,10 @@
1
+ import type { LintNotice, LinterOptions } from '../../index.js';
2
+
3
+ export interface RuleDuplicateValueOptions {
4
+ /** (optional) Token IDs to ignore. Supports globs (`*`). */
5
+ ignore?: string[];
6
+ }
7
+
8
+ export default function ruleDuplicateValues(
9
+ options: LinterOptions<RuleDuplicateValueOptions>,
10
+ ): Promise<LintNotice[] | undefined>;
@@ -0,0 +1,69 @@
1
+ import { isAlias, isTokenMatch } from '@terrazzo/token-tools';
2
+ import deepEqual from 'deep-equal';
3
+
4
+ export default function ruleDuplicateValues({ tokens, rule: { severity }, options }) {
5
+ if (severity === 'off') {
6
+ return;
7
+ }
8
+
9
+ const notices = [];
10
+ const values = {};
11
+
12
+ for (const id in tokens) {
13
+ if (!Object.hasOwn(tokens, id)) {
14
+ continue;
15
+ }
16
+
17
+ const t = tokens[id];
18
+
19
+ // skip ignored tokens
20
+ if (options?.ignore && isTokenMatch(id, options.ignore)) {
21
+ return;
22
+ }
23
+
24
+ if (!values[t.$type]) {
25
+ values[t.$type] = new Set();
26
+ }
27
+
28
+ // primitives: direct comparison is easy
29
+ if (
30
+ t.$type === 'color' ||
31
+ t.$type === 'dimension' ||
32
+ t.$type === 'duration' ||
33
+ t.$type === 'link' ||
34
+ t.$type === 'number' ||
35
+ t.$type === 'fontWeight'
36
+ ) {
37
+ // skip aliases (note: $value will be resolved)
38
+ if (isAlias(t._original.$value)) {
39
+ return;
40
+ }
41
+
42
+ if (values[t.$type]?.has(t.$value)) {
43
+ notices.push({ message: `Duplicated value: "${t.$value}" (${t.id})`, node: t.sourceNode });
44
+ return;
45
+ }
46
+
47
+ values[t.$type]?.add(t.$value);
48
+ return;
49
+ }
50
+
51
+ // everything else: use deepEqual
52
+ let isDuplicate = false;
53
+ for (const v of values[t.$type]?.values() ?? []) {
54
+ if (deepEqual(t.$value, v)) {
55
+ notices.push({ message: `Duplicated value (${t.id})`, node: t.sourceNode });
56
+ isDuplicate = true;
57
+ break;
58
+ }
59
+ }
60
+
61
+ if (isDuplicate) {
62
+ continue;
63
+ }
64
+
65
+ values[t.$type]?.add(t.$value);
66
+ }
67
+
68
+ return notices;
69
+ }
package/logger.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { SourceLocation } from '@babel/code-frame';
2
+ import type { AnyNode, DocumentNode } from '@humanwhocodes/momoa';
3
+
4
+ declare const LOG_ORDER: readonly ['error', 'warn', 'info', 'debug'];
5
+
6
+ export type LogSeverity = 'error' | 'warn' | 'info' | 'debug';
7
+
8
+ export type LogLevel = LogSeverity | 'silent';
9
+
10
+ export type LogGroup = 'core' | 'plugin';
11
+
12
+ export interface LogEntry {
13
+ /** Error message to be logged */
14
+ message: string;
15
+ /** (optional) Prefix message with label */
16
+ label?: string;
17
+ /** Continue on error? (default: false) */
18
+ continueOnError?: boolean;
19
+ /** (optional) Show a code frame for the erring node */
20
+ node?: AnyNode;
21
+ /** (optional) To show code frame, provide entire AST to show which line erred (otherwise it’s floating in space) */
22
+ ast?: DocumentNode;
23
+ /** (optional) To highlight a specifc part of the code frame, provide line no. (1-based) and col. no. */
24
+ loc?: SourceLocation['start'];
25
+ /** (optional) MANUAL codeFrame override (only use for non-JSON errors, like YAML) */
26
+ code?: string;
27
+ }
28
+
29
+ export interface DebugEntry {
30
+ /** `parser` | `plugin` */
31
+ group: 'parser' | 'plugin';
32
+ /** Current subtask or submodule */
33
+ task: string;
34
+ /** Error message to be logged */
35
+ message: string;
36
+ /** (optional) Show code below message */
37
+ codeFrame?: { code: string; line: number; column: number };
38
+ /** (optional) Display performance timing */
39
+ timing?: number;
40
+ }
41
+
42
+ export default class Logger {
43
+ constructor(options?: { level?: LogLevel; debugScope: string });
44
+ }
45
+
46
+ export class TokensJSONError extends Error {
47
+ level: LogLevel;
48
+ debugScope: string;
49
+ node?: AnyNode;
50
+
51
+ constructor(message: string, node: AnyNode);
52
+
53
+ setLevel(level: LogLevel): void;
54
+
55
+ /** Log an error message (always; can’t be silenced) */
56
+ error(entry: LogEntry): void;
57
+
58
+ /** Log an info message (if logging level permits) */
59
+ info(entry: LogEntry): void;
60
+
61
+ /** Log a warning message (if logging level permits) */
62
+ warn(entry: LogEntry): void;
63
+
64
+ /** Log a diagnostics message (if logging level permits) */
65
+ debug(entry: DebugEntry): void;
66
+ }
package/logger.js ADDED
@@ -0,0 +1,121 @@
1
+ import { codeFrameColumns } from '@babel/code-frame';
2
+ import { print } from '@humanwhocodes/momoa';
3
+ import color from 'picocolors';
4
+ import wcmatch from 'wildcard-match';
5
+
6
+ export const LOG_ORDER = ['error', 'warn', 'info', 'debug'];
7
+
8
+ const DEBUG_GROUP_COLOR = { core: color.green, plugin: color.magenta };
9
+
10
+ const MESSAGE_COLOR = { error: color.red, warn: color.yellow };
11
+
12
+ const timeFormatter = new Intl.DateTimeFormat('en-gb', { timeStyle: 'medium' });
13
+
14
+ /**
15
+ * @param {Entry} entry
16
+ * @param {Severity} severity
17
+ * @return {string}
18
+ */
19
+ export function formatMessage(entry, severity) {
20
+ let message = entry.message;
21
+ if (entry.label) {
22
+ message = `${color.bold(`${entry.label}:`)} ${message}`;
23
+ }
24
+ if (severity in MESSAGE_COLOR) {
25
+ message = MESSAGE_COLOR[severity](message);
26
+ }
27
+ if (entry.ast || entry.code) {
28
+ message = `${message}\n\n${codeFrameColumns(entry.ast ? print(entry.ast, { indent: 2 }) : entry.code, {
29
+ start: entry.loc ?? { line: 1 },
30
+ })}`;
31
+ }
32
+ return message;
33
+ }
34
+
35
+ export default class Logger {
36
+ level = 'info';
37
+ debugScope = '*';
38
+
39
+ constructor(options) {
40
+ if (options?.level) {
41
+ this.level = options.level;
42
+ }
43
+ if (options?.debugScope) {
44
+ this.debugScope = options.debugScope;
45
+ }
46
+ }
47
+
48
+ setLevel(level) {
49
+ this.level = level;
50
+ }
51
+
52
+ /** Log an error message (always; can’t be silenced) */
53
+ error(entry) {
54
+ const message = formatMessage(entry, 'error');
55
+ if (entry.continueOnError) {
56
+ console.error(message);
57
+ return;
58
+ }
59
+ if (entry.node) {
60
+ throw new TokensJSONError(message, entry.node);
61
+ } else {
62
+ throw new Error(message);
63
+ }
64
+ }
65
+
66
+ /** Log an info message (if logging level permits) */
67
+ info(entry) {
68
+ if (this.level === 'silent' || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf('info')) {
69
+ return;
70
+ }
71
+ // biome-ignore lint/suspicious/noConsoleLog: this is its job
72
+ console.log(formatMessage(entry, 'info'));
73
+ }
74
+
75
+ /** Log a warning message (if logging level permits) */
76
+ warn(entry) {
77
+ if (this.level === 'silent' || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf('warn')) {
78
+ return;
79
+ }
80
+ console.warn(formatMessage(entry, 'warn'));
81
+ }
82
+
83
+ /** Log a diagnostics message (if logging level permits) */
84
+ debug(entry) {
85
+ if (this.level === 'silent' || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf('debug')) {
86
+ return;
87
+ }
88
+
89
+ let message = formatMessage(entry, 'debug');
90
+
91
+ const debugPrefix = `${entry.group}:${entry.task}`;
92
+ if (!wcmatch(this.debugScope)(debugPrefix)) {
93
+ return;
94
+ }
95
+ message = `${DEBUG_GROUP_COLOR[entry.group || 'core'](debugPrefix)} ${color.dim(
96
+ timeFormatter.format(new Date()),
97
+ )} ${message}`;
98
+ if (typeof entry.timing === 'number') {
99
+ let timing = Math.round(entry.timing);
100
+ if (timing < 1_000) {
101
+ timing = `${timing}ms`;
102
+ } else if (timing < 60_000) {
103
+ timing = `${Math.round(timing * 100) / 100_000}s`;
104
+ }
105
+ message = `${message} ${color.dim(`[${timing}]`)}`;
106
+ }
107
+
108
+ console.debug(message);
109
+ }
110
+ }
111
+
112
+ export class TokensJSONError extends Error {
113
+ /** Erring JSON node */
114
+ node;
115
+
116
+ constructor(message, node) {
117
+ super(message);
118
+ this.name = 'TokensJSONError';
119
+ this.node = node;
120
+ }
121
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@terrazzo/parser",
3
+ "description": "Parser/validator for the Design Tokens Community Group (DTCG) standard.",
4
+ "version": "0.0.0",
5
+ "author": {
6
+ "name": "Drew Powers",
7
+ "email": "drew@pow.rs"
8
+ },
9
+ "keywords": [
10
+ "design tokens",
11
+ "design tokens community group",
12
+ "design tokens format module",
13
+ "dtcg",
14
+ "cli",
15
+ "w3c design tokens",
16
+ "design system",
17
+ "typescript",
18
+ "sass",
19
+ "css",
20
+ "style tokens",
21
+ "style system",
22
+ "linting",
23
+ "lint"
24
+ ],
25
+ "license": "MIT",
26
+ "type": "module",
27
+ "main": "./index.js",
28
+ "dependencies": {
29
+ "@babel/code-frame": "^7.24.2",
30
+ "@humanwhocodes/momoa": "^3.0.1",
31
+ "@types/babel__code-frame": "^7.0.6",
32
+ "@types/culori": "^2.1.0",
33
+ "@types/deep-equal": "^1.0.4",
34
+ "culori": "^4.0.1",
35
+ "deep-equal": "^2.2.3",
36
+ "is-what": "^4.1.16",
37
+ "merge-anything": "^5.1.7",
38
+ "picocolors": "^1.0.0",
39
+ "wildcard-match": "^5.1.3",
40
+ "yaml": "^2.4.1",
41
+ "@terrazzo/token-tools": "^0.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "strip-ansi": "^7.1.0"
45
+ },
46
+ "scripts": {
47
+ "lint": "biome check .",
48
+ "test": "pnpm run \"/^test:.*/\"",
49
+ "test:js": "vitest run",
50
+ "test:ts": "tsc --noEmit"
51
+ }
52
+ }
@@ -0,0 +1,32 @@
1
+ import type { DocumentNode } from '@humanwhocodes/momoa';
2
+ import type { ConfigInit } from '../config.js';
3
+ import type { TokenNormalized } from '../types.js';
4
+ import type Logger from '../logger.js';
5
+
6
+ export * from './validate.js';
7
+
8
+ export interface ParseOptions {
9
+ logger?: Logger;
10
+ /** Skip lint step (default: false) */
11
+ skipLint?: boolean;
12
+ config: ConfigInit;
13
+ }
14
+
15
+ export interface ParseResult {
16
+ tokens: Record<string, TokenNormalized>;
17
+ ast: DocumentNode;
18
+ }
19
+
20
+ /**
21
+ * Parse and validate Tokens JSON, and lint it
22
+ */
23
+ export default function parse(input: string | object, options?: ParseOptions): Promise<ParseResult>;
24
+
25
+ /** Determine if an input is likely a JSON string */
26
+ export function maybeJSONString(input: unknown): boolean;
27
+
28
+ /** Resolve alias */
29
+ export function resolveAlias(
30
+ alias: string,
31
+ options: { tokens: Record<string, TokenNormalized>; logger: Logger; path: string[] },
32
+ ): string | undefined;