@platformos/platformos-graph 0.0.19 → 0.0.20
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/CHANGELOG.md +13 -0
- package/bin/platformos-graph +21 -81
- package/dist/cli.d.ts +46 -0
- package/dist/cli.js +126 -0
- package/dist/cli.js.map +1 -0
- package/dist/graph/augment.js +0 -1
- package/dist/graph/augment.js.map +1 -1
- package/dist/graph/module.d.ts +16 -1
- package/dist/graph/module.js +39 -0
- package/dist/graph/module.js.map +1 -1
- package/dist/graph/test-helpers.d.ts +2 -3
- package/dist/graph/test-helpers.js +2 -6
- package/dist/graph/test-helpers.js.map +1 -1
- package/dist/graph/traverse.d.ts +31 -3
- package/dist/graph/traverse.js +146 -41
- package/dist/graph/traverse.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +14 -19
- package/dist/types.js.map +1 -1
- package/fixtures/background-edges/app/views/pages/broken.liquid +3 -0
- package/fixtures/background-edges/app/views/pages/index.liquid +3 -0
- package/fixtures/background-edges/app/views/partials/jobs/notify.liquid +3 -0
- package/fixtures/function-edges/app/lib/queries/list.liquid +3 -0
- package/fixtures/function-edges/app/views/pages/broken.liquid +3 -0
- package/fixtures/function-edges/app/views/pages/index.liquid +3 -0
- package/fixtures/graphql-edges/app/graphql/blog_posts/find.graphql +10 -0
- package/fixtures/graphql-edges/app/views/pages/broken.liquid +3 -0
- package/fixtures/graphql-edges/app/views/pages/index.liquid +3 -0
- package/fixtures/include-edges/app/views/pages/index.liquid +1 -0
- package/fixtures/include-edges/app/views/partials/shared/header.liquid +1 -0
- package/fixtures/module-edges/app/views/pages/index.liquid +4 -0
- package/fixtures/module-edges/modules/my_module/public/lib/queries/get.liquid +3 -0
- package/fixtures/module-edges/modules/my_module/public/views/partials/card.liquid +1 -0
- package/fixtures/skeleton/app/views/partials/child.liquid +2 -4
- package/fixtures/skeleton/app/views/partials/parent.liquid +2 -4
- package/fixtures/skeleton/assets/app.js +1 -7
- package/package.json +5 -5
- package/src/cli.spec.ts +265 -0
- package/src/cli.ts +151 -0
- package/src/graph/augment.ts +0 -2
- package/src/graph/build.spec.ts +19 -5
- package/src/graph/extract.spec.ts +155 -0
- package/src/graph/module.ts +40 -0
- package/src/graph/test-helpers.ts +2 -9
- package/src/graph/traverse-edges.spec.ts +278 -0
- package/src/graph/traverse.ts +177 -49
- package/src/index.ts +1 -1
- package/src/types.ts +16 -18
- package/bin/jsconfig.json +0 -18
- package/src/getWebComponentMap.ts +0 -81
|
@@ -3,7 +3,6 @@ import { AbstractFileSystem } from '@platformos/platformos-common';
|
|
|
3
3
|
import { NodeFileSystem } from '@platformos/platformos-check-node';
|
|
4
4
|
import { vi } from 'vitest';
|
|
5
5
|
import { URI } from 'vscode-uri';
|
|
6
|
-
import { getWebComponentMap } from '../getWebComponentMap';
|
|
7
6
|
import { toSourceCode } from '../toSourceCode';
|
|
8
7
|
import { identity } from '../utils';
|
|
9
8
|
|
|
@@ -17,18 +16,12 @@ export function makeGetSourceCode(fs: AbstractFileSystem) {
|
|
|
17
16
|
export const fixturesRoot = pathUtils.join(URI.file(__dirname), ...'../../fixtures'.split('/'));
|
|
18
17
|
export const skeleton = pathUtils.join(fixturesRoot, 'skeleton');
|
|
19
18
|
|
|
20
|
-
export
|
|
19
|
+
export function getDependencies(fs: AbstractFileSystem = NodeFileSystem) {
|
|
21
20
|
const getSourceCode = makeGetSourceCode(fs);
|
|
22
|
-
|
|
21
|
+
return {
|
|
23
22
|
fs,
|
|
24
23
|
getSourceCode,
|
|
25
|
-
getWebComponentDefinitionReference: (customElementName: string) =>
|
|
26
|
-
webComponentDefs.get(customElementName),
|
|
27
24
|
};
|
|
28
|
-
|
|
29
|
-
const webComponentDefs = await getWebComponentMap(rootUri, deps);
|
|
30
|
-
|
|
31
|
-
return deps;
|
|
32
25
|
}
|
|
33
26
|
|
|
34
27
|
// This thing is way too hard to type.
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { path as pathUtils } from '@platformos/platformos-check-common';
|
|
2
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { buildAppGraph } from '../index';
|
|
4
|
+
import {
|
|
5
|
+
AppGraph,
|
|
6
|
+
Dependencies,
|
|
7
|
+
LiquidModule,
|
|
8
|
+
LiquidModuleKind,
|
|
9
|
+
ModuleType,
|
|
10
|
+
Reference,
|
|
11
|
+
} from '../types';
|
|
12
|
+
import { getGraphQLModuleByUri, getPartialModuleByUri } from './module';
|
|
13
|
+
import { fixturesRoot, getDependencies } from './test-helpers';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The exact source range of `snippet` within `source`. Derived from the fixture
|
|
17
|
+
* text (rather than hard-coded offsets) so the assertion is self-documenting
|
|
18
|
+
* and survives edits to unrelated lines.
|
|
19
|
+
*/
|
|
20
|
+
function rangeOf(source: string, snippet: string): [number, number] {
|
|
21
|
+
const start = source.indexOf(snippet);
|
|
22
|
+
if (start < 0) throw new Error(`snippet not found in fixture: ${snippet}`);
|
|
23
|
+
return [start, start + snippet.length];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A `direct` dependency Reference with no target range (the common case here). */
|
|
27
|
+
function directRef(
|
|
28
|
+
sourceUri: string,
|
|
29
|
+
sourceRange: [number, number],
|
|
30
|
+
targetUri: string,
|
|
31
|
+
kind: Reference['kind'],
|
|
32
|
+
): Reference {
|
|
33
|
+
return {
|
|
34
|
+
source: { uri: sourceUri, range: sourceRange },
|
|
35
|
+
target: { uri: targetUri },
|
|
36
|
+
type: 'direct',
|
|
37
|
+
kind,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const partialNode = (uri: string, exists: boolean, references: Reference[]): LiquidModule => ({
|
|
42
|
+
type: ModuleType.Liquid,
|
|
43
|
+
kind: LiquidModuleKind.Partial,
|
|
44
|
+
uri,
|
|
45
|
+
exists,
|
|
46
|
+
dependencies: [],
|
|
47
|
+
references,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('URI normalization in graph node factories', () => {
|
|
51
|
+
const emptyGraph = (): AppGraph => ({
|
|
52
|
+
rootUri: 'file:///app',
|
|
53
|
+
entryPoints: [],
|
|
54
|
+
modules: {},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('getPartialModuleByUri returns a Partial node with a forward-slash URI', () => {
|
|
58
|
+
const mod = getPartialModuleByUri(
|
|
59
|
+
emptyGraph(),
|
|
60
|
+
'file:///d:/a/repo\\app\\lib\\queries\\list.liquid',
|
|
61
|
+
);
|
|
62
|
+
expect(mod).toEqual({
|
|
63
|
+
type: ModuleType.Liquid,
|
|
64
|
+
kind: LiquidModuleKind.Partial,
|
|
65
|
+
uri: 'file:///d:/a/repo/app/lib/queries/list.liquid',
|
|
66
|
+
dependencies: [],
|
|
67
|
+
references: [],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('getGraphQLModuleByUri returns a GraphQL node with a forward-slash URI', () => {
|
|
72
|
+
const mod = getGraphQLModuleByUri(
|
|
73
|
+
emptyGraph(),
|
|
74
|
+
'file:///d:/a/repo\\app\\graphql\\find.graphql',
|
|
75
|
+
);
|
|
76
|
+
expect(mod).toEqual({
|
|
77
|
+
type: ModuleType.GraphQL,
|
|
78
|
+
kind: 'graphql',
|
|
79
|
+
uri: 'file:///d:/a/repo/app/graphql/find.graphql',
|
|
80
|
+
dependencies: [],
|
|
81
|
+
references: [],
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('Graph traversal: {% function %} edges', () => {
|
|
87
|
+
const rootUri = pathUtils.join(fixturesRoot, 'function-edges');
|
|
88
|
+
const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
|
|
89
|
+
let graph: AppGraph;
|
|
90
|
+
let indexSource: string;
|
|
91
|
+
let brokenSource: string;
|
|
92
|
+
|
|
93
|
+
beforeAll(async () => {
|
|
94
|
+
const dependencies: Dependencies = getDependencies();
|
|
95
|
+
graph = await buildAppGraph(rootUri, dependencies);
|
|
96
|
+
indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
|
|
97
|
+
brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source;
|
|
98
|
+
}, 15000);
|
|
99
|
+
|
|
100
|
+
it('links a page to the resolved lib query via a single function edge', () => {
|
|
101
|
+
const edge = directRef(
|
|
102
|
+
p('app/views/pages/index.liquid'),
|
|
103
|
+
rangeOf(indexSource, "function items = 'queries/list'"),
|
|
104
|
+
p('app/lib/queries/list.liquid'),
|
|
105
|
+
'function',
|
|
106
|
+
);
|
|
107
|
+
expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
|
|
108
|
+
expect(graph.modules[p('app/lib/queries/list.liquid')]).toEqual(
|
|
109
|
+
partialNode(p('app/lib/queries/list.liquid'), true, [edge]),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('records a missing function target as an exists:false node', () => {
|
|
114
|
+
const edge = directRef(
|
|
115
|
+
p('app/views/pages/broken.liquid'),
|
|
116
|
+
rangeOf(brokenSource, "function ghost = 'queries/missing'"),
|
|
117
|
+
p('app/lib/queries/missing.liquid'),
|
|
118
|
+
'function',
|
|
119
|
+
);
|
|
120
|
+
expect(graph.modules[p('app/lib/queries/missing.liquid')]).toEqual(
|
|
121
|
+
partialNode(p('app/lib/queries/missing.liquid'), false, [edge]),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('Graph traversal: {% graphql %} edges', () => {
|
|
127
|
+
const rootUri = pathUtils.join(fixturesRoot, 'graphql-edges');
|
|
128
|
+
const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
|
|
129
|
+
let graph: AppGraph;
|
|
130
|
+
let indexSource: string;
|
|
131
|
+
let brokenSource: string;
|
|
132
|
+
|
|
133
|
+
const graphqlNode = (uri: string, exists: boolean, references: Reference[]) => ({
|
|
134
|
+
type: ModuleType.GraphQL,
|
|
135
|
+
kind: 'graphql' as const,
|
|
136
|
+
uri,
|
|
137
|
+
exists,
|
|
138
|
+
dependencies: [],
|
|
139
|
+
references,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
const dependencies: Dependencies = getDependencies();
|
|
144
|
+
graph = await buildAppGraph(rootUri, dependencies);
|
|
145
|
+
indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
|
|
146
|
+
brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source;
|
|
147
|
+
}, 15000);
|
|
148
|
+
|
|
149
|
+
it('links a page to the resolved .graphql operation via a single graphql edge', () => {
|
|
150
|
+
const edge = directRef(
|
|
151
|
+
p('app/views/pages/index.liquid'),
|
|
152
|
+
rangeOf(indexSource, "graphql posts = 'blog_posts/find', id: '1'"),
|
|
153
|
+
p('app/graphql/blog_posts/find.graphql'),
|
|
154
|
+
'graphql',
|
|
155
|
+
);
|
|
156
|
+
expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
|
|
157
|
+
expect(graph.modules[p('app/graphql/blog_posts/find.graphql')]).toEqual(
|
|
158
|
+
graphqlNode(p('app/graphql/blog_posts/find.graphql'), true, [edge]),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('records a missing graphql target as an exists:false GraphQL node', () => {
|
|
163
|
+
const edge = directRef(
|
|
164
|
+
p('app/views/pages/broken.liquid'),
|
|
165
|
+
rangeOf(brokenSource, "graphql ghost = 'blog_posts/missing'"),
|
|
166
|
+
p('app/graphql/blog_posts/missing.graphql'),
|
|
167
|
+
'graphql',
|
|
168
|
+
);
|
|
169
|
+
expect(graph.modules[p('app/graphql/blog_posts/missing.graphql')]).toEqual(
|
|
170
|
+
graphqlNode(p('app/graphql/blog_posts/missing.graphql'), false, [edge]),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('Graph traversal: {% include %} edges', () => {
|
|
176
|
+
const rootUri = pathUtils.join(fixturesRoot, 'include-edges');
|
|
177
|
+
const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
|
|
178
|
+
let graph: AppGraph;
|
|
179
|
+
let indexSource: string;
|
|
180
|
+
|
|
181
|
+
beforeAll(async () => {
|
|
182
|
+
const dependencies: Dependencies = getDependencies();
|
|
183
|
+
graph = await buildAppGraph(rootUri, dependencies);
|
|
184
|
+
indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
|
|
185
|
+
}, 15000);
|
|
186
|
+
|
|
187
|
+
it('tags an include edge with kind "include" (distinct from render)', () => {
|
|
188
|
+
const edge = directRef(
|
|
189
|
+
p('app/views/pages/index.liquid'),
|
|
190
|
+
rangeOf(indexSource, "{% include 'shared/header' %}"),
|
|
191
|
+
p('app/views/partials/shared/header.liquid'),
|
|
192
|
+
'include',
|
|
193
|
+
);
|
|
194
|
+
expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
|
|
195
|
+
expect(graph.modules[p('app/views/partials/shared/header.liquid')]).toEqual(
|
|
196
|
+
partialNode(p('app/views/partials/shared/header.liquid'), true, [edge]),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('Graph traversal: {% background %} edges', () => {
|
|
202
|
+
const rootUri = pathUtils.join(fixturesRoot, 'background-edges');
|
|
203
|
+
const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
|
|
204
|
+
let graph: AppGraph;
|
|
205
|
+
let indexSource: string;
|
|
206
|
+
let brokenSource: string;
|
|
207
|
+
|
|
208
|
+
beforeAll(async () => {
|
|
209
|
+
const dependencies: Dependencies = getDependencies();
|
|
210
|
+
graph = await buildAppGraph(rootUri, dependencies);
|
|
211
|
+
indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
|
|
212
|
+
brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source;
|
|
213
|
+
}, 15000);
|
|
214
|
+
|
|
215
|
+
it('links a page to the background partial via a single background edge', () => {
|
|
216
|
+
const edge = directRef(
|
|
217
|
+
p('app/views/pages/index.liquid'),
|
|
218
|
+
rangeOf(indexSource, "background job_id = 'jobs/notify', data: 'x'"),
|
|
219
|
+
p('app/views/partials/jobs/notify.liquid'),
|
|
220
|
+
'background',
|
|
221
|
+
);
|
|
222
|
+
expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
|
|
223
|
+
expect(graph.modules[p('app/views/partials/jobs/notify.liquid')]).toEqual(
|
|
224
|
+
partialNode(p('app/views/partials/jobs/notify.liquid'), true, [edge]),
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('records a missing background target as an exists:false node', () => {
|
|
229
|
+
const edge = directRef(
|
|
230
|
+
p('app/views/pages/broken.liquid'),
|
|
231
|
+
rangeOf(brokenSource, "background job_id = 'jobs/missing'"),
|
|
232
|
+
p('app/lib/jobs/missing.liquid'),
|
|
233
|
+
'background',
|
|
234
|
+
);
|
|
235
|
+
expect(graph.modules[p('app/lib/jobs/missing.liquid')]).toEqual(
|
|
236
|
+
partialNode(p('app/lib/jobs/missing.liquid'), false, [edge]),
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('Graph traversal: module-namespaced targets (modules/<name>/public/...)', () => {
|
|
242
|
+
const rootUri = pathUtils.join(fixturesRoot, 'module-edges');
|
|
243
|
+
const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
|
|
244
|
+
let graph: AppGraph;
|
|
245
|
+
let indexSource: string;
|
|
246
|
+
|
|
247
|
+
beforeAll(async () => {
|
|
248
|
+
const dependencies: Dependencies = getDependencies();
|
|
249
|
+
graph = await buildAppGraph(rootUri, dependencies);
|
|
250
|
+
indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
|
|
251
|
+
}, 15000);
|
|
252
|
+
|
|
253
|
+
it('resolves function + render targets into modules/<name>/public/{lib,views/partials}', () => {
|
|
254
|
+
const functionEdge = directRef(
|
|
255
|
+
p('app/views/pages/index.liquid'),
|
|
256
|
+
rangeOf(indexSource, "function items = 'modules/my_module/queries/get'"),
|
|
257
|
+
p('modules/my_module/public/lib/queries/get.liquid'),
|
|
258
|
+
'function',
|
|
259
|
+
);
|
|
260
|
+
const renderEdge = directRef(
|
|
261
|
+
p('app/views/pages/index.liquid'),
|
|
262
|
+
rangeOf(indexSource, "{% render 'modules/my_module/card' %}"),
|
|
263
|
+
p('modules/my_module/public/views/partials/card.liquid'),
|
|
264
|
+
'render',
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([
|
|
268
|
+
functionEdge,
|
|
269
|
+
renderEdge,
|
|
270
|
+
]);
|
|
271
|
+
expect(graph.modules[p('modules/my_module/public/lib/queries/get.liquid')]).toEqual(
|
|
272
|
+
partialNode(p('modules/my_module/public/lib/queries/get.liquid'), true, [functionEdge]),
|
|
273
|
+
);
|
|
274
|
+
expect(graph.modules[p('modules/my_module/public/views/partials/card.liquid')]).toEqual(
|
|
275
|
+
partialNode(p('modules/my_module/public/views/partials/card.liquid'), true, [renderEdge]),
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
});
|
package/src/graph/traverse.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
|
-
import { NodeTypes } from '@platformos/liquid-html-parser';
|
|
2
|
-
import { SourceCodeType, visit, Visitor } from '@platformos/platformos-check-common';
|
|
1
|
+
import { NamedTags, NodeTypes } from '@platformos/liquid-html-parser';
|
|
2
|
+
import { SourceCodeType, UriString, visit, Visitor } from '@platformos/platformos-check-common';
|
|
3
|
+
import { DocumentsLocator } from '@platformos/platformos-common';
|
|
4
|
+
import { URI } from 'vscode-uri';
|
|
3
5
|
import {
|
|
4
6
|
AugmentedDependencies,
|
|
5
7
|
AppGraph,
|
|
6
8
|
AppModule,
|
|
9
|
+
FileSourceCode,
|
|
7
10
|
LiquidModule,
|
|
8
11
|
ModuleType,
|
|
9
12
|
Range,
|
|
10
13
|
Reference,
|
|
14
|
+
ReferenceKind,
|
|
11
15
|
Void,
|
|
12
16
|
} from '../types';
|
|
13
|
-
import { assertNever, exists, isString } from '../utils';
|
|
14
|
-
import { getAssetModule,
|
|
17
|
+
import { assertNever, exists, isString, unique } from '../utils';
|
|
18
|
+
import { getAssetModule, getGraphQLModuleByUri, getPartialModuleByUri } from './module';
|
|
19
|
+
|
|
20
|
+
/** A resolved outgoing reference: the target graph node + its call-site range + kind. */
|
|
21
|
+
interface ResolvedReference {
|
|
22
|
+
target: AppModule;
|
|
23
|
+
sourceRange: Range;
|
|
24
|
+
kind: ReferenceKind;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The dependency surface the reference resolver needs: just a filesystem (for DocumentsLocator). */
|
|
28
|
+
type ResolverDependencies = Pick<AugmentedDependencies, 'fs'>;
|
|
15
29
|
|
|
16
30
|
export async function traverseModule(
|
|
17
31
|
module: AppModule,
|
|
@@ -44,6 +58,10 @@ export async function traverseModule(
|
|
|
44
58
|
return; // Nothing to traverse in assets
|
|
45
59
|
}
|
|
46
60
|
|
|
61
|
+
case ModuleType.GraphQL: {
|
|
62
|
+
return; // Leaf node — GraphQL documents have no platformOS dependencies
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
default: {
|
|
48
66
|
return assertNever(module);
|
|
49
67
|
}
|
|
@@ -56,13 +74,47 @@ async function traverseLiquidModule(
|
|
|
56
74
|
deps: AugmentedDependencies,
|
|
57
75
|
) {
|
|
58
76
|
const sourceCode = await deps.getSourceCode(module.uri);
|
|
77
|
+
const references = await resolveLiquidReferences(appGraph, sourceCode, deps);
|
|
59
78
|
|
|
60
|
-
|
|
79
|
+
for (const reference of references) {
|
|
80
|
+
bind(module, reference.target, {
|
|
81
|
+
sourceRange: reference.sourceRange,
|
|
82
|
+
kind: reference.kind,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
61
85
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
const modules = unique(references.map((ref) => ref.target));
|
|
87
|
+
const promises = modules.map((mod) => traverseModule(mod, appGraph, deps));
|
|
88
|
+
|
|
89
|
+
return Promise.all(promises);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a single parsed Liquid file's outgoing references — the one place that
|
|
94
|
+
* knows how each Liquid construct (`render`/`include`, `function`, `background`,
|
|
95
|
+
* `graphql`, asset filters) maps to a target module + {@link ReferenceKind}.
|
|
96
|
+
*
|
|
97
|
+
* Both the full-project traversal ({@link traverseLiquidModule}) and the
|
|
98
|
+
* standalone per-file primitive ({@link extractFileReferences}) go through this,
|
|
99
|
+
* so resolution can never drift between "graph build" and "validate one buffer".
|
|
100
|
+
*
|
|
101
|
+
* Targets are produced via the module factories (which normalize URIs), so keys
|
|
102
|
+
* match the rest of the graph on every platform. Unparseable input yields no
|
|
103
|
+
* references rather than throwing.
|
|
104
|
+
*/
|
|
105
|
+
async function resolveLiquidReferences(
|
|
106
|
+
appGraph: AppGraph,
|
|
107
|
+
sourceCode: FileSourceCode,
|
|
108
|
+
deps: ResolverDependencies,
|
|
109
|
+
): Promise<ResolvedReference[]> {
|
|
110
|
+
if (sourceCode.ast instanceof Error) return []; // can't visit what you can't parse
|
|
111
|
+
|
|
112
|
+
// Canonical target resolution (lib paths, module prefixes, extensions) is
|
|
113
|
+
// owned by check-common's DocumentsLocator — never re-derived here.
|
|
114
|
+
const documentsLocator = new DocumentsLocator(deps.fs);
|
|
115
|
+
const rootUri = URI.parse(appGraph.rootUri);
|
|
116
|
+
|
|
117
|
+
const visitor: Visitor<SourceCodeType.LiquidHtml, ResolvedReference> = {
|
|
66
118
|
// {{ 'app.js' | asset_url }}
|
|
67
119
|
// {{ 'image.png' | asset_img_url }}
|
|
68
120
|
// {{ 'icon.svg' | inline_asset_content }}
|
|
@@ -78,61 +130,136 @@ async function traverseLiquidModule(
|
|
|
78
130
|
return {
|
|
79
131
|
target: assetModule,
|
|
80
132
|
sourceRange: [parentNode.position.start, parentNode.position.end],
|
|
133
|
+
kind: 'asset',
|
|
81
134
|
};
|
|
82
135
|
}
|
|
83
136
|
},
|
|
84
137
|
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
138
|
+
// {% render 'partial' %} / {% include 'partial' %}
|
|
139
|
+
// Both resolve through DocumentsLocator (like function/graphql), so module
|
|
140
|
+
// prefixes (`modules/<m>/...`), the lib search path, and `.liquid` /
|
|
141
|
+
// `.html.liquid` extensions are handled uniformly — not hard-coded to
|
|
142
|
+
// `app/views/partials`.
|
|
143
|
+
RenderMarkup: async (node, ancestors) => {
|
|
144
|
+
const partial = node.partial;
|
|
145
|
+
const tag = ancestors.at(-1)!;
|
|
146
|
+
if (!isStringLiteral(partial)) return; // dynamic target — skip
|
|
147
|
+
const isInclude = tag.type === NodeTypes.LiquidTag && tag.name === NamedTags.include;
|
|
148
|
+
const uri = await documentsLocator.locateOrDefault(
|
|
149
|
+
rootUri,
|
|
150
|
+
isInclude ? 'include' : 'render',
|
|
151
|
+
partial.value,
|
|
152
|
+
);
|
|
153
|
+
if (!uri) return;
|
|
154
|
+
return {
|
|
155
|
+
target: getPartialModuleByUri(appGraph, uri),
|
|
156
|
+
sourceRange: [tag.position.start, tag.position.end],
|
|
157
|
+
kind: isInclude ? 'include' : 'render',
|
|
158
|
+
};
|
|
159
|
+
},
|
|
92
160
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
161
|
+
// {% function result = 'queries/...' %} / {% function res = 'commands/...' %}
|
|
162
|
+
FunctionMarkup: async (node, ancestors) => {
|
|
163
|
+
const target = node.partial;
|
|
164
|
+
const tag = ancestors.at(-1)!;
|
|
165
|
+
if (!isStringLiteral(target)) return; // dynamic target — skip
|
|
166
|
+
const uri = await documentsLocator.locateOrDefault(rootUri, 'function', target.value);
|
|
167
|
+
if (!uri) return;
|
|
168
|
+
return {
|
|
169
|
+
target: getPartialModuleByUri(appGraph, uri),
|
|
170
|
+
sourceRange: [tag.position.start, tag.position.end],
|
|
171
|
+
kind: 'function',
|
|
172
|
+
};
|
|
173
|
+
},
|
|
98
174
|
|
|
175
|
+
// {% background job_id = 'partial', ... %} (file-based form)
|
|
176
|
+
// Runs a partial asynchronously; `node.partial` is the partial reference,
|
|
177
|
+
// resolved against the same search paths as {% function %}. The inline form
|
|
178
|
+
// ({% background %}...{% endbackground %}) parses to BackgroundInlineMarkup,
|
|
179
|
+
// has no file target, and is intentionally not matched here.
|
|
180
|
+
BackgroundMarkup: async (node, ancestors) => {
|
|
181
|
+
const target = node.partial;
|
|
182
|
+
const tag = ancestors.at(-1)!;
|
|
183
|
+
if (!isStringLiteral(target)) return; // dynamic target — skip
|
|
184
|
+
const uri = await documentsLocator.locateOrDefault(rootUri, 'function', target.value);
|
|
185
|
+
if (!uri) return;
|
|
99
186
|
return {
|
|
100
|
-
target:
|
|
101
|
-
sourceRange: [
|
|
102
|
-
|
|
187
|
+
target: getPartialModuleByUri(appGraph, uri),
|
|
188
|
+
sourceRange: [tag.position.start, tag.position.end],
|
|
189
|
+
kind: 'background',
|
|
103
190
|
};
|
|
104
191
|
},
|
|
105
192
|
|
|
106
|
-
// {%
|
|
107
|
-
|
|
108
|
-
|
|
193
|
+
// {% graphql result = 'path/to/operation' %}
|
|
194
|
+
// `node.name` is the RESULT variable; the operation-file path is `node.graphql`.
|
|
195
|
+
GraphQLMarkup: async (node, ancestors) => {
|
|
196
|
+
const op = node.graphql;
|
|
109
197
|
const tag = ancestors.at(-1)!;
|
|
110
|
-
if (!
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
198
|
+
if (!isStringLiteral(op)) return; // dynamic/inline — no static file
|
|
199
|
+
const uri = await documentsLocator.locateOrDefault(rootUri, 'graphql', op.value);
|
|
200
|
+
if (!uri) return;
|
|
201
|
+
return {
|
|
202
|
+
target: getGraphQLModuleByUri(appGraph, uri),
|
|
203
|
+
sourceRange: [tag.position.start, tag.position.end],
|
|
204
|
+
kind: 'graphql',
|
|
205
|
+
};
|
|
116
206
|
},
|
|
117
207
|
};
|
|
118
208
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
for (const reference of references) {
|
|
122
|
-
bind(module, reference.target, {
|
|
123
|
-
sourceRange: reference.sourceRange,
|
|
124
|
-
targetRange: reference.targetRange,
|
|
125
|
-
});
|
|
126
|
-
}
|
|
209
|
+
return visit(sourceCode.ast, visitor);
|
|
210
|
+
}
|
|
127
211
|
|
|
128
|
-
|
|
129
|
-
|
|
212
|
+
/**
|
|
213
|
+
* Extract one Liquid file's outgoing dependency references, resolved against the
|
|
214
|
+
* project at `rootUri`, WITHOUT building the whole app graph.
|
|
215
|
+
*
|
|
216
|
+
* This is the per-file primitive for consumers that hold a single (possibly
|
|
217
|
+
* in-flight, not-yet-on-disk) buffer — e.g. a `validate_code`-style tool that
|
|
218
|
+
* parses the buffer with {@link toSourceCode} and wants the file's resolved
|
|
219
|
+
* `render`/`include`/`function`/`background`/`graphql`/asset edges with their
|
|
220
|
+
* canonical target URIs and {@link ReferenceKind}. Resolution uses the same
|
|
221
|
+
* `DocumentsLocator`-backed logic as the full graph build, so a target's URI is
|
|
222
|
+
* identical to the key it would have as a graph node.
|
|
223
|
+
*
|
|
224
|
+
* Notes for consumers:
|
|
225
|
+
* - Targets are returned whether or not they exist on disk (resolution is
|
|
226
|
+
* path-based). To distinguish missing targets, `stat` `target.uri` via the
|
|
227
|
+
* same `fs`; unresolved/missing partials are also surfaced by the linter's
|
|
228
|
+
* `MissingPartial` check, so prefer that for diagnostics.
|
|
229
|
+
* - Only statically resolvable references are returned; dynamic targets
|
|
230
|
+
* (`{% render some_var %}`) and inline forms are skipped.
|
|
231
|
+
* - `sourceCode` is parsed by the caller (from the buffer, not disk); only `fs`
|
|
232
|
+
* is touched here, for target resolution.
|
|
233
|
+
*/
|
|
234
|
+
export async function extractFileReferences(
|
|
235
|
+
rootUri: UriString,
|
|
236
|
+
sourceUri: UriString,
|
|
237
|
+
sourceCode: FileSourceCode,
|
|
238
|
+
deps: ResolverDependencies,
|
|
239
|
+
): Promise<Reference[]> {
|
|
240
|
+
// A throwaway graph so the URI-normalizing module factories can be reused; it
|
|
241
|
+
// is never traversed and is discarded with this call.
|
|
242
|
+
const scratchGraph: AppGraph = { rootUri, entryPoints: [], modules: {} };
|
|
243
|
+
const references = await resolveLiquidReferences(scratchGraph, sourceCode, deps);
|
|
130
244
|
|
|
131
|
-
return
|
|
245
|
+
return references.map((reference) => ({
|
|
246
|
+
source: { uri: sourceUri, range: reference.sourceRange },
|
|
247
|
+
target: { uri: reference.target.uri },
|
|
248
|
+
type: 'direct',
|
|
249
|
+
kind: reference.kind,
|
|
250
|
+
}));
|
|
132
251
|
}
|
|
133
252
|
|
|
134
|
-
|
|
135
|
-
|
|
253
|
+
/**
|
|
254
|
+
* A render/function/graphql target that is a static string literal (e.g.
|
|
255
|
+
* `{% render 'partial' %}`), narrowed to its `NodeTypes.String` member so the
|
|
256
|
+
* `.value` is accessible. Dynamic targets (`{% render var %}`) return false and
|
|
257
|
+
* are skipped — the graph only records statically resolvable edges.
|
|
258
|
+
*/
|
|
259
|
+
function isStringLiteral<T extends { type: NodeTypes }>(
|
|
260
|
+
node: T,
|
|
261
|
+
): node is Extract<T, { type: NodeTypes.String }> {
|
|
262
|
+
return !isString(node) && node.type === NodeTypes.String;
|
|
136
263
|
}
|
|
137
264
|
|
|
138
265
|
/**
|
|
@@ -147,18 +274,19 @@ export function bind(
|
|
|
147
274
|
target: AppModule,
|
|
148
275
|
{
|
|
149
276
|
sourceRange,
|
|
150
|
-
targetRange,
|
|
151
277
|
type = 'direct', // the type of dependency, can be 'direct' or 'indirect'
|
|
278
|
+
kind, // the semantic Liquid construct that created the edge
|
|
152
279
|
}: {
|
|
153
280
|
sourceRange?: Range; // a range in the source module that references the child
|
|
154
|
-
targetRange?: Range; // a range in the child module that is being referenced
|
|
155
281
|
type?: Reference['type']; // the type of dependency
|
|
282
|
+
kind?: ReferenceKind; // render | include | function | background | graphql | asset | layout
|
|
156
283
|
} = {},
|
|
157
284
|
): void {
|
|
158
285
|
const dependency: Reference = {
|
|
159
286
|
source: { uri: source.uri, range: sourceRange },
|
|
160
|
-
target: { uri: target.uri
|
|
287
|
+
target: { uri: target.uri },
|
|
161
288
|
type: type,
|
|
289
|
+
kind: kind,
|
|
162
290
|
};
|
|
163
291
|
|
|
164
292
|
source.dependencies.push(dependency);
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { buildAppGraph } from './graph/build';
|
|
2
|
+
export { extractFileReferences } from './graph/traverse';
|
|
2
3
|
export { serializeAppGraph } from './graph/serialize';
|
|
3
|
-
export { getWebComponentMap, findWebComponentReferences } from './getWebComponentMap';
|
|
4
4
|
export { parseJs, toSourceCode } from './toSourceCode';
|
|
5
5
|
export * from './types';
|