diff --git a/README.md b/README.md index 7813408..7e774d8 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,14 @@ Select **Create extension** from the menu and follow the prompts, or skip them w degoog-cli create --name my-engine --type engine --out ./extensions ``` -Any flag you omit will still be asked interactively. Available types: +Any flag you omit will still be asked interactively, unless the CLI is running headless (see below). Available types: | Type | What it is | | ------------------- | ----------------------------------------------------------- | | `engine` | Custom search engine | | `transport` | Custom HTTP fetch strategy | | `autocomplete` | Search suggestions provider | +| `favicon` | Favicon provider for search results | | `theme` | UI theme with CSS variables and optional template overrides | | `plugin-bang` | Bang command (`!trigger query`) | | `plugin-slot` | Panel injected into search results | @@ -94,14 +95,43 @@ Any flag you omit will still be asked interactively. Available types: Every extension gets: -- `index.ts` - entry file pre-filled with the correct contract for the type you picked +- `index.ts` - entry file pre-filled with the correct contract for the type you picked (favicon providers get `index.js`, matching the official store) - `README.md` - fill this in, it shows as docs on the extension's settings page - `author.json` - auto-filled from your login details Themes also get `style.css` with all CSS variables pre-filled to the degoog defaults (light and dark), plus a `templates/logo.html` example showing how to override a template section. +The generated theme README also covers the script-free `/nojs` page, which reuses your ordinary templates and takes per-file overrides from a `nojs/` folder by filename instead of through `theme.json`. + Plugin types also have `isClientExposed` already in the template. Set it to `true` if your plugin causes the browser to fetch external URLs directly, `false` if everything goes through the server. Leaving it unset shows a warning badge in degoog settings. +Engines ask whether the site puts an Anubis proof-of-work page in front of its results. Answer yes and the template sets `challenges: ["anubis"]`, so degoog solves the page before your engine reads the response. Inside a store, the package.json entry also gets `minDegoogVersion: "1.0.0"`, because degoog solves these pages from 1.0.0 onwards. + +Transports ask whether they drive a real browser that gets through proof-of-work pages. Answer yes and the template sets `handlesChallenges: true`, so degoog leaves those pages to the engine. + +Doctor checks both values. It fails on an unknown `challenges` kind or a `handlesChallenges` that isn't `true` or `false`, and warns when an engine with `challenges` has a `minDegoogVersion` below 1.0.0. + +## Running without prompts + +Every command also runs with no interactive UI, for scripts, CI, or piping. The CLI goes headless when you pass `-y` / `--yes`, when stdin or stdout is not a terminal, or when `CI` or `DEGOOG_HEADLESS=1` is set. Headless runs never prompt: they read flags, fall back to defaults, and exit `1` when a required value is missing or a check fails. Colours are dropped when output is not a terminal, or with `--no-color` / `NO_COLOR`. + +```sh +degoog-cli login --yes --instance-url https://search.example.com --api-key "$DEGOOG_API_KEY" --username "Jane Dev" +degoog-cli create --yes --name my-engine --type engine --challenges +degoog-cli create --yes --name my-theme --type theme --init-store --include css,home-logo +degoog-cli search --json --limit 5 "degoog extensions" +degoog-cli doctor ./my-store --fix --plugin-type command +``` + +| Command | Flags | Headless defaults | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `create` | `--name`, `--type`, `--out`, `--init-store`, `--challenges`, `--handles-challenges`, `--include ` | `--name` and `--type` are required. Output goes to the detected store category, otherwise `.` | +| `search` | ``, `--json`, `--limit ` | Query is required. `--json` always skips the result browser | +| `login` | `--instance-url` (or `DEGOOG_INSTANCE_URL`), `--api-key` (or `DEGOOG_API_KEY`), `--username`, `--website` | Only the values you pass are changed | +| `doctor` | `[path]`, `--fix`, `--plugin-type ` | Path defaults to the current directory. Orphan folders are only registered with `--fix` | + +Boolean flags accept `--flag=false`. Themes created headless without `--include` get `css` only. Orphan plugin folders are skipped unless `--plugin-type` is given, since doctor cannot guess their type. Run `degoog-cli --help` for the full list. + ## Docker Compose If you prefer compose over the wrapper script: diff --git a/package.json b/package.json index ba9045d..74506c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "degoog-cli", - "version": "0.6.0", + "version": "0.7.0", "description": "CLI toolset for degoog", "type": "module", "scripts": { diff --git a/src/commands/create.ts b/src/commands/create.ts index 09b57ae..8ff2f42 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -1,26 +1,29 @@ import * as p from "@clack/prompts"; import { join } from "node:path"; -import { ExtType, type GeneratorCtx } from "../types/index.ts"; -import { promptExtType } from "../prompts/ext-type.ts"; -import { generateEngine } from "../generators/engine.ts"; -import { generateTransport } from "../generators/transport.ts"; -import { generateAutocomplete } from "../generators/autocomplete.ts"; -import { generateTheme } from "../generators/theme.ts"; -import { generatePluginBang } from "../generators/plugin-bang.ts"; -import { generatePluginSlot } from "../generators/plugin-slot.ts"; -import { generatePluginTab } from "../generators/plugin-tab.ts"; -import { generatePluginIntercept } from "../generators/plugin-intercept.ts"; -import { generatePluginMid } from "../generators/plugin-mid.ts"; -import { generatePluginRoute } from "../generators/plugin-route.ts"; -import { loadConfig } from "../config/store.ts"; -import { argv } from "../utils/argv.ts"; +import { ExtType, type GeneratorCtx } from "../types"; +import { promptExtType } from "../prompts/ext-type"; +import { generateEngine } from "../generators/engine"; +import { generateTransport } from "../generators/transport"; +import { generateAutocomplete } from "../generators/autocomplete"; +import { generateFavicon } from "../generators/favicon"; +import { generateTheme, THEME_PARTS } from "../generators/theme"; +import { generatePluginBang } from "../generators/plugin-bang"; +import { generatePluginSlot } from "../generators/plugin-slot"; +import { generatePluginTab } from "../generators/plugin-tab"; +import { generatePluginIntercept } from "../generators/plugin-intercept"; +import { generatePluginMid } from "../generators/plugin-mid"; +import { generatePluginRoute } from "../generators/plugin-route"; +import { loadConfig } from "../config/store"; +import { argv } from "../utils/argv"; import { extTypeToCategory, findStoreRoot, registerExtensionInStore, scaffoldStore, -} from "../utils/store.ts"; -import { t } from "../utils/theme.ts"; +} from "../utils/store"; +import { t } from "../utils/theme"; +import { ui } from "../utils/ui"; +import { isHeadless, fail } from "../utils/headless"; const SLUG_RE = /^[a-z][a-z0-9-]*$/; @@ -30,6 +33,7 @@ const GENERATORS: Record Promise> = { [ExtType.Engine]: generateEngine, [ExtType.Transport]: generateTransport, [ExtType.Autocomplete]: generateAutocomplete, + [ExtType.Favicon]: generateFavicon, [ExtType.Theme]: generateTheme, [ExtType.PluginBang]: generatePluginBang, [ExtType.PluginSlot]: generatePluginSlot, @@ -49,11 +53,13 @@ const resolveOutDir = async ( let store = await findStoreRoot(process.cwd()); if (store) { - p.log.info(t.muted(`store detected — creating in ${category}/`)); + ui.info(t.muted(`store detected — creating in ${category}/`)); return join(store.dir, category); } - const setup = await p.confirm({ + if (isHeadless && !argv.initStore) return "."; + + const setup = argv.initStore || await p.confirm({ message: "No store detected. Set up a store in the current directory?", initialValue: true, }); @@ -63,7 +69,7 @@ const resolveOutDir = async ( await scaffoldStore(process.cwd(), config); store = await findStoreRoot(process.cwd()); if (store) { - p.log.info(t.muted(`store created — creating in ${category}/`)); + ui.info(t.muted(`store created — creating in ${category}/`)); return join(store.dir, category); } } @@ -76,11 +82,37 @@ const resolveOutDir = async ( return input || "."; }; +const parseThemeParts = (): string[] | undefined => { + if (argv.include === undefined) return undefined + const parts = argv.include.split(",").map((s) => s.trim()).filter(Boolean) + if (parts.includes("all")) return THEME_PARTS + const unknown = parts.filter((s) => !THEME_PARTS.includes(s)) + if (unknown.length) { + fail(`unknown --include value(s): ${unknown.join(", ")}. Valid: all, ${THEME_PARTS.join(", ")}`) + } + return parts +} + +const askConfirm = async ( + preset: boolean | undefined, + message: string, +): Promise => { + if (preset !== undefined) return preset + if (isHeadless) return false + const picked = await p.confirm({ message, initialValue: false }) + if (p.isCancel(picked)) return null + return picked +} + export const createCmd = async () => { let name: string if (argv.name && SLUG_RE.test(argv.name)) { name = argv.name + } else if (isHeadless) { + return fail(argv.name + ? `invalid --name "${argv.name}": lowercase letters, numbers and hyphens only` + : "--name is required when running without a terminal") } else { const input = await p.text({ message: "Extension name", @@ -99,26 +131,52 @@ export const createCmd = async () => { if (argv.type && VALID_TYPES.includes(argv.type)) { extType = argv.type as ExtType + } else if (isHeadless) { + return fail(`${argv.type ? `invalid --type "${argv.type}"` : "--type is required when running without a terminal"}. Valid: ${VALID_TYPES.join(", ")}`) } else { const picked = await promptExtType() if (!picked) return extType = picked } + let challenges = false + let handlesChallenges = false + + if (extType === ExtType.Engine) { + const picked = await askConfirm( + argv.challenges, + "Does the site put an Anubis proof-of-work page in front of its results?", + ) + if (picked === null) return + challenges = picked + } + + if (extType === ExtType.Transport) { + const picked = await askConfirm( + argv.handlesChallenges, + "Does this transport drive a real browser that gets through proof-of-work pages?", + ) + if (picked === null) return + handlesChallenges = picked + } + + const themeParts = extType === ExtType.Theme ? parseThemeParts() : undefined + const config = await loadConfig() const outDir = await resolveOutDir(extType, config) if (!outDir) return - const ctx: GeneratorCtx = { name, outDir, config } + const ctx: GeneratorCtx = { name, outDir, config, challenges, handlesChallenges } + if (themeParts) ctx.themeParts = themeParts const createdPath = await GENERATORS[extType](ctx) const store = await findStoreRoot(createdPath) if (store) { - await registerExtensionInStore(store.dir, name, extType) + await registerExtensionInStore(store.dir, name, extType, challenges) } - p.note( + ui.note( `That's it, you now have a sexy template for your extension. Have fun making it your own!`, "done", ) diff --git a/src/commands/doctor/checks.ts b/src/commands/doctor/checks.ts index ebd869e..eaf8302 100644 --- a/src/commands/doctor/checks.ts +++ b/src/commands/doctor/checks.ts @@ -1,8 +1,9 @@ import { readFile, writeFile, readdir } from "node:fs/promises" +import type { Dirent } from "node:fs" import { join, basename } from "node:path" -import { exists } from "./detect.ts" -import { findStoreRoot } from "../../utils/store.ts" -import type { CheckResult, ExtensionKind, RunSummary } from "./types.ts" +import { exists } from "./detect" +import { findStoreRoot } from "../../utils/store" +import { ENGINE_CHALLENGES, type CheckResult, type ExtensionKind, type RunSummary } from "./types" const readJson = async (path: string): Promise => { try { @@ -182,6 +183,119 @@ const checkRouteConventions = async (dir: string, doFix: boolean): Promise => { + let entries: Dirent[] + try { + entries = await readdir(join(dir, rel), { withFileTypes: true }) + } catch { + return [] + } + const out: { file: string; src: string }[] = [] + for (const entry of entries) { + const file = rel ? `${rel}/${entry.name}` : entry.name + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) continue + out.push(...(await readPluginSources(dir, file))) + } else if (FAVICON_SOURCE_EXT.test(entry.name)) { + try { + out.push({ file, src: await readFile(join(dir, file), "utf-8") }) + } catch { + continue + } + } + } + return out +} + +export const checkFaviconUrls = async (dir: string): Promise => { + const offenders = (await readPluginSources(dir)) + .filter(({ src }) => HAND_BUILT_FAVICON_RE.test(src)) + .map(({ file }) => file) + if (offenders.length === 0) { + return [{ label: "favicon URLs are signed by degoog", status: "pass" }] + } + return offenders.map((file) => ({ + label: `${file} favicon URLs`, + status: "fail", + detail: "hand-built /api/proxy/favicon URL gets a 403 - sign it on the server with ctx.signFaviconUrl(url)", + })) +} + +export const usesFaviconSigner = async (dir: string): Promise => + (await readPluginSources(dir)).some(({ src }) => FAVICON_SIGNER_RE.test(src)) + +const ENTRY_FILES = ["index.ts", "index.js"] +const CHALLENGES_RE = /(? => { + for (const file of ENTRY_FILES) { + try { + return await readFile(join(dir, file), "utf-8") + } catch { + continue + } + } + return null +} + +export const checkChallengesValue = (raw: string): CheckResult => { + const label = '"challenges" is valid' + if (!raw.startsWith("[")) { + return { label, status: "fail", detail: 'must be an array, e.g. ["anubis"]' } + } + const items = raw + .slice(1, -1) + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + const unknown = items.filter((item) => { + const kind = STRING_LITERAL_RE.exec(item)?.[2] + return kind === undefined || !ENGINE_CHALLENGES.includes(kind) + }) + if (unknown.length > 0) { + return { + label, + status: "fail", + detail: `unknown kind ${unknown.join(", ")} - supported: ${ENGINE_CHALLENGES.map((k) => `"${k}"`).join(", ")}`, + } + } + return { label, status: "pass" } +} + +export const checkHandlesChallengesValue = (raw: string): CheckResult => { + const label = '"handlesChallenges" is valid' + if (raw === "true" || raw === "false") return { label, status: "pass" } + return { label, status: "fail", detail: `must be true or false, got ${raw}` } +} + +export const declaresChallenges = async (dir: string): Promise => { + const src = await readEntrySource(dir) + return src !== null && CHALLENGES_RE.test(src) +} + +const checkChallengeProps = async ( + dir: string, + kind: ExtensionKind, +): Promise => { + const src = await readEntrySource(dir) + if (src === null) return [] + if (kind === "engine") { + const raw = CHALLENGES_RE.exec(src)?.[1] + return raw === undefined ? [] : [checkChallengesValue(raw)] + } + if (kind === "transport") { + const raw = HANDLES_CHALLENGES_RE.exec(src)?.[1] + return raw === undefined ? [] : [checkHandlesChallengesValue(raw)] + } + return [] +} + const runThemeChecks = async (dir: string, doFix: boolean): Promise => { const results: CheckResult[] = [] let failed = false @@ -287,10 +401,19 @@ export const runChecks = async ( if (authorRes.failed) failed = true } + if (kind === "engine" || kind === "transport") { + const challengeChecks = await checkChallengeProps(dir, kind) + results.push(...challengeChecks) + if (challengeChecks.some((c) => c.status === "fail")) failed = true + } + if (kind === "plugin") { const routeChecks = await checkRouteConventions(dir, doFix) results.push(...routeChecks) if (routeChecks.some((c) => c.status === "fail")) failed = true + const faviconChecks = await checkFaviconUrls(dir) + results.push(...faviconChecks) + if (faviconChecks.some((c) => c.status === "fail")) failed = true } return { results, failed } diff --git a/src/commands/doctor/detect.ts b/src/commands/doctor/detect.ts index 2d27adc..79c1a76 100644 --- a/src/commands/doctor/detect.ts +++ b/src/commands/doctor/detect.ts @@ -1,7 +1,7 @@ import { join, basename, dirname } from "node:path" -import { exists } from "../../utils/files.ts" -import { readStoreManifest } from "../../utils/store.ts" -import type { ExtensionKind, Target } from "./types.ts" +import { exists } from "../../utils/files" +import { readStoreManifest } from "../../utils/store" +import type { ExtensionKind, Target } from "./types" export { exists } @@ -11,6 +11,7 @@ const KIND_FROM_FOLDER: Record = { engines: "engine", transports: "transport", autocomplete: "autocomplete", + favicon: "favicon", } const detectKindFromParent = (dir: string): ExtensionKind => { diff --git a/src/commands/doctor/index.ts b/src/commands/doctor/index.ts index 961229f..13cb3a4 100644 --- a/src/commands/doctor/index.ts +++ b/src/commands/doctor/index.ts @@ -1,26 +1,24 @@ import { resolve } from "node:path" -import * as p from "@clack/prompts" -import { t } from "../../utils/theme.ts" -import { logger } from "../../utils/logger.ts" -import { promptText, promptConfirm } from "../../utils/prompts.ts" -import { exists, detectTarget } from "./detect.ts" -import { runChecks } from "./checks.ts" -import { runStoreChecks } from "./store.ts" -import { printResults } from "./report.ts" -import type { StoreRunSummary } from "./types.ts" +import { t } from "../../utils/theme" +import { logger } from "../../utils/logger" +import { argv, positionalsAfter } from "../../utils/argv" +import { ui } from "../../utils/ui" +import { promptText, promptConfirm } from "../../utils/prompts" +import { exists, detectTarget } from "./detect" +import { runChecks } from "./checks" +import { runStoreChecks } from "./store" +import { printResults } from "./report" +import type { StoreRunSummary } from "./types" type ParsedArgs = { pathArg: string | undefined doFix: boolean } -const parseArgs = (): ParsedArgs => { - const argStart = process.argv[2] === "doctor" ? 3 : 2 - const args = process.argv.slice(argStart) - const doFix = args.includes("--fix") - const pathArg = args.find((a) => !a.startsWith("-")) - return { pathArg, doFix } -} +const parseArgs = (): ParsedArgs => ({ + pathArg: positionalsAfter("doctor")[0], + doFix: argv.fix, +}) const summaryLine = (s: StoreRunSummary): string => [ @@ -34,7 +32,7 @@ const summaryLine = (s: StoreRunSummary): string => export const doctorCmd = async (): Promise => { const { pathArg, doFix: fixFlag } = parseArgs() - p.intro(t.brand("degoog doctor")) + ui.intro(t.brand("degoog doctor")) const cwd = process.cwd() const targetDir = pathArg @@ -56,13 +54,13 @@ export const doctorCmd = async (): Promise => { logger.error( `not a recognisable extension or store: ${targetDir}\n` + " - an extension has theme.json or index.{ts,js}\n" + - " - a store has a package.json with plugins/themes/engines/transports/autocomplete arrays", + " - a store has a package.json with plugins/themes/engines/transports/autocomplete/favicon arrays", ) process.exit(1) } if (target.kind === "store") { - p.log.info(t.muted(`checking store at ${targetDir}`)) + ui.info(t.muted(`checking store at ${targetDir}`)) const summary = await runStoreChecks( target.dir, target.manifestPath, @@ -71,25 +69,25 @@ export const doctorCmd = async (): Promise => { ) console.log("") if (summary.failed > 0) { - p.outro(t.danger(`store has issues - ${summaryLine(summary)}`)) + ui.outro(t.danger(`store has issues - ${summaryLine(summary)}`)) process.exit(1) } - p.outro(t.success(`store looks good - ${summaryLine(summary)}`)) + ui.outro(t.success(`store looks good - ${summaryLine(summary)}`)) return } - p.log.info(t.muted(`checking extension at ${targetDir}`)) + ui.info(t.muted(`checking extension at ${targetDir}`)) const { results, failed } = await runChecks(target.dir, applyFix, target.extKind) printResults(results) console.log("") if (failed && !applyFix) { - p.outro(t.danger("issues found - re-run and choose to apply fixes, or pass --fix")) + ui.outro(t.danger("issues found - re-run and choose to apply fixes, or pass --fix")) process.exit(1) } if (failed) { - p.outro(t.warning("some issues could not be auto-fixed (see WARN above)")) + ui.outro(t.warning("some issues could not be auto-fixed (see WARN above)")) process.exit(1) } - p.outro(t.success("all checks passed")) + ui.outro(t.success("all checks passed")) } diff --git a/src/commands/doctor/report.ts b/src/commands/doctor/report.ts index d9fd8a9..3b7d25a 100644 --- a/src/commands/doctor/report.ts +++ b/src/commands/doctor/report.ts @@ -1,5 +1,5 @@ -import { t } from "../../utils/theme.ts" -import type { CheckResult, CheckStatus } from "./types.ts" +import { t } from "../../utils/theme" +import type { CheckResult, CheckStatus } from "./types" const PASS = t.success("PASS") const FAIL = t.danger("FAIL") diff --git a/src/commands/doctor/store-orphans.ts b/src/commands/doctor/store-orphans.ts index 8dc4696..fbebc72 100644 --- a/src/commands/doctor/store-orphans.ts +++ b/src/commands/doctor/store-orphans.ts @@ -1,11 +1,13 @@ import { readdir, stat } from "node:fs/promises" import { join, basename } from "node:path" -import * as p from "@clack/prompts" -import { t } from "../../utils/theme.ts" -import { promptConfirm, promptSelect } from "../../utils/prompts.ts" -import { exists } from "./detect.ts" -import type { ExtensionCategory, StoreEntry } from "./types.ts" -import { PLUGIN_TYPES, type PluginType } from "./types.ts" +import { t } from "../../utils/theme" +import { ui } from "../../utils/ui" +import { argv } from "../../utils/argv" +import { isHeadless } from "../../utils/headless" +import { promptConfirm, promptSelect } from "../../utils/prompts" +import { exists } from "./detect" +import type { ExtensionCategory, StoreEntry } from "./types" +import { EXTENSION_CATEGORIES, PLUGIN_TYPES, type PluginType } from "./types" export type Orphan = { category: ExtensionCategory @@ -55,8 +57,7 @@ export const collectOrphans = async ( manifest: { [K in ExtensionCategory]?: StoreEntry[] }, ): Promise => { const orphans: Orphan[] = [] - const categories: ExtensionCategory[] = ["plugins", "themes", "engines", "transports", "autocomplete"] - for (const category of categories) { + for (const category of EXTENSION_CATEGORIES) { const entries = manifest[category] ?? [] const found = await listOrphansInCategory(storeDir, category, entries) for (const relPath of found) orphans.push({ category, relPath }) @@ -81,7 +82,22 @@ export type OrphanResolution = { skipped: number } -export const resolveOrphans = async (orphans: Orphan[]): Promise => { +const isPluginType = (v: string | undefined): v is PluginType => + (PLUGIN_TYPES as readonly string[]).includes(v ?? "") + +const pickPluginType = async (orphan: Orphan): Promise => { + if (isPluginType(argv.pluginType)) return argv.pluginType + if (isHeadless) return undefined + return promptSelect( + t.muted(`plugin type for "${orphan.relPath}"`), + PLUGIN_TYPES.map((v) => ({ value: v, label: v })), + ) +} + +export const resolveOrphans = async ( + orphans: Orphan[], + doFix: boolean, +): Promise => { const result: OrphanResolution = { byCategory: new Map(), added: 0, @@ -89,15 +105,17 @@ export const resolveOrphans = async (orphans: Orphan[]): Promise( - t.muted(`plugin type for "${o.relPath}"`), - PLUGIN_TYPES.map((v) => ({ value: v, label: v })), - ) - type = picked + type = await pickPluginType(o) + if (!type) { + console.log(` ${t.warning("WARN")} ${o.relPath} ${t.muted(`- skipped, pass --plugin-type <${PLUGIN_TYPES.join("|")}> to register it`)}`) + result.skipped++ + continue + } } const entry = buildEntry(o, type) const list = result.byCategory.get(o.category) ?? [] diff --git a/src/commands/doctor/store-validate.ts b/src/commands/doctor/store-validate.ts index 4e4e8fd..b236ecb 100644 --- a/src/commands/doctor/store-validate.ts +++ b/src/commands/doctor/store-validate.ts @@ -1,7 +1,14 @@ import { stat } from "node:fs/promises" import { join } from "node:path" -import { exists } from "./detect.ts" -import type { CheckResult, ExtensionCategory, StoreEntry, StoreManifest } from "./types.ts" +import { exists } from "./detect" +import { + CHALLENGES_MIN_DEGOOG_VERSION, + FAVICON_SIGNER_MIN_DEGOOG_VERSION, + type CheckResult, + type ExtensionCategory, + type StoreEntry, + type StoreManifest, +} from "./types" export const validateTopLevel = (manifest: StoreManifest): CheckResult[] => { const results: CheckResult[] = [] @@ -122,3 +129,37 @@ export const validatePathExistence = async ( return { results, toRemove } } + +const versionParts = (version: string): number[] => + (version.trim().replace(/^v/, "").split("-")[0] ?? "") + .split(".") + .map((part) => Number.parseInt(part, 10) || 0) + +export const isVersionAtLeast = (version: string, minimum: string): boolean => { + const have = versionParts(version) + const want = versionParts(minimum) + for (let i = 0; i < Math.max(have.length, want.length); i++) { + const a = have[i] ?? 0 + const b = want[i] ?? 0 + if (a !== b) return a > b + } + return true +} + +const validateMinVersion = (entry: StoreEntry, minimum: string, who: string): CheckResult => { + const label = `package.json minDegoogVersion >= ${minimum}` + const v = entry.minDegoogVersion + if (typeof v !== "string" || v.trim() === "") { + return { label, status: "warn", detail: `missing - ${who} need degoog ${minimum} or newer` } + } + if (!isVersionAtLeast(v, minimum)) { + return { label, status: "warn", detail: `set to ${v} - ${who} need degoog ${minimum} or newer` } + } + return { label, status: "pass" } +} + +export const validateChallengesMinVersion = (entry: StoreEntry): CheckResult => + validateMinVersion(entry, CHALLENGES_MIN_DEGOOG_VERSION, 'engines that set "challenges"') + +export const validateFaviconSignerMinVersion = (entry: StoreEntry): CheckResult => + validateMinVersion(entry, FAVICON_SIGNER_MIN_DEGOOG_VERSION, "plugins that call signFaviconUrl") diff --git a/src/commands/doctor/store.ts b/src/commands/doctor/store.ts index cfcac4f..c6948cc 100644 --- a/src/commands/doctor/store.ts +++ b/src/commands/doctor/store.ts @@ -1,17 +1,19 @@ import { writeFile } from "node:fs/promises" import { join, basename } from "node:path" -import * as p from "@clack/prompts" -import { t } from "../../utils/theme.ts" -import { exists } from "./detect.ts" -import { runChecks } from "./checks.ts" -import { printResults } from "./report.ts" +import { t } from "../../utils/theme" +import { ui } from "../../utils/ui" +import { exists } from "./detect" +import { declaresChallenges, runChecks, usesFaviconSigner } from "./checks" +import { printResults } from "./report" import { validateTopLevel, validateEntry, validatePathExistence, findDuplicateEntries, -} from "./store-validate.ts" -import { collectOrphans, resolveOrphans } from "./store-orphans.ts" + validateChallengesMinVersion, + validateFaviconSignerMinVersion, +} from "./store-validate" +import { collectOrphans, resolveOrphans } from "./store-orphans" import { EXTENSION_CATEGORIES, CATEGORY_TO_KIND, @@ -20,7 +22,7 @@ import { type StoreEntry, type StoreManifest, type StoreRunSummary, -} from "./types.ts" +} from "./types" const writeManifest = async (path: string, manifest: StoreManifest): Promise => { await writeFile(path, JSON.stringify(manifest, null, 2) + "\n", "utf-8") @@ -35,7 +37,7 @@ const tallyResults = (results: CheckResult[], summary: StoreRunSummary): void => } const sectionHeader = (label: string): void => { - p.log.step(t.brand(label)) + ui.step(t.brand(label)) } const collectEntryIssues = async ( @@ -85,12 +87,13 @@ const processCategory = async ( const handleOrphans = async ( storeDir: string, manifest: StoreManifest, + doFix: boolean, summary: StoreRunSummary, ): Promise => { const orphans = await collectOrphans(storeDir, manifest) if (orphans.length === 0) return false - const resolution = await resolveOrphans(orphans) + const resolution = await resolveOrphans(orphans, doFix) summary.fixed += resolution.added summary.failed += resolution.skipped @@ -122,6 +125,12 @@ const runExtensionChecks = async ( sectionHeader(`${category}/${basename(entry.path)}`) const { results } = await runChecks(full, doFix, kind) + if (kind === "engine" && (await declaresChallenges(full))) { + results.push(validateChallengesMinVersion(entry)) + } + if (kind === "plugin" && (await usesFaviconSigner(full))) { + results.push(validateFaviconSignerMinVersion(entry)) + } printResults(results) tallyResults(results, summary) } @@ -147,7 +156,7 @@ export const runStoreChecks = async ( if (changed) manifestMutated = true } - const addedOrphans = await handleOrphans(storeDir, manifest, summary) + const addedOrphans = await handleOrphans(storeDir, manifest, doFix, summary) if (addedOrphans) manifestMutated = true if (manifestMutated) { diff --git a/src/commands/doctor/types.ts b/src/commands/doctor/types.ts index 3f3edcd..f53845e 100644 --- a/src/commands/doctor/types.ts +++ b/src/commands/doctor/types.ts @@ -6,9 +6,9 @@ export type CheckResult = { detail?: string | undefined } -export type ExtensionKind = "theme" | "plugin" | "engine" | "transport" | "autocomplete" | "unknown" +export type ExtensionKind = "theme" | "plugin" | "engine" | "transport" | "autocomplete" | "favicon" | "unknown" -export type ExtensionCategory = "themes" | "plugins" | "engines" | "transports" | "autocomplete" +export type ExtensionCategory = "themes" | "plugins" | "engines" | "transports" | "autocomplete" | "favicon" export const EXTENSION_CATEGORIES: ExtensionCategory[] = [ "plugins", @@ -16,6 +16,7 @@ export const EXTENSION_CATEGORIES: ExtensionCategory[] = [ "engines", "transports", "autocomplete", + "favicon", ] export const CATEGORY_TO_KIND: Record> = { @@ -24,12 +25,19 @@ export const CATEGORY_TO_KIND: Record export type Target = diff --git a/src/commands/login.ts b/src/commands/login.ts index 33bd8c5..c20a0bb 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,7 +1,9 @@ import * as p from "@clack/prompts" -import { loadConfig, saveConfig } from "../config/store.ts" -import { logger } from "../utils/logger.ts" -import type { Config } from "../types/index.ts" +import { loadConfig, saveConfig } from "../config/store" +import { logger } from "../utils/logger" +import { argv } from "../utils/argv" +import { isHeadless, fail } from "../utils/headless" +import type { Config } from "../types" const isValidUrl = (val: string) => { if (!val) return undefined @@ -14,13 +16,40 @@ const isValidUrl = (val: string) => { } } +const loginHeadless = async (existing: Config) => { + const instanceUrl = argv.instanceUrl ?? process.env.DEGOOG_INSTANCE_URL + const apiKey = argv.apiKey ?? process.env.DEGOOG_API_KEY + const username = argv.username + const website = argv.website + + if (!instanceUrl && !apiKey && !username && !website) { + return fail("nothing to save, pass at least one of --instance-url, --api-key, --username, --website") + } + + for (const [flag, val] of [["--instance-url", instanceUrl], ["--website", website]] as const) { + const err = val ? isValidUrl(val) : undefined + if (err) return fail(`${flag}: ${err}`) + } + + const cfg: Config = { ...existing } + if (instanceUrl) cfg.instanceUrl = instanceUrl + if (apiKey) cfg.apiKey = apiKey + if (username) cfg.username = username + if (website) cfg.website = website + + await saveConfig(cfg) + logger.success("config saved") +} + export const loginCmd = async () => { const existing = await loadConfig() + if (isHeadless) return loginHeadless(existing) + const instanceUrl = await p.text({ message: "degoog instance URL", placeholder: "http://localhost:4444", - initialValue: existing.instanceUrl ?? "", + initialValue: argv.instanceUrl ?? existing.instanceUrl ?? "", validate: isValidUrl, }) if (p.isCancel(instanceUrl)) return @@ -38,21 +67,22 @@ export const loginCmd = async () => { const username = await p.text({ message: "Your name (used in author.json when scaffolding extensions)", placeholder: "Jane Dev", - initialValue: existing.username ?? "", + initialValue: argv.username ?? existing.username ?? "", }) if (p.isCancel(username)) return const website = await p.text({ message: "Your website or GitHub URL (optional)", placeholder: "https://github.com/username", - initialValue: existing.website ?? "", + initialValue: argv.website ?? existing.website ?? "", validate: isValidUrl, }) if (p.isCancel(website)) return const cfg: Config = { ...existing } if (instanceUrl) cfg.instanceUrl = instanceUrl - if (apiKey) cfg.apiKey = apiKey + const key = apiKey || argv.apiKey + if (key) cfg.apiKey = key if (username) cfg.username = username if (website) cfg.website = website else if (!website && existing.website) cfg.website = existing.website diff --git a/src/commands/search.ts b/src/commands/search.ts index c05702e..fcd8e66 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,7 +1,9 @@ import * as p from "@clack/prompts"; -import { loadConfig } from "../config/store.ts"; -import { apiGet } from "../utils/api.ts"; -import { t } from "../utils/theme.ts"; +import { loadConfig } from "../config/store"; +import { apiGet } from "../utils/api"; +import { t } from "../utils/theme"; +import { argv, positionalsAfter } from "../utils/argv"; +import { isHeadless, fail } from "../utils/headless"; type Result = { title: string; @@ -19,11 +21,17 @@ type SearchResp = { const PAGE_SIZE = 10; const parseSearchQuery = (): string | undefined => { - const argStart = process.argv[2] === "search" ? 3 : 2; - const args = process.argv.slice(argStart).filter((a) => !a.startsWith("-")); + const args = positionalsAfter("search"); return args.length ? args.join(" ") : undefined; }; +const parseLimit = (): number | undefined => { + if (argv.limit === undefined) return undefined; + const n = Number(argv.limit); + if (!Number.isInteger(n) || n < 1) fail(`invalid --limit "${argv.limit}"`); + return n; +}; + const openUrl = (url: string) => { const cmd = process.platform === "darwin" ? "open" : "xdg-open"; try { @@ -56,25 +64,22 @@ const truncate = (s: string, max: number) => const totalPages = (count: number) => Math.max(1, Math.ceil(count / PAGE_SIZE)); -const renderResults = ( +const renderResultList = ( data: SearchResp, - page: number, + results: Result[], + start: number, + header: string, showRelated: boolean, ) => { const cols = process.stdout.columns ?? 100; const maxWidth = Math.min(cols - 8, 90); const pad = " "; - const pages = totalPages(data.results.length); - const start = (page - 1) * PAGE_SIZE; - const pageResults = data.results.slice(start, start + PAGE_SIZE); console.log(); - console.log( - ` ${t.muted(`page ${page}/${pages} · ${data.results.length} results · ${data.totalTime}ms`)}`, - ); + console.log(` ${t.muted(header)}`); console.log(); - pageResults.forEach((r, i) => { + results.forEach((r, i) => { const num = t.dim(` ${String(start + i + 1).padStart(2)} `); console.log(`${num}${t.bold(t.brand(truncate(r.title, maxWidth)))}`); console.log(`${pad}${t.success(truncate(r.url, maxWidth))}`); @@ -101,6 +106,22 @@ const renderResults = ( } }; +const renderResults = ( + data: SearchResp, + page: number, + showRelated: boolean, +) => { + const pages = totalPages(data.results.length); + const start = (page - 1) * PAGE_SIZE; + renderResultList( + data, + data.results.slice(start, start + PAGE_SIZE), + start, + `page ${page}/${pages} · ${data.results.length} results · ${data.totalTime}ms`, + showRelated, + ); +}; + const doSearch = async ( query: string, config: Awaited>, @@ -183,9 +204,51 @@ const browseResults = async (data: SearchResp): Promise<"again" | "back"> => { } }; +const searchHeadless = async ( + query: string | undefined, + config: Awaited>, +) => { + if (!config.instanceUrl) { + return fail("no instance configured - run: degoog-cli login --instance-url "); + } + if (!query) return fail("a query is required: degoog-cli search "); + + const limit = parseLimit(); + const result = await apiGet( + config, + `/api/search?q=${encodeURIComponent(query)}`, + ); + if (!result.ok) return fail(result.error); + + const data = result.data; + const results = limit ? data.results.slice(0, limit) : data.results; + + if (argv.json) { + console.log(JSON.stringify({ ...data, results }, null, 2)); + return; + } + + if (!results.length) { + console.log(t.muted("no results found")); + return; + } + + renderResultList( + data, + results, + 0, + `${results.length} of ${data.results.length} results · ${data.totalTime}ms`, + true, + ); +}; + export const searchCmd = async () => { const config = await loadConfig(); + if (isHeadless || argv.json) { + return searchHeadless(parseSearchQuery(), config); + } + if (!config.instanceUrl) { p.log.warn(t.warning("no instance configured - run Login / Setup first")); return; diff --git a/src/config/store.ts b/src/config/store.ts index d562eb9..8786290 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -1,8 +1,8 @@ import { readFile, writeFile, mkdir } from "node:fs/promises" import { join, dirname } from "node:path" import { homedir } from "node:os" -import { logger } from "../utils/logger.ts" -import type { Config } from "../types/index.ts" +import { logger } from "../utils/logger" +import type { Config } from "../types" const CONFIG_BASE = process.env.DEGOOG_CONFIG_HOME ?? join(homedir(), ".config", "degoog") const CONFIG_PATH = join(CONFIG_BASE, "config.json") diff --git a/src/generators/autocomplete.ts b/src/generators/autocomplete.ts index 5adb877..49de386 100644 --- a/src/generators/autocomplete.ts +++ b/src/generators/autocomplete.ts @@ -1,5 +1,5 @@ -import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files.ts" -import type { GeneratorCtx } from "../types/index.ts" +import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files" +import type { GeneratorCtx } from "../types" const indexTpl = (name: string) => `type RichSuggestion = { description?: string diff --git a/src/generators/engine.ts b/src/generators/engine.ts index 065edee..41e0628 100644 --- a/src/generators/engine.ts +++ b/src/generators/engine.ts @@ -1,7 +1,7 @@ -import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files.ts"; -import type { GeneratorCtx } from "../types/index.ts"; +import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files"; +import type { GeneratorCtx } from "../types"; -const indexTpl = (name: string) => `export const type = "web" // string or array - e.g. "web", "books", ["web", "any-type"] +const indexTpl = (name: string, challenges: boolean) => `export const type = "web" // string or array - e.g. "web", "books", ["web", "any-type"] // filters example (image engines only): // Declare which image filter groups/values this engine actually supports. @@ -24,7 +24,7 @@ const indexTpl = (name: string) => `export const type = "web" // string or array export const engine = { name: "${name}", bangShortcut: "${name}", - +${challenges ? ` challenges: ["anubis"],\n` : ""} // settingsSchema: [ // { key: "apiKey", label: "API Key", type: "password", required: true }, // ], @@ -83,7 +83,7 @@ export const engine = { export const generateEngine = async (ctx: GeneratorCtx) => scaffoldDir(ctx.outDir, ctx.name, { - "index.ts": indexTpl(ctx.name), + "index.ts": indexTpl(ctx.name, ctx.challenges ?? false), "README.md": readmeTpl(ctx.name, "A custom search engine for degoog."), "author.json": authorJsonTpl(ctx.config), }); diff --git a/src/generators/favicon.ts b/src/generators/favicon.ts new file mode 100644 index 0000000..a70566a --- /dev/null +++ b/src/generators/favicon.ts @@ -0,0 +1,48 @@ +import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files" +import type { GeneratorCtx } from "../types" + +const toClassName = (name: string): string => + name + .split("-") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join("") + "FaviconProvider" + +const indexTpl = (name: string) => `export default class ${toClassName(name)} { + name = "${name}"; + description = "Favicons for search results."; + + settingsSchema = [ + { + key: "endpoint", + label: "Endpoint", + type: "url", + default: "https://icons.example.com", + description: "Base URL of the favicon service.", + }, + ]; + + endpoint = "https://icons.example.com"; + + configure(settings) { + if (typeof settings.endpoint === "string" && settings.endpoint.trim()) { + this.endpoint = settings.endpoint.trim().replace(/\\/+$/, ""); + } + } + + async getFavicon(host, context) { + if (!host) return null; + const size = context?.size ?? 32; + return { + url: \`\${this.endpoint}/\${encodeURIComponent(host)}.png?size=\${size}\`, + }; + } +} +` + +export const generateFavicon = async (ctx: GeneratorCtx): Promise => + scaffoldDir(ctx.outDir, ctx.name, { + "index.js": indexTpl(ctx.name), + "README.md": readmeTpl(ctx.name, "A custom favicon provider for degoog."), + "author.json": authorJsonTpl(ctx.config), + }) diff --git a/src/generators/plugin-bang.ts b/src/generators/plugin-bang.ts index 332745c..869944b 100644 --- a/src/generators/plugin-bang.ts +++ b/src/generators/plugin-bang.ts @@ -1,5 +1,5 @@ -import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files.ts" -import type { GeneratorCtx } from "../types/index.ts" +import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files" +import type { GeneratorCtx } from "../types" const indexTpl = (name: string) => `// If this file also exports other hooks (slot, interceptor, tab, etc.) and // you want a single settings card for all of them, add a top-level plugin identity: @@ -54,6 +54,8 @@ export default { // ctx.routeUrl // (path) => /api/plugin// // ctx.dir // absolute path to plugin folder (do NOT derive route IDs from it) // ctx.readFile // async file reader + // ctx.signProxyUrl // (url) => signed /api/proxy/image URL for an external image + // ctx.signFaviconUrl // (url) => signed /api/proxy/favicon URL for the url's host, "" when no favicon provider is enabled // }, async execute(args: string, context: { diff --git a/src/generators/plugin-intercept.ts b/src/generators/plugin-intercept.ts index 3faf9c6..dd272a8 100644 --- a/src/generators/plugin-intercept.ts +++ b/src/generators/plugin-intercept.ts @@ -1,5 +1,5 @@ -import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files.ts" -import type { GeneratorCtx } from "../types/index.ts" +import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files" +import type { GeneratorCtx } from "../types" const indexTpl = (name: string) => `// If this file also exports a slot or command and you want a single settings // card for all of them, add a top-level plugin identity export: @@ -30,6 +30,8 @@ export const interceptor = { // ctx.routeUrl // (path) => /api/plugin// // ctx.dir // absolute path to plugin folder (do NOT derive route IDs from it) // ctx.readFile // async file reader + // ctx.signProxyUrl // (url) => signed /api/proxy/image URL for an external image + // ctx.signFaviconUrl // (url) => signed /api/proxy/favicon URL for the url's host, "" when no favicon provider is enabled // }, async intercept(query: string, context: { diff --git a/src/generators/plugin-mid.ts b/src/generators/plugin-mid.ts index 720800a..016578f 100644 --- a/src/generators/plugin-mid.ts +++ b/src/generators/plugin-mid.ts @@ -1,5 +1,5 @@ -import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files.ts" -import type { GeneratorCtx } from "../types/index.ts" +import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files" +import type { GeneratorCtx } from "../types" const indexTpl = (name: string) => `// If this file also exports other hooks (slot, interceptor, command, etc.) and // you want a single settings card for all of them, add a top-level plugin identity: diff --git a/src/generators/plugin-route.ts b/src/generators/plugin-route.ts index fc78310..ed14c95 100644 --- a/src/generators/plugin-route.ts +++ b/src/generators/plugin-route.ts @@ -1,5 +1,5 @@ -import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files.ts" -import type { GeneratorCtx } from "../types/index.ts" +import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files" +import type { GeneratorCtx } from "../types" const indexTpl = () => `// Routes are served at /api/plugin//, where is the // installed folder ID assigned by degoog (e.g. --). Never diff --git a/src/generators/plugin-slot.ts b/src/generators/plugin-slot.ts index c9b6bcd..6a3dd9e 100644 --- a/src/generators/plugin-slot.ts +++ b/src/generators/plugin-slot.ts @@ -1,5 +1,5 @@ -import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files.ts" -import type { GeneratorCtx } from "../types/index.ts" +import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files" +import type { GeneratorCtx } from "../types" const indexTpl = (name: string) => `// If this file also exports other hooks (interceptor, command, tab, etc.) and // you want a single settings card for all of them, add a top-level plugin identity: @@ -22,7 +22,7 @@ export const slot = { /** * Set to true if your execute() returns HTML that causes the browser * to fetch external URLs (images, scripts, etc.). - * Set to false if all network access goes through context.fetch / signProxyUrl. + * Set to false if all network access goes through context.fetch / signProxyUrl / signFaviconUrl. * Leaving this unset shows an ambiguous badge in the degoog settings page. */ isClientExposed: false, @@ -43,6 +43,8 @@ export const slot = { // ctx.routeUrl // (path) => /api/plugin// // ctx.dir // absolute path to plugin folder (do NOT derive route IDs from it) // ctx.readFile // async file reader + // ctx.signProxyUrl // (url) => signed /api/proxy/image URL for an external image + // ctx.signFaviconUrl // (url) => signed /api/proxy/favicon URL for the url's host, "" when no favicon provider is enabled // }, async trigger(query: string): Promise { @@ -54,6 +56,7 @@ export const slot = { dir: string readFile: (filename: string) => Promise signProxyUrl: (url: string) => string + signFaviconUrl: (url: string) => string fetch: typeof fetch useCache: (namespace: string, defaultTtlMs: number) => { get: (key: string) => Promise diff --git a/src/generators/plugin-tab.ts b/src/generators/plugin-tab.ts index 269a84f..87e4cdb 100644 --- a/src/generators/plugin-tab.ts +++ b/src/generators/plugin-tab.ts @@ -1,5 +1,5 @@ -import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files.ts" -import type { GeneratorCtx } from "../types/index.ts" +import { scaffoldDir, authorJsonTpl, readmeTpl } from "../utils/files" +import type { GeneratorCtx } from "../types" const indexTpl = (name: string) => `// If this file also exports other hooks (slot, interceptor, command, etc.) and // you want a single settings card for all of them, add a top-level plugin identity: @@ -42,6 +42,8 @@ export const tab = { // ctx.routeUrl // (path) => /api/plugin// // ctx.dir // absolute path to plugin folder (do NOT derive route IDs from it) // ctx.readFile // async file reader + // ctx.signProxyUrl // (url) => signed /api/proxy/image URL for an external image + // ctx.signFaviconUrl // (url) => signed /api/proxy/favicon URL for the url's host, "" when no favicon provider is enabled // }, // dependencies: [], diff --git a/src/generators/theme.ts b/src/generators/theme.ts index 9f84e77..4585f6e 100644 --- a/src/generators/theme.ts +++ b/src/generators/theme.ts @@ -1,7 +1,8 @@ import * as p from "@clack/prompts" -import { scaffoldDir, authorJsonTpl } from "../utils/files.ts" -import { t } from "../utils/theme.ts" -import type { GeneratorCtx } from "../types/index.ts" +import { scaffoldDir, authorJsonTpl } from "../utils/files" +import { t } from "../utils/theme" +import { isHeadless } from "../utils/headless" +import type { GeneratorCtx } from "../types" import { layoutHtml, indexHtml, searchHtml, html404, gandalfHtml, robotsHtml, @@ -9,7 +10,7 @@ import { srchHeaderHtml, srchTabsHtml, srchResultHtml, srchImageCardHtml, srchVideoCardHtml, srchMediaPreviewHtml, srchLightboxHtml, srchAtAGlanceHtml, -} from "../templates/theme-assets.ts" +} from "../templates/theme-assets" import { HTMLBundle } from "bun" export const HTML_PAGE_KEYS = ["layout", "index", "search", "404", "gandalf", "robots-takeover"] as const @@ -187,6 +188,33 @@ The file contains blocks for \`:root\` (light), \`[data-theme="dark"]\`, Templates listed under the \`templates\` key in \`theme.json\` replace individual UI sections without touching anything else. Only include keys you want to change. +## The no-JS page + +If the instance serves the script-free page at \`/nojs\`, your theme already +covers it. That page loads the same \`html\` shells and the same \`templates\` +entries listed above and fills them in on the server, so anything you restyle +here shows up there. + +Only override a section that genuinely cannot work without scripts. Drop a +\`nojs/.html\` file in your theme folder, no \`theme.json\` entry needed. +Each name resolves through your \`nojs/\` folder, then degoog's, then your own +template for that name, then degoog's. + +\`\`\` +nojs/ + logo.html tabs.html pagination.html +\`\`\` + +Those three are the ones degoog itself overrides: a monospace logo, a filter +form of real \`