svelte-vitals 0.0.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/LICENSE.md +21 -0
- package/README.md +63 -0
- package/dist/bin.js +38 -0
- package/dist/chunk-X3W75NJS.js +233 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +6 -0
- package/package.json +50 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kazuma Oe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# svelte-vitals
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/svelte-vitals)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
> **A SvelteKit SEO checker — not a runtime Web Vitals reporter.**
|
|
7
|
+
> Diagnose your project's SEO health by statically analyzing your source code, before it ships. No browser, no build server, no headless Chrome.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx svelte-vitals
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> [!NOTE]
|
|
14
|
+
> **Early development.** Currently ships static-mode analysis and the first SEO rule (`<title>` presence). More rules, scoring, and a build-time plugin are on the roadmap. Output may change before `1.0`.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
Run inside any SvelteKit project:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx svelte-vitals # analyze the current directory
|
|
22
|
+
npx svelte-vitals ./apps/web # or a specific path
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Svelte Vitals · SEO (static mode)
|
|
27
|
+
|
|
28
|
+
Critical (1)
|
|
29
|
+
────────────────────────
|
|
30
|
+
✗ SEO001 Missing <title>
|
|
31
|
+
/none
|
|
32
|
+
src/routes/none/+page.svelte
|
|
33
|
+
|
|
34
|
+
Passed (3)
|
|
35
|
+
────────────────────────
|
|
36
|
+
✓ SEO001 <title> /blog
|
|
37
|
+
✓ SEO001 <title> ↯ dynamic /dynamic
|
|
38
|
+
✓ SEO001 <title> /static
|
|
39
|
+
|
|
40
|
+
↯ = set dynamically (verified at runtime).
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Exit codes
|
|
44
|
+
|
|
45
|
+
| Code | Meaning |
|
|
46
|
+
| ---- | --------------------------------------------------------------- |
|
|
47
|
+
| `0` | No failing findings |
|
|
48
|
+
| `1` | A critical finding is present |
|
|
49
|
+
| `2` | Execution error (not a SvelteKit project, internal error, etc.) |
|
|
50
|
+
|
|
51
|
+
Useful as a CI gate.
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
svelte-vitals resolves the effective `<head>` of every route by walking the layout chain (`+layout.svelte` → … → `+page.svelte`) and parsing `<svelte:head>` with `svelte/compiler`.
|
|
56
|
+
|
|
57
|
+
A dynamic title such as `<title>{data.title}</title>` — the most common, correct SvelteKit pattern — is **never** flagged as missing; it passes with a `↯` marker. Only genuinely missing or empty metadata is penalized.
|
|
58
|
+
|
|
59
|
+
See the [project README](https://github.com/oekazuma/svelte-vitals#readme) for the full picture and roadmap.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
[MIT](https://github.com/oekazuma/svelte-vitals/blob/main/LICENSE.md) © [Kazuma Oe](https://github.com/oekazuma)
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
run
|
|
4
|
+
} from "./chunk-X3W75NJS.js";
|
|
5
|
+
|
|
6
|
+
// src/bin.ts
|
|
7
|
+
var HELP = `svelte-vitals \u2014 a SvelteKit SEO checker (static mode)
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
svelte-vitals [path]
|
|
11
|
+
|
|
12
|
+
Arguments:
|
|
13
|
+
path Project directory to analyze (default: current directory)
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
-h, --help Show this help
|
|
17
|
+
-v, --version Show version
|
|
18
|
+
|
|
19
|
+
Exit codes:
|
|
20
|
+
0 no failing findings
|
|
21
|
+
1 critical finding present
|
|
22
|
+
2 execution error (not a SvelteKit project / internal error)`;
|
|
23
|
+
var VERSION = "0.0.0";
|
|
24
|
+
async function main() {
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
27
|
+
console.log(HELP);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
if (args.includes("-v") || args.includes("--version")) {
|
|
31
|
+
console.log(VERSION);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
const positional = args.find((a) => !a.startsWith("-"));
|
|
35
|
+
const code = await run({ cwd: positional ?? process.cwd() });
|
|
36
|
+
process.exit(code);
|
|
37
|
+
}
|
|
38
|
+
void main();
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
allRules,
|
|
4
|
+
runRules,
|
|
5
|
+
formatConsoleReport,
|
|
6
|
+
summarize,
|
|
7
|
+
hasFailureAtOrAbove,
|
|
8
|
+
defaultConfig
|
|
9
|
+
} from "@svelte-vitals/core";
|
|
10
|
+
|
|
11
|
+
// src/runtime/node.ts
|
|
12
|
+
import { readFile, access } from "fs/promises";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { glob as tinyglob } from "tinyglobby";
|
|
15
|
+
function createNodeRuntime() {
|
|
16
|
+
return {
|
|
17
|
+
readFile(path) {
|
|
18
|
+
return readFile(path, "utf8");
|
|
19
|
+
},
|
|
20
|
+
async exists(path) {
|
|
21
|
+
try {
|
|
22
|
+
await access(path);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
glob(pattern, cwd) {
|
|
29
|
+
return tinyglob(pattern, { cwd, dot: false });
|
|
30
|
+
},
|
|
31
|
+
join(...parts) {
|
|
32
|
+
return join(...parts);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/providers/source/project.ts
|
|
38
|
+
var ProjectError = class extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "ProjectError";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var ROUTES_DIR = "src/routes";
|
|
45
|
+
async function detectProject(rt, cwd) {
|
|
46
|
+
const pkgPath = rt.join(cwd, "package.json");
|
|
47
|
+
let hasKitDep = false;
|
|
48
|
+
if (await rt.exists(pkgPath)) {
|
|
49
|
+
try {
|
|
50
|
+
const pkg = JSON.parse(await rt.readFile(pkgPath));
|
|
51
|
+
hasKitDep = Boolean(pkg.dependencies?.["@sveltejs/kit"] ?? pkg.devDependencies?.["@sveltejs/kit"]);
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const hasConfig = await rt.exists(rt.join(cwd, "svelte.config.js")) || await rt.exists(rt.join(cwd, "svelte.config.ts"));
|
|
56
|
+
const hasRoutes = await rt.exists(rt.join(cwd, ROUTES_DIR));
|
|
57
|
+
if (hasKitDep || hasConfig && hasRoutes) return;
|
|
58
|
+
throw new ProjectError(
|
|
59
|
+
"No SvelteKit project found in the current directory. Run this inside a SvelteKit app, or pass --config."
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
async function enumerateRoutePages(rt, cwd) {
|
|
63
|
+
const pages = await rt.glob(`${ROUTES_DIR}/**/+page.svelte`, cwd);
|
|
64
|
+
return pages.sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/providers/source/parse.ts
|
|
68
|
+
import { parse } from "svelte/compiler";
|
|
69
|
+
function valueFromNodes(nodes) {
|
|
70
|
+
if (!Array.isArray(nodes)) return "absent";
|
|
71
|
+
if (nodes.some((n) => n?.type === "ExpressionTag")) return "dynamic";
|
|
72
|
+
const text = nodes.filter((n) => n?.type === "Text").map((n) => String(n.data ?? "")).join("");
|
|
73
|
+
return text.trim().length > 0 ? "static" : "absent";
|
|
74
|
+
}
|
|
75
|
+
function attrText(attributes, name) {
|
|
76
|
+
const attr = findAttr(attributes, name);
|
|
77
|
+
if (!attr) return void 0;
|
|
78
|
+
const v = attr.value;
|
|
79
|
+
if (v === true) return "";
|
|
80
|
+
if (Array.isArray(v)) {
|
|
81
|
+
return v.filter((n) => n?.type === "Text").map((n) => String(n.data ?? "")).join("");
|
|
82
|
+
}
|
|
83
|
+
return void 0;
|
|
84
|
+
}
|
|
85
|
+
function attrValue(attributes, name) {
|
|
86
|
+
const attr = findAttr(attributes, name);
|
|
87
|
+
if (!attr) return "absent";
|
|
88
|
+
const v = attr.value;
|
|
89
|
+
if (v === true) return "absent";
|
|
90
|
+
if (Array.isArray(v)) return valueFromNodes(v);
|
|
91
|
+
if (v && v.type === "ExpressionTag") return "dynamic";
|
|
92
|
+
return "absent";
|
|
93
|
+
}
|
|
94
|
+
function findAttr(attributes, name) {
|
|
95
|
+
if (!Array.isArray(attributes)) return void 0;
|
|
96
|
+
return attributes.find((a) => a?.type === "Attribute" && a.name === name);
|
|
97
|
+
}
|
|
98
|
+
function collectSvelteHeads(node, acc) {
|
|
99
|
+
if (Array.isArray(node)) {
|
|
100
|
+
for (const child of node) collectSvelteHeads(child, acc);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!node || typeof node !== "object") return;
|
|
104
|
+
if (node.type === "SvelteHead") acc.push(node);
|
|
105
|
+
for (const key of ["fragment", "nodes", "consequent", "alternate", "body"]) {
|
|
106
|
+
if (key in node) collectSvelteHeads(node[key], acc);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function tagsFromHead(head) {
|
|
110
|
+
const tags = [];
|
|
111
|
+
const children = head?.fragment?.nodes ?? [];
|
|
112
|
+
for (const node of children) {
|
|
113
|
+
if (node?.type === "TitleElement") {
|
|
114
|
+
tags.push({ kind: "title", value: valueFromNodes(node.fragment?.nodes ?? []) });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (node?.type !== "RegularElement") continue;
|
|
118
|
+
if (node.name === "meta") {
|
|
119
|
+
const name = attrText(node.attributes, "name");
|
|
120
|
+
const property = attrText(node.attributes, "property");
|
|
121
|
+
tags.push({
|
|
122
|
+
kind: "meta",
|
|
123
|
+
...name ? { name } : {},
|
|
124
|
+
...property ? { property } : {},
|
|
125
|
+
value: attrValue(node.attributes, "content")
|
|
126
|
+
});
|
|
127
|
+
} else if (node.name === "link") {
|
|
128
|
+
const rel = attrText(node.attributes, "rel");
|
|
129
|
+
tags.push({ kind: "link", ...rel ? { rel } : {}, value: attrValue(node.attributes, "href") });
|
|
130
|
+
} else if (node.name === "script" && attrText(node.attributes, "type") === "application/ld+json") {
|
|
131
|
+
tags.push({ kind: "jsonld", value: valueFromNodes(node.fragment?.nodes ?? []) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return tags;
|
|
135
|
+
}
|
|
136
|
+
function parseHeadTags(source, filename) {
|
|
137
|
+
const ast = parse(source, { modern: true, filename });
|
|
138
|
+
const heads = [];
|
|
139
|
+
collectSvelteHeads(ast.fragment ?? ast, heads);
|
|
140
|
+
return heads.flatMap(tagsFromHead);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/providers/source/routes.ts
|
|
144
|
+
var ROUTES_DIR2 = "src/routes";
|
|
145
|
+
function isGroupSegment(segment) {
|
|
146
|
+
return /^\(.+\)$/.test(segment);
|
|
147
|
+
}
|
|
148
|
+
function deriveRoute(pageRel) {
|
|
149
|
+
const inner = pageRel.slice(`${ROUTES_DIR2}/`.length, -"/+page.svelte".length);
|
|
150
|
+
const segments = inner.length === 0 ? [] : inner.split("/").filter((s) => !isGroupSegment(s));
|
|
151
|
+
return "/" + segments.join("/");
|
|
152
|
+
}
|
|
153
|
+
async function chainFiles(rt, cwd, pageRel) {
|
|
154
|
+
const dir = pageRel.slice(0, -"/+page.svelte".length);
|
|
155
|
+
const extra = dir.slice(ROUTES_DIR2.length);
|
|
156
|
+
const segments = extra.length === 0 ? [] : extra.split("/").filter(Boolean);
|
|
157
|
+
const files = [];
|
|
158
|
+
let prefix = ROUTES_DIR2;
|
|
159
|
+
for (let i = 0; i <= segments.length; i++) {
|
|
160
|
+
if (i > 0) prefix = `${prefix}/${segments[i - 1]}`;
|
|
161
|
+
const layout = `${prefix}/+layout.svelte`;
|
|
162
|
+
if (await rt.exists(rt.join(cwd, layout))) files.push({ rel: layout, isPage: false });
|
|
163
|
+
}
|
|
164
|
+
files.push({ rel: pageRel, isPage: true });
|
|
165
|
+
return files;
|
|
166
|
+
}
|
|
167
|
+
function keyOf(tag) {
|
|
168
|
+
switch (tag.kind) {
|
|
169
|
+
case "title":
|
|
170
|
+
return "title";
|
|
171
|
+
case "meta":
|
|
172
|
+
return `meta:${tag.name ? `name=${tag.name}` : tag.property ? `prop=${tag.property}` : "?"}`;
|
|
173
|
+
case "link":
|
|
174
|
+
return `link:${tag.rel ?? "?"}`;
|
|
175
|
+
case "jsonld":
|
|
176
|
+
return "jsonld";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function resolveRoute(rt, cwd, pageRel) {
|
|
180
|
+
const files = await chainFiles(rt, cwd, pageRel);
|
|
181
|
+
const composed = /* @__PURE__ */ new Map();
|
|
182
|
+
for (const { rel, isPage } of files) {
|
|
183
|
+
const source = await rt.readFile(rt.join(cwd, rel));
|
|
184
|
+
for (const tag of parseHeadTags(source, rel)) {
|
|
185
|
+
composed.set(keyOf(tag), { ...tag, presence: isPage ? "own" : "inherited", file: rel });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
route: deriveRoute(pageRel),
|
|
190
|
+
source: "static",
|
|
191
|
+
tags: [...composed.values()],
|
|
192
|
+
file: pageRel
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
var sourceHeadProvider = {
|
|
196
|
+
mode: "static",
|
|
197
|
+
async collect(rt, cwd) {
|
|
198
|
+
const pages = await enumerateRoutePages(rt, cwd);
|
|
199
|
+
return Promise.all(pages.map((page) => resolveRoute(rt, cwd, page)));
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// src/index.ts
|
|
204
|
+
async function run(opts = {}) {
|
|
205
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
206
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
207
|
+
const errorLog = opts.errorLog ?? ((line) => console.error(line));
|
|
208
|
+
const rt = createNodeRuntime();
|
|
209
|
+
const config = defaultConfig;
|
|
210
|
+
try {
|
|
211
|
+
await detectProject(rt, cwd);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (err instanceof ProjectError) {
|
|
214
|
+
errorLog(err.message);
|
|
215
|
+
return 2;
|
|
216
|
+
}
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const heads = await sourceHeadProvider.collect(rt, cwd);
|
|
221
|
+
const results = await runRules(allRules, { heads, config });
|
|
222
|
+
log(formatConsoleReport(results, config));
|
|
223
|
+
const summary = summarize(results, config);
|
|
224
|
+
return hasFailureAtOrAbove(summary, "critical") ? 1 : 0;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
errorLog(`svelte-vitals: ${err instanceof Error ? err.message : String(err)}`);
|
|
227
|
+
return 2;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export {
|
|
232
|
+
run
|
|
233
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface RunOptions {
|
|
2
|
+
/** Project root to analyze. Defaults to the current working directory. */
|
|
3
|
+
cwd?: string;
|
|
4
|
+
/** Where report/diagnostic output goes. Defaults to console. */
|
|
5
|
+
log?: (line: string) => void;
|
|
6
|
+
errorLog?: (line: string) => void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Run static-mode analysis once and return the process exit code (design §6):
|
|
10
|
+
* 0 = no failing findings, 1 = critical finding present, 2 = execution error.
|
|
11
|
+
*/
|
|
12
|
+
declare function run(opts?: RunOptions): Promise<number>;
|
|
13
|
+
|
|
14
|
+
export { type RunOptions, run };
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "svelte-vitals",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A SvelteKit SEO checker — not a runtime Web Vitals reporter. Static analysis of your routes' head metadata.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Kazuma Oe (https://github.com/oekazuma)",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"svelte",
|
|
10
|
+
"sveltekit",
|
|
11
|
+
"seo",
|
|
12
|
+
"meta-tags",
|
|
13
|
+
"cli",
|
|
14
|
+
"svelte-vitals"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/oekazuma/svelte-vitals.git",
|
|
19
|
+
"directory": "packages/cli"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/oekazuma/svelte-vitals/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/oekazuma/svelte-vitals#readme",
|
|
25
|
+
"bin": {
|
|
26
|
+
"svelte-vitals": "./dist/bin.js"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"svelte": "^5.56.3",
|
|
39
|
+
"tinyglobby": "^0.2.17",
|
|
40
|
+
"@svelte-vitals/core": "0.0.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^24.7.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run"
|
|
49
|
+
}
|
|
50
|
+
}
|