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 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
+ [![npm](https://img.shields.io/npm/v/svelte-vitals)](https://www.npmjs.com/package/svelte-vitals)
4
+ [![MIT](https://img.shields.io/npm/l/svelte-vitals)](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
+ };
@@ -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
@@ -0,0 +1,6 @@
1
+ import {
2
+ run
3
+ } from "./chunk-X3W75NJS.js";
4
+ export {
5
+ run
6
+ };
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
+ }