nuxt-layerscope 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/dist/analyze-DS51xy6X.mjs +337 -0
- package/dist/analyze-YePAJEVS.mjs +2 -0
- package/dist/api.d.mts +148 -0
- package/dist/api.mjs +6 -0
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +374 -0
- package/dist/component-files-DgOXNE6l.mjs +12 -0
- package/dist/define-DNRGLVG_.d.mts +126 -0
- package/dist/define-l62B9fVk.mjs +6 -0
- package/dist/define.d.mts +2 -0
- package/dist/define.mjs +2 -0
- package/dist/effective-Dqg4EwH_.d.mts +7 -0
- package/dist/eslint.d.mts +7 -0
- package/dist/eslint.mjs +213 -0
- package/dist/file-analysis-Bkq_69x4.mjs +1085 -0
- package/dist/index.d.mts +24 -0
- package/dist/index.mjs +364 -0
- package/dist/layer-factory-xZmIAPrA.mjs +168 -0
- package/dist/report-BNW0GCl9.mjs +131 -0
- package/dist/report-DtAi0vPb.mjs +2 -0
- package/dist/strings-CrOjNZmB.mjs +17 -0
- package/dist/summary-DiUYKMPe.mjs +10 -0
- package/dist/unused-Dxep1p6u.mjs +164 -0
- package/dist/version-D6whhsHH.mjs +9 -0
- package/package.json +93 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { a as Context, c as Layer, d as Reference, f as ReferenceKind, g as SourceOption, h as Severity, i as BaselineResult, l as LayerRule, m as RuleName, n as AnalyzeResult, o as Edge, p as ResolutionSource, r as BaselineEntry, s as Finding, t as defineConfig, u as LayerscopeConfig } from "./define-DNRGLVG_.mjs";
|
|
2
|
+
import { t as ProjectConfig } from "./effective-Dqg4EwH_.mjs";
|
|
3
|
+
//#region src/module/index.d.ts
|
|
4
|
+
interface ModuleOptions extends ProjectConfig {
|
|
5
|
+
/** Write `.nuxt/layerscope/registry.json`. Defaults to `true`. */
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
/** Add a Layerscope tab to Nuxt DevTools while `nuxi dev` runs. Defaults to `true`. */
|
|
8
|
+
devtools: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Records the registry Nuxt resolves (layers, components including overridden ones, app and
|
|
12
|
+
* server auto-imports) into `.nuxt/layerscope/registry.json` for the `layerscope` CLI.
|
|
13
|
+
*/
|
|
14
|
+
declare const layerscopeModule: import("@nuxt/schema").NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
15
|
+
declare module '@nuxt/schema' {
|
|
16
|
+
interface NuxtConfig {
|
|
17
|
+
layerscope?: Partial<ModuleOptions>;
|
|
18
|
+
}
|
|
19
|
+
interface NuxtOptions {
|
|
20
|
+
layerscope: ModuleOptions;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { type AnalyzeResult, type BaselineEntry, type BaselineResult, type Context, type Edge, type Finding, type Layer, type LayerRule, type LayerscopeConfig, type ModuleOptions, type Reference, type ReferenceKind, type ResolutionSource, type RuleName, type Severity, type SourceOption, layerscopeModule as default, defineConfig };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { f as pickProjectConfig, l as REGISTRY_FILE, r as layerFromNuxt } from "./layer-factory-xZmIAPrA.mjs";
|
|
2
|
+
import { t as summarize } from "./summary-DiUYKMPe.mjs";
|
|
3
|
+
import { i as plural, t as compareStrings } from "./strings-CrOjNZmB.mjs";
|
|
4
|
+
import { t as packageVersion } from "./version-D6whhsHH.mjs";
|
|
5
|
+
import { t as globComponents } from "./component-files-DgOXNE6l.mjs";
|
|
6
|
+
import { t as defineConfig } from "./define-l62B9fVk.mjs";
|
|
7
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
8
|
+
import { addDevServerHandler, defineNuxtModule, getLayerDirectories } from "@nuxt/kit";
|
|
9
|
+
import { dirname, isAbsolute, join, relative, resolve } from "pathe";
|
|
10
|
+
import { defineEventHandler, getQuery, setResponseHeader } from "h3";
|
|
11
|
+
//#region src/module/nuxt.ts
|
|
12
|
+
/** `nitro:init` is typed by Nuxt's Nitro integration, not by `@nuxt/schema`. */
|
|
13
|
+
function onNitroInit(nuxt, handler) {
|
|
14
|
+
nuxt.hook("nitro:init", handler);
|
|
15
|
+
}
|
|
16
|
+
/** Typed by `@nuxt/devtools`, which the module does not depend on. */
|
|
17
|
+
function onDevtoolsCustomTabs(nuxt, handler) {
|
|
18
|
+
nuxt.hook("devtools:customTabs", handler);
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/devtools/html.ts
|
|
22
|
+
const ENTITIES = {
|
|
23
|
+
"&": "&",
|
|
24
|
+
"<": "<",
|
|
25
|
+
">": ">",
|
|
26
|
+
"\"": """,
|
|
27
|
+
"'": "'"
|
|
28
|
+
};
|
|
29
|
+
function escapeHtml(text) {
|
|
30
|
+
return text.replaceAll(/[&<>"']/gu, (char) => ENTITIES[char] ?? char);
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/devtools/page-assets.ts
|
|
34
|
+
const PAGE_STYLE = `
|
|
35
|
+
:root { color-scheme: light dark; font: 14px/1.5 system-ui, sans-serif; }
|
|
36
|
+
body { margin: 0; padding: 16px 24px; }
|
|
37
|
+
header { display: flex; align-items: center; justify-content: space-between; }
|
|
38
|
+
h1 { font-size: 18px; margin: 0; }
|
|
39
|
+
h2 { font-size: 15px; margin: 24px 0 8px; }
|
|
40
|
+
table { border-collapse: collapse; width: 100%; }
|
|
41
|
+
th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #8884; }
|
|
42
|
+
ul.findings { list-style: none; padding: 0; margin: 0; }
|
|
43
|
+
ul.findings li { padding: 8px 0; border-bottom: 1px solid #8884; }
|
|
44
|
+
.badge { font-size: 12px; padding: 1px 6px; border-radius: 4px; color: #fff; }
|
|
45
|
+
.error .badge { background: #d73a49; }
|
|
46
|
+
.warn .badge { background: #b08800; }
|
|
47
|
+
.rule, .summary, .target { opacity: 0.7; }
|
|
48
|
+
.note, .error-message { padding: 8px; border-radius: 4px; background: #b0880022; }
|
|
49
|
+
.ok { color: #22863a; }
|
|
50
|
+
a { color: inherit; }
|
|
51
|
+
button { cursor: pointer; }
|
|
52
|
+
`;
|
|
53
|
+
const PAGE_SCRIPT = `
|
|
54
|
+
const endpoint = document.getElementById('open-endpoint')?.textContent;
|
|
55
|
+
document.addEventListener('click', event => {
|
|
56
|
+
const link = event.target.closest('[data-open]');
|
|
57
|
+
if (link && endpoint) {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
fetch(endpoint + '?file=' + encodeURIComponent(link.dataset.open));
|
|
60
|
+
}
|
|
61
|
+
if (event.target.closest('[data-reload]')) {
|
|
62
|
+
location.reload();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
`;
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/devtools/page.ts
|
|
68
|
+
function fileLink(file, line, column, input) {
|
|
69
|
+
const label = `${relative(input.result.rootDir, file)}:${line}:${column}`;
|
|
70
|
+
return `<a href="#" data-open="${escapeHtml(`${file}:${line}:${column}`)}">${escapeHtml(label)}</a>`;
|
|
71
|
+
}
|
|
72
|
+
function layersTable(input) {
|
|
73
|
+
return `<table><thead><tr><th>Layer</th><th>Root</th><th>May depend on</th></tr></thead><tbody>${input.result.layers.map((layer) => {
|
|
74
|
+
const allow = input.result.config.layers?.[layer.name]?.allow;
|
|
75
|
+
const allowed = allow === void 0 ? "<em>unrestricted</em>" : escapeHtml(allow.join(", ") || "—");
|
|
76
|
+
const root = relative(input.result.rootDir, layer.root) || ".";
|
|
77
|
+
return `<tr><td>${escapeHtml(layer.name)}</td><td><code>${escapeHtml(root)}</code></td><td>${allowed}</td></tr>`;
|
|
78
|
+
}).join("")}</tbody></table>`;
|
|
79
|
+
}
|
|
80
|
+
function findingItem(finding, input) {
|
|
81
|
+
const target = finding.target === null ? "" : `<div class="target">→ ${fileLink(finding.target, 1, 1, input)}</div>`;
|
|
82
|
+
return `<li class="${finding.severity}"><span class="badge">${finding.severity}</span> ${escapeHtml(finding.message)} <span class="rule">${finding.rule}</span><div>${fileLink(finding.file, finding.line, finding.column, input)}</div>${target}</li>`;
|
|
83
|
+
}
|
|
84
|
+
function findingsList(input) {
|
|
85
|
+
const { findings } = input.result;
|
|
86
|
+
if (findings.length === 0) return "<p class=\"ok\">✔ No problems</p>";
|
|
87
|
+
return `<ul class="findings">${findings.map((finding) => findingItem(finding, input)).join("")}</ul>`;
|
|
88
|
+
}
|
|
89
|
+
function summaryLine(result) {
|
|
90
|
+
const { errors, warnings } = summarize(result.findings);
|
|
91
|
+
const source = result.source === "registry" ? "module registry" : "generated .d.ts files";
|
|
92
|
+
return `${plural(result.files.length, "file")} in ${plural(result.layers.length, "layer")} · ${plural(errors, "error")}, ${plural(warnings, "warning")} · symbols from the ${source}`;
|
|
93
|
+
}
|
|
94
|
+
function document(body) {
|
|
95
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>layerscope</title><style>${PAGE_STYLE}</style></head><body>${body}<script>${PAGE_SCRIPT}<\/script></body></html>`;
|
|
96
|
+
}
|
|
97
|
+
/** The DevTools tab: layers, their allowed dependencies and current findings. */
|
|
98
|
+
function renderPage(input) {
|
|
99
|
+
const notes = input.result.notes.map((note) => `<p class="note">${escapeHtml(note)}</p>`).join("");
|
|
100
|
+
return document(`<header><h1>layerscope</h1><button data-reload>Re-run</button></header><p class="summary">${summaryLine(input.result)}</p>${notes}<h2>Layers</h2>${layersTable(input)}<h2>Findings</h2>${findingsList(input)}<template id="open-endpoint">${escapeHtml(input.openInEditor)}</template>`);
|
|
101
|
+
}
|
|
102
|
+
function renderError(message) {
|
|
103
|
+
return document(`<header><h1>layerscope</h1><button data-reload>Re-run</button></header><p class="error-message">${escapeHtml(message)}</p>`);
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/devtools/index.ts
|
|
107
|
+
const DEVTOOLS_ROUTE = "/__layerscope";
|
|
108
|
+
async function renderReport(nuxt, format) {
|
|
109
|
+
const [{ analyze }, { formatResult }] = await Promise.all([import("./analyze-YePAJEVS.mjs"), import("./report-DtAi0vPb.mjs")]);
|
|
110
|
+
const { rootDir, app } = nuxt.options;
|
|
111
|
+
const result = await analyze({ rootDir });
|
|
112
|
+
if (format === "json") return formatResult(result, "json", rootDir);
|
|
113
|
+
return renderPage({
|
|
114
|
+
result,
|
|
115
|
+
openInEditor: join("/", app.baseURL, app.buildAssetsDir, "__open-in-editor")
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** A Nuxt DevTools tab with the layer report, served while `nuxi dev` runs. */
|
|
119
|
+
function setupDevtools(nuxt) {
|
|
120
|
+
addDevServerHandler({
|
|
121
|
+
route: DEVTOOLS_ROUTE,
|
|
122
|
+
handler: defineEventHandler(async (event) => {
|
|
123
|
+
const { format } = getQuery(event);
|
|
124
|
+
setResponseHeader(event, "content-type", format === "json" ? "application/json" : "text/html; charset=utf-8");
|
|
125
|
+
try {
|
|
126
|
+
return await renderReport(nuxt, format);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return renderError(error.message);
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
});
|
|
132
|
+
onDevtoolsCustomTabs(nuxt, (tabs) => {
|
|
133
|
+
tabs.push({
|
|
134
|
+
name: "layerscope",
|
|
135
|
+
title: "Layerscope",
|
|
136
|
+
icon: "carbon:flow-connection",
|
|
137
|
+
view: {
|
|
138
|
+
type: "iframe",
|
|
139
|
+
src: DEVTOOLS_ROUTE
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/module/collect.ts
|
|
146
|
+
const COMPONENT_IGNORE = ["**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}", "**/*.{d.ts,d.mts,d.cts,d.vue.ts,d.vue.mts,d.vue.cts}"];
|
|
147
|
+
const SHARED_BUILTINS = [
|
|
148
|
+
{
|
|
149
|
+
name: "useRuntimeConfig",
|
|
150
|
+
from: "nuxt"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "useAppConfig",
|
|
154
|
+
from: "nuxt"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "defineAppConfig",
|
|
158
|
+
from: "nuxt"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "createError",
|
|
162
|
+
from: "h3"
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "setResponseStatus",
|
|
166
|
+
from: "h3"
|
|
167
|
+
}
|
|
168
|
+
];
|
|
169
|
+
const SHARED_BUILTIN_NAMES = new Set(SHARED_BUILTINS.map((entry) => entry.name));
|
|
170
|
+
const SERVER_PLACEHOLDER = "/components/server-placeholder";
|
|
171
|
+
function resolveAlias(path, aliases) {
|
|
172
|
+
const key = Object.keys(aliases).toSorted((a, b) => b.length - a.length).find((alias) => path === alias || path.startsWith(`${alias}/`));
|
|
173
|
+
return key === void 0 ? path : aliases[key] + path.slice(key.length);
|
|
174
|
+
}
|
|
175
|
+
function toMode(mode) {
|
|
176
|
+
return mode === "client" || mode === "server" ? mode : "all";
|
|
177
|
+
}
|
|
178
|
+
function byName(entries) {
|
|
179
|
+
return entries.toSorted((a, b) => compareStrings(a.name, b.name));
|
|
180
|
+
}
|
|
181
|
+
function importName(entry) {
|
|
182
|
+
return entry.as ?? entry.name;
|
|
183
|
+
}
|
|
184
|
+
async function activeImports(context) {
|
|
185
|
+
return (context === null || context === void 0 ? [] : await context.getImports()).filter((entry) => entry.type !== true && entry.disabled !== true);
|
|
186
|
+
}
|
|
187
|
+
function toRegistryImports(imports, aliases) {
|
|
188
|
+
return byName(imports.map((entry) => {
|
|
189
|
+
const from = resolveAlias(entry.from, aliases);
|
|
190
|
+
return {
|
|
191
|
+
name: importName(entry),
|
|
192
|
+
from: isAbsolute(from) ? resolve(from) : from
|
|
193
|
+
};
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
function withoutDot(extension) {
|
|
197
|
+
return extension.replace(/^\./u, "");
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Records what Nuxt resolves while it prepares or builds the app. Nuxt drops overridden
|
|
201
|
+
* components before `components:extend`, so each component dir's `extendComponent` is wrapped to
|
|
202
|
+
* see every scanned component, including the ones a higher-priority layer replaces.
|
|
203
|
+
*/
|
|
204
|
+
var RegistryCollector = class {
|
|
205
|
+
constructor(nuxt, config) {
|
|
206
|
+
this.dirs = [];
|
|
207
|
+
this.scanned = /* @__PURE__ */ new Map();
|
|
208
|
+
this.registered = [];
|
|
209
|
+
this.appImports = null;
|
|
210
|
+
this.nitro = null;
|
|
211
|
+
this.config = config;
|
|
212
|
+
this.nuxt = nuxt;
|
|
213
|
+
}
|
|
214
|
+
install() {
|
|
215
|
+
const { nuxt } = this;
|
|
216
|
+
nuxt.hook("modules:done", () => {
|
|
217
|
+
nuxt.hook("components:dirs", (dirs) => {
|
|
218
|
+
this.wrapDirs(dirs);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
nuxt.hook("components:extend", (components) => {
|
|
222
|
+
this.registered = components;
|
|
223
|
+
});
|
|
224
|
+
nuxt.hook("imports:context", (context) => {
|
|
225
|
+
this.appImports = context;
|
|
226
|
+
});
|
|
227
|
+
onNitroInit(nuxt, (nitro) => {
|
|
228
|
+
this.nitro = nitro;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
wrapDirs(dirs) {
|
|
232
|
+
this.dirs = [];
|
|
233
|
+
for (const [index, entry] of dirs.entries()) {
|
|
234
|
+
const dir = typeof entry === "string" ? { path: entry } : entry;
|
|
235
|
+
const extend = dir.extendComponent;
|
|
236
|
+
dir.extendComponent = async (component) => {
|
|
237
|
+
const result = (extend === void 0 ? void 0 : await extend(component)) ?? component;
|
|
238
|
+
this.scanned.set(result.filePath, result);
|
|
239
|
+
return result;
|
|
240
|
+
};
|
|
241
|
+
dirs[index] = dir;
|
|
242
|
+
this.dirs.push(dir);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async collect() {
|
|
246
|
+
const [componentDirs, imports] = await Promise.all([this.componentDirs(), this.imports()]);
|
|
247
|
+
const layers = this.layers();
|
|
248
|
+
const components = this.components();
|
|
249
|
+
return {
|
|
250
|
+
version: 1,
|
|
251
|
+
generator: {
|
|
252
|
+
name: "nuxt-layerscope",
|
|
253
|
+
version: packageVersion(),
|
|
254
|
+
nuxt: this.nuxt._version
|
|
255
|
+
},
|
|
256
|
+
layers,
|
|
257
|
+
components,
|
|
258
|
+
shadowedComponents: this.shadowed(components),
|
|
259
|
+
componentDirs,
|
|
260
|
+
imports,
|
|
261
|
+
...Object.keys(this.config).length > 0 ? { config: this.config } : {}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
layers() {
|
|
265
|
+
const dirs = getLayerDirectories(this.nuxt);
|
|
266
|
+
return this.nuxt.options._layers.map((layer, index) => layerFromNuxt(layer, index, dirs[index]));
|
|
267
|
+
}
|
|
268
|
+
toFile(path) {
|
|
269
|
+
const file = resolveAlias(path, this.nuxt.options.alias);
|
|
270
|
+
return isAbsolute(file) ? resolve(file) : file;
|
|
271
|
+
}
|
|
272
|
+
toComponent(component) {
|
|
273
|
+
return {
|
|
274
|
+
name: component.pascalName,
|
|
275
|
+
file: this.toFile(component.filePath),
|
|
276
|
+
mode: toMode(component.mode),
|
|
277
|
+
island: component.island === true,
|
|
278
|
+
priority: component.priority ?? 0
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
components() {
|
|
282
|
+
return this.registered.filter((component) => !component.filePath.includes(SERVER_PLACEHOLDER)).map((component) => this.toComponent(component));
|
|
283
|
+
}
|
|
284
|
+
shadowed(registered) {
|
|
285
|
+
const files = new Set(registered.map((component) => component.file));
|
|
286
|
+
return [...this.scanned.values()].map((component) => this.toComponent(component)).filter((component) => !files.has(component.file)).flatMap((component) => {
|
|
287
|
+
const winner = registered.find((other) => other.name === component.name && (other.mode === "all" || component.mode === "all" || other.mode === component.mode));
|
|
288
|
+
return winner === void 0 ? [] : [{
|
|
289
|
+
...component,
|
|
290
|
+
shadowedBy: winner.file
|
|
291
|
+
}];
|
|
292
|
+
}).toSorted((a, b) => compareStrings(a.file, b.file));
|
|
293
|
+
}
|
|
294
|
+
async componentDirs() {
|
|
295
|
+
const { alias, extensions } = this.nuxt.options;
|
|
296
|
+
const jobs = this.dirs.map(async (dir) => {
|
|
297
|
+
const path = resolve(resolveAlias(dir.path, alias));
|
|
298
|
+
const exts = (dir.extensions ?? extensions).map((ext) => withoutDot(ext));
|
|
299
|
+
const pattern = dir.pattern ?? (exts.length > 1 ? `**/*.{${exts.join(",")}}` : `**/*.${exts[0] ?? "*"}`);
|
|
300
|
+
const ignore = [...COMPONENT_IGNORE, ...dir.ignore ?? []];
|
|
301
|
+
return {
|
|
302
|
+
path,
|
|
303
|
+
pattern,
|
|
304
|
+
ignore,
|
|
305
|
+
files: path.includes("/node_modules/") ? [] : await globComponents(path, pattern, ignore)
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
return await Promise.all(jobs);
|
|
309
|
+
}
|
|
310
|
+
async imports() {
|
|
311
|
+
const serverContext = this.nitro?.unimport;
|
|
312
|
+
await serverContext?.init?.();
|
|
313
|
+
const app = await activeImports(this.appImports);
|
|
314
|
+
const server = await activeImports(serverContext);
|
|
315
|
+
const serverFrom = new Map(server.map((entry) => [importName(entry), entry.from]));
|
|
316
|
+
const shared = app.filter((entry) => !SHARED_BUILTIN_NAMES.has(importName(entry)) && serverFrom.get(importName(entry)) === entry.from);
|
|
317
|
+
const nuxtAliases = this.nuxt.options.alias;
|
|
318
|
+
const serverAliases = {
|
|
319
|
+
...nuxtAliases,
|
|
320
|
+
...this.nitro?.options.alias
|
|
321
|
+
};
|
|
322
|
+
return {
|
|
323
|
+
app: toRegistryImports(app, nuxtAliases),
|
|
324
|
+
server: toRegistryImports(server, serverAliases),
|
|
325
|
+
shared: byName([...SHARED_BUILTINS, ...toRegistryImports(shared, nuxtAliases)])
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region src/module/index.ts
|
|
331
|
+
async function writeRegistry(collector, buildDir) {
|
|
332
|
+
const file = join(buildDir, REGISTRY_FILE);
|
|
333
|
+
await mkdir(dirname(file), { recursive: true });
|
|
334
|
+
await writeFile(file, `${JSON.stringify(await collector.collect(), null, 2)}\n`);
|
|
335
|
+
}
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/index.ts
|
|
338
|
+
var src_default = defineNuxtModule({
|
|
339
|
+
meta: {
|
|
340
|
+
name: "nuxt-layerscope",
|
|
341
|
+
configKey: "layerscope",
|
|
342
|
+
version: packageVersion(),
|
|
343
|
+
compatibility: { nuxt: ">=3.12.0" }
|
|
344
|
+
},
|
|
345
|
+
defaults: {
|
|
346
|
+
enabled: true,
|
|
347
|
+
devtools: true
|
|
348
|
+
},
|
|
349
|
+
setup(options, nuxt) {
|
|
350
|
+
if (!options.enabled) return;
|
|
351
|
+
const collector = new RegistryCollector(nuxt, pickProjectConfig(options));
|
|
352
|
+
collector.install();
|
|
353
|
+
const write = async () => {
|
|
354
|
+
await writeRegistry(collector, nuxt.options.buildDir);
|
|
355
|
+
};
|
|
356
|
+
nuxt.hook("prepare:types", write);
|
|
357
|
+
if (nuxt.options.dev) {
|
|
358
|
+
nuxt.hook("app:templatesGenerated", write);
|
|
359
|
+
if (options.devtools) setupDevtools(nuxt);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
//#endregion
|
|
364
|
+
export { src_default as default, defineConfig };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { basename, dirname, join, resolve } from "pathe";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
//#region src/errors.ts
|
|
4
|
+
/** A config or input problem; the CLI exits with code 2. */
|
|
5
|
+
var LayerscopeError = class extends Error {
|
|
6
|
+
constructor(..._args) {
|
|
7
|
+
super(..._args);
|
|
8
|
+
this.name = "LayerscopeError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/config/rules.ts
|
|
13
|
+
const DEFAULT_SEVERITY = {
|
|
14
|
+
"layer-boundary": "error",
|
|
15
|
+
"unresolved-reference": "warn",
|
|
16
|
+
"shadowed-component": "warn"
|
|
17
|
+
};
|
|
18
|
+
/** Accepted in config for forward compatibility; not implemented yet. */
|
|
19
|
+
const RESERVED_RULES = /* @__PURE__ */ new Set(["unused-symbol"]);
|
|
20
|
+
const SEVERITIES = /* @__PURE__ */ new Set([
|
|
21
|
+
"off",
|
|
22
|
+
"warn",
|
|
23
|
+
"error"
|
|
24
|
+
]);
|
|
25
|
+
function isRuleName(name) {
|
|
26
|
+
return Object.hasOwn(DEFAULT_SEVERITY, name);
|
|
27
|
+
}
|
|
28
|
+
function ruleSeverity(config, rule) {
|
|
29
|
+
return config.rules?.[rule] ?? DEFAULT_SEVERITY[rule];
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/config/validate.ts
|
|
33
|
+
function validateConfig(config, file) {
|
|
34
|
+
for (const [rule, severity] of Object.entries(config.rules ?? {})) {
|
|
35
|
+
if (!isRuleName(rule) && !RESERVED_RULES.has(rule)) throw new LayerscopeError(`${file}: unknown rule "${rule}"`);
|
|
36
|
+
if (!SEVERITIES.has(String(severity))) throw new LayerscopeError(`${file}: rule "${rule}" must be "off", "warn" or "error", got ${JSON.stringify(severity)}`);
|
|
37
|
+
}
|
|
38
|
+
for (const [name, layer] of Object.entries(config.layers ?? {})) {
|
|
39
|
+
if (layer.allow !== void 0 && !Array.isArray(layer.allow)) throw new LayerscopeError(`${file}: layers.${name}.allow must be an array of layer names`);
|
|
40
|
+
if (layer.path !== void 0 && layer.source !== void 0) throw new LayerscopeError(`${file}: layers.${name} sets both "path" and "source"; use one`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/config/effective.ts
|
|
45
|
+
/** Where the module's options live when they come from `nuxt.config`. */
|
|
46
|
+
const NUXT_CONFIG_LABEL = "nuxt.config (layerscope)";
|
|
47
|
+
/** The keys that describe the project; `buildDir` only says where to look. */
|
|
48
|
+
const PROJECT_CONFIG_KEYS = [
|
|
49
|
+
"layers",
|
|
50
|
+
"rules",
|
|
51
|
+
"ignore",
|
|
52
|
+
"globals"
|
|
53
|
+
];
|
|
54
|
+
function hasProjectConfig(config) {
|
|
55
|
+
return PROJECT_CONFIG_KEYS.some((key) => config[key] !== void 0);
|
|
56
|
+
}
|
|
57
|
+
function pickProjectConfig(config) {
|
|
58
|
+
return Object.fromEntries(PROJECT_CONFIG_KEYS.flatMap((key) => config[key] === void 0 ? [] : [[key, config[key]]]));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* `layerscope.config.*` or the `layerscope` key of `nuxt.config`, which the module records in the
|
|
62
|
+
* registry. Both at once is an error rather than a merge, so it is always clear which one applies.
|
|
63
|
+
*/
|
|
64
|
+
function effectiveConfig(fileConfig, recorded) {
|
|
65
|
+
if (recorded === void 0 || !hasProjectConfig(recorded)) return fileConfig;
|
|
66
|
+
if (hasProjectConfig(fileConfig)) throw new LayerscopeError("Layers or rules are set both in layerscope.config and in the \"layerscope\" key of nuxt.config. Keep one.");
|
|
67
|
+
validateConfig(recorded, NUXT_CONFIG_LABEL);
|
|
68
|
+
return {
|
|
69
|
+
...fileConfig,
|
|
70
|
+
...recorded
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/registry/schema.ts
|
|
75
|
+
/** Bumped on breaking changes to `registry.json`; readers fall back to the `.d.ts` files. */
|
|
76
|
+
const REGISTRY_VERSION = 1;
|
|
77
|
+
/** Relative to the Nuxt build dir. */
|
|
78
|
+
const REGISTRY_FILE = "layerscope/registry.json";
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/utils/fs.ts
|
|
81
|
+
function isFile(path) {
|
|
82
|
+
try {
|
|
83
|
+
return statSync(path).isFile();
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function readJson(path) {
|
|
89
|
+
return JSON.parse(readFileSync(path, "utf8").replaceAll(/^\s*\/\/.*$/gmu, ""));
|
|
90
|
+
}
|
|
91
|
+
/** Symlinks resolved (pnpm links packages), or the path itself when it does not exist. */
|
|
92
|
+
function realPath(path) {
|
|
93
|
+
try {
|
|
94
|
+
return realpathSync(path);
|
|
95
|
+
} catch {
|
|
96
|
+
return path;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/nuxt/remote-layer.ts
|
|
101
|
+
/** c12 clones remote layers into `node_modules/.c12`, or into `~/.cache/c12` without one. */
|
|
102
|
+
const CLONE_DIRS = /* @__PURE__ */ new Set([".c12", "c12"]);
|
|
103
|
+
/**
|
|
104
|
+
* c12 names a clone after the first three word segments of its `extends` source plus a hash of
|
|
105
|
+
* the whole source (`github:org/repo#v1` → `github_org_repo_<hash>`). The hash changes with the
|
|
106
|
+
* ref, so names and config matching use the prefix only.
|
|
107
|
+
*/
|
|
108
|
+
function clonePrefix(source) {
|
|
109
|
+
return source.replaceAll(/\W+/gu, "_").split("_").slice(0, 3).join("_");
|
|
110
|
+
}
|
|
111
|
+
/** The clone name without its hash, so it survives ref changes; `null` for other layers. */
|
|
112
|
+
function remoteLayerName(root) {
|
|
113
|
+
if (!CLONE_DIRS.has(basename(dirname(root)))) return null;
|
|
114
|
+
const name = basename(root);
|
|
115
|
+
const hashStart = name.lastIndexOf("_");
|
|
116
|
+
return hashStart === -1 ? name : name.slice(0, hashStart);
|
|
117
|
+
}
|
|
118
|
+
/** Whether the layer at `root` was cloned from this `extends` source. */
|
|
119
|
+
function isClonedFrom(root, source) {
|
|
120
|
+
return remoteLayerName(root) === clonePrefix(source);
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/nuxt/layer-factory.ts
|
|
124
|
+
const ROOT_LAYER_NAME = "root";
|
|
125
|
+
/** Fallback for `@nuxt/kit` releases without `getLayerDirectories`. */
|
|
126
|
+
function deriveDirectories(layer, index, options) {
|
|
127
|
+
const root = resolve(layer.config.rootDir ?? layer.cwd);
|
|
128
|
+
return {
|
|
129
|
+
root,
|
|
130
|
+
app: resolve(root, layer.config.srcDir ?? "."),
|
|
131
|
+
server: index === 0 && options.serverDir !== void 0 ? resolve(options.serverDir) : resolve(root, layer.config.serverDir ?? "server"),
|
|
132
|
+
shared: resolve(root, layer.config.dir?.shared ?? options.dir?.shared ?? "shared")
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** Real paths, so they match symlinked paths in generated types and imports. */
|
|
136
|
+
function toDirs(dirs) {
|
|
137
|
+
const dir = (path) => realPath(resolve(path));
|
|
138
|
+
return {
|
|
139
|
+
root: dir(dirs.root),
|
|
140
|
+
srcDir: dir(dirs.app),
|
|
141
|
+
serverDir: dir(dirs.server),
|
|
142
|
+
sharedDir: dir(dirs.shared)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function layerFromNuxt(layer, index, dirs) {
|
|
146
|
+
const resolved = toDirs(dirs);
|
|
147
|
+
return {
|
|
148
|
+
name: layer.meta?.name ?? layer.config.$meta?.name ?? (index === 0 ? "root" : remoteLayerName(resolved.root) ?? basename(resolved.root)),
|
|
149
|
+
...resolved,
|
|
150
|
+
defaultComponents: layer.config.components === void 0
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** Without @nuxt/kit, assume Nuxt defaults: `app/` as srcDir when it exists (Nuxt 4). */
|
|
154
|
+
function layerFromPath(name, root) {
|
|
155
|
+
const appDir = join(root, "app");
|
|
156
|
+
return {
|
|
157
|
+
name,
|
|
158
|
+
...toDirs({
|
|
159
|
+
root,
|
|
160
|
+
app: existsSync(appDir) ? appDir : root,
|
|
161
|
+
server: join(root, "server"),
|
|
162
|
+
shared: join(root, "shared")
|
|
163
|
+
}),
|
|
164
|
+
defaultComponents: true
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
export { isClonedFrom as a, realPath as c, effectiveConfig as d, pickProjectConfig as f, LayerscopeError as g, ruleSeverity as h, layerFromPath as i, REGISTRY_FILE as l, isRuleName as m, deriveDirectories as n, isFile as o, validateConfig as p, layerFromNuxt as r, readJson as s, ROOT_LAYER_NAME as t, REGISTRY_VERSION as u };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { t as summarize } from "./summary-DiUYKMPe.mjs";
|
|
2
|
+
import { i as plural } from "./strings-CrOjNZmB.mjs";
|
|
3
|
+
import { relative } from "pathe";
|
|
4
|
+
//#region src/report/baseline.ts
|
|
5
|
+
/** `layer-boundary useCart → web (×2)` */
|
|
6
|
+
function describeEntry(entry) {
|
|
7
|
+
const target = entry.toLayer === null ? "" : ` → ${entry.toLayer}`;
|
|
8
|
+
const count = (entry.count ?? 1) > 1 ? ` (×${entry.count})` : "";
|
|
9
|
+
return `${entry.rule} ${entry.symbol}${target}${count}`;
|
|
10
|
+
}
|
|
11
|
+
const UPDATE_HINT = "Run \"layerscope check --update-baseline\" to remove";
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/report/details.ts
|
|
14
|
+
/** Where the symbol resolves to and what the layer may use, so the fix is obvious. */
|
|
15
|
+
function findingDetails(finding, cwd) {
|
|
16
|
+
const details = [];
|
|
17
|
+
if (finding.target !== null) details.push(`${finding.symbol} → ${relative(cwd, finding.target)}`);
|
|
18
|
+
if (finding.allowed !== void 0) {
|
|
19
|
+
const allowed = finding.allowed.length > 0 ? finding.allowed.join(", ") : "no other layers";
|
|
20
|
+
details.push(`allowed for "${finding.fromLayer}": ${allowed}`);
|
|
21
|
+
}
|
|
22
|
+
return details;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/report/github.ts
|
|
26
|
+
function escapeData(value) {
|
|
27
|
+
return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
28
|
+
}
|
|
29
|
+
function escapeProperty(value) {
|
|
30
|
+
return escapeData(value).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
31
|
+
}
|
|
32
|
+
function formatAnnotation(finding, cwd) {
|
|
33
|
+
return `::${finding.severity === "error" ? "error" : "warning"} ${[
|
|
34
|
+
`file=${escapeProperty(relative(cwd, finding.file))}`,
|
|
35
|
+
`line=${finding.line}`,
|
|
36
|
+
`col=${finding.column}`,
|
|
37
|
+
`title=${escapeProperty(`layerscope ${finding.rule}`)}`
|
|
38
|
+
].join(",")}::${escapeData([finding.message, ...findingDetails(finding, cwd)].join("\n"))}\n`;
|
|
39
|
+
}
|
|
40
|
+
function formatRemovable$1(entry, file) {
|
|
41
|
+
const message = `Fixed baseline entry in ${entry.file}: ${describeEntry(entry)}. ${UPDATE_HINT} it.`;
|
|
42
|
+
return `::notice file=${escapeProperty(file)},title=layerscope baseline::${escapeData(message)}\n`;
|
|
43
|
+
}
|
|
44
|
+
/** Workflow commands that GitHub shows inline on the pull request diff. */
|
|
45
|
+
function formatGithub(result, cwd) {
|
|
46
|
+
const annotations = result.findings.map((finding) => formatAnnotation(finding, cwd));
|
|
47
|
+
const { baseline } = result;
|
|
48
|
+
const removable = baseline === void 0 ? [] : baseline.removable.map((entry) => formatRemovable$1(entry, relative(cwd, baseline.file)));
|
|
49
|
+
return [...annotations, ...removable].join("");
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/report/json.ts
|
|
53
|
+
/** Bumped on breaking changes to the JSON report shape. */
|
|
54
|
+
const JSON_REPORT_VERSION = 1;
|
|
55
|
+
function relativeFinding(finding, cwd) {
|
|
56
|
+
return {
|
|
57
|
+
...finding,
|
|
58
|
+
file: relative(cwd, finding.file),
|
|
59
|
+
target: finding.target === null ? null : relative(cwd, finding.target)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function formatBaseline(baseline, cwd) {
|
|
63
|
+
return {
|
|
64
|
+
file: relative(cwd, baseline.file),
|
|
65
|
+
suppressed: baseline.suppressed.map((finding) => relativeFinding(finding, cwd)),
|
|
66
|
+
removable: baseline.removable
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function formatJson(result, cwd) {
|
|
70
|
+
const report = {
|
|
71
|
+
version: 1,
|
|
72
|
+
source: result.source,
|
|
73
|
+
notes: result.notes,
|
|
74
|
+
layers: result.layers.map((layer) => ({
|
|
75
|
+
name: layer.name,
|
|
76
|
+
root: relative(cwd, layer.root) || "."
|
|
77
|
+
})),
|
|
78
|
+
summary: {
|
|
79
|
+
files: result.files.length,
|
|
80
|
+
...summarize(result.findings)
|
|
81
|
+
},
|
|
82
|
+
findings: result.findings.map((finding) => relativeFinding(finding, cwd)),
|
|
83
|
+
...result.baseline === void 0 ? {} : { baseline: formatBaseline(result.baseline, cwd) }
|
|
84
|
+
};
|
|
85
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/report/text.ts
|
|
89
|
+
const DETAIL_INDENT = " ".repeat(17);
|
|
90
|
+
function formatFinding(finding, cwd) {
|
|
91
|
+
return [` ${`${finding.line}:${finding.column}`.padEnd(8)}${finding.severity.padEnd(7)}${finding.message} ${finding.rule}`, ...findingDetails(finding, cwd).map((detail) => `${DETAIL_INDENT}${detail}`)].join("\n");
|
|
92
|
+
}
|
|
93
|
+
function formatRemovable(baseline, cwd) {
|
|
94
|
+
if (baseline.removable.length === 0) return null;
|
|
95
|
+
const entries = baseline.removable.map((entry) => ` ${entry.file} ${describeEntry(entry)}`);
|
|
96
|
+
const count = baseline.removable.length;
|
|
97
|
+
return [`${relative(cwd, baseline.file)}: ${plural(count, "fixed entry", "fixed entries")}. ${UPDATE_HINT} ${count === 1 ? "it" : "them"}.`, ...entries].join("\n");
|
|
98
|
+
}
|
|
99
|
+
function formatFooter(result) {
|
|
100
|
+
const suppressed = result.baseline?.suppressed.length ?? 0;
|
|
101
|
+
const baselined = suppressed > 0 ? `, ${suppressed} more in the baseline` : "";
|
|
102
|
+
if (result.findings.length === 0) return `✔ No problems in ${plural(result.files.length, "file")} across ${plural(result.layers.length, "layer")}${baselined}`;
|
|
103
|
+
const { errors, warnings } = summarize(result.findings);
|
|
104
|
+
return `✖ ${plural(result.findings.length, "problem")} (${plural(errors, "error")}, ${plural(warnings, "warning")})${baselined}`;
|
|
105
|
+
}
|
|
106
|
+
function formatText(result, cwd) {
|
|
107
|
+
const sections = [...Map.groupBy(result.findings, (finding) => relative(cwd, finding.file))].map(([file, findings]) => [file, ...findings.map((finding) => formatFinding(finding, cwd))].join("\n"));
|
|
108
|
+
const removable = result.baseline === void 0 ? null : formatRemovable(result.baseline, cwd);
|
|
109
|
+
return `${[...removable === null ? sections : [...sections, removable], formatFooter(result)].join("\n\n")}\n`;
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/report/index.ts
|
|
113
|
+
const OUTPUT_FORMATS = [
|
|
114
|
+
"text",
|
|
115
|
+
"github",
|
|
116
|
+
"json"
|
|
117
|
+
];
|
|
118
|
+
const formatters = {
|
|
119
|
+
text: formatText,
|
|
120
|
+
github: formatGithub,
|
|
121
|
+
json: formatJson
|
|
122
|
+
};
|
|
123
|
+
function isOutputFormat(value) {
|
|
124
|
+
return Object.hasOwn(formatters, value);
|
|
125
|
+
}
|
|
126
|
+
/** Paths in the output are relative to `cwd`. */
|
|
127
|
+
function formatResult(result, format, cwd = process.cwd()) {
|
|
128
|
+
return formatters[format](result, cwd);
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
export { JSON_REPORT_VERSION as i, formatResult as n, isOutputFormat as r, OUTPUT_FORMATS as t };
|