diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6e15656 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +# Convenience targets. The authoritative build commands live in cmd/*/ +# (for the Go CLI) and extension/package.json (for the browser extension). + +.PHONY: all cli wasm extension extension-dev extension-clean test clean help + +help: + @echo "scry — available targets:" + @echo " make cli Build the Go CLI → ./scry" + @echo " make wasm Build the Go WASM bundle into extension/public/" + @echo " make extension Full production build of the Chrome extension" + @echo " make extension-dev Start the CRXJS dev server" + @echo " make test Run Go tests" + @echo " make clean Remove build artefacts" + +cli: + go build -o scry . + +wasm: + cd extension && bun run build:wasm + +extension: wasm + cd extension && bun run build:vite + +extension-dev: + cd extension && bun run dev + +extension-clean: + rm -rf extension/dist extension/public/scry.wasm extension/public/wasm_exec.js + +test: + go test ./core/... ./internal/... + +clean: extension-clean + rm -f scry diff --git a/cmd/check/check.go b/cmd/check/check.go index c577018..b2f2ff8 100644 --- a/cmd/check/check.go +++ b/cmd/check/check.go @@ -9,13 +9,13 @@ import ( "github.com/urfave/cli/v3" - "github.com/meysam81/scry/internal/audit" + "github.com/meysam81/scry/core/checks" + "github.com/meysam81/scry/core/model" + "github.com/meysam81/scry/core/rules" "github.com/meysam81/scry/internal/cmdutil" "github.com/meysam81/scry/internal/config" "github.com/meysam81/scry/internal/crawler" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" - "github.com/meysam81/scry/internal/rules" ) var ( @@ -198,7 +198,7 @@ func runSingleCheck(ctx context.Context, cfg *config.Config, fetcher crawler.Fet // Run audit checks. l.Info().Msg("running audit checks") - registry := audit.DefaultRegistry(l, cfg.SchemaPath) + registry := checks.DefaultRegistry(l, cfg.SchemaPath) // Load and register custom CEL rules if configured. if cfg.RulesFile != "" { diff --git a/cmd/crawl/crawl.go b/cmd/crawl/crawl.go index ba2f53d..32b6481 100644 --- a/cmd/crawl/crawl.go +++ b/cmd/crawl/crawl.go @@ -8,14 +8,14 @@ import ( "github.com/urfave/cli/v3" - "github.com/meysam81/scry/internal/audit" + "github.com/meysam81/scry/core/checks" + "github.com/meysam81/scry/core/rules" "github.com/meysam81/scry/internal/baseline" "github.com/meysam81/scry/internal/cmdutil" "github.com/meysam81/scry/internal/config" "github.com/meysam81/scry/internal/crawler" "github.com/meysam81/scry/internal/logger" "github.com/meysam81/scry/internal/metrics" - "github.com/meysam81/scry/internal/rules" ) var ( @@ -256,7 +256,7 @@ func runCrawl(ctx context.Context, cmd *cli.Command) error { // Run audit checks. l.Info().Msg("running audit checks") - registry := audit.DefaultRegistry(l, cfg.SchemaPath) + registry := checks.DefaultRegistry(l, cfg.SchemaPath) // Load and register custom CEL rules if configured. if cfg.RulesFile != "" { diff --git a/cmd/lighthouse/lighthouse.go b/cmd/lighthouse/lighthouse.go index f336076..4686db1 100644 --- a/cmd/lighthouse/lighthouse.go +++ b/cmd/lighthouse/lighthouse.go @@ -11,11 +11,11 @@ import ( "github.com/urfave/cli/v3" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/cmdutil" "github.com/meysam81/scry/internal/config" lh "github.com/meysam81/scry/internal/lighthouse" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) var ( diff --git a/cmd/update/update.go b/cmd/update/update.go index 2933007..97c00e0 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -7,8 +7,8 @@ import ( "github.com/urfave/cli/v3" + "github.com/meysam81/scry/core/schema" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/schema" ) var flagURL string diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go new file mode 100644 index 0000000..4b89d60 --- /dev/null +++ b/cmd/wasm/main.go @@ -0,0 +1,91 @@ +//go:build js && wasm + +// WASM entry point for the scry browser extension. Exposes a narrow, +// JSON-over-string surface that mirrors core/engine so the extension +// calls into the same audit pipeline the CLI uses. +package main + +import ( + "context" + "encoding/json" + "syscall/js" + + "github.com/meysam81/scry/core/engine" + "github.com/meysam81/scry/core/model" +) + +var defaultEngine *engine.Engine + +func init() { + e, err := engine.New(engine.Options{IncludeDeepStructuredData: true}) + if err != nil { + // Fall back to a schema-less engine if the embedded registry is + // somehow corrupt. A degraded audit beats a dead extension. + e, _ = engine.New(engine.Options{IncludeDeepStructuredData: false}) + } + defaultEngine = e +} + +// resp is the canonical envelope every exported function returns. +// The extension validates { ok, data?, error? } with Zod. +type resp struct { + OK bool `json:"ok"` + Data any `json:"data,omitempty"` + Error any `json:"error,omitempty"` +} + +func ok(data any) string { + b, _ := json.Marshal(resp{OK: true, Data: data}) + return string(b) +} + +func fail(msg string) string { + b, _ := json.Marshal(resp{OK: false, Error: msg}) + return string(b) +} + +// auditPageInput is the JSON contract with the extension. Body is sent +// separately because model.Page excludes Body from JSON marshaling. +type auditPageInput struct { + Page model.Page `json:"page"` + Body string `json:"body"` +} + +func auditPage(_ js.Value, args []js.Value) any { + if len(args) < 1 || args[0].Type() != js.TypeString { + return fail("scryAuditPage expects one string argument (JSON)") + } + var in auditPageInput + if err := json.Unmarshal([]byte(args[0].String()), &in); err != nil { + return fail("invalid input JSON: " + err.Error()) + } + if in.Body != "" { + in.Page.Body = []byte(in.Body) + } + issues := defaultEngine.AuditPage(context.Background(), &in.Page) + if issues == nil { + issues = []model.Issue{} + } + return ok(map[string]any{ + "issues": issues, + "url": in.Page.URL, + }) +} + +func listChecks(_ js.Value, _ []js.Value) any { + return ok(map[string]any{"checks": engine.ListAllCheckNames()}) +} + +func version(_ js.Value, _ []js.Value) any { + return ok(map[string]any{ + "engine": "scry-wasm", + "api": 1, + }) +} + +func main() { + js.Global().Set("scryAuditPage", js.FuncOf(auditPage)) + js.Global().Set("scryListChecks", js.FuncOf(listChecks)) + js.Global().Set("scryVersion", js.FuncOf(version)) + select {} +} diff --git a/internal/audit/accessibility.go b/core/checks/accessibility.go similarity index 99% rename from internal/audit/accessibility.go rename to core/checks/accessibility.go index e746a73..5d413a3 100644 --- a/internal/audit/accessibility.go +++ b/core/checks/accessibility.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "golang.org/x/net/html" ) diff --git a/internal/audit/accessibility_test.go b/core/checks/accessibility_test.go similarity index 99% rename from internal/audit/accessibility_test.go rename to core/checks/accessibility_test.go index 15069a5..a6ed342 100644 --- a/internal/audit/accessibility_test.go +++ b/core/checks/accessibility_test.go @@ -1,11 +1,11 @@ -package audit +package checks import ( "context" "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestAccessibilityChecker_Name(t *testing.T) { diff --git a/internal/audit/external_links.go b/core/checks/external_links.go similarity index 99% rename from internal/audit/external_links.go rename to core/checks/external_links.go index cfe1710..fe8ce05 100644 --- a/internal/audit/external_links.go +++ b/core/checks/external_links.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/safenet" "golang.org/x/time/rate" ) diff --git a/internal/audit/external_links_test.go b/core/checks/external_links_test.go similarity index 99% rename from internal/audit/external_links_test.go rename to core/checks/external_links_test.go index 89ce6cb..82b6ad5 100644 --- a/internal/audit/external_links_test.go +++ b/core/checks/external_links_test.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestExternalLinkChecker_Check_ReturnsNil(t *testing.T) { diff --git a/internal/audit/health.go b/core/checks/health.go similarity index 98% rename from internal/audit/health.go rename to core/checks/health.go index ef7d6ae..52a3983 100644 --- a/internal/audit/health.go +++ b/core/checks/health.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) var versionRe = regexp.MustCompile(`\d+\.\d+`) diff --git a/internal/audit/health_test.go b/core/checks/health_test.go similarity index 99% rename from internal/audit/health_test.go rename to core/checks/health_test.go index 059da23..fae1ebb 100644 --- a/internal/audit/health_test.go +++ b/core/checks/health_test.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestHealthChecker_Check(t *testing.T) { diff --git a/internal/audit/helpers.go b/core/checks/helpers.go similarity index 98% rename from internal/audit/helpers.go rename to core/checks/helpers.go index 3185742..6489ffb 100644 --- a/internal/audit/helpers.go +++ b/core/checks/helpers.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "bytes" @@ -8,8 +8,8 @@ import ( "sync" "sync/atomic" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" "golang.org/x/net/html" ) diff --git a/internal/audit/hreflang.go b/core/checks/hreflang.go similarity index 98% rename from internal/audit/hreflang.go rename to core/checks/hreflang.go index 85cb824..9e8564f 100644 --- a/internal/audit/hreflang.go +++ b/core/checks/hreflang.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "golang.org/x/net/html" ) diff --git a/internal/audit/hreflang_test.go b/core/checks/hreflang_test.go similarity index 99% rename from internal/audit/hreflang_test.go rename to core/checks/hreflang_test.go index 1752b46..6405403 100644 --- a/internal/audit/hreflang_test.go +++ b/core/checks/hreflang_test.go @@ -1,11 +1,11 @@ -package audit +package checks import ( "context" "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func hreflangPage(url, body string) *model.Page { diff --git a/internal/audit/images.go b/core/checks/images.go similarity index 99% rename from internal/audit/images.go rename to core/checks/images.go index cdf8b6d..7503a4e 100644 --- a/internal/audit/images.go +++ b/core/checks/images.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/safenet" "golang.org/x/net/html" ) diff --git a/internal/audit/images_test.go b/core/checks/images_test.go similarity index 99% rename from internal/audit/images_test.go rename to core/checks/images_test.go index 7aec08f..a6de82a 100644 --- a/internal/audit/images_test.go +++ b/core/checks/images_test.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestImageChecker_Check(t *testing.T) { diff --git a/internal/audit/links.go b/core/checks/links.go similarity index 98% rename from internal/audit/links.go rename to core/checks/links.go index 13f9c58..e31e70c 100644 --- a/internal/audit/links.go +++ b/core/checks/links.go @@ -1,11 +1,11 @@ -package audit +package checks import ( "context" "fmt" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "golang.org/x/net/html" ) diff --git a/internal/audit/links_test.go b/core/checks/links_test.go similarity index 99% rename from internal/audit/links_test.go rename to core/checks/links_test.go index fec100c..06f9eba 100644 --- a/internal/audit/links_test.go +++ b/core/checks/links_test.go @@ -1,11 +1,11 @@ -package audit +package checks import ( "context" "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestLinkChecker_Check_NonHTML_ReturnsNil(t *testing.T) { diff --git a/internal/audit/performance.go b/core/checks/performance.go similarity index 99% rename from internal/audit/performance.go rename to core/checks/performance.go index 402bd9e..2bd91cf 100644 --- a/internal/audit/performance.go +++ b/core/checks/performance.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -7,7 +7,7 @@ import ( "regexp" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "golang.org/x/net/html" ) diff --git a/internal/audit/performance_test.go b/core/checks/performance_test.go similarity index 99% rename from internal/audit/performance_test.go rename to core/checks/performance_test.go index f6d6803..313d187 100644 --- a/internal/audit/performance_test.go +++ b/core/checks/performance_test.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestPerformanceChecker_Check(t *testing.T) { diff --git a/internal/audit/registry.go b/core/checks/registry.go similarity index 90% rename from internal/audit/registry.go rename to core/checks/registry.go index 903dba5..bf2802c 100644 --- a/internal/audit/registry.go +++ b/core/checks/registry.go @@ -1,5 +1,5 @@ // Package audit provides checkers that analyse crawled pages and report issues. -package audit +package checks import ( "context" @@ -7,9 +7,9 @@ import ( "sort" "sync" + "github.com/meysam81/scry/core/model" + "github.com/meysam81/scry/core/schema" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" - "github.com/meysam81/scry/internal/schema" ) // Checker runs checks against a single crawled page. @@ -40,6 +40,16 @@ func (r *Registry) Register(c Checker) { r.checkers = append(r.checkers, c) } +// CheckerNames returns the canonical Name() of every registered checker, in +// registration order. +func (r *Registry) CheckerNames() []string { + out := make([]string, len(r.checkers)) + for i, c := range r.checkers { + out[i] = c.Name() + } + return out +} + // DefaultRegistry creates a Registry with all built-in checkers registered. // If schemaPath is empty, it falls back to the default local schema path. func DefaultRegistry(l logger.Logger, schemaPath string) *Registry { diff --git a/internal/audit/registry_test.go b/core/checks/registry_test.go similarity index 98% rename from internal/audit/registry_test.go rename to core/checks/registry_test.go index 80f9def..a6d6633 100644 --- a/internal/audit/registry_test.go +++ b/core/checks/registry_test.go @@ -1,12 +1,12 @@ -package audit +package checks import ( "context" "sync/atomic" "testing" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) type mockChecker struct { diff --git a/internal/audit/security.go b/core/checks/security.go similarity index 99% rename from internal/audit/security.go rename to core/checks/security.go index e913171..8276118 100644 --- a/internal/audit/security.go +++ b/core/checks/security.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -7,7 +7,7 @@ import ( "strconv" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) const ( diff --git a/internal/audit/security_test.go b/core/checks/security_test.go similarity index 99% rename from internal/audit/security_test.go rename to core/checks/security_test.go index e222cba..daad5d9 100644 --- a/internal/audit/security_test.go +++ b/core/checks/security_test.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestSecurityChecker_Name(t *testing.T) { diff --git a/internal/audit/seo.go b/core/checks/seo.go similarity index 99% rename from internal/audit/seo.go rename to core/checks/seo.go index 9ac097c..ce0139b 100644 --- a/internal/audit/seo.go +++ b/core/checks/seo.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -6,7 +6,7 @@ import ( "net/url" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "golang.org/x/net/html" ) diff --git a/internal/audit/seo_test.go b/core/checks/seo_test.go similarity index 99% rename from internal/audit/seo_test.go rename to core/checks/seo_test.go index 40a94f5..e49a3b5 100644 --- a/internal/audit/seo_test.go +++ b/core/checks/seo_test.go @@ -1,11 +1,11 @@ -package audit +package checks import ( "context" "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func htmlPage(body string) *model.Page { diff --git a/internal/audit/structured_data.go b/core/checks/structured_data.go similarity index 96% rename from internal/audit/structured_data.go rename to core/checks/structured_data.go index e0fc524..dd81f24 100644 --- a/internal/audit/structured_data.go +++ b/core/checks/structured_data.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "golang.org/x/net/html" ) diff --git a/internal/audit/structured_data_test.go b/core/checks/structured_data_test.go similarity index 97% rename from internal/audit/structured_data_test.go rename to core/checks/structured_data_test.go index a2e220a..2db57fc 100644 --- a/internal/audit/structured_data_test.go +++ b/core/checks/structured_data_test.go @@ -1,11 +1,11 @@ -package audit +package checks import ( "context" "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestStructuredDataChecker_Check(t *testing.T) { diff --git a/internal/audit/structured_data_v2.go b/core/checks/structured_data_v2.go similarity index 98% rename from internal/audit/structured_data_v2.go rename to core/checks/structured_data_v2.go index b09985e..a37fcba 100644 --- a/internal/audit/structured_data_v2.go +++ b/core/checks/structured_data_v2.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -6,8 +6,8 @@ import ( "fmt" "strings" - "github.com/meysam81/scry/internal/model" - "github.com/meysam81/scry/internal/schema" + "github.com/meysam81/scry/core/model" + "github.com/meysam81/scry/core/schema" "golang.org/x/net/html" ) diff --git a/internal/audit/structured_data_v2_test.go b/core/checks/structured_data_v2_test.go similarity index 99% rename from internal/audit/structured_data_v2_test.go rename to core/checks/structured_data_v2_test.go index c01fa0f..a640bd7 100644 --- a/internal/audit/structured_data_v2_test.go +++ b/core/checks/structured_data_v2_test.go @@ -1,12 +1,12 @@ -package audit +package checks import ( "context" "strings" "testing" - "github.com/meysam81/scry/internal/model" - "github.com/meysam81/scry/internal/schema" + "github.com/meysam81/scry/core/model" + "github.com/meysam81/scry/core/schema" ) func TestDeepStructuredDataChecker_Name(t *testing.T) { diff --git a/internal/audit/tls.go b/core/checks/tls.go similarity index 99% rename from internal/audit/tls.go rename to core/checks/tls.go index c9a0903..464bc3a 100644 --- a/internal/audit/tls.go +++ b/core/checks/tls.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "bytes" @@ -12,7 +12,7 @@ import ( "sync" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) const ( diff --git a/internal/audit/tls_test.go b/core/checks/tls_test.go similarity index 99% rename from internal/audit/tls_test.go rename to core/checks/tls_test.go index 8bfbbd1..f0e255d 100644 --- a/internal/audit/tls_test.go +++ b/core/checks/tls_test.go @@ -1,4 +1,4 @@ -package audit +package checks import ( "context" @@ -16,7 +16,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestTLSChecker_Name(t *testing.T) { diff --git a/core/engine/engine.go b/core/engine/engine.go new file mode 100644 index 0000000..ce26713 --- /dev/null +++ b/core/engine/engine.go @@ -0,0 +1,151 @@ +// Package engine is the public orchestrator used by every frontend +// (CLI, WASM, tests). It composes check registries, rule evaluators, +// and schema validators behind a single narrow API so that the business +// logic remains one implementation with many callers. +package engine + +import ( + "context" + "fmt" + + "github.com/meysam81/scry/core/checks" + "github.com/meysam81/scry/core/model" + "github.com/meysam81/scry/core/rules" + "github.com/meysam81/scry/core/schema" + "github.com/meysam81/scry/internal/logger" + "gopkg.in/yaml.v3" +) + +// Options configures a new Engine. Zero value is valid and produces an +// engine with the default built-in check set and embedded schema registry. +type Options struct { + // Logger is used for check-time diagnostics. Defaults to a no-op logger. + Logger logger.Logger + + // SchemaRegistry overrides the embedded Schema.org registry. When nil, + // the engine loads the registry bundled via go:embed. + SchemaRegistry *schema.Registry + + // RulesYAML provides optional user-defined CEL rules. Empty string skips + // rule evaluation entirely. + RulesYAML string + + // DisableChecks names built-in checkers to skip (matched by Name()). + // Use ListCheckNames() for valid identifiers. + DisableChecks []string + + // IncludeDeepStructuredData toggles the JSON-LD Schema.org deep validator. + // Defaults to true. + IncludeDeepStructuredData bool +} + +// Engine is the shared audit surface. It is safe to reuse across goroutines; +// every method is a pure function of its arguments once the engine is built. +type Engine struct { + registry *checks.Registry + rules *rules.Engine + log logger.Logger +} + +// New builds an Engine from the provided options. It fails only when the +// rules YAML or schema registry is malformed. +func New(opts Options) (*Engine, error) { + log := opts.Logger + // logger.Logger wraps a zerolog.Logger which is not comparable, so we + // cannot zero-check it. Callers that want silence pass logger.Nop(). + _ = log + + reg := checks.NewRegistry(log) + + skip := make(map[string]bool, len(opts.DisableChecks)) + for _, n := range opts.DisableChecks { + skip[n] = true + } + + register := func(c checks.Checker) { + if !skip[c.Name()] { + reg.Register(c) + } + } + register(checks.NewSEOChecker()) + register(checks.NewHealthChecker()) + register(checks.NewImageChecker()) + register(checks.NewLinkChecker()) + register(checks.NewPerformanceChecker()) + register(checks.NewStructuredDataChecker()) + register(checks.NewSecurityChecker()) + register(checks.NewAccessibilityChecker()) + register(checks.NewHreflangChecker()) + register(checks.NewExternalLinkChecker()) + register(checks.NewTLSChecker()) + + if opts.IncludeDeepStructuredData || opts.SchemaRegistry != nil { + schemaReg := opts.SchemaRegistry + if schemaReg == nil { + loaded, err := schema.LoadEmbedded() + if err != nil { + return nil, fmt.Errorf("load embedded schemas: %w", err) + } + schemaReg = loaded + } + register(checks.NewDeepStructuredDataChecker(schemaReg)) + } + + e := &Engine{registry: reg, log: log} + + if opts.RulesYAML != "" { + var rf rules.RuleFile + if err := yaml.Unmarshal([]byte(opts.RulesYAML), &rf); err != nil { + return nil, fmt.Errorf("parse rules yaml: %w", err) + } + rulesEng, err := rules.NewEngine(rf.Rules, log) + if err != nil { + return nil, fmt.Errorf("compile rules: %w", err) + } + e.rules = rulesEng + } + + return e, nil +} + +// AuditPage runs every registered check against a single page and returns +// every issue found, sorted deterministically by severity then URL. +func (e *Engine) AuditPage(ctx context.Context, page *model.Page) []model.Issue { + return e.AuditSite(ctx, []*model.Page{page}) +} + +// AuditSite runs every registered check (page-level and site-level) across +// all pages and returns the aggregated, sorted issues list. +func (e *Engine) AuditSite(ctx context.Context, pages []*model.Page) []model.Issue { + issues := e.registry.RunAll(ctx, pages) + if e.rules != nil { + for _, p := range pages { + issues = append(issues, e.rules.Evaluate(ctx, p)...) + } + } + return issues +} + +// CheckNames returns the names of every checker currently registered, in +// registration order. Useful for building UI filters. +func (e *Engine) CheckNames() []string { + return e.registry.CheckerNames() +} + +// ListAllCheckNames returns every built-in checker's canonical name without +// constructing an engine. Handy for CLI flag validation and UI prep. +func ListAllCheckNames() []string { + return []string{ + (&checks.SEOChecker{}).Name(), + (&checks.HealthChecker{}).Name(), + (&checks.ImageChecker{}).Name(), + (&checks.LinkChecker{}).Name(), + (&checks.PerformanceChecker{}).Name(), + (&checks.StructuredDataChecker{}).Name(), + (&checks.SecurityChecker{}).Name(), + (&checks.AccessibilityChecker{}).Name(), + (&checks.HreflangChecker{}).Name(), + (&checks.ExternalLinkChecker{}).Name(), + (&checks.TLSChecker{}).Name(), + } +} diff --git a/internal/model/model.go b/core/model/model.go similarity index 100% rename from internal/model/model.go rename to core/model/model.go diff --git a/internal/model/model_test.go b/core/model/model_test.go similarity index 100% rename from internal/model/model_test.go rename to core/model/model_test.go diff --git a/internal/rules/checker.go b/core/rules/checker.go similarity index 93% rename from internal/rules/checker.go rename to core/rules/checker.go index 0ff0722..3576897 100644 --- a/internal/rules/checker.go +++ b/core/rules/checker.go @@ -3,7 +3,7 @@ package rules import ( "context" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // RuleChecker adapts a rules [Engine] to the [audit.Checker] interface. diff --git a/internal/rules/engine.go b/core/rules/engine.go similarity index 99% rename from internal/rules/engine.go rename to core/rules/engine.go index a4090f1..12ee6e4 100644 --- a/internal/rules/engine.go +++ b/core/rules/engine.go @@ -16,8 +16,8 @@ import ( "github.com/google/cel-go/cel" "github.com/google/cel-go/ext" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" "gopkg.in/yaml.v3" ) diff --git a/internal/rules/engine_test.go b/core/rules/engine_test.go similarity index 99% rename from internal/rules/engine_test.go rename to core/rules/engine_test.go index e934e40..4e3a5a4 100644 --- a/internal/rules/engine_test.go +++ b/core/rules/engine_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) func TestEvaluate_StatusCode(t *testing.T) { diff --git a/internal/schema/context.go b/core/schema/context.go similarity index 100% rename from internal/schema/context.go rename to core/schema/context.go diff --git a/internal/schema/context_test.go b/core/schema/context_test.go similarity index 100% rename from internal/schema/context_test.go rename to core/schema/context_test.go diff --git a/internal/schema/crosscheck.go b/core/schema/crosscheck.go similarity index 100% rename from internal/schema/crosscheck.go rename to core/schema/crosscheck.go diff --git a/internal/schema/crosscheck_test.go b/core/schema/crosscheck_test.go similarity index 100% rename from internal/schema/crosscheck_test.go rename to core/schema/crosscheck_test.go diff --git a/core/schema/data/schemas.json b/core/schema/data/schemas.json new file mode 100644 index 0000000..bb8696e --- /dev/null +++ b/core/schema/data/schemas.json @@ -0,0 +1,980 @@ +{ + "types": { + "AggregateOffer": { + "google_eligible": false, + "name": "AggregateOffer", + "parent": "Offer", + "properties": { + "highPrice": { + "expected_types": ["Text", "Number"] + }, + "lowPrice": { + "expected_types": ["Text", "Number"] + }, + "offerCount": { + "expected_types": ["Number"] + }, + "priceCurrency": { + "expected_types": ["Text"] + } + }, + "required_fields": [] + }, + "AggregateRating": { + "google_eligible": false, + "name": "AggregateRating", + "parent": "Rating", + "properties": { + "bestRating": { + "expected_types": ["Text", "Number"] + }, + "ratingValue": { + "expected_types": ["Text", "Number"] + }, + "reviewCount": { + "expected_types": ["Number"] + }, + "worstRating": { + "expected_types": ["Text", "Number"] + } + }, + "required_fields": [] + }, + "Answer": { + "google_eligible": false, + "name": "Answer", + "parent": "CreativeWork", + "properties": { + "text": { + "expected_types": ["Text"] + } + }, + "required_fields": [] + }, + "Article": { + "google_eligible": true, + "google_recommended": ["dateModified", "mainEntityOfPage"], + "google_required": ["headline", "author", "datePublished", "image"], + "name": "Article", + "parent": "CreativeWork", + "properties": { + "author": { + "expected_types": ["Person", "Organization"] + }, + "dateModified": { + "expected_types": ["Text"], + "is_date": true + }, + "datePublished": { + "expected_types": ["Text"], + "is_date": true + }, + "headline": { + "expected_types": ["Text"] + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "mainEntityOfPage": { + "expected_types": ["WebPage", "URL"], + "is_url": true + }, + "publisher": { + "expected_types": ["Organization"] + } + }, + "required_fields": ["headline", "datePublished", "author"] + }, + "BlogPosting": { + "google_eligible": true, + "google_recommended": ["dateModified", "mainEntityOfPage"], + "google_required": ["headline", "author", "datePublished", "image"], + "name": "BlogPosting", + "parent": "Article", + "properties": {}, + "required_fields": ["headline", "datePublished", "author"] + }, + "Book": { + "google_eligible": true, + "google_recommended": ["image", "aggregateRating", "isbn"], + "google_required": ["name", "author"], + "name": "Book", + "parent": "CreativeWork", + "properties": { + "aggregateRating": { + "expected_types": ["AggregateRating"] + }, + "author": { + "expected_types": ["Person", "Organization"] + }, + "bookFormat": { + "expected_types": ["Text"] + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "isbn": { + "expected_types": ["Text"] + }, + "name": { + "expected_types": ["Text"] + }, + "publisher": { + "expected_types": ["Organization"] + } + }, + "required_fields": ["name", "author"] + }, + "BreadcrumbList": { + "google_eligible": true, + "google_required": ["itemListElement"], + "name": "BreadcrumbList", + "parent": "ItemList", + "properties": { + "itemListElement": { + "expected_types": ["ListItem"] + } + }, + "required_fields": ["itemListElement"] + }, + "Course": { + "google_eligible": true, + "google_recommended": ["aggregateRating", "offers"], + "google_required": ["name", "description", "provider"], + "name": "Course", + "parent": "CreativeWork", + "properties": { + "aggregateRating": { + "expected_types": ["AggregateRating"] + }, + "description": { + "expected_types": ["Text"] + }, + "name": { + "expected_types": ["Text"] + }, + "offers": { + "expected_types": ["Offer", "AggregateOffer"] + }, + "provider": { + "expected_types": ["Organization"] + } + }, + "required_fields": ["name", "description"] + }, + "CreativeWork": { + "google_eligible": false, + "name": "CreativeWork", + "parent": "Thing", + "properties": { + "author": { + "expected_types": ["Person", "Organization"] + }, + "dateModified": { + "expected_types": ["Text"], + "is_date": true + }, + "datePublished": { + "expected_types": ["Text"], + "is_date": true + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "name": { + "expected_types": ["Text"] + }, + "url": { + "expected_types": ["URL"], + "is_url": true + } + }, + "required_fields": [] + }, + "Dataset": { + "google_eligible": true, + "google_recommended": [ + "distribution", + "temporalCoverage", + "spatialCoverage" + ], + "google_required": ["name", "description"], + "name": "Dataset", + "parent": "CreativeWork", + "properties": { + "creator": { + "expected_types": ["Person", "Organization"] + }, + "description": { + "expected_types": ["Text"] + }, + "distribution": { + "expected_types": ["DataDownload"] + }, + "name": { + "expected_types": ["Text"] + }, + "spatialCoverage": { + "expected_types": ["Place"] + }, + "temporalCoverage": { + "expected_types": ["Text"] + } + }, + "required_fields": ["name", "description"] + }, + "Event": { + "google_eligible": true, + "google_recommended": ["endDate", "description", "image"], + "google_required": ["name", "startDate", "location"], + "name": "Event", + "parent": "Thing", + "properties": { + "description": { + "expected_types": ["Text"] + }, + "endDate": { + "expected_types": ["Text"], + "is_date": true + }, + "eventStatus": { + "enum_values": [ + "EventScheduled", + "EventCancelled", + "EventMovedOnline", + "EventPostponed", + "EventRescheduled" + ], + "expected_types": ["Text"] + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "location": { + "expected_types": ["Place", "VirtualLocation"] + }, + "name": { + "expected_types": ["Text"] + }, + "offers": { + "expected_types": ["Offer", "AggregateOffer"] + }, + "organizer": { + "expected_types": ["Organization", "Person"] + }, + "performer": { + "expected_types": ["Person", "Organization"] + }, + "startDate": { + "expected_types": ["Text"], + "is_date": true + } + }, + "required_fields": ["name", "startDate", "location"] + }, + "FAQPage": { + "google_eligible": true, + "google_required": ["mainEntity"], + "name": "FAQPage", + "parent": "WebPage", + "properties": { + "mainEntity": { + "expected_types": ["Question"] + } + }, + "required_fields": ["mainEntity"] + }, + "HowTo": { + "google_eligible": true, + "google_recommended": ["image", "totalTime"], + "google_required": ["name", "step"], + "name": "HowTo", + "parent": "CreativeWork", + "properties": { + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "name": { + "expected_types": ["Text"] + }, + "step": { + "expected_types": ["HowToStep"] + }, + "supply": { + "expected_types": ["Text", "HowToSupply"] + }, + "tool": { + "expected_types": ["Text", "HowToTool"] + }, + "totalTime": { + "expected_types": ["Text"] + } + }, + "required_fields": ["name", "step"] + }, + "ImageObject": { + "google_eligible": false, + "name": "ImageObject", + "parent": "MediaObject", + "properties": { + "contentUrl": { + "expected_types": ["URL"], + "is_url": true + }, + "height": { + "expected_types": ["Text", "Number"] + }, + "url": { + "expected_types": ["URL"], + "is_url": true + }, + "width": { + "expected_types": ["Text", "Number"] + } + }, + "required_fields": [] + }, + "JobPosting": { + "google_eligible": true, + "google_recommended": ["validThrough", "employmentType", "jobLocation"], + "google_required": [ + "title", + "description", + "datePosted", + "hiringOrganization" + ], + "name": "JobPosting", + "parent": "Intangible", + "properties": { + "baseSalary": { + "expected_types": ["MonetaryAmount"] + }, + "datePosted": { + "expected_types": ["Text"], + "is_date": true + }, + "description": { + "expected_types": ["Text"] + }, + "employmentType": { + "expected_types": ["Text"] + }, + "hiringOrganization": { + "expected_types": ["Organization"] + }, + "jobLocation": { + "expected_types": ["Place"] + }, + "title": { + "expected_types": ["Text"] + }, + "validThrough": { + "expected_types": ["Text"], + "is_date": true + } + }, + "required_fields": [ + "title", + "description", + "datePosted", + "hiringOrganization" + ] + }, + "ListItem": { + "google_eligible": false, + "name": "ListItem", + "parent": "Intangible", + "properties": { + "item": { + "expected_types": ["Thing", "URL"] + }, + "name": { + "expected_types": ["Text"] + }, + "position": { + "expected_types": ["Number", "Text"] + }, + "url": { + "expected_types": ["URL"], + "is_url": true + } + }, + "required_fields": [] + }, + "LocalBusiness": { + "google_eligible": true, + "google_recommended": ["image", "priceRange", "openingHours"], + "google_required": ["name", "address"], + "name": "LocalBusiness", + "parent": "Organization", + "properties": { + "address": { + "expected_types": ["PostalAddress"] + }, + "geo": { + "expected_types": ["GeoCoordinates"] + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "name": { + "expected_types": ["Text"] + }, + "openingHours": { + "expected_types": ["Text"] + }, + "priceRange": { + "expected_types": ["Text"] + }, + "telephone": { + "expected_types": ["Text"] + }, + "url": { + "expected_types": ["URL"], + "is_url": true + } + }, + "required_fields": ["name", "address", "telephone"] + }, + "MobileApplication": { + "google_eligible": true, + "google_recommended": ["aggregateRating", "applicationCategory"], + "google_required": ["name", "offers"], + "name": "MobileApplication", + "parent": "SoftwareApplication", + "properties": {}, + "required_fields": ["name"] + }, + "Movie": { + "google_eligible": true, + "google_recommended": ["image", "dateCreated", "director"], + "google_required": ["name"], + "name": "Movie", + "parent": "CreativeWork", + "properties": { + "actor": { + "expected_types": ["Person"] + }, + "aggregateRating": { + "expected_types": ["AggregateRating"] + }, + "dateCreated": { + "expected_types": ["Text"], + "is_date": true + }, + "director": { + "expected_types": ["Person"] + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "name": { + "expected_types": ["Text"] + } + }, + "required_fields": ["name"] + }, + "MusicAlbum": { + "google_eligible": true, + "google_recommended": ["byArtist", "datePublished", "image"], + "google_required": ["name"], + "name": "MusicAlbum", + "parent": "MusicPlaylist", + "properties": { + "byArtist": { + "expected_types": ["Person", "MusicGroup"] + }, + "datePublished": { + "expected_types": ["Text"], + "is_date": true + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "name": { + "expected_types": ["Text"] + }, + "track": { + "expected_types": ["MusicRecording"] + } + }, + "required_fields": ["name"] + }, + "NewsArticle": { + "google_eligible": true, + "google_recommended": ["dateModified", "mainEntityOfPage"], + "google_required": ["headline", "author", "datePublished", "image"], + "name": "NewsArticle", + "parent": "Article", + "properties": {}, + "required_fields": ["headline", "datePublished", "author"] + }, + "Offer": { + "google_eligible": false, + "name": "Offer", + "parent": "Intangible", + "properties": { + "availability": { + "enum_values": [ + "InStock", + "OutOfStock", + "PreOrder", + "SoldOut", + "BackOrder", + "Discontinued", + "InStoreOnly", + "LimitedAvailability", + "OnlineOnly" + ], + "expected_types": ["Text"] + }, + "price": { + "expected_types": ["Text", "Number"] + }, + "priceCurrency": { + "expected_types": ["Text"] + }, + "url": { + "expected_types": ["URL"], + "is_url": true + }, + "validFrom": { + "expected_types": ["Text"], + "is_date": true + } + }, + "required_fields": [] + }, + "Organization": { + "google_eligible": false, + "name": "Organization", + "parent": "Thing", + "properties": { + "address": { + "expected_types": ["PostalAddress"] + }, + "email": { + "expected_types": ["Text"] + }, + "logo": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "name": { + "expected_types": ["Text"] + }, + "sameAs": { + "expected_types": ["URL"], + "is_url": true + }, + "telephone": { + "expected_types": ["Text"] + }, + "url": { + "expected_types": ["URL"], + "is_url": true + } + }, + "required_fields": [] + }, + "Person": { + "google_eligible": false, + "name": "Person", + "parent": "Thing", + "properties": { + "email": { + "expected_types": ["Text"] + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "jobTitle": { + "expected_types": ["Text"] + }, + "name": { + "expected_types": ["Text"] + }, + "sameAs": { + "expected_types": ["URL"], + "is_url": true + }, + "url": { + "expected_types": ["URL"], + "is_url": true + } + }, + "required_fields": [] + }, + "Place": { + "google_eligible": false, + "name": "Place", + "parent": "Thing", + "properties": { + "address": { + "expected_types": ["PostalAddress"] + }, + "geo": { + "expected_types": ["GeoCoordinates"] + }, + "name": { + "expected_types": ["Text"] + } + }, + "required_fields": [] + }, + "PostalAddress": { + "google_eligible": false, + "name": "PostalAddress", + "parent": "ContactPoint", + "properties": { + "addressCountry": { + "expected_types": ["Text"] + }, + "addressLocality": { + "expected_types": ["Text"] + }, + "addressRegion": { + "expected_types": ["Text"] + }, + "postalCode": { + "expected_types": ["Text"] + }, + "streetAddress": { + "expected_types": ["Text"] + } + }, + "required_fields": [] + }, + "Product": { + "google_eligible": true, + "google_recommended": ["image", "offers", "review", "aggregateRating"], + "google_required": ["name"], + "name": "Product", + "parent": "Thing", + "properties": { + "aggregateRating": { + "expected_types": ["AggregateRating"] + }, + "brand": { + "expected_types": ["Brand", "Organization"] + }, + "description": { + "expected_types": ["Text"] + }, + "gtin": { + "expected_types": ["Text"] + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "name": { + "expected_types": ["Text"] + }, + "offers": { + "expected_types": ["Offer", "AggregateOffer"] + }, + "review": { + "expected_types": ["Review"] + }, + "sku": { + "expected_types": ["Text"] + } + }, + "required_fields": ["name", "description"] + }, + "Question": { + "google_eligible": false, + "name": "Question", + "parent": "CreativeWork", + "properties": { + "acceptedAnswer": { + "expected_types": ["Answer"] + }, + "name": { + "expected_types": ["Text"] + }, + "text": { + "expected_types": ["Text"] + } + }, + "required_fields": [] + }, + "Rating": { + "google_eligible": false, + "name": "Rating", + "parent": "Intangible", + "properties": { + "bestRating": { + "expected_types": ["Text", "Number"] + }, + "ratingValue": { + "expected_types": ["Text", "Number"] + }, + "worstRating": { + "expected_types": ["Text", "Number"] + } + }, + "required_fields": [] + }, + "Recipe": { + "google_eligible": true, + "google_recommended": [ + "aggregateRating", + "cookTime", + "prepTime", + "totalTime" + ], + "google_required": [ + "name", + "image", + "recipeIngredient", + "recipeInstructions" + ], + "name": "Recipe", + "parent": "HowTo", + "properties": { + "aggregateRating": { + "expected_types": ["AggregateRating"] + }, + "author": { + "expected_types": ["Person", "Organization"] + }, + "cookTime": { + "expected_types": ["Text"] + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "name": { + "expected_types": ["Text"] + }, + "prepTime": { + "expected_types": ["Text"] + }, + "recipeCategory": { + "expected_types": ["Text"] + }, + "recipeCuisine": { + "expected_types": ["Text"] + }, + "recipeIngredient": { + "expected_types": ["Text"] + }, + "recipeInstructions": { + "expected_types": ["Text", "HowToStep"] + }, + "recipeYield": { + "expected_types": ["Text"] + }, + "totalTime": { + "expected_types": ["Text"] + } + }, + "required_fields": ["name", "image", "recipeIngredient"] + }, + "Review": { + "google_eligible": true, + "google_recommended": ["datePublished"], + "google_required": ["itemReviewed", "author", "reviewRating"], + "name": "Review", + "parent": "CreativeWork", + "properties": { + "author": { + "expected_types": ["Person", "Organization"] + }, + "datePublished": { + "expected_types": ["Text"], + "is_date": true + }, + "itemReviewed": { + "expected_types": ["Thing"] + }, + "reviewBody": { + "expected_types": ["Text"] + }, + "reviewRating": { + "expected_types": ["Rating"] + } + }, + "required_fields": ["itemReviewed"] + }, + "SearchAction": { + "google_eligible": false, + "name": "SearchAction", + "parent": "Action", + "properties": { + "query-input": { + "expected_types": ["Text"] + }, + "target": { + "expected_types": ["Text", "URL"] + } + }, + "required_fields": [] + }, + "SoftwareApplication": { + "google_eligible": true, + "google_recommended": ["aggregateRating", "applicationCategory"], + "google_required": ["name", "offers"], + "name": "SoftwareApplication", + "parent": "CreativeWork", + "properties": { + "aggregateRating": { + "expected_types": ["AggregateRating"] + }, + "applicationCategory": { + "expected_types": ["Text", "URL"] + }, + "name": { + "expected_types": ["Text"] + }, + "offers": { + "expected_types": ["Offer", "AggregateOffer"] + }, + "operatingSystem": { + "expected_types": ["Text"] + } + }, + "required_fields": ["name"] + }, + "Thing": { + "google_eligible": false, + "name": "Thing", + "parent": "", + "properties": { + "description": { + "expected_types": ["Text"] + }, + "image": { + "expected_types": ["ImageObject", "URL"], + "is_url": true + }, + "name": { + "expected_types": ["Text"] + }, + "url": { + "expected_types": ["URL"], + "is_url": true + } + }, + "required_fields": [] + }, + "VideoObject": { + "google_eligible": true, + "google_recommended": ["duration", "contentUrl", "embedUrl"], + "google_required": ["name", "description", "thumbnailUrl", "uploadDate"], + "name": "VideoObject", + "parent": "MediaObject", + "properties": { + "contentUrl": { + "expected_types": ["URL"], + "is_url": true + }, + "description": { + "expected_types": ["Text"] + }, + "duration": { + "expected_types": ["Text"] + }, + "embedUrl": { + "expected_types": ["URL"], + "is_url": true + }, + "name": { + "expected_types": ["Text"] + }, + "thumbnailUrl": { + "expected_types": ["URL"], + "is_url": true + }, + "uploadDate": { + "expected_types": ["Text"], + "is_date": true + } + }, + "required_fields": ["name", "description", "thumbnailUrl", "uploadDate"] + }, + "VirtualLocation": { + "google_eligible": false, + "name": "VirtualLocation", + "parent": "Intangible", + "properties": { + "url": { + "expected_types": ["URL"], + "is_url": true + } + }, + "required_fields": [] + }, + "WebApplication": { + "google_eligible": true, + "google_recommended": ["aggregateRating", "applicationCategory"], + "google_required": ["name", "offers"], + "name": "WebApplication", + "parent": "SoftwareApplication", + "properties": { + "browserRequirements": { + "expected_types": ["Text"] + } + }, + "required_fields": ["name"] + }, + "WebPage": { + "google_eligible": false, + "name": "WebPage", + "parent": "CreativeWork", + "properties": { + "dateCreated": { + "expected_types": ["Text"], + "is_date": true + }, + "dateModified": { + "expected_types": ["Text"], + "is_date": true + }, + "datePublished": { + "expected_types": ["Text"], + "is_date": true + }, + "name": { + "expected_types": ["Text"] + }, + "url": { + "expected_types": ["URL"], + "is_url": true + } + }, + "required_fields": [] + }, + "WebSite": { + "google_eligible": false, + "name": "WebSite", + "parent": "CreativeWork", + "properties": { + "name": { + "expected_types": ["Text"] + }, + "potentialAction": { + "expected_types": ["SearchAction"] + }, + "url": { + "expected_types": ["URL"], + "is_url": true + } + }, + "required_fields": [] + } + }, + "version": "2026-03-26" +} diff --git a/internal/schema/embed.go b/core/schema/embed.go similarity index 100% rename from internal/schema/embed.go rename to core/schema/embed.go diff --git a/internal/schema/google.go b/core/schema/google.go similarity index 100% rename from internal/schema/google.go rename to core/schema/google.go diff --git a/internal/schema/google_test.go b/core/schema/google_test.go similarity index 100% rename from internal/schema/google_test.go rename to core/schema/google_test.go diff --git a/internal/schema/registry.go b/core/schema/registry.go similarity index 100% rename from internal/schema/registry.go rename to core/schema/registry.go diff --git a/internal/schema/registry_test.go b/core/schema/registry_test.go similarity index 100% rename from internal/schema/registry_test.go rename to core/schema/registry_test.go diff --git a/internal/schema/types.go b/core/schema/types.go similarity index 100% rename from internal/schema/types.go rename to core/schema/types.go diff --git a/internal/schema/update.go b/core/schema/update.go similarity index 100% rename from internal/schema/update.go rename to core/schema/update.go diff --git a/internal/schema/update_test.go b/core/schema/update_test.go similarity index 100% rename from internal/schema/update_test.go rename to core/schema/update_test.go diff --git a/internal/schema/validate.go b/core/schema/validate.go similarity index 100% rename from internal/schema/validate.go rename to core/schema/validate.go diff --git a/internal/schema/validate_test.go b/core/schema/validate_test.go similarity index 100% rename from internal/schema/validate_test.go rename to core/schema/validate_test.go diff --git a/internal/schema/validators.go b/core/schema/validators.go similarity index 100% rename from internal/schema/validators.go rename to core/schema/validators.go diff --git a/internal/schema/validators_test.go b/core/schema/validators_test.go similarity index 100% rename from internal/schema/validators_test.go rename to core/schema/validators_test.go diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 0000000..7b57f06 --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,12 @@ +# Built artefacts — always derived, never committed. +dist/ +node_modules/ + +# WASM build output — reproduced from core/ sources via scripts/build-wasm.ts +public/scry.wasm +public/wasm_exec.js + +# Editor / OS noise +.DS_Store +*.log +.vite/ diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..476de9a --- /dev/null +++ b/extension/README.md @@ -0,0 +1,45 @@ +# scry — Chrome extension + +Client-side audit engine for the page you're on. The heavy lifting (94 checks +across SEO, performance, security, accessibility, images, structured data, +hreflang, TLS, and more) runs in a Go WebAssembly module compiled from the +same `core/` packages the CLI uses — one source of truth, two frontends. + +## Architecture at a glance + +``` +┌── Chrome service worker (background/index.ts) ─────────────────────┐ +│ • Instantiates scry.wasm ONCE │ +│ • Listens on chrome.webRequest.onResponseStarted per tab │ +│ • Routes ui:request-audit messages through the WASM engine │ +└───────────────────────────────────────────────────────────────────┘ + ▲ ▲ + ui:request-audit content:snapshot + │ │ +┌── Side panel (sidepanel/App.vue) ──┐ ┌── Content script ──────────┐ +│ Vue 3 + Pinia + Zod-validated │ │ DOM snapshot, tech probe, │ +│ state, design tokens, no inline │ │ OG/twitter/JSON-LD scrape │ +│ colours, styles, or spacings. │ └───────────────────────────┘ +└────────────────────────────────────┘ +``` + +## Local dev + +```bash +# one-shot production build (WASM + Vite) +make extension + +# iterative development +make extension-dev +``` + +Then load-unpacked `extension/dist/` in `chrome://extensions`. + +## Stack + +- Vue 3 + Pinia + TypeScript (strict) +- Vite 8 + @crxjs/vite-plugin 2.x +- Zod 4 at every WASM ↔ JS boundary +- Design tokens in `src/styles/tokens.css` — **never** hard-code a colour, + radius, font, or spacing value outside that file +- Manifest defined in `manifest.config.ts` (typed, derived from `package.json`) diff --git a/extension/bun.lock b/extension/bun.lock new file mode 100644 index 0000000..6fa8961 --- /dev/null +++ b/extension/bun.lock @@ -0,0 +1,317 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "scry-extension", + "dependencies": { + "pinia": "^3.0.0", + "vue": "^3.5.22", + "zod": "^4.0.14", + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.4.0", + "@types/chrome": "^0.1.0", + "@types/node": "^24.1.0", + "@vitejs/plugin-vue": "^6.0.1", + "bun-types": "^1.3.13", + "typescript": "~5.9.0", + "vite": "^8.0.0", + "vue-tsc": "^3.1.0", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.4.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "node-html-parser": "^7.0.2", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bDLdq0W2V1SkMQDJjrcYyjK9/uKtdl4joT7GRImcootCjZdKRiRYt+cv9z8tJoU/tK3o1lX48LTqN7JMsk5AQg=="], + + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.13", "", {}, "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/chrome": ["@types/chrome@0.1.40", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA=="], + + "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], + + "@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="], + + "@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="], + + "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.6", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.13" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg=="], + + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.33", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.33", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.33", "", { "dependencies": { "@vue/compiler-core": "3.5.33", "@vue/shared": "3.5.33" } }, "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.33", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.33", "@vue/compiler-dom": "3.5.33", "@vue/compiler-ssr": "3.5.33", "@vue/shared": "3.5.33", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.10", "source-map-js": "^1.2.1" } }, "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.33", "", { "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/shared": "3.5.33" } }, "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A=="], + + "@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="], + + "@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="], + + "@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="], + + "@vue/language-core": ["@vue/language-core@3.2.7", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.1.2", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-Gn4q/tRxbpVGLEuARQ43p3YELlNAFgRUVCgW9U5Cr+5q4vfD2bWDWpl3ABbJMXUt5xlE1dF8dkigg2aUq7JYYw=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.33", "", { "dependencies": { "@vue/shared": "3.5.33" } }, "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.33", "", { "dependencies": { "@vue/reactivity": "3.5.33", "@vue/shared": "3.5.33" } }, "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.33", "", { "dependencies": { "@vue/reactivity": "3.5.33", "@vue/runtime-core": "3.5.33", "@vue/shared": "3.5.33", "csstype": "^3.2.3" } }, "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.33", "", { "dependencies": { "@vue/compiler-ssr": "3.5.33", "@vue/shared": "3.5.33" }, "peerDependencies": { "vue": "3.5.33" } }, "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw=="], + + "@vue/shared": ["@vue/shared@3.5.33", "", {}, "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ=="], + + "@webcomponents/custom-elements": ["@webcomponents/custom-elements@1.6.0", "", {}, "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + + "alien-signals": ["alien-signals@3.1.2", "", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="], + + "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + + "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "es-module-lexer": ["es-module-lexer@0.10.5", "", {}, "sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pinia": ["pinia@3.0.4", "", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="], + + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react-refresh": ["react-refresh@0.13.0", "", {}, "sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="], + + "rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "rxjs": ["rxjs@7.5.7", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], + + "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + + "vue": ["vue@3.5.33", "", { "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", "@vue/runtime-dom": "3.5.33", "@vue/server-renderer": "3.5.33", "@vue/shared": "3.5.33" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ=="], + + "vue-tsc": ["vue-tsc@3.2.7", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.7" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-zc1tL3HoQni1zGTGrwBVRQb7rGP5SWdu/m4rGB6JcnAC5MT5LFZIxF7Y+EJEnt4hGF23d60rXH7gRjHGb5KQQQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="], + } +} diff --git a/extension/manifest.config.ts b/extension/manifest.config.ts new file mode 100644 index 0000000..ff54220 --- /dev/null +++ b/extension/manifest.config.ts @@ -0,0 +1,71 @@ +import { defineManifest } from "@crxjs/vite-plugin"; +import pkg from "./package.json" with { type: "json" }; + +// Keeping the manifest in TS means every field is typed against the +// @types/chrome manifest definition, so typos surface at build time. +export default defineManifest({ + manifest_version: 3, + name: "Scry", + short_name: "Scry", + description: + "Audit the page you are on: SEO, performance, security, a11y, schema.", + version: pkg.version, + + action: { + default_title: "Scry", + default_icon: { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png", + }, + }, + + icons: { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png", + }, + + side_panel: { + default_path: "src/entrypoints/sidepanel/index.html", + }, + + background: { + service_worker: "src/entrypoints/background/index.ts", + type: "module", + }, + + content_scripts: [ + { + matches: [""], + js: ["src/entrypoints/content/index.ts"], + run_at: "document_idle", + all_frames: false, + }, + ], + + permissions: [ + "activeTab", + "tabs", + "storage", + "sidePanel", + "scripting", + "webRequest", + ], + + host_permissions: [""], + + web_accessible_resources: [ + { + resources: ["scry.wasm", "wasm_exec.js"], + matches: [""], + }, + ], + + content_security_policy: { + // Allow WebAssembly.instantiate in the service worker and pages. + extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", + }, +}); diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..8092f9a --- /dev/null +++ b/extension/package.json @@ -0,0 +1,30 @@ +{ + "dependencies": { + "pinia": "^3.0.0", + "vue": "^3.5.22", + "zod": "^4.0.14" + }, + "description": "Chrome extension front-end for the scry audit engine (Go/WASM).", + "devDependencies": { + "@crxjs/vite-plugin": "^2.4.0", + "@types/chrome": "^0.1.0", + "@types/node": "^24.1.0", + "@vitejs/plugin-vue": "^6.0.1", + "bun-types": "^1.3.13", + "typescript": "~5.9.0", + "vite": "^8.0.0", + "vue-tsc": "^3.1.0" + }, + "name": "scry-extension", + "private": true, + "scripts": { + "build": "bun run build:wasm && vite build", + "build:vite": "vite build", + "build:wasm": "bun run scripts/build-wasm.ts", + "dev": "vite", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit" + }, + "type": "module", + "version": "0.1.0" +} diff --git a/extension/public/icons/icon-128.png b/extension/public/icons/icon-128.png new file mode 100644 index 0000000..c41ac90 Binary files /dev/null and b/extension/public/icons/icon-128.png differ diff --git a/extension/public/icons/icon-16.png b/extension/public/icons/icon-16.png new file mode 100644 index 0000000..7fec5b4 Binary files /dev/null and b/extension/public/icons/icon-16.png differ diff --git a/extension/public/icons/icon-32.png b/extension/public/icons/icon-32.png new file mode 100644 index 0000000..e23f819 Binary files /dev/null and b/extension/public/icons/icon-32.png differ diff --git a/extension/public/icons/icon-48.png b/extension/public/icons/icon-48.png new file mode 100644 index 0000000..99b765a Binary files /dev/null and b/extension/public/icons/icon-48.png differ diff --git a/extension/scripts/build-wasm.ts b/extension/scripts/build-wasm.ts new file mode 100644 index 0000000..ac2ac96 --- /dev/null +++ b/extension/scripts/build-wasm.ts @@ -0,0 +1,35 @@ +// Compiles cmd/wasm into extension/public/scry.wasm and copies Go's +// official wasm_exec.js shim alongside it. Run from the extension/ dir: +// bun run build:wasm +import { $ } from "bun"; +import { mkdir, cp } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const here = import.meta.dir; +const repoRoot = resolve(here, "..", ".."); +const publicDir = resolve(here, "..", "public"); + +await mkdir(publicDir, { recursive: true }); + +const goroot = (await $`go env GOROOT`.text()).trim(); +if (!goroot) throw new Error("GOROOT is empty — is Go installed?"); + +// Go 1.21+ ships the shim at $GOROOT/lib/wasm/wasm_exec.js. Older versions +// had it under misc/wasm. Support both so we do not break on older Go. +const shimCandidates = [ + resolve(goroot, "lib", "wasm", "wasm_exec.js"), + resolve(goroot, "misc", "wasm", "wasm_exec.js"), +]; +const shim = shimCandidates.find((p) => existsSync(p)); +if (!shim) throw new Error(`wasm_exec.js not found under ${goroot}`); + +console.log(`[scry] compiling WASM from ${repoRoot}/cmd/wasm …`); +await $`GOOS=js GOARCH=wasm go build -ldflags="-s -w" -trimpath -o ${publicDir}/scry.wasm ./cmd/wasm/`.cwd( + repoRoot, +); + +console.log(`[scry] copying wasm shim from ${shim}`); +await cp(shim, resolve(publicDir, "wasm_exec.js")); + +console.log(`[scry] ok → ${publicDir}/scry.wasm`); diff --git a/extension/scripts/make-icons.py b/extension/scripts/make-icons.py new file mode 100644 index 0000000..d6fdd3c --- /dev/null +++ b/extension/scripts/make-icons.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Generate scry icon PNGs from a single SVG-ish recipe. + +The icon is a flat coral square with a stylised eye glyph. Keeps brand +consistent across the manifest icon sizes. Run: `python3 scripts/make-icons.py`. +""" +from pathlib import Path +from PIL import Image, ImageDraw + +here = Path(__file__).resolve().parent +out = here.parent / "public" / "icons" +out.mkdir(parents=True, exist_ok=True) + +BG = (15, 16, 20, 255) # matches --color-bg-base +FG = (240, 62, 47, 255) # matches --color-accent (Sanity coral) + +def make(size: int) -> Image.Image: + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + # Rounded square background + radius = max(2, size // 6) + draw.rounded_rectangle([(0, 0), (size - 1, size - 1)], radius=radius, fill=BG) + # Eye: horizontal oval "cornea" + pupil + inset_x = size * 0.18 + inset_y = size * 0.32 + cornea = [inset_x, inset_y, size - inset_x, size - inset_y] + draw.ellipse(cornea, outline=FG, width=max(1, size // 14)) + # Pupil + pr = size * 0.13 + cx, cy = size / 2, size / 2 + draw.ellipse( + [cx - pr, cy - pr, cx + pr, cy + pr], + fill=FG, + ) + return img + +for s in (16, 32, 48, 128): + img = make(s) + path = out / f"icon-{s}.png" + img.save(path, "PNG") + print(f"wrote {path}") diff --git a/extension/src/components/headers/HeadersTable.vue b/extension/src/components/headers/HeadersTable.vue new file mode 100644 index 0000000..0ab7066 --- /dev/null +++ b/extension/src/components/headers/HeadersTable.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/extension/src/components/home/CategoryBar.vue b/extension/src/components/home/CategoryBar.vue new file mode 100644 index 0000000..29098df --- /dev/null +++ b/extension/src/components/home/CategoryBar.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/extension/src/components/home/CriticalList.vue b/extension/src/components/home/CriticalList.vue new file mode 100644 index 0000000..54f778e --- /dev/null +++ b/extension/src/components/home/CriticalList.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/extension/src/components/home/PageFacts.vue b/extension/src/components/home/PageFacts.vue new file mode 100644 index 0000000..3f2cb93 --- /dev/null +++ b/extension/src/components/home/PageFacts.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/extension/src/components/home/ScoreDial.vue b/extension/src/components/home/ScoreDial.vue new file mode 100644 index 0000000..cec7f89 --- /dev/null +++ b/extension/src/components/home/ScoreDial.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/extension/src/components/issues/IssueList.vue b/extension/src/components/issues/IssueList.vue new file mode 100644 index 0000000..745adbb --- /dev/null +++ b/extension/src/components/issues/IssueList.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/extension/src/components/schema/SchemaView.vue b/extension/src/components/schema/SchemaView.vue new file mode 100644 index 0000000..fa3b435 --- /dev/null +++ b/extension/src/components/schema/SchemaView.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/extension/src/components/shared/ButtonGhost.vue b/extension/src/components/shared/ButtonGhost.vue new file mode 100644 index 0000000..a2c8a21 --- /dev/null +++ b/extension/src/components/shared/ButtonGhost.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/extension/src/components/shared/Panel.vue b/extension/src/components/shared/Panel.vue new file mode 100644 index 0000000..61ea026 --- /dev/null +++ b/extension/src/components/shared/Panel.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/extension/src/components/shared/ScryIcon.vue b/extension/src/components/shared/ScryIcon.vue new file mode 100644 index 0000000..f5e1976 --- /dev/null +++ b/extension/src/components/shared/ScryIcon.vue @@ -0,0 +1,79 @@ + + + diff --git a/extension/src/components/shared/SeverityChip.vue b/extension/src/components/shared/SeverityChip.vue new file mode 100644 index 0000000..2228ff1 --- /dev/null +++ b/extension/src/components/shared/SeverityChip.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/extension/src/components/tech/TechStack.vue b/extension/src/components/tech/TechStack.vue new file mode 100644 index 0000000..bd4bb8f --- /dev/null +++ b/extension/src/components/tech/TechStack.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/extension/src/composables/useActiveTab.ts b/extension/src/composables/useActiveTab.ts new file mode 100644 index 0000000..5a65d5c --- /dev/null +++ b/extension/src/composables/useActiveTab.ts @@ -0,0 +1,58 @@ +// Watches chrome.tabs to keep a reactive `activeTab` ref in sync with the +// tab currently focused in the user's foreground window. Used by every UI +// surface so they react to tab switches without re-plumbing. +import { ref, onMounted, onBeforeUnmount } from "vue"; + +export interface ActiveTab { + id: number; + url: string; + title: string; +} + +export function useActiveTab() { + const activeTab = ref(null); + + async function read() { + const [tab] = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true, + }); + if (!tab?.id) { + activeTab.value = null; + return; + } + activeTab.value = { + id: tab.id, + url: tab.url ?? "", + title: tab.title ?? "", + }; + } + + const onActivated = () => void read(); + const onUpdated = ( + _tabId: number, + changeInfo: { url?: string; title?: string; status?: string }, + ) => { + // Refresh when URL or title changes on the active tab. + if ( + changeInfo.url || + changeInfo.title || + changeInfo.status === "complete" + ) { + void read(); + } + }; + + onMounted(() => { + void read(); + chrome.tabs.onActivated.addListener(onActivated); + chrome.tabs.onUpdated.addListener(onUpdated); + }); + + onBeforeUnmount(() => { + chrome.tabs.onActivated.removeListener(onActivated); + chrome.tabs.onUpdated.removeListener(onUpdated); + }); + + return { activeTab, refresh: read }; +} diff --git a/extension/src/entrypoints/background/index.ts b/extension/src/entrypoints/background/index.ts new file mode 100644 index 0000000..1da06cb --- /dev/null +++ b/extension/src/entrypoints/background/index.ts @@ -0,0 +1,181 @@ +// The service worker is the only place that owns a WasmRuntime instance. +// It: +// 1. Listens to chrome.webRequest to cache real response headers per tab. +// 2. Answers UI panels with audit results via chrome.runtime.sendMessage. +// 3. Requests a DOM snapshot from the active tab's content script. +// +// Rule of thumb: never store derived state here — only raw captures. The +// UI derives scores/categories/etc. off the raw issue list. +import { WasmRuntime } from "@/lib/wasm-runtime"; +import { PageSnapshotSchema } from "@/schemas/page"; +import type { MsgAuditResult, MsgAuditError } from "@/schemas/messages"; + +type Headers = Record; + +interface HeaderCapture { + url: string; + statusCode: number; + headers: Headers; + capturedAt: number; +} + +// Per-tab last-known real response headers. Populated by webRequest, +// consumed at audit time. Cleared on tab removal. +const headerCache = new Map(); + +const wasm = new WasmRuntime({ + wasmUrl: chrome.runtime.getURL("scry.wasm"), + shimUrl: chrome.runtime.getURL("wasm_exec.js"), +}); + +// ----------------------------------------------------------------------------- +// Side panel plumbing — tapping the toolbar icon opens the panel. +// ----------------------------------------------------------------------------- +chrome.sidePanel + .setPanelBehavior({ openPanelOnActionClick: true }) + .catch((err) => console.warn("[scry] setPanelBehavior failed", err)); + +// ----------------------------------------------------------------------------- +// Header capture — chrome.webRequest.onResponseStarted fires before the page +// has finished loading, giving us the real response headers as the browser +// received them (including HSTS, CSP, cache-control, etc.). +// ----------------------------------------------------------------------------- +chrome.webRequest.onResponseStarted.addListener( + (details) => { + if (details.type !== "main_frame") return; + if (details.tabId < 0) return; + + const headers: Headers = {}; + for (const h of details.responseHeaders ?? []) { + const key = h.name.toLowerCase(); + (headers[key] ??= []).push(h.value ?? ""); + } + + headerCache.set(details.tabId, { + url: details.url, + statusCode: details.statusCode, + headers, + capturedAt: Date.now(), + }); + }, + { urls: [""] }, + ["responseHeaders", "extraHeaders"], +); + +chrome.tabs.onRemoved.addListener((tabId) => headerCache.delete(tabId)); + +// ----------------------------------------------------------------------------- +// Snapshot request helper. Injects the content script on demand if needed +// (handles edge cases where the content script hasn't loaded yet — e.g. when +// the user opens the side panel before the page's `document_idle` event). +// ----------------------------------------------------------------------------- +async function requestSnapshot(tabId: number): Promise { + try { + return await chrome.tabs.sendMessage(tabId, { + kind: "bg:request-snapshot", + }); + } catch { + // Content script not loaded: inject it programmatically. + await chrome.scripting.executeScript({ + target: { tabId }, + files: ["src/entrypoints/content/index.ts"], + }); + return chrome.tabs.sendMessage(tabId, { kind: "bg:request-snapshot" }); + } +} + +// ----------------------------------------------------------------------------- +// Audit pipeline +// ----------------------------------------------------------------------------- +async function runAudit( + tabId: number, +): Promise { + const t0 = performance.now(); + + let snapshotRaw: unknown; + try { + snapshotRaw = await requestSnapshot(tabId); + } catch (e) { + return { + kind: "bg:audit-error", + tabId, + error: `snapshot failed: ${String(e)}`, + }; + } + + // Trust-but-verify. + const envelope = + snapshotRaw && typeof snapshotRaw === "object" && "snapshot" in snapshotRaw + ? (snapshotRaw as { snapshot: unknown }).snapshot + : null; + + const parsed = PageSnapshotSchema.safeParse(envelope); + if (!parsed.success) { + return { + kind: "bg:audit-error", + tabId, + error: `invalid snapshot shape: ${parsed.error.issues[0]?.message ?? "unknown"}`, + }; + } + + const snapshot = parsed.data; + + // Overlay real headers / status onto the snapshot. + const capture = headerCache.get(tabId); + if (capture && capture.url === snapshot.page.url) { + snapshot.page.headers = capture.headers; + snapshot.page.status_code = capture.statusCode; + } + + const auditData = await wasm.auditPage(snapshot.page, snapshot.body); + if (!auditData) { + return { + kind: "bg:audit-error", + tabId, + error: "WASM audit returned invalid data", + }; + } + + return { + kind: "bg:audit-result", + tabId, + url: auditData.url || snapshot.page.url, + issues: auditData.issues, + snapshot, + ran_at: new Date().toISOString(), + duration_ms: Math.round(performance.now() - t0), + }; +} + +// ----------------------------------------------------------------------------- +// Message router +// ----------------------------------------------------------------------------- +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.kind === "ui:request-audit" || msg?.kind === "ui:refresh") { + const tabId = Number(msg.tabId); + if (!Number.isFinite(tabId)) { + sendResponse({ + kind: "bg:audit-error", + tabId: 0, + error: "missing tabId", + } satisfies MsgAuditError); + return false; + } + runAudit(tabId) + .then(sendResponse) + .catch((err) => + sendResponse({ + kind: "bg:audit-error", + tabId, + error: String(err), + } satisfies MsgAuditError), + ); + return true; // async + } + return false; +}); + +// Warm the WASM runtime on install so the first audit feels snappy. +chrome.runtime.onInstalled.addListener(() => { + wasm.boot().catch((err) => console.warn("[scry] WASM boot failed", err)); +}); diff --git a/extension/src/entrypoints/content/index.ts b/extension/src/entrypoints/content/index.ts new file mode 100644 index 0000000..39a6ee3 --- /dev/null +++ b/extension/src/entrypoints/content/index.ts @@ -0,0 +1,172 @@ +// Content script: runs in the active tab, captures DOM + extracts metadata, +// ships a PageSnapshot to the service worker. Pure data collection; never +// calls into WASM (that lives in the SW, so one WASM instance serves all tabs). +import type { PageSnapshot } from "@/schemas/page"; +import { detectTech, type DetectInput } from "@/lib/tech-detect"; + +function readMeta(name: string): string { + return ( + document + .querySelector(`meta[name="${name}" i]`) + ?.content?.trim() ?? "" + ); +} + +function collectOg(): Record { + const out: Record = {}; + document + .querySelectorAll('meta[property^="og:" i]') + .forEach((el) => { + const key = el.getAttribute("property")?.slice(3); + if (key) out[key] = el.content.trim(); + }); + return out; +} + +function collectTwitter(): Record { + const out: Record = {}; + document + .querySelectorAll('meta[name^="twitter:" i]') + .forEach((el) => { + const key = el.getAttribute("name")?.slice(8); + if (key) out[key] = el.content.trim(); + }); + return out; +} + +function collectLinks(): string[] { + const seen = new Set(); + document.querySelectorAll("a[href]").forEach((a) => { + try { + const abs = new URL(a.href, window.location.href).toString(); + seen.add(abs); + } catch { + /* invalid href, skip */ + } + }); + return Array.from(seen).slice(0, 500); +} + +function collectAssets(): string[] { + const seen = new Set(); + document + .querySelectorAll< + HTMLImageElement | HTMLScriptElement | HTMLLinkElement + >('img[src], script[src], link[rel="stylesheet"][href]') + .forEach((el) => { + const src = + (el as HTMLImageElement).src || + (el as HTMLScriptElement).src || + (el as HTMLLinkElement).href; + if (src) seen.add(src); + }); + return Array.from(seen).slice(0, 500); +} + +function countWords(text: string): number { + return text.split(/\s+/).filter(Boolean).length; +} + +function collectMetaTags(): Array<{ + name?: string; + property?: string; + content?: string; +}> { + return Array.from(document.querySelectorAll("meta")).map((m) => ({ + name: m.getAttribute("name") ?? undefined, + property: m.getAttribute("property") ?? undefined, + content: m.getAttribute("content") ?? undefined, + })); +} + +function collectScripts(): string[] { + return Array.from(document.querySelectorAll("script[src]")) + .map((s) => s.src) + .filter(Boolean); +} + +function buildSnapshot(): PageSnapshot { + const html = document.documentElement.outerHTML; + const bodyText = document.body?.innerText ?? ""; + + const imgs = document.querySelectorAll("img"); + const imgWithoutAlt = Array.from(imgs).filter((i) => !i.alt?.trim()).length; + + const origin = window.location.origin; + const links = Array.from( + document.querySelectorAll("a[href]"), + ) + .map((a) => a.href) + .filter(Boolean); + const externalLinks = links.filter((l) => { + try { + return new URL(l).origin !== origin; + } catch { + return false; + } + }); + + const detectInput: DetectInput = { + html, + headers: {}, // headers are added by the SW before forwarding to WASM + scripts: collectScripts(), + metaTags: collectMetaTags(), + }; + const technologies = detectTech(detectInput).map( + (t) => `${t.category}:${t.name}`, + ); + + return { + page: { + url: window.location.href, + status_code: 200, // refined by the SW from webRequest + content_type: document.contentType || "text/html", + redirect_chain: [], + headers: {}, + links: collectLinks(), + assets: collectAssets(), + depth: 0, + fetched_at: new Date().toISOString(), + fetch_duration: 0, + in_sitemap: false, + }, + body: html, + html_meta: { + title: document.title.trim(), + description: readMeta("description"), + lang: document.documentElement.lang || "", + canonical: + document.querySelector('link[rel="canonical" i]') + ?.href ?? "", + og: collectOg(), + twitter: collectTwitter(), + json_ld_count: document.querySelectorAll( + 'script[type="application/ld+json"]', + ).length, + h1_count: document.querySelectorAll("h1").length, + h2_count: document.querySelectorAll("h2").length, + img_count: imgs.length, + img_without_alt: imgWithoutAlt, + link_count: links.length, + external_link_count: externalLinks.length, + word_count: countWords(bodyText), + }, + technologies, + }; +} + +// Respond on demand when the SW asks for a snapshot. Using request-response +// instead of fire-and-forget means the SW only collects when a UI is open. +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.kind === "bg:request-snapshot") { + try { + const snapshot = buildSnapshot(); + sendResponse({ kind: "content:snapshot", snapshot }); + } catch (e) { + sendResponse({ kind: "content:error", error: String(e) }); + } + return true; // keep channel open for async response + } + // Drop others silently. Do not `return true` for messages we don't handle. + return false; +}); diff --git a/extension/src/entrypoints/sidepanel/App.vue b/extension/src/entrypoints/sidepanel/App.vue new file mode 100644 index 0000000..578a0c7 --- /dev/null +++ b/extension/src/entrypoints/sidepanel/App.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/extension/src/entrypoints/sidepanel/index.html b/extension/src/entrypoints/sidepanel/index.html new file mode 100644 index 0000000..5c237dc --- /dev/null +++ b/extension/src/entrypoints/sidepanel/index.html @@ -0,0 +1,12 @@ + + + + + + Scry + + +
+ + + diff --git a/extension/src/entrypoints/sidepanel/main.ts b/extension/src/entrypoints/sidepanel/main.ts new file mode 100644 index 0000000..fb7a678 --- /dev/null +++ b/extension/src/entrypoints/sidepanel/main.ts @@ -0,0 +1,8 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import App from "./App.vue"; +import "@/styles/base.css"; + +const app = createApp(App); +app.use(createPinia()); +app.mount("#app"); diff --git a/extension/src/lib/categories.ts b/extension/src/lib/categories.ts new file mode 100644 index 0000000..34c5f56 --- /dev/null +++ b/extension/src/lib/categories.ts @@ -0,0 +1,135 @@ +// Maps a check's canonical name (e.g. "seo/missing-title") to the audit +// category it belongs to. One source of truth for both sidebar grouping and +// category chip colours. +export type CategoryKey = + | "seo" + | "performance" + | "security" + | "accessibility" + | "images" + | "structured-data" + | "health" + | "links" + | "tls" + | "hreflang" + | "external-links" + | "other"; + +export interface CategoryMeta { + key: CategoryKey; + label: string; + cssVar: string; // e.g. "--color-cat-seo" + description: string; +} + +export const CATEGORIES: Record = { + seo: { + key: "seo", + label: "SEO", + cssVar: "--color-cat-seo", + description: "Titles, meta, OG, Twitter, canonical, lang", + }, + performance: { + key: "performance", + label: "Performance", + cssVar: "--color-cat-performance", + description: "Cache, compression, HTTP/2, resource hints", + }, + security: { + key: "security", + label: "Security", + cssVar: "--color-cat-security", + description: "CSP, HSTS, XFO, Permissions-Policy, security.txt", + }, + accessibility: { + key: "accessibility", + label: "Accessibility", + cssVar: "--color-cat-accessibility", + description: "ARIA landmarks, skip-nav, lang attribute", + }, + images: { + key: "images", + label: "Images", + cssVar: "--color-cat-images", + description: "Alt text, dimensions, loading attributes", + }, + "structured-data": { + key: "structured-data", + label: "Structured Data", + cssVar: "--color-cat-structured", + description: "JSON-LD, microdata, Schema.org coverage", + }, + health: { + key: "health", + label: "Health", + cssVar: "--color-cat-health", + description: "Status codes, charset, content-type", + }, + links: { + key: "links", + label: "Links", + cssVar: "--color-cat-links", + description: "Anchor text, broken internal links", + }, + tls: { + key: "tls", + label: "TLS", + cssVar: "--color-cat-tls", + description: "Certificate strength, HTTPS posture", + }, + hreflang: { + key: "hreflang", + label: "Hreflang", + cssVar: "--color-cat-hreflang", + description: "Internationalisation annotations", + }, + "external-links": { + key: "external-links", + label: "External Links", + cssVar: "--color-cat-links", + description: "rel=noopener, safe target attributes", + }, + other: { + key: "other", + label: "Other", + cssVar: "--color-cat-links", + description: "Miscellaneous findings", + }, +}; + +const CHECK_PREFIX_TO_CATEGORY: Array<[string, CategoryKey]> = [ + ["seo/", "seo"], + ["performance/", "performance"], + ["security/", "security"], + ["accessibility/", "accessibility"], + ["images/", "images"], + ["structured-data/", "structured-data"], + ["schema/", "structured-data"], + ["health/", "health"], + ["links/", "links"], + ["tls/", "tls"], + ["hreflang/", "hreflang"], + ["external-links/", "external-links"], +]; + +export function categoryFor(checkName: string): CategoryKey { + for (const [prefix, cat] of CHECK_PREFIX_TO_CATEGORY) { + if (checkName.startsWith(prefix)) return cat; + } + return "other"; +} + +export const CATEGORY_ORDER: CategoryKey[] = [ + "seo", + "performance", + "security", + "accessibility", + "images", + "structured-data", + "health", + "links", + "tls", + "hreflang", + "external-links", + "other", +]; diff --git a/extension/src/lib/scoring.ts b/extension/src/lib/scoring.ts new file mode 100644 index 0000000..5b2d589 --- /dev/null +++ b/extension/src/lib/scoring.ts @@ -0,0 +1,87 @@ +// Aggregates raw issues into the at-a-glance scores the Home tab renders. +// One implementation, consumed by every UI surface. +import type { Issue } from "@/schemas/audit"; +import { + categoryFor, + CATEGORIES, + CATEGORY_ORDER, + type CategoryKey, +} from "./categories"; + +const SEVERITY_WEIGHT = { critical: 14, warning: 5, info: 1 } as const; + +export interface CategoryScore { + key: CategoryKey; + label: string; + cssVar: string; + score: number; // 0-100, higher = better + critical: number; + warning: number; + info: number; + total: number; +} + +export interface ScoreSummary { + overall: number; // 0-100 + grade: "A" | "B" | "C" | "D" | "F"; + counts: { critical: number; warning: number; info: number; total: number }; + byCategory: CategoryScore[]; +} + +function toScore(critical: number, warning: number, info: number): number { + const penalty = + critical * SEVERITY_WEIGHT.critical + + warning * SEVERITY_WEIGHT.warning + + info * SEVERITY_WEIGHT.info; + return Math.max(0, Math.min(100, 100 - penalty)); +} + +function grade(score: number): ScoreSummary["grade"] { + if (score >= 90) return "A"; + if (score >= 75) return "B"; + if (score >= 60) return "C"; + if (score >= 40) return "D"; + return "F"; +} + +export function summarize(issues: Issue[]): ScoreSummary { + const counts = { critical: 0, warning: 0, info: 0, total: issues.length }; + const perCat = new Map(); + + for (const cat of CATEGORY_ORDER) { + const meta = CATEGORIES[cat]; + perCat.set(cat, { + key: cat, + label: meta.label, + cssVar: meta.cssVar, + score: 100, + critical: 0, + warning: 0, + info: 0, + total: 0, + }); + } + + for (const issue of issues) { + counts[issue.severity]++; + const cat = categoryFor(issue.check_name); + const row = perCat.get(cat)!; + row[issue.severity]++; + row.total++; + } + + for (const row of perCat.values()) { + row.score = toScore(row.critical, row.warning, row.info); + } + + const overall = toScore(counts.critical, counts.warning, counts.info); + + return { + overall, + grade: grade(overall), + counts, + byCategory: Array.from(perCat.values()) + .filter((c) => c.total > 0) + .sort((a, b) => a.score - b.score), + }; +} diff --git a/extension/src/lib/tech-detect.ts b/extension/src/lib/tech-detect.ts new file mode 100644 index 0000000..6a96603 --- /dev/null +++ b/extension/src/lib/tech-detect.ts @@ -0,0 +1,294 @@ +// Lightweight, zero-dependency tech-stack detection. Runs inside the content +// script (where it has access to DOM + response headers relayed from the +// background) and classifies the page by framework, analytics, CMS, CDN, etc. +// +// This is a "best effort" v1 — not as exhaustive as Wappalyzer. Add patterns +// by editing the SIGNATURES array. Every match must come with an icon hint so +// the UI can render it consistently. + +export interface TechSignature { + name: string; + category: TechCategory; + /** If any of these DOM selectors match, the signature fires. */ + dom?: string[]; + /** If the HTML source contains any of these strings, the signature fires. */ + html?: RegExp[]; + /** If any header value contains any of these strings, the signature fires. */ + headers?: Array<{ name: string; match: RegExp }>; + /** If any script src contains this, the signature fires. */ + script?: RegExp[]; + /** If any meta tag with this name/property exists, the signature fires. */ + meta?: Array<{ name?: string; property?: string; match?: RegExp }>; +} + +export type TechCategory = + | "framework" + | "cms" + | "analytics" + | "tag-manager" + | "cdn" + | "server" + | "ecommerce" + | "ui-library" + | "advertising" + | "font" + | "search"; + +export interface DetectedTech { + name: string; + category: TechCategory; +} + +export const SIGNATURES: TechSignature[] = [ + // --- Frameworks --- + { + name: "React", + category: "framework", + dom: ["[data-reactroot]"], + html: [/<[^>]+data-reactroot/i, /\b__REACT_DEVTOOLS_GLOBAL_HOOK__\b/], + }, + { + name: "Next.js", + category: "framework", + dom: ["#__next"], + script: [/_next\/static\//], + }, + { + name: "Vue", + category: "framework", + html: [/\b__VUE__\b/, /v-(?:if|for|bind|on|model)=/], + dom: ["[data-v-app]"], + }, + { + name: "Nuxt", + category: "framework", + dom: ["#__nuxt"], + script: [/\/_nuxt\//], + }, + { + name: "Svelte", + category: "framework", + html: [/\bclass=\"svelte-[a-z0-9]+/i], + }, + { name: "Angular", category: "framework", dom: ["[ng-version]", "[ng-app]"] }, + { + name: "Astro", + category: "framework", + html: [/\bdata-astro-cid-\b/, /_astro\//], + }, + { + name: "Remix", + category: "framework", + script: [/\/build\/_assets\//], + html: [/__remixContext/], + }, + { name: "SvelteKit", category: "framework", script: [/\/_app\/immutable\//] }, + + // --- CMS --- + { + name: "WordPress", + category: "cms", + html: [/\/wp-content\//, /\/wp-includes\//], + meta: [{ name: "generator", match: /wordpress/i }], + }, + { name: "Shopify", category: "cms", html: [/cdn\.shopify\.com/] }, + { + name: "Webflow", + category: "cms", + html: [/webflow\.(?:com|io)/], + meta: [{ name: "generator", match: /webflow/i }], + }, + { + name: "Wix", + category: "cms", + html: [/static\.parastorage\.com/, /wix\.com/], + }, + { + name: "Squarespace", + category: "cms", + html: [/static1\.squarespace\.com/, /squarespace\.com/], + }, + { + name: "Ghost", + category: "cms", + meta: [{ name: "generator", match: /ghost/i }], + }, + { name: "Sanity", category: "cms", html: [/cdn\.sanity\.io/] }, + { name: "Contentful", category: "cms", html: [/images\.ctfassets\.net/] }, + { + name: "Hugo", + category: "cms", + meta: [{ name: "generator", match: /hugo/i }], + }, + { + name: "Jekyll", + category: "cms", + meta: [{ name: "generator", match: /jekyll/i }], + }, + + // --- Analytics / tags --- + { + name: "Google Analytics", + category: "analytics", + script: [ + /google-analytics\.com\/(analytics|ga)\.js/, + /googletagmanager\.com\/gtag\/js/, + ], + }, + { + name: "Google Tag Manager", + category: "tag-manager", + script: [/googletagmanager\.com\/gtm\.js/], + }, + { name: "Plausible", category: "analytics", script: [/plausible\.io\/js/] }, + { name: "Fathom", category: "analytics", script: [/cdn\.usefathom\.com/] }, + { name: "Mixpanel", category: "analytics", script: [/cdn\.mxpnl\.com/] }, + { name: "Segment", category: "analytics", script: [/cdn\.segment\.com/] }, + { name: "Hotjar", category: "analytics", script: [/static\.hotjar\.com/] }, + { name: "Amplitude", category: "analytics", script: [/cdn\.amplitude\.com/] }, + { name: "PostHog", category: "analytics", script: [/posthog\.com/] }, + { + name: "Cloudflare Insights", + category: "analytics", + script: [/static\.cloudflareinsights\.com/], + }, + + // --- CDN / infra --- + { + name: "Cloudflare", + category: "cdn", + headers: [ + { name: "server", match: /cloudflare/i }, + { name: "cf-ray", match: /./ }, + ], + }, + { + name: "Vercel", + category: "cdn", + headers: [ + { name: "server", match: /vercel/i }, + { name: "x-vercel-id", match: /./ }, + ], + }, + { + name: "Netlify", + category: "cdn", + headers: [ + { name: "server", match: /netlify/i }, + { name: "x-nf-request-id", match: /./ }, + ], + }, + { + name: "AWS CloudFront", + category: "cdn", + headers: [{ name: "via", match: /cloudfront/i }], + }, + { + name: "Fastly", + category: "cdn", + headers: [ + { name: "x-served-by", match: /cache/i }, + { name: "via", match: /varnish/i }, + ], + }, + + // --- Servers --- + { + name: "nginx", + category: "server", + headers: [{ name: "server", match: /nginx/i }], + }, + { + name: "Apache", + category: "server", + headers: [{ name: "server", match: /apache/i }], + }, + { + name: "Caddy", + category: "server", + headers: [{ name: "server", match: /caddy/i }], + }, + + // --- Ecommerce --- + { name: "Stripe", category: "ecommerce", script: [/js\.stripe\.com/] }, + { + name: "WooCommerce", + category: "ecommerce", + html: [/\/wp-content\/plugins\/woocommerce\//], + }, + + // --- UI libs / fonts --- + { + name: "Tailwind CSS", + category: "ui-library", + html: [/\btw-|class=\"[^\"]*(?:bg|text|p|m|flex|grid)-\w+/i], + }, + { + name: "Bootstrap", + category: "ui-library", + html: [/\bclass=\"[^\"]*\b(?:container|row|col-\w+)\b/i], + }, + { name: "Google Fonts", category: "font", html: [/fonts\.googleapis\.com/] }, + + // --- Search --- + { + name: "Algolia", + category: "search", + script: [/cdn\.jsdelivr\.net\/npm\/algoliasearch/], + }, +]; + +export interface DetectInput { + html: string; + headers: Record; + scripts: string[]; + metaTags: Array<{ name?: string; property?: string; content?: string }>; +} + +export function detectTech(input: DetectInput): DetectedTech[] { + const hits = new Map(); + + for (const sig of SIGNATURES) { + if (hits.has(sig.name)) continue; + + if (sig.html?.some((re) => re.test(input.html))) { + hits.set(sig.name, { name: sig.name, category: sig.category }); + continue; + } + if (sig.script?.some((re) => input.scripts.some((s) => re.test(s)))) { + hits.set(sig.name, { name: sig.name, category: sig.category }); + continue; + } + if ( + sig.meta?.some((m) => + input.metaTags.some((t) => { + if (m.name && t.name?.toLowerCase() !== m.name.toLowerCase()) + return false; + if ( + m.property && + t.property?.toLowerCase() !== m.property.toLowerCase() + ) + return false; + if (m.match && !(t.content && m.match.test(t.content))) return false; + return true; + }), + ) + ) { + hits.set(sig.name, { name: sig.name, category: sig.category }); + continue; + } + if ( + sig.headers?.some((h) => { + const values = input.headers[h.name.toLowerCase()] ?? []; + return values.some((v) => h.match.test(v)); + }) + ) { + hits.set(sig.name, { name: sig.name, category: sig.category }); + } + } + + return Array.from(hits.values()).sort( + (a, b) => + a.category.localeCompare(b.category) || a.name.localeCompare(b.name), + ); +} diff --git a/extension/src/lib/wasm-runtime.ts b/extension/src/lib/wasm-runtime.ts new file mode 100644 index 0000000..51fa805 --- /dev/null +++ b/extension/src/lib/wasm-runtime.ts @@ -0,0 +1,96 @@ +// A thin wrapper around the Go WASM module. Hides the one-shot `go.run()` +// bootstrap and exposes a typed, Zod-validated surface. Designed to be +// instantiated once per service worker (or UI context) and reused. +// +// The shim file `wasm_exec.js` defines `globalThis.Go`. We dynamic-import it +// so that environments without a DOM (service workers) get a clean bootstrap. +import { + parseEnvelope, + AuditDataSchema, + VersionSchema, + ChecksListSchema, + type AuditData, +} from "@/schemas/audit"; +import type { Page } from "@/schemas/page"; + +declare global { + // eslint-disable-next-line no-var + var Go: undefined | (new () => GoInstance); + // Functions Go exports once `go.run()` starts. + // eslint-disable-next-line no-var + var scryAuditPage: undefined | ((input: string) => string); + // eslint-disable-next-line no-var + var scryListChecks: undefined | (() => string); + // eslint-disable-next-line no-var + var scryVersion: undefined | (() => string); +} + +interface GoInstance { + importObject: WebAssembly.Imports; + run(instance: WebAssembly.Instance): Promise; +} + +export interface WasmRuntimeOptions { + /** URL to the compiled Go WASM binary. Must be a chrome-extension:// URL. */ + wasmUrl: string; + /** URL to the Go wasm_exec.js shim. */ + shimUrl: string; +} + +export class WasmRuntime { + #booted: Promise | null = null; + #opts: WasmRuntimeOptions; + + constructor(opts: WasmRuntimeOptions) { + this.#opts = opts; + } + + /** Idempotent. Returns the same promise on every call until boot resolves. */ + async boot(): Promise { + this.#booted ??= this.#bootOnce(); + return this.#booted; + } + + async #bootOnce(): Promise { + if (typeof globalThis.Go !== "function") { + await import(/* @vite-ignore */ this.#opts.shimUrl); + } + if (typeof globalThis.Go !== "function") { + throw new Error("wasm_exec.js did not register globalThis.Go"); + } + + const go = new globalThis.Go(); + const wasm = await fetch(this.#opts.wasmUrl).then((r) => r.arrayBuffer()); + const { instance } = await WebAssembly.instantiate(wasm, go.importObject); + + // Intentionally do not await — go.run blocks until the Go program exits, + // and our program blocks forever on `select {}` to keep js.FuncOf wrappers + // alive. We just let it run in the background. + void go.run(instance); + + // Poll briefly for the exported globals to appear. This is pragmatic: + // the Go runtime sets them synchronously in main(), but "synchronously" + // here means "on the first microtask after go.run() starts". + for (let i = 0; i < 50; i++) { + if (typeof globalThis.scryAuditPage === "function") return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error("scryAuditPage never materialised after boot"); + } + + async version() { + await this.boot(); + return parseEnvelope(globalThis.scryVersion!(), VersionSchema); + } + + async listChecks() { + await this.boot(); + return parseEnvelope(globalThis.scryListChecks!(), ChecksListSchema); + } + + async auditPage(page: Page, body: string): Promise { + await this.boot(); + const input = JSON.stringify({ page, body }); + return parseEnvelope(globalThis.scryAuditPage!(input), AuditDataSchema); + } +} diff --git a/extension/src/schemas/audit.ts b/extension/src/schemas/audit.ts new file mode 100644 index 0000000..a200b5a --- /dev/null +++ b/extension/src/schemas/audit.ts @@ -0,0 +1,66 @@ +// Zod schemas at the WASM ↔ JS boundary. Every value crossing this line is +// parsed, not cast — invalid data becomes `null`, never a runtime explosion. +// These mirror core/model/model.go exactly. +import { z } from "zod"; + +export const SeveritySchema = z.enum(["critical", "warning", "info"]); +export type Severity = z.infer; + +export const IssueSchema = z.object({ + check_name: z.string(), + severity: SeveritySchema, + message: z.string(), + url: z.string(), + detail: z.string().optional().default(""), +}); +export type Issue = z.infer; + +export const AuditDataSchema = z.object({ + issues: z.array(IssueSchema), + url: z.string(), +}); +export type AuditData = z.infer; + +export const WasmEnvelopeSchema = z.object({ + ok: z.boolean(), + data: z.unknown().optional(), + error: z.unknown().optional(), +}); +export type WasmEnvelope = z.infer; + +export const VersionSchema = z.object({ + engine: z.string(), + api: z.number(), +}); +export type Version = z.infer; + +export const ChecksListSchema = z.object({ + checks: z.array(z.string()), +}); +export type ChecksList = z.infer; + +/** + * Parse a WASM envelope string and narrow its `data` using the supplied + * schema. Returns null on ANY failure (invalid JSON, envelope not ok, + * data shape mismatch). The null-on-failure contract is why callers can + * trust-but-verify without try/catch. + */ +export function parseEnvelope( + raw: unknown, + dataSchema: T, +): z.infer | null { + if (typeof raw !== "string") return null; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + const env = WasmEnvelopeSchema.safeParse(parsed); + if (!env.success || !env.data.ok) return null; + + const data = dataSchema.safeParse(env.data.data); + return data.success ? data.data : null; +} diff --git a/extension/src/schemas/messages.ts b/extension/src/schemas/messages.ts new file mode 100644 index 0000000..76b1dd6 --- /dev/null +++ b/extension/src/schemas/messages.ts @@ -0,0 +1,56 @@ +// Typed contracts for every chrome.runtime.sendMessage exchange between +// service worker, content script, and UI surfaces. +import { z } from "zod"; +import { PageSnapshotSchema } from "./page"; +import { IssueSchema } from "./audit"; + +export const MsgContentSnapshotSchema = z.object({ + kind: z.literal("content:snapshot"), + snapshot: PageSnapshotSchema, +}); + +export const MsgRequestAuditSchema = z.object({ + kind: z.literal("ui:request-audit"), + tabId: z.number(), +}); + +export const MsgAuditResultSchema = z.object({ + kind: z.literal("bg:audit-result"), + tabId: z.number(), + url: z.string(), + issues: z.array(IssueSchema), + snapshot: PageSnapshotSchema.nullable(), + ran_at: z.string(), + duration_ms: z.number(), +}); + +export const MsgAuditErrorSchema = z.object({ + kind: z.literal("bg:audit-error"), + tabId: z.number(), + error: z.string(), +}); + +export const MsgRequestRefreshSchema = z.object({ + kind: z.literal("ui:refresh"), + tabId: z.number(), +}); + +export const AnyMessageSchema = z.discriminatedUnion("kind", [ + MsgContentSnapshotSchema, + MsgRequestAuditSchema, + MsgAuditResultSchema, + MsgAuditErrorSchema, + MsgRequestRefreshSchema, +]); +export type AnyMessage = z.infer; + +export type MsgContentSnapshot = z.infer; +export type MsgRequestAudit = z.infer; +export type MsgAuditResult = z.infer; +export type MsgAuditError = z.infer; +export type MsgRequestRefresh = z.infer; + +export function parseMessage(raw: unknown): AnyMessage | null { + const r = AnyMessageSchema.safeParse(raw); + return r.success ? r.data : null; +} diff --git a/extension/src/schemas/page.ts b/extension/src/schemas/page.ts new file mode 100644 index 0000000..25b8f3b --- /dev/null +++ b/extension/src/schemas/page.ts @@ -0,0 +1,47 @@ +// The page snapshot the content script collects and hands to the background. +// Shape intentionally mirrors core/model/model.go so the Go side can consume +// it with a single json.Unmarshal. +import { z } from "zod"; + +export const HeadersSchema = z.record(z.string(), z.array(z.string())); +export type Headers = z.infer; + +export const PageSchema = z.object({ + url: z.string(), + status_code: z.number().int(), + content_type: z.string().default(""), + redirect_chain: z.array(z.string()).optional().default([]), + headers: HeadersSchema.default({}), + links: z.array(z.string()).default([]), + assets: z.array(z.string()).default([]), + depth: z.number().int().default(0), + fetched_at: z.string(), + fetch_duration: z.number().default(0), + in_sitemap: z.boolean().default(false), +}); +export type Page = z.infer; + +export const PageSnapshotSchema = z.object({ + page: PageSchema, + body: z.string(), + html_meta: z + .object({ + title: z.string().optional().default(""), + description: z.string().optional().default(""), + lang: z.string().optional().default(""), + canonical: z.string().optional().default(""), + og: z.record(z.string(), z.string()).default({}), + twitter: z.record(z.string(), z.string()).default({}), + json_ld_count: z.number().default(0), + h1_count: z.number().default(0), + h2_count: z.number().default(0), + img_count: z.number().default(0), + img_without_alt: z.number().default(0), + link_count: z.number().default(0), + external_link_count: z.number().default(0), + word_count: z.number().default(0), + }) + .optional(), + technologies: z.array(z.string()).default([]), +}); +export type PageSnapshot = z.infer; diff --git a/extension/src/stores/audit.ts b/extension/src/stores/audit.ts new file mode 100644 index 0000000..12990c1 --- /dev/null +++ b/extension/src/stores/audit.ts @@ -0,0 +1,87 @@ +// The single source of truth for the UI. Each tab gets one AuditState slot; +// we never store more than one because the side panel only ever shows the +// active tab. Cached snapshots are timestamped so "stale" badges can fire. +import { defineStore } from "pinia"; +import { ref, computed, shallowRef } from "vue"; +import type { Issue } from "@/schemas/audit"; +import type { PageSnapshot } from "@/schemas/page"; +import { summarize } from "@/lib/scoring"; +import { parseMessage } from "@/schemas/messages"; + +export type AuditStatus = "idle" | "loading" | "ready" | "error"; + +export const useAuditStore = defineStore("audit", () => { + const tabId = ref(null); + const url = ref(""); + const status = ref("idle"); + const error = ref(""); + const issues = shallowRef([]); + const snapshot = shallowRef(null); + const ranAt = ref(""); + const durationMs = ref(0); + + const score = computed(() => summarize(issues.value)); + + async function request(currentTabId: number) { + tabId.value = currentTabId; + status.value = "loading"; + error.value = ""; + + try { + const response = await chrome.runtime.sendMessage({ + kind: "ui:request-audit", + tabId: currentTabId, + }); + applyResponse(response); + } catch (e) { + status.value = "error"; + error.value = `no response from background: ${String(e)}`; + } + } + + function applyResponse(raw: unknown) { + const msg = parseMessage(raw); + if (!msg) { + status.value = "error"; + error.value = "invalid message from background"; + return; + } + if (msg.kind === "bg:audit-error") { + status.value = "error"; + error.value = msg.error; + return; + } + if (msg.kind === "bg:audit-result") { + url.value = msg.url; + issues.value = msg.issues; + snapshot.value = msg.snapshot; + ranAt.value = msg.ran_at; + durationMs.value = msg.duration_ms; + status.value = "ready"; + } + } + + function reset() { + issues.value = []; + snapshot.value = null; + status.value = "idle"; + error.value = ""; + url.value = ""; + ranAt.value = ""; + durationMs.value = 0; + } + + return { + tabId, + url, + status, + error, + issues, + snapshot, + ranAt, + durationMs, + score, + request, + reset, + }; +}); diff --git a/extension/src/styles/base.css b/extension/src/styles/base.css new file mode 100644 index 0000000..22f0292 --- /dev/null +++ b/extension/src/styles/base.css @@ -0,0 +1,95 @@ +@import "./tokens.css"; +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Instrument+Serif&family=JetBrains+Mono:wght@400;500;600&display=swap"); + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--color-bg-base); + color: var(--color-text); + font-family: var(--font-sans); + font-size: var(--font-size-base); + line-height: var(--line-height-normal); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-feature-settings: "cv08", "cv09", "ss01", "ss03", "tnum"; +} + +body { + min-height: 100vh; + min-width: var(--layout-sidepanel-min); +} + +button { + font-family: inherit; + font-size: inherit; + cursor: pointer; + border: none; + background: transparent; + color: inherit; + padding: 0; +} + +button:focus-visible, +a:focus-visible, +[tabindex]:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); + border-radius: var(--radius-sm); +} + +a { + color: inherit; + text-decoration: none; +} + +code, +pre, +kbd, +samp { + font-family: var(--font-mono); + font-size: 0.92em; +} + +::selection { + background: var(--color-accent-soft); + color: var(--color-text); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: var(--radius-full); + border: 2px solid var(--color-bg-base); +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-border-strong); +} + +/* Utility classes that only wrap tokens; no bespoke values. */ +.text-muted { + color: var(--color-text-muted); +} +.text-faint { + color: var(--color-text-faint); +} +.text-mono { + font-family: var(--font-mono); +} +.text-display { + font-family: var(--font-display); + letter-spacing: var(--letter-tight); +} diff --git a/extension/src/styles/tokens.css b/extension/src/styles/tokens.css new file mode 100644 index 0000000..52a9ba6 --- /dev/null +++ b/extension/src/styles/tokens.css @@ -0,0 +1,203 @@ +/* + * Design tokens. Adapted from Sanity's UI system (airy whitespace, editorial + * numerals, signature coral accent, flat low-radius panels) and re-tuned for + * a dark-first side-panel surface. Nothing in the app hard-codes colours, + * spacing, radii, or fonts — they all resolve through these custom properties. + */ + +:root { + color-scheme: dark; + + /* === PALETTE: raw colours, never referenced directly outside this file === */ + --palette-ink-950: #08090c; + --palette-ink-900: #0f1014; + --palette-ink-850: #14151a; + --palette-ink-800: #1a1b21; + --palette-ink-700: #22232a; + --palette-ink-600: #2c2d35; + --palette-ink-500: #3a3b45; + --palette-ink-400: #5b5d68; + --palette-ink-300: #8a8c95; + --palette-ink-200: #b4b6bd; + --palette-ink-100: #e2e3e8; + --palette-ink-50: #f5f5f7; + + --palette-coral-600: #d93628; + --palette-coral-500: #f03e2f; /* Sanity signature red */ + --palette-coral-400: #ff5a4c; + --palette-coral-300: #ff8073; + --palette-coral-100: #ffd6d1; + + --palette-amber-500: #f2a365; + --palette-amber-400: #ffb66b; + --palette-amber-100: #ffe3c2; + + --palette-lime-500: #7ec97b; + --palette-lime-400: #95d892; + --palette-lime-100: #cfeecf; + + --palette-cyan-500: #59b8d3; + --palette-cyan-400: #6ec5dc; + --palette-cyan-100: #c8e8f0; + + --palette-violet-500: #a579f2; + --palette-violet-400: #b68eff; + --palette-violet-100: #dfcfff; + + /* === SEMANTIC: everything in components binds to these === */ + + /* Surfaces */ + --color-bg-base: var(--palette-ink-950); + --color-bg-surface: var(--palette-ink-900); + --color-bg-surface-2: var(--palette-ink-850); + --color-bg-surface-3: var(--palette-ink-800); + --color-bg-hover: var(--palette-ink-700); + --color-bg-active: var(--palette-ink-600); + + /* Borders & dividers */ + --color-border-subtle: var(--palette-ink-700); + --color-border: var(--palette-ink-600); + --color-border-strong: var(--palette-ink-500); + + /* Text */ + --color-text: var(--palette-ink-50); + --color-text-muted: var(--palette-ink-200); + --color-text-faint: var(--palette-ink-300); + --color-text-disabled: var(--palette-ink-400); + --color-text-on-accent: var(--palette-ink-950); + + /* Brand */ + --color-accent: var(--palette-coral-500); + --color-accent-hover: var(--palette-coral-400); + --color-accent-active: var(--palette-coral-600); + --color-accent-soft: color-mix( + in oklab, + var(--palette-coral-500) 18%, + transparent + ); + + /* Severity */ + --color-critical: var(--palette-coral-500); + --color-critical-soft: color-mix( + in oklab, + var(--palette-coral-500) 15%, + transparent + ); + --color-warning: var(--palette-amber-500); + --color-warning-soft: color-mix( + in oklab, + var(--palette-amber-500) 15%, + transparent + ); + --color-info: var(--palette-cyan-500); + --color-info-soft: color-mix( + in oklab, + var(--palette-cyan-500) 15%, + transparent + ); + --color-success: var(--palette-lime-500); + --color-success-soft: color-mix( + in oklab, + var(--palette-lime-500) 15%, + transparent + ); + + /* Category accents — each audit family gets a distinct hue, used sparingly */ + --color-cat-seo: var(--palette-coral-400); + --color-cat-performance: var(--palette-amber-400); + --color-cat-security: var(--palette-violet-400); + --color-cat-accessibility: var(--palette-cyan-400); + --color-cat-images: var(--palette-lime-400); + --color-cat-structured: var(--palette-violet-400); + --color-cat-health: var(--palette-amber-400); + --color-cat-links: var(--palette-cyan-400); + --color-cat-tls: var(--palette-violet-500); + --color-cat-hreflang: var(--palette-cyan-500); + + /* === TYPOGRAPHY === */ + --font-sans: + "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; + --font-display: + "Instrument Serif", "PP Editorial New", "Times New Roman", Georgia, serif; + --font-mono: "JetBrains Mono", "SF Mono", ui-monospace, "Menlo", monospace; + + --font-size-xs: 11px; + --font-size-sm: 12px; + --font-size-base: 13px; + --font-size-md: 14px; + --font-size-lg: 16px; + --font-size-xl: 20px; + --font-size-2xl: 24px; + --font-size-3xl: 32px; + --font-size-display: 56px; + --font-size-display-lg: 80px; + + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + --line-height-tight: 1.15; + --line-height-snug: 1.3; + --line-height-normal: 1.5; + --line-height-loose: 1.7; + + --letter-tight: -0.02em; + --letter-normal: 0; + --letter-wide: 0.08em; + + /* === SPACING — 4px base === */ + --space-0: 0; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 32px; + --space-8: 40px; + --space-9: 56px; + --space-10: 72px; + + /* === RADII — deliberately small, Sanity-style === */ + --radius-sm: 3px; + --radius-md: 6px; + --radius-lg: 10px; + --radius-xl: 14px; + --radius-full: 9999px; + + /* === ELEVATION — borders do most of the work, shadows are restrained === */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.32); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.42); + --shadow-focus: 0 0 0 3px var(--color-accent-soft); + + /* === MOTION === */ + --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --duration-fast: 120ms; + --duration-medium: 200ms; + --duration-slow: 360ms; + + /* === LAYOUT === */ + --layout-sidepanel-min: 320px; + --layout-header-height: 48px; + --layout-tab-height: 40px; + + /* === Z-INDEX === */ + --z-base: 1; + --z-sticky: 10; + --z-overlay: 100; + --z-modal: 1000; + --z-toast: 2000; +} + +/* Respect user's OS preference for reduced motion. */ +@media (prefers-reduced-motion: reduce) { + :root { + --duration-fast: 0ms; + --duration-medium: 0ms; + --duration-slow: 0ms; + } +} diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 0000000..213bf6b --- /dev/null +++ b/extension/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "baseUrl": ".", + "esModuleInterop": true, + "exactOptionalPropertyTypes": false, + "isolatedModules": true, + "jsx": "preserve", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "paths": { + "@/*": ["src/*"] + }, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["chrome", "vite/client", "bun-types"], + "useDefineForClassFields": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.vue", + "src/**/*.d.ts", + "manifest.config.ts", + "vite.config.ts", + "scripts/**/*.ts" + ] +} diff --git a/extension/tsconfig.node.json b/extension/tsconfig.node.json new file mode 100644 index 0000000..2f8493c --- /dev/null +++ b/extension/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["vite.config.ts", "manifest.config.ts", "scripts/**/*.ts"] +} diff --git a/extension/vite.config.ts b/extension/vite.config.ts new file mode 100644 index 0000000..b1f16b5 --- /dev/null +++ b/extension/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { crx } from "@crxjs/vite-plugin"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import manifest from "./manifest.config"; + +const here = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [vue(), crx({ manifest })], + resolve: { + alias: { + "@": resolve(here, "src"), + }, + }, + build: { + target: "esnext", + sourcemap: true, + emptyOutDir: true, + }, + server: { + port: 5174, + strictPort: true, + hmr: { + port: 5174, + }, + }, +}); diff --git a/internal/analysis/classify.go b/internal/analysis/classify.go index a17caec..02e46f0 100644 --- a/internal/analysis/classify.go +++ b/internal/analysis/classify.go @@ -3,7 +3,7 @@ package analysis import ( "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // PageClass represents the classification of a page. diff --git a/internal/analysis/classify_test.go b/internal/analysis/classify_test.go index 3b267c8..d2506a5 100644 --- a/internal/analysis/classify_test.go +++ b/internal/analysis/classify_test.go @@ -3,7 +3,7 @@ package analysis import ( "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestClassifyPages_Homepage(t *testing.T) { diff --git a/internal/analysis/content.go b/internal/analysis/content.go index 2e69123..1dab527 100644 --- a/internal/analysis/content.go +++ b/internal/analysis/content.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "golang.org/x/net/html" ) diff --git a/internal/analysis/content_test.go b/internal/analysis/content_test.go index 9efcb9b..6b562a4 100644 --- a/internal/analysis/content_test.go +++ b/internal/analysis/content_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "golang.org/x/net/html" ) diff --git a/internal/analysis/crawlbudget.go b/internal/analysis/crawlbudget.go index 6bd54c6..a5fc5c7 100644 --- a/internal/analysis/crawlbudget.go +++ b/internal/analysis/crawlbudget.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // CrawlBudgetAnalyzer detects crawl budget waste across a site. diff --git a/internal/analysis/crawlbudget_test.go b/internal/analysis/crawlbudget_test.go index eb9b133..f7136fc 100644 --- a/internal/analysis/crawlbudget_test.go +++ b/internal/analysis/crawlbudget_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestCrawlBudgetAnalyzer_Name(t *testing.T) { diff --git a/internal/analysis/cwv.go b/internal/analysis/cwv.go index c60a079..f17d4aa 100644 --- a/internal/analysis/cwv.go +++ b/internal/analysis/cwv.go @@ -4,7 +4,7 @@ import ( "fmt" "sort" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) const ( diff --git a/internal/analysis/cwv_test.go b/internal/analysis/cwv_test.go index 188791a..cbd3afc 100644 --- a/internal/analysis/cwv_test.go +++ b/internal/analysis/cwv_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestAggregateCWV_Empty(t *testing.T) { diff --git a/internal/analysis/duplicates.go b/internal/analysis/duplicates.go index c3dbc1d..b2a45b5 100644 --- a/internal/analysis/duplicates.go +++ b/internal/analysis/duplicates.go @@ -11,7 +11,7 @@ import ( "math/bits" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "golang.org/x/net/html" ) diff --git a/internal/analysis/duplicates_test.go b/internal/analysis/duplicates_test.go index c0433a7..06fb264 100644 --- a/internal/analysis/duplicates_test.go +++ b/internal/analysis/duplicates_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func wrapHTML(body string) []byte { diff --git a/internal/analysis/linkgraph.go b/internal/analysis/linkgraph.go index 8ff5e14..7da5434 100644 --- a/internal/analysis/linkgraph.go +++ b/internal/analysis/linkgraph.go @@ -9,7 +9,7 @@ import ( "sort" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) const ( diff --git a/internal/analysis/linkgraph_test.go b/internal/analysis/linkgraph_test.go index 90438af..25e4749 100644 --- a/internal/analysis/linkgraph_test.go +++ b/internal/analysis/linkgraph_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func makePages(links map[string][]string) []*model.Page { diff --git a/internal/analysis/redirects.go b/internal/analysis/redirects.go index 75e0c95..1c19247 100644 --- a/internal/analysis/redirects.go +++ b/internal/analysis/redirects.go @@ -8,7 +8,7 @@ import ( "sort" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // RedirectEntry represents a single redirect, including its full chain, diff --git a/internal/analysis/redirects_test.go b/internal/analysis/redirects_test.go index f3d80a2..9171e45 100644 --- a/internal/analysis/redirects_test.go +++ b/internal/analysis/redirects_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestAnalyzeRedirects_Empty(t *testing.T) { diff --git a/internal/analysis/score.go b/internal/analysis/score.go index 54e3309..7f16066 100644 --- a/internal/analysis/score.go +++ b/internal/analysis/score.go @@ -6,7 +6,7 @@ import ( "sort" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // ScoreResult holds the computed health score for a crawled site. diff --git a/internal/analysis/score_test.go b/internal/analysis/score_test.go index 668de06..baa1fa6 100644 --- a/internal/analysis/score_test.go +++ b/internal/analysis/score_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func makeCrawlResult(issues []model.Issue, pageCount int) *model.CrawlResult { diff --git a/internal/analysis/techdetect.go b/internal/analysis/techdetect.go index ce731a6..0427417 100644 --- a/internal/analysis/techdetect.go +++ b/internal/analysis/techdetect.go @@ -6,7 +6,7 @@ import ( "sort" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // Technology represents a detected technology on a website. diff --git a/internal/analysis/techdetect_test.go b/internal/analysis/techdetect_test.go index 3e41e07..0dcdbe7 100644 --- a/internal/analysis/techdetect_test.go +++ b/internal/analysis/techdetect_test.go @@ -4,7 +4,7 @@ import ( "net/http" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestDetectTechnologies_Empty(t *testing.T) { diff --git a/internal/baseline/baseline.go b/internal/baseline/baseline.go index 1895cdc..b520e87 100644 --- a/internal/baseline/baseline.go +++ b/internal/baseline/baseline.go @@ -8,7 +8,7 @@ import ( "os" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // version is the schema version written into every baseline file. diff --git a/internal/baseline/baseline_test.go b/internal/baseline/baseline_test.go index f5897af..2b9fa8b 100644 --- a/internal/baseline/baseline_test.go +++ b/internal/baseline/baseline_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // helper builds a minimal CrawlResult with the given issues. diff --git a/internal/browser/browser.go b/internal/browser/browser.go index 660fc01..5ea6aa3 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -12,8 +12,8 @@ import ( "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/proto" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) // defaultContentType is the content type returned for all browser-fetched pages. diff --git a/internal/cmdutil/report.go b/internal/cmdutil/report.go index 7521f6e..b6e137e 100644 --- a/internal/cmdutil/report.go +++ b/internal/cmdutil/report.go @@ -8,9 +8,9 @@ import ( "github.com/urfave/cli/v3" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/config" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" "github.com/meysam81/scry/internal/report" ) diff --git a/internal/cmdutil/report_test.go b/internal/cmdutil/report_test.go index e8d29f0..fb4457e 100644 --- a/internal/cmdutil/report_test.go +++ b/internal/cmdutil/report_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestDetermineExitCode(t *testing.T) { diff --git a/internal/crawler/crawler.go b/internal/crawler/crawler.go index a7fd1ba..75cef75 100644 --- a/internal/crawler/crawler.go +++ b/internal/crawler/crawler.go @@ -11,9 +11,9 @@ import ( "golang.org/x/time/rate" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/config" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) // Crawler orchestrates concurrent web crawling. diff --git a/internal/crawler/fetcher.go b/internal/crawler/fetcher.go index 587bb21..c89d850 100644 --- a/internal/crawler/fetcher.go +++ b/internal/crawler/fetcher.go @@ -13,10 +13,10 @@ import ( "strings" "time" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/browser" "github.com/meysam81/scry/internal/config" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" "github.com/meysam81/scry/internal/safenet" ) diff --git a/internal/lighthouse/browserless.go b/internal/lighthouse/browserless.go index f80c21a..6cdd72e 100644 --- a/internal/lighthouse/browserless.go +++ b/internal/lighthouse/browserless.go @@ -12,8 +12,8 @@ import ( "golang.org/x/time/rate" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) // browserlessTimeout is the default timeout for browserless requests. diff --git a/internal/lighthouse/lighthouse.go b/internal/lighthouse/lighthouse.go index 4d63d07..2b2a120 100644 --- a/internal/lighthouse/lighthouse.go +++ b/internal/lighthouse/lighthouse.go @@ -5,9 +5,9 @@ import ( "context" "fmt" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/config" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) // Score thresholds for converting Lighthouse scores to issues. diff --git a/internal/lighthouse/lighthouse_test.go b/internal/lighthouse/lighthouse_test.go index 6614734..612a7de 100644 --- a/internal/lighthouse/lighthouse_test.go +++ b/internal/lighthouse/lighthouse_test.go @@ -3,9 +3,9 @@ package lighthouse import ( "testing" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/config" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) func TestScoreToIssues(t *testing.T) { diff --git a/internal/lighthouse/psi.go b/internal/lighthouse/psi.go index c6dbe71..dcc589a 100644 --- a/internal/lighthouse/psi.go +++ b/internal/lighthouse/psi.go @@ -11,8 +11,8 @@ import ( "golang.org/x/time/rate" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) // PSI API constants. diff --git a/internal/lighthouse/psi_test.go b/internal/lighthouse/psi_test.go index 9a07ee8..6a8ecdb 100644 --- a/internal/lighthouse/psi_test.go +++ b/internal/lighthouse/psi_test.go @@ -8,8 +8,8 @@ import ( "net/http/httptest" "testing" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) func TestPSIClientRun_Success(t *testing.T) { diff --git a/internal/metrics/prometheus.go b/internal/metrics/prometheus.go index 83cf5eb..42bef0c 100644 --- a/internal/metrics/prometheus.go +++ b/internal/metrics/prometheus.go @@ -6,7 +6,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/push" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/report" ) diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index 825d3d7..b89f52c 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestPushMetrics_EmptyURL(t *testing.T) { diff --git a/internal/report/csv.go b/internal/report/csv.go index 18c12ab..773ed06 100644 --- a/internal/report/csv.go +++ b/internal/report/csv.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // sanitizeCSVCell prevents CSV formula injection by prefixing cells that start diff --git a/internal/report/csv_test.go b/internal/report/csv_test.go index 95b76bd..0b763a8 100644 --- a/internal/report/csv_test.go +++ b/internal/report/csv_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestCSVReporterWriteAndParse(t *testing.T) { diff --git a/internal/report/filter.go b/internal/report/filter.go index 7015477..f230065 100644 --- a/internal/report/filter.go +++ b/internal/report/filter.go @@ -3,7 +3,7 @@ package report import ( "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // FilterIssues returns only issues matching the given criteria. diff --git a/internal/report/filter_test.go b/internal/report/filter_test.go index 0aea299..15d6d21 100644 --- a/internal/report/filter_test.go +++ b/internal/report/filter_test.go @@ -3,7 +3,7 @@ package report import ( "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func sampleIssues() []model.Issue { diff --git a/internal/report/html.go b/internal/report/html.go index 2cb9aef..2b495d3 100644 --- a/internal/report/html.go +++ b/internal/report/html.go @@ -7,7 +7,7 @@ import ( "html/template" "io" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) //go:embed templates/report.html.tmpl diff --git a/internal/report/html_test.go b/internal/report/html_test.go index 334f421..554975c 100644 --- a/internal/report/html_test.go +++ b/internal/report/html_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestHTMLReporterValidHTML(t *testing.T) { diff --git a/internal/report/json.go b/internal/report/json.go index 6d93cf8..8ff01de 100644 --- a/internal/report/json.go +++ b/internal/report/json.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // jsonOutput wraps CrawlResult with summary statistics for the JSON reporter. diff --git a/internal/report/json_test.go b/internal/report/json_test.go index da1f7ea..51eaf67 100644 --- a/internal/report/json_test.go +++ b/internal/report/json_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestJSONReporterRoundTrip(t *testing.T) { diff --git a/internal/report/jsonl.go b/internal/report/jsonl.go index 13fdd4e..c14e3cf 100644 --- a/internal/report/jsonl.go +++ b/internal/report/jsonl.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // JSONLReporter writes the CrawlResult as JSON Lines — one JSON object per line. diff --git a/internal/report/jsonl_test.go b/internal/report/jsonl_test.go index ef0c16a..f473a86 100644 --- a/internal/report/jsonl_test.go +++ b/internal/report/jsonl_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestJSONLReporterName(t *testing.T) { diff --git a/internal/report/junit.go b/internal/report/junit.go index 788efcc..b5ab93e 100644 --- a/internal/report/junit.go +++ b/internal/report/junit.go @@ -7,7 +7,7 @@ import ( "io" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // JUnitReporter writes the CrawlResult issues as JUnit XML. diff --git a/internal/report/junit_test.go b/internal/report/junit_test.go index 926f7ce..df25af0 100644 --- a/internal/report/junit_test.go +++ b/internal/report/junit_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestJUnitReporterName(t *testing.T) { diff --git a/internal/report/markdown.go b/internal/report/markdown.go index 4e6fa23..e54390e 100644 --- a/internal/report/markdown.go +++ b/internal/report/markdown.go @@ -7,7 +7,7 @@ import ( "strings" "text/template" // nosemgrep: import-text-template -- generates markdown files, not HTML; html/template would break output - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) const markdownTimeFmt = "2006-01-02 15:04:05" diff --git a/internal/report/markdown_test.go b/internal/report/markdown_test.go index 8bd4e20..38f4151 100644 --- a/internal/report/markdown_test.go +++ b/internal/report/markdown_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestMarkdownReporterContainsHeader(t *testing.T) { diff --git a/internal/report/pdf.go b/internal/report/pdf.go index 47faba4..0f2c3d2 100644 --- a/internal/report/pdf.go +++ b/internal/report/pdf.go @@ -10,8 +10,8 @@ import ( "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/proto" + "github.com/meysam81/scry/core/model" "github.com/meysam81/scry/internal/logger" - "github.com/meysam81/scry/internal/model" ) // PDFReporter renders a CrawlResult as a PDF document by first generating diff --git a/internal/report/reporter.go b/internal/report/reporter.go index 99c70bf..5de2cc9 100644 --- a/internal/report/reporter.go +++ b/internal/report/reporter.go @@ -6,7 +6,7 @@ import ( "context" "io" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // Reporter formats and writes a CrawlResult to an output destination. diff --git a/internal/report/sarif.go b/internal/report/sarif.go index 737137d..4c71717 100644 --- a/internal/report/sarif.go +++ b/internal/report/sarif.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // SARIFReporter writes the CrawlResult issues in SARIF 2.1.0 format. diff --git a/internal/report/sarif_test.go b/internal/report/sarif_test.go index 9392995..c57000c 100644 --- a/internal/report/sarif_test.go +++ b/internal/report/sarif_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestSARIFReporterName(t *testing.T) { diff --git a/internal/report/summary.go b/internal/report/summary.go index 7d72644..57ccd90 100644 --- a/internal/report/summary.go +++ b/internal/report/summary.go @@ -4,7 +4,7 @@ import ( "sort" "strings" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // SummaryStats holds aggregated statistics about audit results. diff --git a/internal/report/summary_test.go b/internal/report/summary_test.go index 33c00cb..83e2b80 100644 --- a/internal/report/summary_test.go +++ b/internal/report/summary_test.go @@ -4,7 +4,7 @@ import ( "fmt" "testing" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func TestComputeSummaryEmpty(t *testing.T) { diff --git a/internal/report/terminal.go b/internal/report/terminal.go index 1404122..edf7bf7 100644 --- a/internal/report/terminal.go +++ b/internal/report/terminal.go @@ -9,7 +9,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/jedib0t/go-pretty/v6/table" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) // TerminalReporter renders a human-readable audit report to a terminal, diff --git a/internal/report/terminal_test.go b/internal/report/terminal_test.go index 4e710a2..d423cb7 100644 --- a/internal/report/terminal_test.go +++ b/internal/report/terminal_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/meysam81/scry/internal/model" + "github.com/meysam81/scry/core/model" ) func sampleResult() *model.CrawlResult { diff --git a/internal/schema/data/schemas.json b/internal/schema/data/schemas.json deleted file mode 100644 index ba1914c..0000000 --- a/internal/schema/data/schemas.json +++ /dev/null @@ -1,1567 +0,0 @@ -{ - "types": { - "AggregateOffer": { - "google_eligible": false, - "name": "AggregateOffer", - "parent": "Offer", - "properties": { - "highPrice": { - "expected_types": [ - "Text", - "Number" - ] - }, - "lowPrice": { - "expected_types": [ - "Text", - "Number" - ] - }, - "offerCount": { - "expected_types": [ - "Number" - ] - }, - "priceCurrency": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [] - }, - "AggregateRating": { - "google_eligible": false, - "name": "AggregateRating", - "parent": "Rating", - "properties": { - "bestRating": { - "expected_types": [ - "Text", - "Number" - ] - }, - "ratingValue": { - "expected_types": [ - "Text", - "Number" - ] - }, - "reviewCount": { - "expected_types": [ - "Number" - ] - }, - "worstRating": { - "expected_types": [ - "Text", - "Number" - ] - } - }, - "required_fields": [] - }, - "Answer": { - "google_eligible": false, - "name": "Answer", - "parent": "CreativeWork", - "properties": { - "text": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [] - }, - "Article": { - "google_eligible": true, - "google_recommended": [ - "dateModified", - "mainEntityOfPage" - ], - "google_required": [ - "headline", - "author", - "datePublished", - "image" - ], - "name": "Article", - "parent": "CreativeWork", - "properties": { - "author": { - "expected_types": [ - "Person", - "Organization" - ] - }, - "dateModified": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "datePublished": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "headline": { - "expected_types": [ - "Text" - ] - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "mainEntityOfPage": { - "expected_types": [ - "WebPage", - "URL" - ], - "is_url": true - }, - "publisher": { - "expected_types": [ - "Organization" - ] - } - }, - "required_fields": [ - "headline", - "datePublished", - "author" - ] - }, - "BlogPosting": { - "google_eligible": true, - "google_recommended": [ - "dateModified", - "mainEntityOfPage" - ], - "google_required": [ - "headline", - "author", - "datePublished", - "image" - ], - "name": "BlogPosting", - "parent": "Article", - "properties": {}, - "required_fields": [ - "headline", - "datePublished", - "author" - ] - }, - "Book": { - "google_eligible": true, - "google_recommended": [ - "image", - "aggregateRating", - "isbn" - ], - "google_required": [ - "name", - "author" - ], - "name": "Book", - "parent": "CreativeWork", - "properties": { - "aggregateRating": { - "expected_types": [ - "AggregateRating" - ] - }, - "author": { - "expected_types": [ - "Person", - "Organization" - ] - }, - "bookFormat": { - "expected_types": [ - "Text" - ] - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "isbn": { - "expected_types": [ - "Text" - ] - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "publisher": { - "expected_types": [ - "Organization" - ] - } - }, - "required_fields": [ - "name", - "author" - ] - }, - "BreadcrumbList": { - "google_eligible": true, - "google_required": [ - "itemListElement" - ], - "name": "BreadcrumbList", - "parent": "ItemList", - "properties": { - "itemListElement": { - "expected_types": [ - "ListItem" - ] - } - }, - "required_fields": [ - "itemListElement" - ] - }, - "Course": { - "google_eligible": true, - "google_recommended": [ - "aggregateRating", - "offers" - ], - "google_required": [ - "name", - "description", - "provider" - ], - "name": "Course", - "parent": "CreativeWork", - "properties": { - "aggregateRating": { - "expected_types": [ - "AggregateRating" - ] - }, - "description": { - "expected_types": [ - "Text" - ] - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "offers": { - "expected_types": [ - "Offer", - "AggregateOffer" - ] - }, - "provider": { - "expected_types": [ - "Organization" - ] - } - }, - "required_fields": [ - "name", - "description" - ] - }, - "CreativeWork": { - "google_eligible": false, - "name": "CreativeWork", - "parent": "Thing", - "properties": { - "author": { - "expected_types": [ - "Person", - "Organization" - ] - }, - "dateModified": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "datePublished": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - } - }, - "required_fields": [] - }, - "Dataset": { - "google_eligible": true, - "google_recommended": [ - "distribution", - "temporalCoverage", - "spatialCoverage" - ], - "google_required": [ - "name", - "description" - ], - "name": "Dataset", - "parent": "CreativeWork", - "properties": { - "creator": { - "expected_types": [ - "Person", - "Organization" - ] - }, - "description": { - "expected_types": [ - "Text" - ] - }, - "distribution": { - "expected_types": [ - "DataDownload" - ] - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "spatialCoverage": { - "expected_types": [ - "Place" - ] - }, - "temporalCoverage": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [ - "name", - "description" - ] - }, - "Event": { - "google_eligible": true, - "google_recommended": [ - "endDate", - "description", - "image" - ], - "google_required": [ - "name", - "startDate", - "location" - ], - "name": "Event", - "parent": "Thing", - "properties": { - "description": { - "expected_types": [ - "Text" - ] - }, - "endDate": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "eventStatus": { - "enum_values": [ - "EventScheduled", - "EventCancelled", - "EventMovedOnline", - "EventPostponed", - "EventRescheduled" - ], - "expected_types": [ - "Text" - ] - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "location": { - "expected_types": [ - "Place", - "VirtualLocation" - ] - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "offers": { - "expected_types": [ - "Offer", - "AggregateOffer" - ] - }, - "organizer": { - "expected_types": [ - "Organization", - "Person" - ] - }, - "performer": { - "expected_types": [ - "Person", - "Organization" - ] - }, - "startDate": { - "expected_types": [ - "Text" - ], - "is_date": true - } - }, - "required_fields": [ - "name", - "startDate", - "location" - ] - }, - "FAQPage": { - "google_eligible": true, - "google_required": [ - "mainEntity" - ], - "name": "FAQPage", - "parent": "WebPage", - "properties": { - "mainEntity": { - "expected_types": [ - "Question" - ] - } - }, - "required_fields": [ - "mainEntity" - ] - }, - "HowTo": { - "google_eligible": true, - "google_recommended": [ - "image", - "totalTime" - ], - "google_required": [ - "name", - "step" - ], - "name": "HowTo", - "parent": "CreativeWork", - "properties": { - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "step": { - "expected_types": [ - "HowToStep" - ] - }, - "supply": { - "expected_types": [ - "Text", - "HowToSupply" - ] - }, - "tool": { - "expected_types": [ - "Text", - "HowToTool" - ] - }, - "totalTime": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [ - "name", - "step" - ] - }, - "ImageObject": { - "google_eligible": false, - "name": "ImageObject", - "parent": "MediaObject", - "properties": { - "contentUrl": { - "expected_types": [ - "URL" - ], - "is_url": true - }, - "height": { - "expected_types": [ - "Text", - "Number" - ] - }, - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - }, - "width": { - "expected_types": [ - "Text", - "Number" - ] - } - }, - "required_fields": [] - }, - "JobPosting": { - "google_eligible": true, - "google_recommended": [ - "validThrough", - "employmentType", - "jobLocation" - ], - "google_required": [ - "title", - "description", - "datePosted", - "hiringOrganization" - ], - "name": "JobPosting", - "parent": "Intangible", - "properties": { - "baseSalary": { - "expected_types": [ - "MonetaryAmount" - ] - }, - "datePosted": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "description": { - "expected_types": [ - "Text" - ] - }, - "employmentType": { - "expected_types": [ - "Text" - ] - }, - "hiringOrganization": { - "expected_types": [ - "Organization" - ] - }, - "jobLocation": { - "expected_types": [ - "Place" - ] - }, - "title": { - "expected_types": [ - "Text" - ] - }, - "validThrough": { - "expected_types": [ - "Text" - ], - "is_date": true - } - }, - "required_fields": [ - "title", - "description", - "datePosted", - "hiringOrganization" - ] - }, - "ListItem": { - "google_eligible": false, - "name": "ListItem", - "parent": "Intangible", - "properties": { - "item": { - "expected_types": [ - "Thing", - "URL" - ] - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "position": { - "expected_types": [ - "Number", - "Text" - ] - }, - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - } - }, - "required_fields": [] - }, - "LocalBusiness": { - "google_eligible": true, - "google_recommended": [ - "image", - "priceRange", - "openingHours" - ], - "google_required": [ - "name", - "address" - ], - "name": "LocalBusiness", - "parent": "Organization", - "properties": { - "address": { - "expected_types": [ - "PostalAddress" - ] - }, - "geo": { - "expected_types": [ - "GeoCoordinates" - ] - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "openingHours": { - "expected_types": [ - "Text" - ] - }, - "priceRange": { - "expected_types": [ - "Text" - ] - }, - "telephone": { - "expected_types": [ - "Text" - ] - }, - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - } - }, - "required_fields": [ - "name", - "address", - "telephone" - ] - }, - "MobileApplication": { - "google_eligible": true, - "google_recommended": [ - "aggregateRating", - "applicationCategory" - ], - "google_required": [ - "name", - "offers" - ], - "name": "MobileApplication", - "parent": "SoftwareApplication", - "properties": {}, - "required_fields": [ - "name" - ] - }, - "Movie": { - "google_eligible": true, - "google_recommended": [ - "image", - "dateCreated", - "director" - ], - "google_required": [ - "name" - ], - "name": "Movie", - "parent": "CreativeWork", - "properties": { - "actor": { - "expected_types": [ - "Person" - ] - }, - "aggregateRating": { - "expected_types": [ - "AggregateRating" - ] - }, - "dateCreated": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "director": { - "expected_types": [ - "Person" - ] - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "name": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [ - "name" - ] - }, - "MusicAlbum": { - "google_eligible": true, - "google_recommended": [ - "byArtist", - "datePublished", - "image" - ], - "google_required": [ - "name" - ], - "name": "MusicAlbum", - "parent": "MusicPlaylist", - "properties": { - "byArtist": { - "expected_types": [ - "Person", - "MusicGroup" - ] - }, - "datePublished": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "track": { - "expected_types": [ - "MusicRecording" - ] - } - }, - "required_fields": [ - "name" - ] - }, - "NewsArticle": { - "google_eligible": true, - "google_recommended": [ - "dateModified", - "mainEntityOfPage" - ], - "google_required": [ - "headline", - "author", - "datePublished", - "image" - ], - "name": "NewsArticle", - "parent": "Article", - "properties": {}, - "required_fields": [ - "headline", - "datePublished", - "author" - ] - }, - "Offer": { - "google_eligible": false, - "name": "Offer", - "parent": "Intangible", - "properties": { - "availability": { - "enum_values": [ - "InStock", - "OutOfStock", - "PreOrder", - "SoldOut", - "BackOrder", - "Discontinued", - "InStoreOnly", - "LimitedAvailability", - "OnlineOnly" - ], - "expected_types": [ - "Text" - ] - }, - "price": { - "expected_types": [ - "Text", - "Number" - ] - }, - "priceCurrency": { - "expected_types": [ - "Text" - ] - }, - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - }, - "validFrom": { - "expected_types": [ - "Text" - ], - "is_date": true - } - }, - "required_fields": [] - }, - "Organization": { - "google_eligible": false, - "name": "Organization", - "parent": "Thing", - "properties": { - "address": { - "expected_types": [ - "PostalAddress" - ] - }, - "email": { - "expected_types": [ - "Text" - ] - }, - "logo": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "sameAs": { - "expected_types": [ - "URL" - ], - "is_url": true - }, - "telephone": { - "expected_types": [ - "Text" - ] - }, - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - } - }, - "required_fields": [] - }, - "Person": { - "google_eligible": false, - "name": "Person", - "parent": "Thing", - "properties": { - "email": { - "expected_types": [ - "Text" - ] - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "jobTitle": { - "expected_types": [ - "Text" - ] - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "sameAs": { - "expected_types": [ - "URL" - ], - "is_url": true - }, - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - } - }, - "required_fields": [] - }, - "Place": { - "google_eligible": false, - "name": "Place", - "parent": "Thing", - "properties": { - "address": { - "expected_types": [ - "PostalAddress" - ] - }, - "geo": { - "expected_types": [ - "GeoCoordinates" - ] - }, - "name": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [] - }, - "PostalAddress": { - "google_eligible": false, - "name": "PostalAddress", - "parent": "ContactPoint", - "properties": { - "addressCountry": { - "expected_types": [ - "Text" - ] - }, - "addressLocality": { - "expected_types": [ - "Text" - ] - }, - "addressRegion": { - "expected_types": [ - "Text" - ] - }, - "postalCode": { - "expected_types": [ - "Text" - ] - }, - "streetAddress": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [] - }, - "Product": { - "google_eligible": true, - "google_recommended": [ - "image", - "offers", - "review", - "aggregateRating" - ], - "google_required": [ - "name" - ], - "name": "Product", - "parent": "Thing", - "properties": { - "aggregateRating": { - "expected_types": [ - "AggregateRating" - ] - }, - "brand": { - "expected_types": [ - "Brand", - "Organization" - ] - }, - "description": { - "expected_types": [ - "Text" - ] - }, - "gtin": { - "expected_types": [ - "Text" - ] - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "offers": { - "expected_types": [ - "Offer", - "AggregateOffer" - ] - }, - "review": { - "expected_types": [ - "Review" - ] - }, - "sku": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [ - "name", - "description" - ] - }, - "Question": { - "google_eligible": false, - "name": "Question", - "parent": "CreativeWork", - "properties": { - "acceptedAnswer": { - "expected_types": [ - "Answer" - ] - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "text": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [] - }, - "Rating": { - "google_eligible": false, - "name": "Rating", - "parent": "Intangible", - "properties": { - "bestRating": { - "expected_types": [ - "Text", - "Number" - ] - }, - "ratingValue": { - "expected_types": [ - "Text", - "Number" - ] - }, - "worstRating": { - "expected_types": [ - "Text", - "Number" - ] - } - }, - "required_fields": [] - }, - "Recipe": { - "google_eligible": true, - "google_recommended": [ - "aggregateRating", - "cookTime", - "prepTime", - "totalTime" - ], - "google_required": [ - "name", - "image", - "recipeIngredient", - "recipeInstructions" - ], - "name": "Recipe", - "parent": "HowTo", - "properties": { - "aggregateRating": { - "expected_types": [ - "AggregateRating" - ] - }, - "author": { - "expected_types": [ - "Person", - "Organization" - ] - }, - "cookTime": { - "expected_types": [ - "Text" - ] - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "prepTime": { - "expected_types": [ - "Text" - ] - }, - "recipeCategory": { - "expected_types": [ - "Text" - ] - }, - "recipeCuisine": { - "expected_types": [ - "Text" - ] - }, - "recipeIngredient": { - "expected_types": [ - "Text" - ] - }, - "recipeInstructions": { - "expected_types": [ - "Text", - "HowToStep" - ] - }, - "recipeYield": { - "expected_types": [ - "Text" - ] - }, - "totalTime": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [ - "name", - "image", - "recipeIngredient" - ] - }, - "Review": { - "google_eligible": true, - "google_recommended": [ - "datePublished" - ], - "google_required": [ - "itemReviewed", - "author", - "reviewRating" - ], - "name": "Review", - "parent": "CreativeWork", - "properties": { - "author": { - "expected_types": [ - "Person", - "Organization" - ] - }, - "datePublished": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "itemReviewed": { - "expected_types": [ - "Thing" - ] - }, - "reviewBody": { - "expected_types": [ - "Text" - ] - }, - "reviewRating": { - "expected_types": [ - "Rating" - ] - } - }, - "required_fields": [ - "itemReviewed" - ] - }, - "SearchAction": { - "google_eligible": false, - "name": "SearchAction", - "parent": "Action", - "properties": { - "query-input": { - "expected_types": [ - "Text" - ] - }, - "target": { - "expected_types": [ - "Text", - "URL" - ] - } - }, - "required_fields": [] - }, - "SoftwareApplication": { - "google_eligible": true, - "google_recommended": [ - "aggregateRating", - "applicationCategory" - ], - "google_required": [ - "name", - "offers" - ], - "name": "SoftwareApplication", - "parent": "CreativeWork", - "properties": { - "aggregateRating": { - "expected_types": [ - "AggregateRating" - ] - }, - "applicationCategory": { - "expected_types": [ - "Text", - "URL" - ] - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "offers": { - "expected_types": [ - "Offer", - "AggregateOffer" - ] - }, - "operatingSystem": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [ - "name" - ] - }, - "Thing": { - "google_eligible": false, - "name": "Thing", - "parent": "", - "properties": { - "description": { - "expected_types": [ - "Text" - ] - }, - "image": { - "expected_types": [ - "ImageObject", - "URL" - ], - "is_url": true - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - } - }, - "required_fields": [] - }, - "VideoObject": { - "google_eligible": true, - "google_recommended": [ - "duration", - "contentUrl", - "embedUrl" - ], - "google_required": [ - "name", - "description", - "thumbnailUrl", - "uploadDate" - ], - "name": "VideoObject", - "parent": "MediaObject", - "properties": { - "contentUrl": { - "expected_types": [ - "URL" - ], - "is_url": true - }, - "description": { - "expected_types": [ - "Text" - ] - }, - "duration": { - "expected_types": [ - "Text" - ] - }, - "embedUrl": { - "expected_types": [ - "URL" - ], - "is_url": true - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "thumbnailUrl": { - "expected_types": [ - "URL" - ], - "is_url": true - }, - "uploadDate": { - "expected_types": [ - "Text" - ], - "is_date": true - } - }, - "required_fields": [ - "name", - "description", - "thumbnailUrl", - "uploadDate" - ] - }, - "VirtualLocation": { - "google_eligible": false, - "name": "VirtualLocation", - "parent": "Intangible", - "properties": { - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - } - }, - "required_fields": [] - }, - "WebApplication": { - "google_eligible": true, - "google_recommended": [ - "aggregateRating", - "applicationCategory" - ], - "google_required": [ - "name", - "offers" - ], - "name": "WebApplication", - "parent": "SoftwareApplication", - "properties": { - "browserRequirements": { - "expected_types": [ - "Text" - ] - } - }, - "required_fields": [ - "name" - ] - }, - "WebPage": { - "google_eligible": false, - "name": "WebPage", - "parent": "CreativeWork", - "properties": { - "dateCreated": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "dateModified": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "datePublished": { - "expected_types": [ - "Text" - ], - "is_date": true - }, - "name": { - "expected_types": [ - "Text" - ] - }, - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - } - }, - "required_fields": [] - }, - "WebSite": { - "google_eligible": false, - "name": "WebSite", - "parent": "CreativeWork", - "properties": { - "name": { - "expected_types": [ - "Text" - ] - }, - "potentialAction": { - "expected_types": [ - "SearchAction" - ] - }, - "url": { - "expected_types": [ - "URL" - ], - "is_url": true - } - }, - "required_fields": [] - } - }, - "version": "2026-03-26" -}