@facilis.dev/analyze 0.1.0 → 0.1.1
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/dist/graph.js +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/passes/jsx-text.d.ts +28 -0
- package/dist/passes/jsx-text.js +121 -0
- package/dist/passes/jsx.d.ts +2 -0
- package/dist/passes/jsx.js +47 -1
- package/dist/passes/screen-intrinsics.d.ts +18 -0
- package/dist/passes/screen-intrinsics.js +21 -0
- package/dist/passes/screen-preview.js +25 -10
- package/dist/types-graph.d.ts +14 -0
- package/package.json +1 -1
- package/src/graph.ts +4 -0
- package/src/index.ts +2 -0
- package/src/passes/jsx-text.ts +126 -0
- package/src/passes/jsx.ts +51 -1
- package/src/passes/screen-intrinsics.ts +29 -0
- package/src/passes/screen-preview.ts +33 -6
- package/src/types-graph.ts +15 -0
package/dist/graph.js
CHANGED
|
@@ -7,6 +7,10 @@ function tableNode(table) {
|
|
|
7
7
|
columns: table.columns,
|
|
8
8
|
rlsEnabled: table.rlsEnabled,
|
|
9
9
|
scope: table.scope,
|
|
10
|
+
policies: table.policies.map((policy) => ({
|
|
11
|
+
name: policy.name,
|
|
12
|
+
restrictive: policy.restrictive,
|
|
13
|
+
})),
|
|
10
14
|
framework: table.source.file.startsWith("node_modules/"),
|
|
11
15
|
unknown: table.unknown.length > 0 ||
|
|
12
16
|
table.columns.some((column) => column.unknown.length > 0) ||
|
package/dist/index.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type { PathAlias } from "./path-resolver.js";
|
|
|
18
18
|
export type { Parser, ParseError, ParsedModule } from "./parser.js";
|
|
19
19
|
export { diagnostic, error, info, warning } from "./diagnostics.js";
|
|
20
20
|
export { computeRevision } from "./revision.js";
|
|
21
|
+
export { pageId, routeId, tableId, viewId, workflowId } from "./graph-ids.js";
|
|
21
22
|
export type { TransportSpec } from "./passes/clients.js";
|
|
22
23
|
export type { JobFact, JobRelationsFact } from "./types-jobs.js";
|
|
23
24
|
export type * from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -13,3 +13,5 @@ export { createModuleGraph } from "./module-graph.js";
|
|
|
13
13
|
export { createPathResolver, normalizePath } from "./path-resolver.js";
|
|
14
14
|
export { diagnostic, error, info, warning } from "./diagnostics.js";
|
|
15
15
|
export { computeRevision } from "./revision.js";
|
|
16
|
+
// The one id spelling shared by graph nodes and edges (D-092), for UI joins by node id.
|
|
17
|
+
export { pageId, routeId, tableId, viewId, workflowId } from "./graph-ids.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct text/i18n facts for one page-preview node.
|
|
3
|
+
*
|
|
4
|
+
* The preview is schematic, not a render: reading only an element's own JSX children keeps a
|
|
5
|
+
* section's label without pulling in what nested elements render. A `t("key")` child becomes
|
|
6
|
+
* `tKey` (D-043) and literal text stays alongside it as the UI's fallback, because the UI
|
|
7
|
+
* prefers the translated key. Dynamic expressions are skipped rather than guessed. Content
|
|
8
|
+
* intrinsics (<input>, <img>, ...) carry their copy in an attribute instead of a child, so
|
|
9
|
+
* `jsxContentFields` falls back to that attribute.
|
|
10
|
+
*/
|
|
11
|
+
import { type AstNode } from "../ast/node.js";
|
|
12
|
+
/** The optional `PagePreviewNode` text fields; a field is omitted, never empty. */
|
|
13
|
+
export interface JsxTextFields {
|
|
14
|
+
readonly text?: string;
|
|
15
|
+
readonly tKey?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The first direct non-empty text child and the first direct `t("key")` child of an element.
|
|
19
|
+
* Both are kept when the element renders one of each: the UI shows `tKey`, falling back to
|
|
20
|
+
* `text`. Returns undefined when the element has neither, so callers emit no empty field.
|
|
21
|
+
*/
|
|
22
|
+
export declare function jsxTextFields(node: AstNode): JsxTextFields | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* The copy a content intrinsic renders: its direct JSX text first (where `t("key")` becomes
|
|
25
|
+
* `tKey`), then the first static placeholder/alt/aria-label attribute it carries. A dynamic
|
|
26
|
+
* attribute never becomes copy, but does not hide a later static fallback either.
|
|
27
|
+
*/
|
|
28
|
+
export declare function jsxContentFields(node: AstNode, name: string): JsxTextFields | undefined;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct text/i18n facts for one page-preview node.
|
|
3
|
+
*
|
|
4
|
+
* The preview is schematic, not a render: reading only an element's own JSX children keeps a
|
|
5
|
+
* section's label without pulling in what nested elements render. A `t("key")` child becomes
|
|
6
|
+
* `tKey` (D-043) and literal text stays alongside it as the UI's fallback, because the UI
|
|
7
|
+
* prefers the translated key. Dynamic expressions are skipped rather than guessed. Content
|
|
8
|
+
* intrinsics (<input>, <img>, ...) carry their copy in an attribute instead of a child, so
|
|
9
|
+
* `jsxContentFields` falls back to that attribute.
|
|
10
|
+
*/
|
|
11
|
+
import { asNode, memberName, nodeArray, unwrapExpression } from "../ast/node.js";
|
|
12
|
+
import { jsxAttribute, jsxAttributeValue, jsxChildren } from "./jsx.js";
|
|
13
|
+
/** JSX collapses source indentation into spaces; mirror it so labels carry no layout trivia. */
|
|
14
|
+
function collapseJsxText(value) {
|
|
15
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
16
|
+
return collapsed === "" ? undefined : collapsed;
|
|
17
|
+
}
|
|
18
|
+
/** A string literal value, or undefined for any other expression. */
|
|
19
|
+
function literalText(node) {
|
|
20
|
+
// oxc emits ESTree `Literal`; `StringLiteral` is accepted for parser independence.
|
|
21
|
+
if (node.type !== "Literal" && node.type !== "StringLiteral")
|
|
22
|
+
return undefined;
|
|
23
|
+
return typeof node.value === "string" ? node.value : undefined;
|
|
24
|
+
}
|
|
25
|
+
/** A template literal without substitutions keeps its static text. */
|
|
26
|
+
function templateText(node) {
|
|
27
|
+
if (nodeArray(node, "expressions").length > 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
const quasi = nodeArray(node, "quasis")[0];
|
|
30
|
+
const value = quasi?.value;
|
|
31
|
+
if (typeof value !== "object" || value === null)
|
|
32
|
+
return undefined;
|
|
33
|
+
const cooked = value.cooked;
|
|
34
|
+
if (typeof cooked === "string")
|
|
35
|
+
return cooked;
|
|
36
|
+
const raw = value.raw;
|
|
37
|
+
return typeof raw === "string" ? raw : undefined;
|
|
38
|
+
}
|
|
39
|
+
/** A direct text child: JSX text, or a static string/template expression. */
|
|
40
|
+
function textChild(child) {
|
|
41
|
+
if (child.type === "JSXText") {
|
|
42
|
+
return typeof child.value === "string" ? collapseJsxText(child.value) : undefined;
|
|
43
|
+
}
|
|
44
|
+
if (child.type !== "JSXExpressionContainer")
|
|
45
|
+
return undefined;
|
|
46
|
+
const expression = unwrapExpression(child.expression);
|
|
47
|
+
if (expression === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
const value = expression.type === "TemplateLiteral" ? templateText(expression) : literalText(expression);
|
|
50
|
+
// A literal was authored as written, so only surrounding whitespace is dropped here.
|
|
51
|
+
const trimmed = value?.trim();
|
|
52
|
+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
|
|
53
|
+
}
|
|
54
|
+
/** A `t("key")` / `i18n.t("key")` child; the callee may be a member, the key may not be dynamic. */
|
|
55
|
+
function translationChild(child) {
|
|
56
|
+
if (child.type !== "JSXExpressionContainer")
|
|
57
|
+
return undefined;
|
|
58
|
+
const call = unwrapExpression(child.expression);
|
|
59
|
+
if (call === undefined || call.type !== "CallExpression")
|
|
60
|
+
return undefined;
|
|
61
|
+
const callee = asNode(call.callee);
|
|
62
|
+
if (callee === undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
const calleeName = callee.type === "Identifier" ? callee.name : memberName(callee);
|
|
65
|
+
if (calleeName !== "t")
|
|
66
|
+
return undefined;
|
|
67
|
+
const argument = nodeArray(call, "arguments")[0];
|
|
68
|
+
const key = argument === undefined ? undefined : literalText(argument);
|
|
69
|
+
return key === undefined || key.trim() === "" ? undefined : key;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The first direct non-empty text child and the first direct `t("key")` child of an element.
|
|
73
|
+
* Both are kept when the element renders one of each: the UI shows `tKey`, falling back to
|
|
74
|
+
* `text`. Returns undefined when the element has neither, so callers emit no empty field.
|
|
75
|
+
*/
|
|
76
|
+
export function jsxTextFields(node) {
|
|
77
|
+
let text;
|
|
78
|
+
let tKey;
|
|
79
|
+
for (const child of jsxChildren(node)) {
|
|
80
|
+
text ??= textChild(child);
|
|
81
|
+
tKey ??= translationChild(child);
|
|
82
|
+
}
|
|
83
|
+
if (text === undefined && tKey === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
return { ...(text === undefined ? {} : { text }), ...(tKey === undefined ? {} : { tKey }) };
|
|
86
|
+
}
|
|
87
|
+
/** The attributes that stand in for missing JSX copy, in reading order per intrinsic. */
|
|
88
|
+
const COPY_ATTRIBUTES = {
|
|
89
|
+
input: ["placeholder", "aria-label", "value"],
|
|
90
|
+
textarea: ["placeholder", "aria-label"],
|
|
91
|
+
select: ["aria-label"],
|
|
92
|
+
button: ["aria-label"],
|
|
93
|
+
a: ["aria-label"],
|
|
94
|
+
img: ["alt"],
|
|
95
|
+
};
|
|
96
|
+
const DEFAULT_COPY_ATTRIBUTES = ["aria-label"];
|
|
97
|
+
/** An attribute value as static text: a string literal or a substitution-free template. */
|
|
98
|
+
function attributeText(attribute) {
|
|
99
|
+
const expression = jsxAttributeValue(attribute);
|
|
100
|
+
if (expression === undefined)
|
|
101
|
+
return undefined;
|
|
102
|
+
const value = expression.type === "TemplateLiteral" ? templateText(expression) : literalText(expression);
|
|
103
|
+
const trimmed = value?.trim();
|
|
104
|
+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The copy a content intrinsic renders: its direct JSX text first (where `t("key")` becomes
|
|
108
|
+
* `tKey`), then the first static placeholder/alt/aria-label attribute it carries. A dynamic
|
|
109
|
+
* attribute never becomes copy, but does not hide a later static fallback either.
|
|
110
|
+
*/
|
|
111
|
+
export function jsxContentFields(node, name) {
|
|
112
|
+
const fields = jsxTextFields(node);
|
|
113
|
+
if (fields !== undefined)
|
|
114
|
+
return fields;
|
|
115
|
+
for (const attributeName of COPY_ATTRIBUTES[name] ?? DEFAULT_COPY_ATTRIBUTES) {
|
|
116
|
+
const text = attributeText(jsxAttribute(node, attributeName));
|
|
117
|
+
if (text !== undefined)
|
|
118
|
+
return { text };
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
package/dist/passes/jsx.d.ts
CHANGED
|
@@ -20,3 +20,5 @@ export declare function jsxAttributeValue(attribute: AstNode | undefined): AstNo
|
|
|
20
20
|
export declare function jsxElementValue(value: AstNode | undefined): AstNode | undefined;
|
|
21
21
|
export declare function isJsxElement(node: AstNode | undefined): node is AstNode;
|
|
22
22
|
export declare function isSectionIntrinsic(name: string): boolean;
|
|
23
|
+
export declare function isContentIntrinsic(name: string): boolean;
|
|
24
|
+
export declare function isRoleIntrinsic(name: string): boolean;
|
package/dist/passes/jsx.js
CHANGED
|
@@ -69,7 +69,7 @@ export function jsxElementValue(value) {
|
|
|
69
69
|
export function isJsxElement(node) {
|
|
70
70
|
return node !== undefined && (node.type === "JSXElement" || node.type === "JSXFragment");
|
|
71
71
|
}
|
|
72
|
-
/** JSX elements that carry a page section (`<div
|
|
72
|
+
/** JSX elements that carry a page section (layout-only `<div>` stays flattened away). */
|
|
73
73
|
const SECTION_INTRINSICS = new Set([
|
|
74
74
|
"header",
|
|
75
75
|
"main",
|
|
@@ -84,6 +84,52 @@ const SECTION_INTRINSICS = new Set([
|
|
|
84
84
|
"h2",
|
|
85
85
|
"h3",
|
|
86
86
|
]);
|
|
87
|
+
/**
|
|
88
|
+
* Text-bearing intrinsics: they get a preview node when they carry copy, so the copy is not
|
|
89
|
+
* lost inside a flattened layout wrapper.
|
|
90
|
+
*/
|
|
91
|
+
const CONTENT_INTRINSICS = new Set([
|
|
92
|
+
"p",
|
|
93
|
+
"span",
|
|
94
|
+
"strong",
|
|
95
|
+
"em",
|
|
96
|
+
"small",
|
|
97
|
+
"b",
|
|
98
|
+
"i",
|
|
99
|
+
"code",
|
|
100
|
+
"pre",
|
|
101
|
+
"blockquote",
|
|
102
|
+
"button",
|
|
103
|
+
"a",
|
|
104
|
+
"input",
|
|
105
|
+
"select",
|
|
106
|
+
"textarea",
|
|
107
|
+
"label",
|
|
108
|
+
"img",
|
|
109
|
+
"video",
|
|
110
|
+
"audio",
|
|
111
|
+
"h4",
|
|
112
|
+
"h5",
|
|
113
|
+
"h6",
|
|
114
|
+
]);
|
|
115
|
+
/** Content intrinsics with a control role: drawn even without readable copy (a bare input, media). */
|
|
116
|
+
const ROLE_INTRINSICS = new Set([
|
|
117
|
+
"button",
|
|
118
|
+
"a",
|
|
119
|
+
"input",
|
|
120
|
+
"select",
|
|
121
|
+
"textarea",
|
|
122
|
+
"label",
|
|
123
|
+
"img",
|
|
124
|
+
"video",
|
|
125
|
+
"audio",
|
|
126
|
+
]);
|
|
87
127
|
export function isSectionIntrinsic(name) {
|
|
88
128
|
return SECTION_INTRINSICS.has(name);
|
|
89
129
|
}
|
|
130
|
+
export function isContentIntrinsic(name) {
|
|
131
|
+
return CONTENT_INTRINSICS.has(name);
|
|
132
|
+
}
|
|
133
|
+
export function isRoleIntrinsic(name) {
|
|
134
|
+
return ROLE_INTRINSICS.has(name);
|
|
135
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which intrinsic JSX elements the page preview draws, and which copy they carry.
|
|
3
|
+
*
|
|
4
|
+
* Sections are the page's layout skeleton and are always drawn; content intrinsics are the
|
|
5
|
+
* copy-bearing blocks and controls that make the schematic page readable (D-170). Layout-only
|
|
6
|
+
* wrappers such as `<div>` are flattened, and a copy-bearing text element with no readable copy
|
|
7
|
+
* is flattened too, so the preview never draws empty boxes for spacing wrappers.
|
|
8
|
+
*/
|
|
9
|
+
import type { AstNode } from "../ast/node.js";
|
|
10
|
+
import { type JsxTextFields } from "./jsx-text.js";
|
|
11
|
+
/** What a drawable intrinsic contributes: its copy and whether it spends the content budget. */
|
|
12
|
+
export interface IntrinsicPlan {
|
|
13
|
+
/** Content intrinsics have their own node budget and only draw when they carry something. */
|
|
14
|
+
readonly content: boolean;
|
|
15
|
+
readonly fields: JsxTextFields | undefined;
|
|
16
|
+
}
|
|
17
|
+
/** Classify one intrinsic element, or null when it is flattened into its parent. */
|
|
18
|
+
export declare function planIntrinsic(node: AstNode, name: string): IntrinsicPlan | null;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which intrinsic JSX elements the page preview draws, and which copy they carry.
|
|
3
|
+
*
|
|
4
|
+
* Sections are the page's layout skeleton and are always drawn; content intrinsics are the
|
|
5
|
+
* copy-bearing blocks and controls that make the schematic page readable (D-170). Layout-only
|
|
6
|
+
* wrappers such as `<div>` are flattened, and a copy-bearing text element with no readable copy
|
|
7
|
+
* is flattened too, so the preview never draws empty boxes for spacing wrappers.
|
|
8
|
+
*/
|
|
9
|
+
import { isContentIntrinsic, isRoleIntrinsic, isSectionIntrinsic } from "./jsx.js";
|
|
10
|
+
import { jsxContentFields, jsxTextFields } from "./jsx-text.js";
|
|
11
|
+
/** Classify one intrinsic element, or null when it is flattened into its parent. */
|
|
12
|
+
export function planIntrinsic(node, name) {
|
|
13
|
+
const section = isSectionIntrinsic(name);
|
|
14
|
+
if (!section && !isContentIntrinsic(name))
|
|
15
|
+
return null;
|
|
16
|
+
const fields = section ? jsxTextFields(node) : jsxContentFields(node, name);
|
|
17
|
+
// A control or media element is a visible block even without copy; a bare span is not.
|
|
18
|
+
if (!section && fields === undefined && !isRoleIntrinsic(name))
|
|
19
|
+
return null;
|
|
20
|
+
return { content: !section, fields };
|
|
21
|
+
}
|
|
@@ -7,9 +7,16 @@
|
|
|
7
7
|
* bounded subset is omitted rather than guessed.
|
|
8
8
|
*/
|
|
9
9
|
import { asNode, nodeArray, unwrapExpression } from "../ast/node.js";
|
|
10
|
-
import {
|
|
10
|
+
import { jsxTextFields } from "./jsx-text.js";
|
|
11
|
+
import { elementName, jsxAttributeValue, jsxAttributes, jsxChildren, jsxElementValue, } from "./jsx.js";
|
|
11
12
|
import { canExpand, componentExpression, firstComponentName, lineRef, resolveComponent, returnedExpression, } from "./screen-components.js";
|
|
13
|
+
import { planIntrinsic } from "./screen-intrinsics.js";
|
|
12
14
|
const MAX_NODES = 60;
|
|
15
|
+
/**
|
|
16
|
+
* Content intrinsics spend their own smaller budget, so a copy-heavy page cannot displace the
|
|
17
|
+
* structural nodes (sections/components) the preview already drew before content facts existed.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_CONTENT_NODES = 30;
|
|
13
20
|
const MAX_DEPTH = 4;
|
|
14
21
|
/** Build the bounded static structure for one page. */
|
|
15
22
|
export function buildPagePreview(input) {
|
|
@@ -17,13 +24,19 @@ export function buildPagePreview(input) {
|
|
|
17
24
|
const nodes = [];
|
|
18
25
|
const active = new Set();
|
|
19
26
|
let truncated = false;
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
let structuralNodes = 0;
|
|
28
|
+
let contentNodes = 0;
|
|
29
|
+
const addNode = (parentId, name, kind, file, source, dynamic, text, content = false) => {
|
|
30
|
+
if (content ? contentNodes >= MAX_CONTENT_NODES : structuralNodes >= MAX_NODES) {
|
|
22
31
|
truncated = true;
|
|
23
32
|
return null;
|
|
24
33
|
}
|
|
34
|
+
if (content)
|
|
35
|
+
contentNodes += 1;
|
|
36
|
+
else
|
|
37
|
+
structuralNodes += 1;
|
|
25
38
|
const id = "p" + nodes.length;
|
|
26
|
-
nodes.push({ id, parentId, name, kind, file, source, dynamic });
|
|
39
|
+
nodes.push({ id, parentId, name, kind, file, source, dynamic, ...text });
|
|
27
40
|
return id;
|
|
28
41
|
};
|
|
29
42
|
const appendChildren = (node, originFile, parentId, depth, dynamic, outletChild) => {
|
|
@@ -45,25 +58,27 @@ export function buildPagePreview(input) {
|
|
|
45
58
|
if (name === null)
|
|
46
59
|
return;
|
|
47
60
|
if (name === "Outlet") {
|
|
48
|
-
if (outletChild !== null)
|
|
61
|
+
if (outletChild !== null) {
|
|
49
62
|
appendRoot(outletChild, parentId, depth, dynamic);
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
addNode(parentId, "Outlet", "slot", null, lineRef(graph, originFile, node), dynamic, jsxTextFields(node));
|
|
52
66
|
return;
|
|
53
67
|
}
|
|
54
68
|
const intrinsic = name[0] !== undefined && name[0] === name[0].toLowerCase();
|
|
55
69
|
if (intrinsic) {
|
|
56
|
-
|
|
70
|
+
const plan = planIntrinsic(node, name);
|
|
71
|
+
if (plan === null) {
|
|
57
72
|
appendChildren(node, originFile, parentId, depth, dynamic, outletChild);
|
|
58
73
|
return;
|
|
59
74
|
}
|
|
60
|
-
const id = addNode(parentId, name, "intrinsic", null, lineRef(graph, originFile, node), dynamic);
|
|
75
|
+
const id = addNode(parentId, name, "intrinsic", null, lineRef(graph, originFile, node), dynamic, plan.fields, plan.content);
|
|
61
76
|
if (id !== null)
|
|
62
77
|
appendChildren(node, originFile, id, depth + 1, dynamic, outletChild);
|
|
63
78
|
return;
|
|
64
79
|
}
|
|
65
80
|
const ref = resolveComponent(graph, originFile, name);
|
|
66
|
-
const id = addNode(parentId, name, "component", ref?.file ?? null, lineRef(graph, originFile, node), dynamic);
|
|
81
|
+
const id = addNode(parentId, name, "component", ref?.file ?? null, lineRef(graph, originFile, node), dynamic, jsxTextFields(node));
|
|
67
82
|
if (id === null)
|
|
68
83
|
return;
|
|
69
84
|
// JSX-valued props are real sections too (AuthShell footer, Page actions, ...).
|
package/dist/types-graph.d.ts
CHANGED
|
@@ -26,12 +26,22 @@ export interface RouteNodeData {
|
|
|
26
26
|
readonly description: string | null;
|
|
27
27
|
readonly unknown: boolean;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* One policy as the graph names it (D-177): the card needs the grant/gate distinction, while the
|
|
31
|
+
* command and source stay in the inspector's facts.
|
|
32
|
+
*/
|
|
33
|
+
export interface TablePolicyNodeFact {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly restrictive: boolean;
|
|
36
|
+
}
|
|
29
37
|
export interface TableNodeData {
|
|
30
38
|
readonly kind: "table";
|
|
31
39
|
readonly table: string;
|
|
32
40
|
readonly columns: readonly ColumnFact[];
|
|
33
41
|
readonly rlsEnabled: boolean;
|
|
34
42
|
readonly scope: ScopeFact | null;
|
|
43
|
+
/** The declared policy set, in declaration order (D-177). */
|
|
44
|
+
readonly policies: readonly TablePolicyNodeFact[];
|
|
35
45
|
/**
|
|
36
46
|
* True when an installed @facilis.dev package declares the table and the project only
|
|
37
47
|
* re-exports it: its source file lives under `node_modules/`.
|
|
@@ -103,6 +113,10 @@ export interface PagePreviewNode {
|
|
|
103
113
|
readonly source: SourceRef;
|
|
104
114
|
/** True when the node sits under a conditional/map branch. */
|
|
105
115
|
readonly dynamic: boolean;
|
|
116
|
+
/** Direct literal text child, trimmed and whitespace-collapsed; absent when none. */
|
|
117
|
+
readonly text?: string;
|
|
118
|
+
/** i18n key when that text child is a `t("key")` call (D-043); absent otherwise. */
|
|
119
|
+
readonly tKey?: string;
|
|
106
120
|
}
|
|
107
121
|
/** Static component structure of one page, capped by the analyzer. */
|
|
108
122
|
export interface PagePreview {
|
package/package.json
CHANGED
package/src/graph.ts
CHANGED
|
@@ -37,6 +37,10 @@ function tableNode(table: TableFact): GraphNode {
|
|
|
37
37
|
columns: table.columns,
|
|
38
38
|
rlsEnabled: table.rlsEnabled,
|
|
39
39
|
scope: table.scope,
|
|
40
|
+
policies: table.policies.map((policy) => ({
|
|
41
|
+
name: policy.name,
|
|
42
|
+
restrictive: policy.restrictive,
|
|
43
|
+
})),
|
|
40
44
|
framework: table.source.file.startsWith("node_modules/"),
|
|
41
45
|
unknown:
|
|
42
46
|
table.unknown.length > 0 ||
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type { PathAlias } from "./path-resolver.ts";
|
|
|
18
18
|
export type { Parser, ParseError, ParsedModule } from "./parser.ts";
|
|
19
19
|
export { diagnostic, error, info, warning } from "./diagnostics.ts";
|
|
20
20
|
export { computeRevision } from "./revision.ts";
|
|
21
|
+
// The one id spelling shared by graph nodes and edges (D-092), for UI joins by node id.
|
|
22
|
+
export { pageId, routeId, tableId, viewId, workflowId } from "./graph-ids.ts";
|
|
21
23
|
export type { TransportSpec } from "./passes/clients.ts";
|
|
22
24
|
// Job facts live in types-jobs.ts (the 300-line split of types.ts), so export them here.
|
|
23
25
|
export type { JobFact, JobRelationsFact } from "./types-jobs.ts";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct text/i18n facts for one page-preview node.
|
|
3
|
+
*
|
|
4
|
+
* The preview is schematic, not a render: reading only an element's own JSX children keeps a
|
|
5
|
+
* section's label without pulling in what nested elements render. A `t("key")` child becomes
|
|
6
|
+
* `tKey` (D-043) and literal text stays alongside it as the UI's fallback, because the UI
|
|
7
|
+
* prefers the translated key. Dynamic expressions are skipped rather than guessed. Content
|
|
8
|
+
* intrinsics (<input>, <img>, ...) carry their copy in an attribute instead of a child, so
|
|
9
|
+
* `jsxContentFields` falls back to that attribute.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { asNode, memberName, nodeArray, unwrapExpression, type AstNode } from "../ast/node.ts";
|
|
13
|
+
import { jsxAttribute, jsxAttributeValue, jsxChildren } from "./jsx.ts";
|
|
14
|
+
|
|
15
|
+
/** The optional `PagePreviewNode` text fields; a field is omitted, never empty. */
|
|
16
|
+
export interface JsxTextFields {
|
|
17
|
+
readonly text?: string;
|
|
18
|
+
readonly tKey?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** JSX collapses source indentation into spaces; mirror it so labels carry no layout trivia. */
|
|
22
|
+
function collapseJsxText(value: string): string | undefined {
|
|
23
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
24
|
+
return collapsed === "" ? undefined : collapsed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A string literal value, or undefined for any other expression. */
|
|
28
|
+
function literalText(node: AstNode): string | undefined {
|
|
29
|
+
// oxc emits ESTree `Literal`; `StringLiteral` is accepted for parser independence.
|
|
30
|
+
if (node.type !== "Literal" && node.type !== "StringLiteral") return undefined;
|
|
31
|
+
return typeof node.value === "string" ? node.value : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A template literal without substitutions keeps its static text. */
|
|
35
|
+
function templateText(node: AstNode): string | undefined {
|
|
36
|
+
if (nodeArray(node, "expressions").length > 0) return undefined;
|
|
37
|
+
const quasi = nodeArray(node, "quasis")[0];
|
|
38
|
+
const value = quasi?.value;
|
|
39
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
40
|
+
const cooked = (value as { cooked?: unknown }).cooked;
|
|
41
|
+
if (typeof cooked === "string") return cooked;
|
|
42
|
+
const raw = (value as { raw?: unknown }).raw;
|
|
43
|
+
return typeof raw === "string" ? raw : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A direct text child: JSX text, or a static string/template expression. */
|
|
47
|
+
function textChild(child: AstNode): string | undefined {
|
|
48
|
+
if (child.type === "JSXText") {
|
|
49
|
+
return typeof child.value === "string" ? collapseJsxText(child.value) : undefined;
|
|
50
|
+
}
|
|
51
|
+
if (child.type !== "JSXExpressionContainer") return undefined;
|
|
52
|
+
const expression = unwrapExpression(child.expression);
|
|
53
|
+
if (expression === undefined) return undefined;
|
|
54
|
+
const value =
|
|
55
|
+
expression.type === "TemplateLiteral" ? templateText(expression) : literalText(expression);
|
|
56
|
+
// A literal was authored as written, so only surrounding whitespace is dropped here.
|
|
57
|
+
const trimmed = value?.trim();
|
|
58
|
+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A `t("key")` / `i18n.t("key")` child; the callee may be a member, the key may not be dynamic. */
|
|
62
|
+
function translationChild(child: AstNode): string | undefined {
|
|
63
|
+
if (child.type !== "JSXExpressionContainer") return undefined;
|
|
64
|
+
const call = unwrapExpression(child.expression);
|
|
65
|
+
if (call === undefined || call.type !== "CallExpression") return undefined;
|
|
66
|
+
const callee = asNode(call.callee);
|
|
67
|
+
if (callee === undefined) return undefined;
|
|
68
|
+
const calleeName = callee.type === "Identifier" ? callee.name : memberName(callee);
|
|
69
|
+
if (calleeName !== "t") return undefined;
|
|
70
|
+
const argument = nodeArray(call, "arguments")[0];
|
|
71
|
+
const key = argument === undefined ? undefined : literalText(argument);
|
|
72
|
+
return key === undefined || key.trim() === "" ? undefined : key;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The first direct non-empty text child and the first direct `t("key")` child of an element.
|
|
77
|
+
* Both are kept when the element renders one of each: the UI shows `tKey`, falling back to
|
|
78
|
+
* `text`. Returns undefined when the element has neither, so callers emit no empty field.
|
|
79
|
+
*/
|
|
80
|
+
export function jsxTextFields(node: AstNode): JsxTextFields | undefined {
|
|
81
|
+
let text: string | undefined;
|
|
82
|
+
let tKey: string | undefined;
|
|
83
|
+
for (const child of jsxChildren(node)) {
|
|
84
|
+
text ??= textChild(child);
|
|
85
|
+
tKey ??= translationChild(child);
|
|
86
|
+
}
|
|
87
|
+
if (text === undefined && tKey === undefined) return undefined;
|
|
88
|
+
return { ...(text === undefined ? {} : { text }), ...(tKey === undefined ? {} : { tKey }) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The attributes that stand in for missing JSX copy, in reading order per intrinsic. */
|
|
92
|
+
const COPY_ATTRIBUTES: Readonly<Record<string, readonly string[]>> = {
|
|
93
|
+
input: ["placeholder", "aria-label", "value"],
|
|
94
|
+
textarea: ["placeholder", "aria-label"],
|
|
95
|
+
select: ["aria-label"],
|
|
96
|
+
button: ["aria-label"],
|
|
97
|
+
a: ["aria-label"],
|
|
98
|
+
img: ["alt"],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const DEFAULT_COPY_ATTRIBUTES: readonly string[] = ["aria-label"];
|
|
102
|
+
|
|
103
|
+
/** An attribute value as static text: a string literal or a substitution-free template. */
|
|
104
|
+
function attributeText(attribute: AstNode | undefined): string | undefined {
|
|
105
|
+
const expression = jsxAttributeValue(attribute);
|
|
106
|
+
if (expression === undefined) return undefined;
|
|
107
|
+
const value =
|
|
108
|
+
expression.type === "TemplateLiteral" ? templateText(expression) : literalText(expression);
|
|
109
|
+
const trimmed = value?.trim();
|
|
110
|
+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The copy a content intrinsic renders: its direct JSX text first (where `t("key")` becomes
|
|
115
|
+
* `tKey`), then the first static placeholder/alt/aria-label attribute it carries. A dynamic
|
|
116
|
+
* attribute never becomes copy, but does not hide a later static fallback either.
|
|
117
|
+
*/
|
|
118
|
+
export function jsxContentFields(node: AstNode, name: string): JsxTextFields | undefined {
|
|
119
|
+
const fields = jsxTextFields(node);
|
|
120
|
+
if (fields !== undefined) return fields;
|
|
121
|
+
for (const attributeName of COPY_ATTRIBUTES[name] ?? DEFAULT_COPY_ATTRIBUTES) {
|
|
122
|
+
const text = attributeText(jsxAttribute(node, attributeName));
|
|
123
|
+
if (text !== undefined) return { text };
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
package/src/passes/jsx.ts
CHANGED
|
@@ -71,7 +71,7 @@ export function isJsxElement(node: AstNode | undefined): node is AstNode {
|
|
|
71
71
|
return node !== undefined && (node.type === "JSXElement" || node.type === "JSXFragment");
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/** JSX elements that carry a page section (`<div
|
|
74
|
+
/** JSX elements that carry a page section (layout-only `<div>` stays flattened away). */
|
|
75
75
|
const SECTION_INTRINSICS = new Set([
|
|
76
76
|
"header",
|
|
77
77
|
"main",
|
|
@@ -87,6 +87,56 @@ const SECTION_INTRINSICS = new Set([
|
|
|
87
87
|
"h3",
|
|
88
88
|
]);
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Text-bearing intrinsics: they get a preview node when they carry copy, so the copy is not
|
|
92
|
+
* lost inside a flattened layout wrapper.
|
|
93
|
+
*/
|
|
94
|
+
const CONTENT_INTRINSICS = new Set([
|
|
95
|
+
"p",
|
|
96
|
+
"span",
|
|
97
|
+
"strong",
|
|
98
|
+
"em",
|
|
99
|
+
"small",
|
|
100
|
+
"b",
|
|
101
|
+
"i",
|
|
102
|
+
"code",
|
|
103
|
+
"pre",
|
|
104
|
+
"blockquote",
|
|
105
|
+
"button",
|
|
106
|
+
"a",
|
|
107
|
+
"input",
|
|
108
|
+
"select",
|
|
109
|
+
"textarea",
|
|
110
|
+
"label",
|
|
111
|
+
"img",
|
|
112
|
+
"video",
|
|
113
|
+
"audio",
|
|
114
|
+
"h4",
|
|
115
|
+
"h5",
|
|
116
|
+
"h6",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/** Content intrinsics with a control role: drawn even without readable copy (a bare input, media). */
|
|
120
|
+
const ROLE_INTRINSICS = new Set([
|
|
121
|
+
"button",
|
|
122
|
+
"a",
|
|
123
|
+
"input",
|
|
124
|
+
"select",
|
|
125
|
+
"textarea",
|
|
126
|
+
"label",
|
|
127
|
+
"img",
|
|
128
|
+
"video",
|
|
129
|
+
"audio",
|
|
130
|
+
]);
|
|
131
|
+
|
|
90
132
|
export function isSectionIntrinsic(name: string): boolean {
|
|
91
133
|
return SECTION_INTRINSICS.has(name);
|
|
92
134
|
}
|
|
135
|
+
|
|
136
|
+
export function isContentIntrinsic(name: string): boolean {
|
|
137
|
+
return CONTENT_INTRINSICS.has(name);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function isRoleIntrinsic(name: string): boolean {
|
|
141
|
+
return ROLE_INTRINSICS.has(name);
|
|
142
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which intrinsic JSX elements the page preview draws, and which copy they carry.
|
|
3
|
+
*
|
|
4
|
+
* Sections are the page's layout skeleton and are always drawn; content intrinsics are the
|
|
5
|
+
* copy-bearing blocks and controls that make the schematic page readable (D-170). Layout-only
|
|
6
|
+
* wrappers such as `<div>` are flattened, and a copy-bearing text element with no readable copy
|
|
7
|
+
* is flattened too, so the preview never draws empty boxes for spacing wrappers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AstNode } from "../ast/node.ts";
|
|
11
|
+
import { isContentIntrinsic, isRoleIntrinsic, isSectionIntrinsic } from "./jsx.ts";
|
|
12
|
+
import { jsxContentFields, jsxTextFields, type JsxTextFields } from "./jsx-text.ts";
|
|
13
|
+
|
|
14
|
+
/** What a drawable intrinsic contributes: its copy and whether it spends the content budget. */
|
|
15
|
+
export interface IntrinsicPlan {
|
|
16
|
+
/** Content intrinsics have their own node budget and only draw when they carry something. */
|
|
17
|
+
readonly content: boolean;
|
|
18
|
+
readonly fields: JsxTextFields | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Classify one intrinsic element, or null when it is flattened into its parent. */
|
|
22
|
+
export function planIntrinsic(node: AstNode, name: string): IntrinsicPlan | null {
|
|
23
|
+
const section = isSectionIntrinsic(name);
|
|
24
|
+
if (!section && !isContentIntrinsic(name)) return null;
|
|
25
|
+
const fields = section ? jsxTextFields(node) : jsxContentFields(node, name);
|
|
26
|
+
// A control or media element is a visible block even without copy; a bare span is not.
|
|
27
|
+
if (!section && fields === undefined && !isRoleIntrinsic(name)) return null;
|
|
28
|
+
return { content: !section, fields };
|
|
29
|
+
}
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
import { asNode, nodeArray, unwrapExpression, type AstNode } from "../ast/node.ts";
|
|
11
11
|
import type { ModuleGraph } from "../module-graph.ts";
|
|
12
12
|
import type { PagePreview, PagePreviewNode } from "../types-graph.ts";
|
|
13
|
+
import { jsxTextFields, type JsxTextFields } from "./jsx-text.ts";
|
|
13
14
|
import {
|
|
14
15
|
elementName,
|
|
15
|
-
isSectionIntrinsic,
|
|
16
16
|
jsxAttributeValue,
|
|
17
17
|
jsxAttributes,
|
|
18
18
|
jsxChildren,
|
|
@@ -26,8 +26,14 @@ import {
|
|
|
26
26
|
resolveComponent,
|
|
27
27
|
returnedExpression,
|
|
28
28
|
} from "./screen-components.ts";
|
|
29
|
+
import { planIntrinsic } from "./screen-intrinsics.ts";
|
|
29
30
|
|
|
30
31
|
const MAX_NODES = 60;
|
|
32
|
+
/**
|
|
33
|
+
* Content intrinsics spend their own smaller budget, so a copy-heavy page cannot displace the
|
|
34
|
+
* structural nodes (sections/components) the preview already drew before content facts existed.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_CONTENT_NODES = 30;
|
|
31
37
|
const MAX_DEPTH = 4;
|
|
32
38
|
|
|
33
39
|
/** A route element, its source file and the child route that replaces its <Outlet>. */
|
|
@@ -53,6 +59,8 @@ export function buildPagePreview(input: PagePreviewInput): PagePreview {
|
|
|
53
59
|
const nodes: PagePreviewNode[] = [];
|
|
54
60
|
const active = new Set<string>();
|
|
55
61
|
let truncated = false;
|
|
62
|
+
let structuralNodes = 0;
|
|
63
|
+
let contentNodes = 0;
|
|
56
64
|
|
|
57
65
|
const addNode = (
|
|
58
66
|
parentId: string | null,
|
|
@@ -61,13 +69,17 @@ export function buildPagePreview(input: PagePreviewInput): PagePreview {
|
|
|
61
69
|
file: string | null,
|
|
62
70
|
source: PagePreviewNode["source"],
|
|
63
71
|
dynamic: boolean,
|
|
72
|
+
text?: JsxTextFields,
|
|
73
|
+
content = false,
|
|
64
74
|
): string | null => {
|
|
65
|
-
if (
|
|
75
|
+
if (content ? contentNodes >= MAX_CONTENT_NODES : structuralNodes >= MAX_NODES) {
|
|
66
76
|
truncated = true;
|
|
67
77
|
return null;
|
|
68
78
|
}
|
|
79
|
+
if (content) contentNodes += 1;
|
|
80
|
+
else structuralNodes += 1;
|
|
69
81
|
const id = "p" + nodes.length;
|
|
70
|
-
nodes.push({ id, parentId, name, kind, file, source, dynamic });
|
|
82
|
+
nodes.push({ id, parentId, name, kind, file, source, dynamic, ...text });
|
|
71
83
|
return id;
|
|
72
84
|
};
|
|
73
85
|
|
|
@@ -110,13 +122,25 @@ export function buildPagePreview(input: PagePreviewInput): PagePreview {
|
|
|
110
122
|
const name = elementName(node);
|
|
111
123
|
if (name === null) return;
|
|
112
124
|
if (name === "Outlet") {
|
|
113
|
-
if (outletChild !== null)
|
|
114
|
-
|
|
125
|
+
if (outletChild !== null) {
|
|
126
|
+
appendRoot(outletChild, parentId, depth, dynamic);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
addNode(
|
|
130
|
+
parentId,
|
|
131
|
+
"Outlet",
|
|
132
|
+
"slot",
|
|
133
|
+
null,
|
|
134
|
+
lineRef(graph, originFile, node),
|
|
135
|
+
dynamic,
|
|
136
|
+
jsxTextFields(node),
|
|
137
|
+
);
|
|
115
138
|
return;
|
|
116
139
|
}
|
|
117
140
|
const intrinsic = name[0] !== undefined && name[0] === name[0].toLowerCase();
|
|
118
141
|
if (intrinsic) {
|
|
119
|
-
|
|
142
|
+
const plan = planIntrinsic(node, name);
|
|
143
|
+
if (plan === null) {
|
|
120
144
|
appendChildren(node, originFile, parentId, depth, dynamic, outletChild);
|
|
121
145
|
return;
|
|
122
146
|
}
|
|
@@ -127,6 +151,8 @@ export function buildPagePreview(input: PagePreviewInput): PagePreview {
|
|
|
127
151
|
null,
|
|
128
152
|
lineRef(graph, originFile, node),
|
|
129
153
|
dynamic,
|
|
154
|
+
plan.fields,
|
|
155
|
+
plan.content,
|
|
130
156
|
);
|
|
131
157
|
if (id !== null) appendChildren(node, originFile, id, depth + 1, dynamic, outletChild);
|
|
132
158
|
return;
|
|
@@ -139,6 +165,7 @@ export function buildPagePreview(input: PagePreviewInput): PagePreview {
|
|
|
139
165
|
ref?.file ?? null,
|
|
140
166
|
lineRef(graph, originFile, node),
|
|
141
167
|
dynamic,
|
|
168
|
+
jsxTextFields(node),
|
|
142
169
|
);
|
|
143
170
|
if (id === null) return;
|
|
144
171
|
// JSX-valued props are real sections too (AuthShell footer, Page actions, ...).
|
package/src/types-graph.ts
CHANGED
|
@@ -43,12 +43,23 @@ export interface RouteNodeData {
|
|
|
43
43
|
readonly unknown: boolean;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* One policy as the graph names it (D-177): the card needs the grant/gate distinction, while the
|
|
48
|
+
* command and source stay in the inspector's facts.
|
|
49
|
+
*/
|
|
50
|
+
export interface TablePolicyNodeFact {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly restrictive: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
export interface TableNodeData {
|
|
47
56
|
readonly kind: "table";
|
|
48
57
|
readonly table: string;
|
|
49
58
|
readonly columns: readonly ColumnFact[];
|
|
50
59
|
readonly rlsEnabled: boolean;
|
|
51
60
|
readonly scope: ScopeFact | null;
|
|
61
|
+
/** The declared policy set, in declaration order (D-177). */
|
|
62
|
+
readonly policies: readonly TablePolicyNodeFact[];
|
|
52
63
|
/**
|
|
53
64
|
* True when an installed @facilis.dev package declares the table and the project only
|
|
54
65
|
* re-exports it: its source file lives under `node_modules/`.
|
|
@@ -131,6 +142,10 @@ export interface PagePreviewNode {
|
|
|
131
142
|
readonly source: SourceRef;
|
|
132
143
|
/** True when the node sits under a conditional/map branch. */
|
|
133
144
|
readonly dynamic: boolean;
|
|
145
|
+
/** Direct literal text child, trimmed and whitespace-collapsed; absent when none. */
|
|
146
|
+
readonly text?: string;
|
|
147
|
+
/** i18n key when that text child is a `t("key")` call (D-043); absent otherwise. */
|
|
148
|
+
readonly tKey?: string;
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
/** Static component structure of one page, capped by the analyzer. */
|