@prongsjs/ui 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/classes.d.ts +23 -0
- package/dist/classes.js +98 -0
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +36 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/profile.d.ts +26 -0
- package/dist/profile.js +101 -0
- package/dist/tooling/analyze.d.ts +42 -0
- package/dist/tooling/analyze.js +175 -0
- package/dist/tooling/components.d.ts +24 -0
- package/dist/tooling/components.js +161 -0
- package/dist/tooling/index.d.ts +17 -0
- package/dist/tooling/index.js +540 -0
- package/dist/tooling/spec.d.ts +24 -0
- package/dist/tooling/spec.js +453 -0
- package/dist/tooling/theme.d.ts +83 -0
- package/dist/tooling/theme.js +288 -0
- package/dist/vocabulary.d.ts +32 -0
- package/dist/vocabulary.js +57 -0
- package/package.json +72 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Drew Poling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @prongsjs/ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Prongs UI profile: intents that resolve to an element, ARIA attributes and Tailwind classes, the theme (`@prongsjs/ui/theme`, with self-layering surfaces), and the checks that find raw styling and overrides.
|
|
4
|
+
|
|
5
|
+
`npx prongs add` copies the components (`Text`, `Action`, `Field`, `Layout`, `Media`, `Dialog`, `Popover`) and a starter registry into your repo, so you own them. Peer dependency: `tailwind-merge`. Early (0.x): expect changes. Full guide: https://github.com/drewpoling2/prongs#readme
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifies Tailwind classes as layout (where an element sits) or visual
|
|
3
|
+
* (what it looks like). Intents own visual identity; layout is the only thing
|
|
4
|
+
* a call site may add, and only on Layout.
|
|
5
|
+
*/
|
|
6
|
+
/** Removes variants (`md:`, `hover:`, `[&>*]:`) and the important modifier. */
|
|
7
|
+
export declare function stripVariants(cls: string): string;
|
|
8
|
+
export declare function isLayoutClass(cls: string): boolean;
|
|
9
|
+
/** True for classes that set text size, colour, weight or style. */
|
|
10
|
+
export declare function isTextStyleClass(cls: string): boolean;
|
|
11
|
+
/** True for any class that changes how something looks rather than where it sits. */
|
|
12
|
+
export declare function isVisualClass(cls: string): boolean;
|
|
13
|
+
export declare function splitClasses(value: string | undefined | null): string[];
|
|
14
|
+
/**
|
|
15
|
+
* What a className does to an intent's classes: which visual classes it adds,
|
|
16
|
+
* and which of the intent's classes it overrides.
|
|
17
|
+
*/
|
|
18
|
+
export declare function analyzeClassName(intentClasses: string | undefined, className: string | undefined, merge: (...c: (string | undefined)[]) => string): {
|
|
19
|
+
visual: string[];
|
|
20
|
+
overridden: string[];
|
|
21
|
+
};
|
|
22
|
+
/** Jaccard similarity of two class lists, ignoring variants. */
|
|
23
|
+
export declare function classSimilarity(a: readonly string[], b: readonly string[]): number;
|
package/dist/classes.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifies Tailwind classes as layout (where an element sits) or visual
|
|
3
|
+
* (what it looks like). Intents own visual identity; layout is the only thing
|
|
4
|
+
* a call site may add, and only on Layout.
|
|
5
|
+
*/
|
|
6
|
+
/** Removes variants (`md:`, `hover:`, `[&>*]:`) and the important modifier. */
|
|
7
|
+
export function stripVariants(cls) {
|
|
8
|
+
let depth = 0;
|
|
9
|
+
let start = 0;
|
|
10
|
+
for (let i = 0; i < cls.length; i++) {
|
|
11
|
+
const ch = cls[i];
|
|
12
|
+
if (ch === '[' || ch === '(')
|
|
13
|
+
depth++;
|
|
14
|
+
else if (ch === ']' || ch === ')')
|
|
15
|
+
depth--;
|
|
16
|
+
else if (ch === ':' && depth === 0)
|
|
17
|
+
start = i + 1;
|
|
18
|
+
}
|
|
19
|
+
return cls.slice(start).replace(/^!/, '').replace(/!$/, '');
|
|
20
|
+
}
|
|
21
|
+
const LAYOUT_PATTERNS = [
|
|
22
|
+
// box size and position
|
|
23
|
+
/^-?m[trblxyse]?-/,
|
|
24
|
+
/^(min-|max-)?[wh]-/,
|
|
25
|
+
/^size-/,
|
|
26
|
+
/^(static|fixed|absolute|relative|sticky)$/,
|
|
27
|
+
/^-?(inset|top|right|bottom|left|start|end)(-[xy])?-/,
|
|
28
|
+
/^z-/,
|
|
29
|
+
/^(hidden|block|inline|inline-block|contents|flow-root|sr-only|not-sr-only)$/,
|
|
30
|
+
/^(overflow|overscroll)(-[xy])?-/,
|
|
31
|
+
/^-?scroll-[mp][trblxyse]?-/,
|
|
32
|
+
/^aspect-/,
|
|
33
|
+
/^object-/,
|
|
34
|
+
// flex and grid
|
|
35
|
+
/^(flex|inline-flex|grid|inline-grid)$/,
|
|
36
|
+
// flex-1, flex-3, flex-[2_1_0%]: how a box shares the row, which is layout.
|
|
37
|
+
/^flex-(row|col|wrap|nowrap|auto|initial|none|\d+|\[[^\]]+\])/,
|
|
38
|
+
/^(flex-)?(grow|shrink)(-\d+|-\[[^\]]+\])?$/,
|
|
39
|
+
/^basis-/,
|
|
40
|
+
/^order-/,
|
|
41
|
+
/^(items|justify|content|self|place)-/,
|
|
42
|
+
/^(col|row)-/,
|
|
43
|
+
/^grid-(cols|rows|flow)-/,
|
|
44
|
+
/^auto-(cols|rows)-/,
|
|
45
|
+
// spacing between and inside
|
|
46
|
+
/^gap(-[xy])?-/,
|
|
47
|
+
/^-?space-[xy]-/,
|
|
48
|
+
/^p[trblxyse]?-/,
|
|
49
|
+
// text flow (not text style)
|
|
50
|
+
/^text-(left|center|right|justify|start|end|balance|pretty|wrap|nowrap)$/,
|
|
51
|
+
/^(truncate|text-ellipsis|text-clip)$/,
|
|
52
|
+
/^line-clamp-/,
|
|
53
|
+
/^whitespace-/,
|
|
54
|
+
/^break-/,
|
|
55
|
+
];
|
|
56
|
+
export function isLayoutClass(cls) {
|
|
57
|
+
const base = stripVariants(cls);
|
|
58
|
+
return LAYOUT_PATTERNS.some((pattern) => pattern.test(base));
|
|
59
|
+
}
|
|
60
|
+
const TEXT_STYLE = /^(font-|leading-|tracking-|(uppercase|lowercase|capitalize|normal-case|italic|not-italic|underline|line-through|tabular-nums)$)/;
|
|
61
|
+
/** True for classes that set text size, colour, weight or style. */
|
|
62
|
+
export function isTextStyleClass(cls) {
|
|
63
|
+
const base = stripVariants(cls);
|
|
64
|
+
if (TEXT_STYLE.test(base))
|
|
65
|
+
return true;
|
|
66
|
+
return base.startsWith('text-') && !isLayoutClass(base);
|
|
67
|
+
}
|
|
68
|
+
/** True for any class that changes how something looks rather than where it sits. */
|
|
69
|
+
export function isVisualClass(cls) {
|
|
70
|
+
return !isLayoutClass(cls);
|
|
71
|
+
}
|
|
72
|
+
export function splitClasses(value) {
|
|
73
|
+
return (value ?? '').split(/\s+/).filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* What a className does to an intent's classes: which visual classes it adds,
|
|
77
|
+
* and which of the intent's classes it overrides.
|
|
78
|
+
*/
|
|
79
|
+
export function analyzeClassName(intentClasses, className, merge) {
|
|
80
|
+
const own = splitClasses(intentClasses);
|
|
81
|
+
const merged = new Set(splitClasses(merge(intentClasses, className)));
|
|
82
|
+
return {
|
|
83
|
+
visual: splitClasses(className).filter(isVisualClass),
|
|
84
|
+
overridden: own.filter((cls) => !merged.has(cls)),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Jaccard similarity of two class lists, ignoring variants. */
|
|
88
|
+
export function classSimilarity(a, b) {
|
|
89
|
+
const left = new Set(a.map(stripVariants));
|
|
90
|
+
const right = new Set(b.map(stripVariants));
|
|
91
|
+
if (left.size === 0 && right.size === 0)
|
|
92
|
+
return 0;
|
|
93
|
+
let shared = 0;
|
|
94
|
+
for (const cls of left)
|
|
95
|
+
if (right.has(cls))
|
|
96
|
+
shared++;
|
|
97
|
+
return shared / (left.size + right.size - shared);
|
|
98
|
+
}
|
package/dist/dev.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function isDev(): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* When elements carry `data-intent`: `always` (the default) so analytics, tests and A/B tools can
|
|
4
|
+
* find elements by purpose in production; `development` to keep intent names out of production
|
|
5
|
+
* HTML; `never` to leave it off.
|
|
6
|
+
*/
|
|
7
|
+
export type IntentAttribute = 'always' | 'development' | 'never';
|
|
8
|
+
/** The value for `data-intent` under the project's setting, or undefined to leave it off. */
|
|
9
|
+
export declare function intentAttr(intent: string, mode?: IntentAttribute): string | undefined;
|
|
10
|
+
/** The intent id in development, undefined in production. Kept for components written before `intentAttr`. */
|
|
11
|
+
export declare function devIntent(intent: string): string | undefined;
|
|
12
|
+
export declare function warnOnce(key: string, message: string): void;
|
|
13
|
+
/** Warns (once, in development) when a Layout's className carries visual classes. */
|
|
14
|
+
export declare function checkLayoutClassName(intent: string, className: string | undefined): void;
|
package/dist/dev.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isVisualClass, splitClasses } from './classes.js';
|
|
2
|
+
export function isDev() {
|
|
3
|
+
try {
|
|
4
|
+
return process.env.NODE_ENV !== 'production';
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** The value for `data-intent` under the project's setting, or undefined to leave it off. */
|
|
11
|
+
export function intentAttr(intent, mode = 'always') {
|
|
12
|
+
if (mode === 'always')
|
|
13
|
+
return intent;
|
|
14
|
+
return mode === 'development' && isDev() ? intent : undefined;
|
|
15
|
+
}
|
|
16
|
+
/** The intent id in development, undefined in production. Kept for components written before `intentAttr`. */
|
|
17
|
+
export function devIntent(intent) {
|
|
18
|
+
return intentAttr(intent, 'development');
|
|
19
|
+
}
|
|
20
|
+
const warned = new Set();
|
|
21
|
+
export function warnOnce(key, message) {
|
|
22
|
+
if (!isDev() || warned.has(key))
|
|
23
|
+
return;
|
|
24
|
+
warned.add(key);
|
|
25
|
+
console.warn(`[prongs] ${message}`);
|
|
26
|
+
}
|
|
27
|
+
/** Warns (once, in development) when a Layout's className carries visual classes. */
|
|
28
|
+
export function checkLayoutClassName(intent, className) {
|
|
29
|
+
if (!className || !isDev())
|
|
30
|
+
return;
|
|
31
|
+
const visual = splitClasses(className).filter(isVisualClass);
|
|
32
|
+
if (visual.length === 0)
|
|
33
|
+
return;
|
|
34
|
+
warnOnce(`${intent}|${className}`, `${intent}: className "${className}" has visual classes (${visual.join(' ')}). ` +
|
|
35
|
+
'Layout className is for layout and spacing only. Add or change an intent, or use unsafeClassName with a reason.');
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createUiProfile, uiProfile } from './profile.js';
|
|
2
|
+
export type { ClassMerge, UiBinding, UiProfile, UiProfileOptions } from './profile.js';
|
|
3
|
+
export { ALLOWED_ELEMENTS, COMPONENT_DOMAINS, COMPONENT_NAMES, LOOK_DOMAINS, LOOK_WORDS, MEANING_DOMAINS, componentFor, isLookDomain, } from './vocabulary.js';
|
|
4
|
+
export type { ComponentName, LookDomain, MeaningDomain } from './vocabulary.js';
|
|
5
|
+
export { analyzeClassName, classSimilarity, isLayoutClass, isTextStyleClass, isVisualClass, splitClasses, stripVariants, } from './classes.js';
|
|
6
|
+
export { checkLayoutClassName, devIntent, intentAttr, isDev, warnOnce, type IntentAttribute } from './dev.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createUiProfile, uiProfile } from './profile.js';
|
|
2
|
+
export { ALLOWED_ELEMENTS, COMPONENT_DOMAINS, COMPONENT_NAMES, LOOK_DOMAINS, LOOK_WORDS, MEANING_DOMAINS, componentFor, isLookDomain, } from './vocabulary.js';
|
|
3
|
+
export { analyzeClassName, classSimilarity, isLayoutClass, isTextStyleClass, isVisualClass, splitClasses, stripVariants, } from './classes.js';
|
|
4
|
+
export { checkLayoutClassName, devIntent, intentAttr, isDev, warnOnce } from './dev.js';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Profile } from '@prongsjs/core';
|
|
2
|
+
export type UiBinding = {
|
|
3
|
+
/** Tailwind classes. Keep them in your own source so Tailwind can see them. */
|
|
4
|
+
classes?: string;
|
|
5
|
+
/** Element the component renders, e.g. 'h1' or 'textarea'. */
|
|
6
|
+
element?: string;
|
|
7
|
+
ariaRole?: string;
|
|
8
|
+
/** Extra attributes the element needs to behave correctly, e.g. { type: 'email' }. */
|
|
9
|
+
behavior?: Readonly<Record<string, string | boolean>>;
|
|
10
|
+
};
|
|
11
|
+
export type ClassMerge = (...classes: (string | false | null | undefined)[]) => string;
|
|
12
|
+
export type UiProfile = Profile<UiBinding> & {
|
|
13
|
+
/** The class merger this profile uses. Components use it too, so conflicts resolve the same way. */
|
|
14
|
+
classes: ClassMerge;
|
|
15
|
+
};
|
|
16
|
+
export type UiProfileOptions = {
|
|
17
|
+
/** A tailwind-merge instance configured for your theme (extendTailwindMerge). Defaults to twMerge. */
|
|
18
|
+
merge?: ClassMerge;
|
|
19
|
+
/**
|
|
20
|
+
* Words meaning intents may not use in their ids, because they name a look or a widget.
|
|
21
|
+
* Defaults to LOOK_WORDS. Pass a function to adjust the list: (words) => words.filter(...).
|
|
22
|
+
*/
|
|
23
|
+
lookWords?: readonly string[] | ((defaults: readonly string[]) => readonly string[]);
|
|
24
|
+
};
|
|
25
|
+
export declare function createUiProfile(options?: UiProfileOptions): UiProfile;
|
|
26
|
+
export declare const uiProfile: UiProfile;
|
package/dist/profile.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { twMerge } from 'tailwind-merge';
|
|
2
|
+
import { domainOf } from '@prongsjs/core';
|
|
3
|
+
import { ALLOWED_ELEMENTS, LOOK_DOMAINS, LOOK_WORDS, MEANING_DOMAINS, componentFor, isLookDomain, } from './vocabulary.js';
|
|
4
|
+
export function createUiProfile(options = {}) {
|
|
5
|
+
const classes = options.merge ?? twMerge;
|
|
6
|
+
const lookWords = new Set(typeof options.lookWords === 'function' ? options.lookWords(LOOK_WORDS) : options.lookWords ?? LOOK_WORDS);
|
|
7
|
+
return {
|
|
8
|
+
name: 'ui',
|
|
9
|
+
domains: [...MEANING_DOMAINS, ...LOOK_DOMAINS],
|
|
10
|
+
internalDomains: LOOK_DOMAINS,
|
|
11
|
+
classes,
|
|
12
|
+
merge(base, next) {
|
|
13
|
+
if (!base)
|
|
14
|
+
return { ...next };
|
|
15
|
+
if (!next)
|
|
16
|
+
return base;
|
|
17
|
+
return {
|
|
18
|
+
classes: classes(base.classes, next.classes) || undefined,
|
|
19
|
+
element: next.element ?? base.element,
|
|
20
|
+
ariaRole: next.ariaRole ?? base.ariaRole,
|
|
21
|
+
behavior: base.behavior || next.behavior ? { ...base.behavior, ...next.behavior } : undefined,
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
validate(id, intent) {
|
|
25
|
+
const problems = [];
|
|
26
|
+
const domain = domainOf(id);
|
|
27
|
+
const binding = intent.binding;
|
|
28
|
+
for (const ref of intent.refs ?? []) {
|
|
29
|
+
const refDomain = domainOf(ref);
|
|
30
|
+
if (isLookDomain(domain) && !isLookDomain(refDomain)) {
|
|
31
|
+
problems.push(`look intents may only ref other look intents, not "${ref}"`);
|
|
32
|
+
}
|
|
33
|
+
if (!isLookDomain(domain) && !isLookDomain(refDomain) && refDomain !== domain) {
|
|
34
|
+
problems.push(`may ref look intents or intents in its own domain, not "${ref}"`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (isLookDomain(domain)) {
|
|
38
|
+
if (binding?.element || binding?.ariaRole || binding?.behavior) {
|
|
39
|
+
problems.push('look intents only carry classes; element, ariaRole and behavior belong on meaning intents');
|
|
40
|
+
}
|
|
41
|
+
return problems;
|
|
42
|
+
}
|
|
43
|
+
for (const word of id.split('.').slice(1)) {
|
|
44
|
+
if (lookWords.has(word)) {
|
|
45
|
+
problems.push(`"${word}" describes a look or a widget, not a purpose. Name the intent for what the element is for or does (e.g. action.share, layout.group, nav.view); put looks in the look layer.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const component = componentFor(id);
|
|
49
|
+
if (binding?.element && component && !ALLOWED_ELEMENTS[component].includes(binding.element)) {
|
|
50
|
+
problems.push(ALLOWED_ELEMENTS[component].length
|
|
51
|
+
? `element "${binding.element}" is not allowed for ${component} (allowed: ${ALLOWED_ELEMENTS[component].join(', ')})`
|
|
52
|
+
: `${component} decides its own element; remove "element"`);
|
|
53
|
+
}
|
|
54
|
+
if (binding?.behavior && domain === 'popover') {
|
|
55
|
+
const keys = Object.keys(binding.behavior);
|
|
56
|
+
if (keys.some((key) => key !== 'selection') || !['one', 'many'].includes(String(binding.behavior.selection))) {
|
|
57
|
+
problems.push("a popover's behavior is only { selection: 'one' | 'many' }: how many of its options can be picked");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (binding?.behavior && !['action', 'nav', 'choice', 'field'].includes(domain)) {
|
|
61
|
+
problems.push('behavior is only for action, nav, choice and field intents, and selection on popover intents');
|
|
62
|
+
}
|
|
63
|
+
return problems;
|
|
64
|
+
},
|
|
65
|
+
validateAll(defs) {
|
|
66
|
+
// Neighbours must contrast: in a domain with more than two intents, each one needs a
|
|
67
|
+
// "not for ... use instead" link to or from a sibling, or an agent cannot tell them apart.
|
|
68
|
+
const issues = [];
|
|
69
|
+
const byDomain = new Map();
|
|
70
|
+
for (const id of Object.keys(defs)) {
|
|
71
|
+
const domain = domainOf(id);
|
|
72
|
+
// Variants (domain.meaning.variant) are told apart by their parent's name.
|
|
73
|
+
if (isLookDomain(domain) || id.split('.').length > 2)
|
|
74
|
+
continue;
|
|
75
|
+
byDomain.set(domain, [...(byDomain.get(domain) ?? []), id]);
|
|
76
|
+
}
|
|
77
|
+
for (const ids of byDomain.values()) {
|
|
78
|
+
if (ids.length <= 2)
|
|
79
|
+
continue;
|
|
80
|
+
const linked = new Set();
|
|
81
|
+
for (const id of ids) {
|
|
82
|
+
for (const e of defs[id].notFor ?? []) {
|
|
83
|
+
if (e.instead && ids.includes(e.instead)) {
|
|
84
|
+
linked.add(id);
|
|
85
|
+
linked.add(e.instead);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const id of ids.filter((i) => !linked.has(i))) {
|
|
90
|
+
issues.push({
|
|
91
|
+
id,
|
|
92
|
+
severity: 'warning',
|
|
93
|
+
message: `no "not for ... use instead" link to or from another ${domainOf(id)}.* intent; agents will struggle to tell it apart from its neighbours`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return issues;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export const uiProfile = createUiProfile();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SourceFile } from '@prongsjs/core/engine';
|
|
2
|
+
import { type ComponentName } from '../vocabulary.js';
|
|
3
|
+
/** A JSX attribute's value, reduced to what static analysis can know. */
|
|
4
|
+
export type StaticValue = {
|
|
5
|
+
kind: 'absent';
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'strings';
|
|
8
|
+
values: string[];
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'dynamic';
|
|
11
|
+
};
|
|
12
|
+
export type JsxSite = {
|
|
13
|
+
tag: string;
|
|
14
|
+
/** Set when the tag is a Prongs component imported from the components dir. */
|
|
15
|
+
component?: ComponentName;
|
|
16
|
+
/** True when the tag is next/link's Link. */
|
|
17
|
+
nextLink: boolean;
|
|
18
|
+
offset: number;
|
|
19
|
+
line: number;
|
|
20
|
+
attrs: Map<string, StaticValue>;
|
|
21
|
+
/** Property names of a literal `style={{...}}` object, when there is one. */
|
|
22
|
+
styleKeys?: string[];
|
|
23
|
+
/** CSS variables set in `style` or `vars` to a literal colour, like '--panel': 'rgb(39 39 42)'. */
|
|
24
|
+
colorVars?: {
|
|
25
|
+
attr: string;
|
|
26
|
+
name: string;
|
|
27
|
+
value: string;
|
|
28
|
+
}[];
|
|
29
|
+
/** Source text of the onClick handler, if any. */
|
|
30
|
+
handlerText?: string;
|
|
31
|
+
/** Plain text directly inside the element. */
|
|
32
|
+
childText?: string;
|
|
33
|
+
};
|
|
34
|
+
export type FileAnalysis = {
|
|
35
|
+
sites: JsxSite[];
|
|
36
|
+
lines: string[];
|
|
37
|
+
};
|
|
38
|
+
/** A colour written as a value: #rrggbb, rgb(), hsl(), oklch()... */
|
|
39
|
+
export declare const COLOR_LITERAL: RegExp;
|
|
40
|
+
export declare function analyzeFile(file: SourceFile, componentsDir: string): FileAnalysis;
|
|
41
|
+
/** True when one of the two lines above `line` (or the line itself) has `prongs-<marker>: <reason>`. */
|
|
42
|
+
export declare function hasMarker(analysis: FileAnalysis, line: number, marker: 'unsafe' | 'ignore'): boolean;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { COMPONENT_NAMES } from '../vocabulary.js';
|
|
3
|
+
const CLASS_HELPERS = new Set(['cn', 'clsx', 'cx', 'classNames', 'twMerge', 'twJoin']);
|
|
4
|
+
function staticStrings(expr) {
|
|
5
|
+
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr))
|
|
6
|
+
return [expr.text];
|
|
7
|
+
if (ts.isParenthesizedExpression(expr))
|
|
8
|
+
return staticStrings(expr.expression);
|
|
9
|
+
if (ts.isConditionalExpression(expr)) {
|
|
10
|
+
const a = staticStrings(expr.whenTrue);
|
|
11
|
+
const b = staticStrings(expr.whenFalse);
|
|
12
|
+
return a && b ? [...a, ...b] : null;
|
|
13
|
+
}
|
|
14
|
+
if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
15
|
+
return staticStrings(expr.right);
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
/** Class strings from a className value: literals, ternaries and cn()/clsx() string arguments. */
|
|
20
|
+
function classStrings(expr) {
|
|
21
|
+
const direct = staticStrings(expr);
|
|
22
|
+
if (direct)
|
|
23
|
+
return [direct.join(' ')];
|
|
24
|
+
if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) && CLASS_HELPERS.has(expr.expression.text)) {
|
|
25
|
+
const parts = expr.arguments.flatMap((arg) => staticStrings(arg) ?? []);
|
|
26
|
+
return parts.length ? [parts.join(' ')] : null;
|
|
27
|
+
}
|
|
28
|
+
if (ts.isTemplateExpression(expr)) {
|
|
29
|
+
const parts = [expr.head.text, ...expr.templateSpans.map((s) => s.literal.text)];
|
|
30
|
+
return [parts.join(' ')];
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/** A colour written as a value: #rrggbb, rgb(), hsl(), oklch()... */
|
|
35
|
+
export const COLOR_LITERAL = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i;
|
|
36
|
+
/** `{...}`, also when wrapped in parentheses or `as` / `satisfies`. */
|
|
37
|
+
function unwrapObject(expr) {
|
|
38
|
+
let current = expr;
|
|
39
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current))
|
|
40
|
+
current = current.expression;
|
|
41
|
+
return ts.isObjectLiteralExpression(current) ? current : undefined;
|
|
42
|
+
}
|
|
43
|
+
function attrValue(name, init) {
|
|
44
|
+
if (!init)
|
|
45
|
+
return { kind: 'strings', values: ['true'] };
|
|
46
|
+
if (ts.isStringLiteral(init))
|
|
47
|
+
return { kind: 'strings', values: [init.text] };
|
|
48
|
+
if (ts.isJsxExpression(init) && init.expression) {
|
|
49
|
+
const values = name.toLowerCase().includes('classname')
|
|
50
|
+
? classStrings(init.expression)
|
|
51
|
+
: staticStrings(init.expression);
|
|
52
|
+
return values ? { kind: 'strings', values } : { kind: 'dynamic' };
|
|
53
|
+
}
|
|
54
|
+
return { kind: 'dynamic' };
|
|
55
|
+
}
|
|
56
|
+
function isComponentsImport(specifier, fromPath, componentsDir) {
|
|
57
|
+
const dir = componentsDir.replace(/\/+$/, '');
|
|
58
|
+
if (specifier.startsWith('.')) {
|
|
59
|
+
const base = fromPath.split('/').slice(0, -1);
|
|
60
|
+
for (const part of specifier.split('/')) {
|
|
61
|
+
if (part === '..')
|
|
62
|
+
base.pop();
|
|
63
|
+
else if (part !== '.')
|
|
64
|
+
base.push(part);
|
|
65
|
+
}
|
|
66
|
+
const resolved = base.join('/');
|
|
67
|
+
return resolved === dir || resolved.startsWith(dir + '/');
|
|
68
|
+
}
|
|
69
|
+
// Path aliases such as "@/components/prongs/Text": match on the directory's trailing segments.
|
|
70
|
+
const tail = dir.split('/').slice(-2).join('/');
|
|
71
|
+
return specifier.includes(tail + '/') || specifier.endsWith(tail);
|
|
72
|
+
}
|
|
73
|
+
const cache = new WeakMap();
|
|
74
|
+
export function analyzeFile(file, componentsDir) {
|
|
75
|
+
const cached = cache.get(file);
|
|
76
|
+
if (cached)
|
|
77
|
+
return cached;
|
|
78
|
+
const kind = file.path.endsWith('.jsx') ? ts.ScriptKind.JSX : ts.ScriptKind.TSX;
|
|
79
|
+
const source = ts.createSourceFile(file.path, file.text, ts.ScriptTarget.Latest, true, kind);
|
|
80
|
+
const components = new Map();
|
|
81
|
+
const nextLinks = new Set();
|
|
82
|
+
for (const statement of source.statements) {
|
|
83
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
84
|
+
continue;
|
|
85
|
+
const specifier = statement.moduleSpecifier.text;
|
|
86
|
+
const clause = statement.importClause;
|
|
87
|
+
if (!clause)
|
|
88
|
+
continue;
|
|
89
|
+
if (specifier === 'next/link' && clause.name)
|
|
90
|
+
nextLinks.add(clause.name.text);
|
|
91
|
+
if (!isComponentsImport(specifier, file.path, componentsDir))
|
|
92
|
+
continue;
|
|
93
|
+
const named = clause.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings.elements : [];
|
|
94
|
+
for (const element of named) {
|
|
95
|
+
const imported = (element.propertyName ?? element.name).text;
|
|
96
|
+
if (COMPONENT_NAMES.includes(imported))
|
|
97
|
+
components.set(element.name.text, imported);
|
|
98
|
+
}
|
|
99
|
+
if (clause.name && COMPONENT_NAMES.includes(clause.name.text)) {
|
|
100
|
+
components.set(clause.name.text, clause.name.text);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const sites = [];
|
|
104
|
+
const visit = (node) => {
|
|
105
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
|
|
106
|
+
if (opening) {
|
|
107
|
+
const tag = opening.tagName.getText(source);
|
|
108
|
+
const attrs = new Map();
|
|
109
|
+
let styleKeys;
|
|
110
|
+
let handlerText;
|
|
111
|
+
const colorVars = [];
|
|
112
|
+
for (const prop of opening.attributes.properties) {
|
|
113
|
+
if (!ts.isJsxAttribute(prop))
|
|
114
|
+
continue;
|
|
115
|
+
const name = prop.name.getText(source);
|
|
116
|
+
attrs.set(name, attrValue(name, prop.initializer));
|
|
117
|
+
const expr = prop.initializer && ts.isJsxExpression(prop.initializer) ? prop.initializer.expression : undefined;
|
|
118
|
+
const object = expr ? unwrapObject(expr) : undefined;
|
|
119
|
+
if (name === 'style' && object) {
|
|
120
|
+
styleKeys = object.properties.flatMap((p) => p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) ? [p.name.text] : []);
|
|
121
|
+
}
|
|
122
|
+
if ((name === 'style' || name === 'vars') && object) {
|
|
123
|
+
for (const p of object.properties) {
|
|
124
|
+
if (!ts.isPropertyAssignment(p) || !ts.isStringLiteral(p.name) || !p.name.text.startsWith('--'))
|
|
125
|
+
continue;
|
|
126
|
+
for (const value of staticStrings(p.initializer) ?? []) {
|
|
127
|
+
if (COLOR_LITERAL.test(value))
|
|
128
|
+
colorVars.push({ attr: name, name: p.name.text, value });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (name === 'onClick' && expr)
|
|
133
|
+
handlerText = expr.getText(source).slice(0, 200);
|
|
134
|
+
}
|
|
135
|
+
// Plain text and literal strings in {...} children (both branches of a ternary).
|
|
136
|
+
const childText = ts.isJsxElement(node)
|
|
137
|
+
? node.children
|
|
138
|
+
.flatMap((c) => ts.isJsxText(c)
|
|
139
|
+
? [c.text.trim()]
|
|
140
|
+
: ts.isJsxExpression(c) && c.expression
|
|
141
|
+
? staticStrings(c.expression) ?? []
|
|
142
|
+
: [])
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join(' ')
|
|
145
|
+
: undefined;
|
|
146
|
+
const offset = opening.getStart(source);
|
|
147
|
+
sites.push({
|
|
148
|
+
tag,
|
|
149
|
+
component: components.get(tag),
|
|
150
|
+
nextLink: nextLinks.has(tag),
|
|
151
|
+
offset,
|
|
152
|
+
line: source.getLineAndCharacterOfPosition(offset).line + 1,
|
|
153
|
+
attrs,
|
|
154
|
+
styleKeys,
|
|
155
|
+
colorVars: colorVars.length ? colorVars : undefined,
|
|
156
|
+
handlerText,
|
|
157
|
+
childText: childText || undefined,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
ts.forEachChild(node, visit);
|
|
161
|
+
};
|
|
162
|
+
visit(source);
|
|
163
|
+
const analysis = { sites, lines: file.text.split('\n') };
|
|
164
|
+
cache.set(file, analysis);
|
|
165
|
+
return analysis;
|
|
166
|
+
}
|
|
167
|
+
/** True when one of the two lines above `line` (or the line itself) has `prongs-<marker>: <reason>`. */
|
|
168
|
+
export function hasMarker(analysis, line, marker) {
|
|
169
|
+
const pattern = new RegExp(`prongs-${marker}:\\s*\\S`);
|
|
170
|
+
for (let l = Math.max(1, line - 2); l <= line; l++) {
|
|
171
|
+
if (pattern.test(analysis.lines[l - 1] ?? ''))
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ComponentName } from '../vocabulary.js';
|
|
2
|
+
/**
|
|
3
|
+
* What each component takes and the rules for using it: the source for the "Component props"
|
|
4
|
+
* section of PRONGS.md and for component reference pages. It describes the components that
|
|
5
|
+
* `prongs add` copies; a project that changes them can change this too.
|
|
6
|
+
*/
|
|
7
|
+
export type PropDoc = {
|
|
8
|
+
name: string;
|
|
9
|
+
type: string;
|
|
10
|
+
description: string;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type ComponentDoc = {
|
|
14
|
+
/** What the component is for, in one or two sentences. */
|
|
15
|
+
summary: string;
|
|
16
|
+
/** Whether it takes the shared layout props (align, width, ...). */
|
|
17
|
+
layoutProps: boolean;
|
|
18
|
+
/** Its own props, most used first. Element attributes it passes through are not listed. */
|
|
19
|
+
props: readonly PropDoc[];
|
|
20
|
+
rules: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
/** Where an element sits, never how it looks. Spacing between elements comes from the parent <Layout>. */
|
|
23
|
+
export declare const LAYOUT_PROPS: readonly PropDoc[];
|
|
24
|
+
export declare const COMPONENT_DOCS: Record<ComponentName, ComponentDoc>;
|