diff --git a/AGENTS.md b/AGENTS.md index 5c66124b..3734a96b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Before starting new work, review [docs/concepts.md](docs/concepts.md) for canoni - see `package.json` - Releases go through the `/release` skill. Its tag push runs `.github/workflows/release.yml`, which publishes to npm. -Space names (e.g. `personal`, `politics`) are resolved via a config file - `$SCTX_CONFIG`, `$XDG_CONFIG_HOME/structured-context/config.json`, `--config ` param, or the nearest `.sctx.json` walking up from the working directory +Space names (e.g. `personal`, `politics`) are resolved via a config file - `$SCTX_CONFIG`, `$XDG_CONFIG_HOME/structured-context/config.json`, `--config ` param, or the nearest `.sctx.json` or `.sctx/config.json` walking up from the working directory. Relative paths resolve from the config root, and local plugins, schemas and map views sit in `.sctx/` (`src/config/layout.ts`) The lefthook pre-commit hook syncs the plugin version (below), then runs biome (`--write`, re-staged) and the typecheck. diff --git a/README.md b/README.md index 20c3ac15..ba319e49 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ See [docs/concepts.md](docs/concepts.md) (`sctx docs concepts`) for terminology: ## Configuration -The config file maps space names to paths and schemas, with optional plugin settings. It is found from `--config`, then `$SCTX_CONFIG`, then the nearest `.sctx.json` walking up from the working directory, then `$XDG_CONFIG_HOME/structured-context/config.json` (default `~/.config/...`). Paths resolve relative to the config file, and `includeSpacesFrom` pulls in spaces from other config files. +The config file maps space names to paths and schemas, with optional plugin settings. It is found from `--config`, then `$SCTX_CONFIG`, then the nearest `.sctx.json` or `.sctx/config.json` walking up from the working directory, then `$XDG_CONFIG_HOME/structured-context/config.json` (default `~/.config/...`). Paths resolve from the folder holding `.sctx.json` or `.sctx/`. Local plugins, schemas and map views go in `.sctx/`, and `includeSpacesFrom` pulls in spaces from other config files. See `config.example.json` and [docs/config.md](docs/config.md) (`sctx docs config`) for the full reference, including `fieldMap`, `typeInference`, templates, filter views and plugin loading. diff --git a/docs/api.md b/docs/api.md index b205431d..7fac5cfe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -109,12 +109,19 @@ const config: Config = { const graph = await loadSpaceGraph('my-space', config, { configDir: process.cwd() }); ``` -Relative paths in a space's plugin config are resolved against the directory of the config file that -defined the space. A hand-assembled `Config` has no such file, so you must pass an explicit +Relative paths in a space's plugin config are resolved against the [config root](config.md#config-file-location) +of the file that defined the space. A hand-assembled `Config` has no such file, so you must pass an explicit `configDir` to anchor them (`createSpaceContext`, `loadSpaceGraph`, and `validateFile` all accept it). Omitting it throws rather than silently guessing the working directory — prefer absolute paths so the anchor never matters. +## Resources + +`resolveConfigPath()` returns the config's `root` along with its path. `resourceDir(root, name)` returns the +folder holding one kind of resource for that root (such as `plugins` or `map-views`), or `undefined` when +there is none. It follows the [resources folder](config.md#resources-folder) layout, so a tool that adds its +own kind of resource finds it the same way. + ## Plugin render hooks Plugins declare render formats via `render.formats` and implement `render(context, graph, options)`. The hook may return a single string or multi-file output: diff --git a/docs/concepts.md b/docs/concepts.md index 7586efc9..652733bc 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -10,7 +10,7 @@ This document is the canonical reference for concepts and terminology used in th ## Space -A **space** is a named collection of nodes organised according to a schema. Spaces are the primary unit of organisation — a space has a backing format (a `space directory` or a `space on a page` file) and may be registered in a config file (`.sctx.json` or the global `config.json`) with a name for convenient access. +A **space** is a named collection of nodes organised according to a schema. Spaces are the primary unit of organisation — a space has a backing format (a `space directory` or a `space on a page` file) and may be registered in a config file with a name for convenient access. ```mermaid flowchart TD @@ -478,10 +478,14 @@ Plugins that support parsing produce raw `SpaceNode[]` results given a suitable The `plugins` field in config is a **map** from plugin name to plugin config object. All plugin names must start with `sctx-`, but the prefix is optional in config and normalised on load. Built-in plugins take precedence and all are loaded by default. External plugin names specified in config are then resolved in order: -1. Config-adjacent: `{configDir}/plugins/{sctx-name}` +1. The config root's plugins folder: `.sctx/plugins/{sctx-name}` 2. npm: a package matching `sctx-*` -Each plugin declares a `configSchema` JSON Schema; the loader validates the config block against it before invoking the plugin. Config fields annotated with `format: 'path'` are resolved relative to `configDir` by the loader. +Each plugin declares a `configSchema` JSON Schema; the loader validates the config block against it before invoking the plugin. Config fields annotated with `format: 'path'` are resolved relative to the config root by the loader. + +### Config root + +The **config root** is the folder a config file's relative paths resolve from: the folder holding `.sctx.json` or `.sctx/`, or the global config's folder. Its **resources folder** (`.sctx/`, or the global config's folder) holds plugins, schemas and map view definitions. In code, `configDir` and `SpaceConfig.sourceDir` hold a config root. See [Resources folder](config.md#resources-folder). ### Dispatch contract diff --git a/docs/config.md b/docs/config.md index 762f08fa..0bc8c6d5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -10,12 +10,22 @@ structured-context uses the first config file it finds, in this order: 1. `--config ` 2. `$SCTX_CONFIG` -3. The nearest `.sctx.json`, looking in the working directory and then each parent folder +3. The nearest `.sctx.json` or `.sctx/config.json`, looking in the working directory and then each parent folder 4. `~/.config/structured-context/config.json` (or `$XDG_CONFIG_HOME/structured-context/config.json`) -It is recommended to keep a `.sctx.json` at the base of the notes it describes (for example the root of an Obsidian vault), or to use the global config. The CLI and agent sessions look in parent folders only, so run commands from inside the folder that holds the config. To use the spaces from another config file too, list it in `includeSpacesFrom`. +It is recommended to keep a local config at the base of the notes it describes (for example the root of an Obsidian vault), or to use the global config. A local config is either `.sctx.json` or `config.json` inside a `.sctx/` folder. A folder can't hold both. The CLI and agent sessions look in parent folders only, so run commands from inside the folder that holds the config. To use the spaces from another config file too, list it in `includeSpacesFrom`. -Paths in a config file are relative to the folder that holds that file. In a `.sctx.json` at a vault root, `"path": "."` is the whole vault and `"path": "notes"` is one folder. +The **config root** is the folder that holds `.sctx.json` or `.sctx/`, or the global config's folder. Relative paths in a config file resolve from its root. In a vault-root config, `"path": "."` is the whole vault and `"path": "notes"` is one folder, whichever of the two file names it uses. + +### Resources folder + +Files that structured-context reads besides the config go in the root's `.sctx/` folder (for the global config, its own folder), one subfolder each: + +| Folder | Holds | +|---|---| +| `.sctx/plugins/` | Local [plugins](#plugins) | +| `.sctx/schemas/` | Custom schemas, found by file name (`"schema": "my_wiki.json"`) | +| `.sctx/map-views/` | Map view definitions, read by `structured-context-map-views` | `sctx spaces` shows which config file is used and reports problems with it. `sctx spaces --json` returns the same information for tools and hooks. @@ -27,7 +37,7 @@ sctx spaces add wiki notes --schema knowledge_wiki.json This adds the space to the config file found from the working directory, or to the one named with `--config`. When no `.sctx.json` is found, or only the global config exists, pass `--config /.sctx.json` (creates the file) or `--global`. If the config file has comments or trailing commas, the command prints the entry to add by hand. -Custom schemas can sit anywhere. Reference them by relative path, for example `./.sctx/schemas/my_wiki.json`; `.sctx/schemas/` is the suggested folder. +Custom schemas can sit anywhere. A schema in `.sctx/schemas/` is found by its file name, before one of the same name in the root; reference others by relative path, for example `./schemas/my_wiki.json`. See `config.example.json` for the full structure. @@ -78,7 +88,7 @@ Use `plugins` to load parse plugins that read spaces from non-markdown sources. } ``` -All plugin names must start with `sctx-` (the prefix is optional in config and normalised on load). The special name `markdown` refers to the built-in markdown plugin. External plugins are resolved in order: config-adjacent (`{configDir}/plugins/{name}`), then npm. Each plugin must export a `configSchema` JSON Schema; config is validated against it on load. Fields annotated `format: 'path'` in a plugin's `configSchema` are resolved relative to the config file directory. +All plugin names must start with `sctx-` (the prefix is optional in config and normalised on load). The special name `markdown` refers to the built-in markdown plugin. External plugins are resolved in order: the root's plugins folder (`.sctx/plugins/{name}`, see [Resources folder](#resources-folder)), then npm. Each plugin must export a `configSchema` JSON Schema; config is validated against it on load. Fields annotated `format: 'path'` in a plugin's `configSchema` are resolved relative to the config root. Plugins can also contribute schemas and partials via a `/schemas/` directory (or `"sctx": { "schemas": "..." }` in `package.json`). If a space specifies a plugin-prefixed schema (e.g. `"sctx-wardley-mapping/wardley_map.json"`), schemas are resolved statically without executing plugin code, and the plugin's code hooks are loaded when parsing or rendering. diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 46ef8cc0..5f73ceec 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "structured-context", - "version": "0.23.12", + "version": "0.23.13", "description": "Structured content authoring for Obsidian — automatic validation hooks, slash commands, and agent skills to keep markdown files compliant with your schema.", "homepage": "https://github.com/mindsocket/structured-context", "repository": "https://github.com/mindsocket/structured-context", diff --git a/plugin/skills/structured-context/references/troubleshooting.md b/plugin/skills/structured-context/references/troubleshooting.md index 2ee09a86..f289a5ca 100644 --- a/plugin/skills/structured-context/references/troubleshooting.md +++ b/plugin/skills/structured-context/references/troubleshooting.md @@ -11,6 +11,7 @@ empty or `info` only. Hooks pick up a fixed config on the next prompt. A missing | `E_SCTX_OUTDATED` | The major.minor of `sctx --version` should match the plugin's version in `plugin/.claude-plugin/plugin.json` | | `E_CONFIG_NOT_FOUND` | At `info` level, nothing is set up: use the setup-space skill. As an error, `--config` or `$SCTX_CONFIG` points at a missing file: correct the path or unset the variable | | `E_TARGET_REQUIRED` | Ask where the config belongs. Usually `--config /.sctx.json`; `--global` only if the user asks | +| `E_CONFIG_AMBIGUOUS` | Ask the user which of the two config files to keep, then merge the other's spaces into it | | `E_HOOK_FAILED` | A hook crashed and was skipped. Set `SCTX_DEBUG=1` for the session, or run the same `sctx` command by hand. Usually a problem with its own code above | Other codes (`E_CONFIG_INVALID`, `E_INCLUDE_NOT_FOUND`, `E_SPACE_PATH_MISSING`, `E_SCHEMA_*`, diff --git a/src/api.ts b/src/api.ts index 4eabad6a..ccb13815 100644 --- a/src/api.ts +++ b/src/api.ts @@ -23,6 +23,7 @@ export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv'; export { type SpaceGraph, type SpaceNode, spaceGraphFrom } from './browser'; export type { Config, SpaceConfig } from './config'; export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config'; +export { resourceDir } from './config/layout'; export { addSpace } from './config/write'; export { LOCAL_CONFIG_FILE, PLUGIN_PREFIX } from './constants'; export { SctxError, type SctxErrorCode } from './errors'; diff --git a/src/config/health.ts b/src/config/health.ts index 02fcd667..e01c7ad3 100644 --- a/src/config/health.ts +++ b/src/config/health.ts @@ -1,7 +1,14 @@ import { existsSync } from 'node:fs'; import { LOCAL_CONFIG_FILE } from '../constants'; import { SctxError, type SctxErrorCode } from '../errors'; -import { type Config, getConfigSourceFiles, loadConfig, type ResolvedConfigPath, resolveConfigPath } from '.'; +import { + type Config, + getConfigSourceFiles, + LOCAL_CONFIG_DESCRIPTION, + loadConfig, + type ResolvedConfigPath, + resolveConfigPath, +} from '.'; export type ConfigProblem = { code: SctxErrorCode; @@ -21,6 +28,7 @@ export type ConfigHealth = { const FIXES: Partial> = { E_CONFIG_NOT_FOUND: `Create a config with \`sctx spaces add --schema --config /${LOCAL_CONFIG_FILE}\`, or use the setup-space skill.`, E_CONFIG_INVALID: 'Fix the JSON in the config file named in the message; `sctx docs config` describes the shape.', + E_CONFIG_AMBIGUOUS: 'Merge the two config files into one and delete the other.', E_INCLUDE_NOT_FOUND: 'Correct or remove the missing path in `includeSpacesFrom`.', E_NO_SPACES: 'Add a space with `sctx spaces add`.', E_SPACE_PATH_MISSING: 'The folder or file was moved or deleted. Correct `path` in the config, or remove the space.', @@ -56,7 +64,7 @@ export function inspectConfig(options: { startDir?: string } = {}): { health: Co if (!exists) { const detail = resolved.source === 'none' - ? `No ${LOCAL_CONFIG_FILE} found from the working directory upward, and no global config at ${resolved.path}` + ? `No ${LOCAL_CONFIG_DESCRIPTION} found from the working directory upward, and no global config at ${resolved.path}` : `Config file not found: ${resolved.path}`; health.problems.push(problem('E_CONFIG_NOT_FOUND', resolved.source === 'none' ? 'info' : 'error', detail)); return { health }; diff --git a/src/config/index.ts b/src/config/index.ts index 870479b7..5f3e5297 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,9 +1,9 @@ -import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import Ajv2020 from 'ajv/dist/2020'; import JSON5 from 'json5'; -import { ENV_CONFIG_VAR, LOCAL_CONFIG_FILE, XDG_CONFIG_DIR } from '../constants'; +import { ENV_CONFIG_VAR, LOCAL_CONFIG_FILE, RESOURCES_DIR } from '../constants'; import { SctxError } from '../errors'; import { type JudgeConfig, MODEL_PROVIDER_APIS, type ModelProviderConfig } from '../judge/types'; import { @@ -13,6 +13,7 @@ import { shortenPluginName, } from '../plugins/util'; import { bundledSchemasDir } from '../schema/schema'; +import { configFileIn, configRoot, globalConfigPath, resourceDir, SCHEMAS_RESOURCE } from './layout'; const MODEL_PROVIDER_SCHEMA = { type: 'object', @@ -82,8 +83,8 @@ export type SpaceConfig = { /** Model provider for this space's semantic rules; overrides the top-level `semantic`. */ semantic?: JudgeConfig; /** - * Directory of the config file that defined this space, used to anchor relative plugin-config - * paths. Set by loadConfig; absent for a hand-assembled Config, whose callers must pass an + * Root of the config file that defined this space (see `configRoot`), used to anchor relative + * plugin-config paths and find plugins. Set by loadConfig; absent for a hand-assembled Config, whose callers must pass an * explicit configDir to createSpaceContext. Not persisted by updateSpaceField. */ sourceDir?: string; @@ -119,19 +120,21 @@ export function getConfigSourceFiles(): Set { /** How the config file was found. `none` means nothing exists and `path` is where the global file would go. */ export type ConfigSource = 'override' | 'env' | 'local' | 'global' | 'none'; -export type ResolvedConfigPath = { path: string; source: ConfigSource }; +export type ResolvedConfigPath = { + path: string; + source: ConfigSource; + /** The folder the config's relative paths resolve from, and where its resources are found (see `configRoot`). */ + root: string; +}; -/** Path of the global config file, whether or not it exists. */ -export function globalConfigPath(): string { - const xdgBase = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'); - return join(xdgBase, XDG_CONFIG_DIR, 'config.json'); -} +/** How messages name a local config file. */ +export const LOCAL_CONFIG_DESCRIPTION = `${LOCAL_CONFIG_FILE} or ${RESOURCES_DIR}/config.json`; function findLocalConfig(startDir: string): string | undefined { let dir = resolve(startDir); for (;;) { - const candidate = join(dir, LOCAL_CONFIG_FILE); - if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + const found = configFileIn(dir); + if (found) return found; const parent = dirname(dir); if (parent === dir) return undefined; dir = parent; @@ -140,15 +143,16 @@ function findLocalConfig(startDir: string): string | undefined { /** * Find the config file. Precedence: explicit override (`--config`), `$SCTX_CONFIG`, the nearest - * `.sctx.json` walking up from `startDir` (default: the working directory), then the global file. + * `.sctx.json` or `.sctx/config.json` walking up from `startDir` (default: the working directory), then the global file. */ export function resolveConfigPath(startDir: string = process.cwd()): ResolvedConfigPath { - if (_configPathOverride) return { path: _configPathOverride, source: 'override' }; - if (process.env[ENV_CONFIG_VAR]) return { path: process.env[ENV_CONFIG_VAR]!, source: 'env' }; + const found = (path: string, source: ConfigSource) => ({ path, source, root: configRoot(path) }); + if (_configPathOverride) return found(_configPathOverride, 'override'); + if (process.env[ENV_CONFIG_VAR]) return found(process.env[ENV_CONFIG_VAR]!, 'env'); const local = findLocalConfig(startDir); - if (local) return { path: local, source: 'local' }; + if (local) return found(local, 'local'); const global = globalConfigPath(); - return { path: global, source: existsSync(global) ? 'global' : 'none' }; + return found(global, existsSync(global) ? 'global' : 'none'); } export function configPath(startDir?: string): string { @@ -231,7 +235,7 @@ function loadConfigFile(startDir: string | undefined, warnIfMissing: boolean): C if (warnIfMissing) { const where = source === 'none' - ? `no ${LOCAL_CONFIG_FILE} found from ${resolve(startDir ?? process.cwd())} upward, and none at ${path}` + ? `no ${LOCAL_CONFIG_DESCRIPTION} found from ${resolve(startDir ?? process.cwd())} upward, and none at ${path}` : path; console.warn(`E_CONFIG_NOT_FOUND: Config file not found (${where})`); } @@ -240,7 +244,7 @@ function loadConfigFile(startDir: string | undefined, warnIfMissing: boolean): C const config = _loadConfig(path); _spaceSourceFiles.clear(); // Track which spaces come from the main config file - const mainSourceDir = dirname(resolve(path)); + const mainSourceDir = configRoot(path); const ownNames = new Set(config.spaces.map((s) => s.name)); for (const space of config.spaces) { _spaceSourceFiles.set(space.name, path); @@ -252,15 +256,15 @@ function loadConfigFile(startDir: string | undefined, warnIfMissing: boolean): C for (const includePath of config.includeSpacesFrom) { // Resolve relative to the main config file const expanded = expandHome(includePath); - const resolvedIncludePath = isAbsolute(expanded) ? expanded : resolve(dirname(path), expanded); + const resolvedIncludePath = isAbsolute(expanded) ? expanded : resolve(mainSourceDir, expanded); if (!existsSync(resolvedIncludePath)) { throw new SctxError( 'E_INCLUDE_NOT_FOUND', `Config ${path} includes ${resolvedIncludePath}, which does not exist`, ); } - const includedConfig = resolveRelativePaths(_loadConfig(resolvedIncludePath), dirname(resolvedIncludePath)); - const includeSourceDir = dirname(resolvedIncludePath); + const includeSourceDir = configRoot(resolvedIncludePath); + const includedConfig = resolveRelativePaths(_loadConfig(resolvedIncludePath), includeSourceDir); for (const space of includedConfig.spaces) { if (ownNames.has(space.name)) continue; space.sourceDir = includeSourceDir; @@ -271,7 +275,7 @@ function loadConfigFile(startDir: string | undefined, warnIfMissing: boolean): C } } } - return resolveRelativePaths(config, dirname(resolve(path))); + return resolveRelativePaths(config, mainSourceDir); } /** Get the full space config entry by name. Throws if not found. */ @@ -292,6 +296,7 @@ export function getSpaceConfig(name: string, config: Config): SpaceConfig { * 3. Plugin-qualified path (e.g. "sctx-wardley-mapping/wardley_map.json" or "wardley-mapping/wardley_map.json"): * resolved against the matching loaded plugin's schemasDir. * 4. Simple filename without directory (e.g. "wardley_map.json" or "strategy_general.json"): + * - /filename if it exists * - configDir/filename if it exists * - plugin.schemasDir/filename for any loaded plugin with schemasDir * - bundledSchemasDir/filename if it exists @@ -344,7 +349,11 @@ export function resolveSchemaPath( } // Simple filename without directory: - // 1. configDir/filename + // 1. The config root's schemas folder, then configDir/filename + const schemasDir = resourceDir(configDir, SCHEMAS_RESOURCE); + if (schemasDir && existsSync(join(schemasDir, rawSchema))) { + return join(schemasDir, rawSchema); + } const localCandidate = resolve(configDir, rawSchema); if (existsSync(localCandidate)) { return localCandidate; diff --git a/src/config/layout.ts b/src/config/layout.ts new file mode 100644 index 00000000..9572d9fa --- /dev/null +++ b/src/config/layout.ts @@ -0,0 +1,42 @@ +import { existsSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { LOCAL_CONFIG_FILE, RESOURCES_DIR, XDG_CONFIG_DIR } from '../constants'; +import { SctxError } from '../errors'; + +const NESTED_CONFIG_FILE = 'config.json'; + +export const SCHEMAS_RESOURCE = 'schemas'; + +/** Path of the global config file, whether or not it exists. */ +export function globalConfigPath(): string { + const xdgBase = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'); + return join(xdgBase, XDG_CONFIG_DIR, NESTED_CONFIG_FILE); +} + +function isFile(path: string): boolean { + return existsSync(path) && statSync(path).isFile(); +} + +/** The config file that makes `dir` a config root, if any. */ +export function configFileIn(dir: string): string | undefined { + const beside = join(dir, LOCAL_CONFIG_FILE); + const inside = join(dir, RESOURCES_DIR, NESTED_CONFIG_FILE); + const found = [beside, inside].filter(isFile); + if (found.length > 1) { + throw new SctxError('E_CONFIG_AMBIGUOUS', `Both ${beside} and ${inside} exist. Keep one of them.`); + } + return found[0]; +} + +export function configRoot(configFile: string): string { + const dir = dirname(resolve(configFile)); + return basename(dir) === RESOURCES_DIR && basename(configFile) === NESTED_CONFIG_FILE ? dirname(dir) : dir; +} + +/** The folder holding one kind of resource (such as `plugins`) for a config root, if it exists. */ +export function resourceDir(root: string, name: string): string | undefined { + const absRoot = resolve(root); + const dir = absRoot === dirname(globalConfigPath()) ? join(absRoot, name) : join(absRoot, RESOURCES_DIR, name); + return existsSync(dir) && statSync(dir).isDirectory() ? dir : undefined; +} diff --git a/src/config/write.ts b/src/config/write.ts index efca5e9d..0f0099d2 100644 --- a/src/config/write.ts +++ b/src/config/write.ts @@ -1,8 +1,8 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { dirname, isAbsolute, relative, resolve } from 'node:path'; -import { LOCAL_CONFIG_FILE } from '../constants'; import { SctxError } from '../errors'; -import { globalConfigPath, resolveConfigPath, resolveSchemaPath, type SpaceConfig } from '.'; +import { LOCAL_CONFIG_DESCRIPTION, resolveConfigPath, resolveSchemaPath, type SpaceConfig } from '.'; +import { configRoot, globalConfigPath } from './layout'; type AddSpaceOptions = Pick & { /** Write to the global config file. */ @@ -39,7 +39,7 @@ export function resolveWriteTarget(options: { global?: boolean; configFile?: str const found = resolved.source === 'global' ? `only the global config (${resolved.path}) exists` : 'no config exists'; throw new SctxError( 'E_TARGET_REQUIRED', - `No ${LOCAL_CONFIG_FILE} found from ${resolve(options.startDir ?? process.cwd())} upward; ${found}. ` + + `No ${LOCAL_CONFIG_DESCRIPTION} found from ${resolve(options.startDir ?? process.cwd())} upward; ${found}. ` + `Specify the config file location or global option.`, ); } @@ -55,7 +55,7 @@ function realOrSelf(p: string): string { /** The path as written in the config: relative to the config's folder when inside it, else absolute. */ function storedPath(configFile: string, absolutePath: string): string { // Compare real paths so a symlinked folder (such as macOS's /var -> /private/var) does not look like it escapes - const rel = relative(realOrSelf(dirname(resolve(configFile))), realOrSelf(absolutePath)); + const rel = relative(realOrSelf(configRoot(configFile)), realOrSelf(absolutePath)); if (rel === '') return '.'; return rel.startsWith('..') || isAbsolute(rel) ? absolutePath : rel; } @@ -96,7 +96,7 @@ export function addSpace(options: AddSpaceOptions): AddSpaceResult { ); } if (options.schema && !/^https?:\/\//i.test(options.schema)) { - if (!existsSync(resolveSchemaPath(options.schema, dirname(target)))) { + if (!existsSync(resolveSchemaPath(options.schema, configRoot(target)))) { const hint = options.schema.startsWith('sctx://') ? 'Name the schema by file, such as strategy_general.json, not by sctx:// URI.' : '`sctx schemas list` shows the bundled schemas; a file name needs its extension.'; diff --git a/src/constants.ts b/src/constants.ts index 6f87d515..7e2db9a9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,3 +6,5 @@ export const ENV_CONFIG_VAR = 'SCTX_CONFIG'; export const SCHEMA_URI_SCHEME = CLI_NAME; /** Name of the config file discovered by walking up from the working directory. */ export const LOCAL_CONFIG_FILE = '.sctx.json'; +/** Folder beside the content holding structured-context's resources (plugins, map views) and optionally its config. */ +export const RESOURCES_DIR = '.sctx'; diff --git a/src/errors.ts b/src/errors.ts index cae831bf..5fbc6769 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -5,6 +5,7 @@ export type SctxErrorCode = | 'E_CONFIG_NOT_FOUND' | 'E_CONFIG_INVALID' + | 'E_CONFIG_AMBIGUOUS' | 'E_INCLUDE_NOT_FOUND' | 'E_NO_SPACES' | 'E_SPACE_PATH_MISSING' diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 7a3ac66c..9ff30280 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1,8 +1,9 @@ import { existsSync, readdirSync } from 'node:fs'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import type { AnySchemaObject } from 'ajv'; import Ajv2020 from 'ajv/dist/2020'; import { getConfigSourceFiles } from '../config'; +import { configRoot, resourceDir } from '../config/layout'; import { builtinPlugins } from '.'; import { isBuiltinPluginName } from './builtin-names'; import { @@ -43,11 +44,13 @@ function resolveConfigPaths( /** * Resolve an external plugin by canonical name. - * Resolution order: config-adjacent ({configDir}/plugins/{name}) → npm (import(name)). + * Resolution order: the config root's plugins folder → npm (import(name)). */ async function resolveExternalPlugin(name: string, configDir: string): Promise { - const localPath = resolve(join(configDir, CONFIG_PLUGINS_DIR, name)); - const module = existsSync(localPath) || existsSync(`${localPath}.ts`) ? await import(localPath) : await import(name); + const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); + const localPath = pluginsDir && resolve(join(pluginsDir, name)); + const local = localPath && (existsSync(localPath) || existsSync(`${localPath}.ts`)); + const module = local ? await import(localPath) : await import(name); const plugin = (module as { default?: StructuredContextPlugin }).default ?? (module as StructuredContextPlugin); if (!plugin || typeof plugin.name !== 'string') { throw new Error(`Plugin "${name}" must export a StructuredContextPlugin as its default export`); @@ -56,20 +59,19 @@ async function resolveExternalPlugin(name: string, configDir: string): Promise { - // Convert the Set of files into a Set of dirs - const configDirs = [...new Set(Array.from(getConfigSourceFiles()).map((f) => dirname(f)))]; + const configDirs = [...new Set(Array.from(getConfigSourceFiles()).map((f) => configRoot(f)))]; const plugins: StructuredContextPlugin[] = [...builtinPlugins]; const seenNames = new Set(builtinPlugins.map((p) => p.name)); for (const configDir of configDirs) { - const pluginsDir = join(configDir, CONFIG_PLUGINS_DIR); - if (existsSync(pluginsDir)) { + const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); + if (pluginsDir) { const entries = readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX)); for (const entry of entries) { if (!seenNames.has(entry)) { diff --git a/src/plugins/util.ts b/src/plugins/util.ts index 7eb1eeb8..9bd0aa30 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -3,6 +3,7 @@ import { createRequire } from 'node:module'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import type { AnySchemaObject } from 'ajv'; import type { Config, SpaceConfig } from '../config'; +import { resourceDir } from '../config/layout'; import { PLUGIN_PREFIX as _PLUGIN_PREFIX } from '../constants'; import type { PluginSchemaSource } from '../schema/schema'; import type { SpaceGraph } from '../space-graph'; @@ -119,15 +120,16 @@ export type StructuredContextPlugin = { /** * Synchronously locate the root directory of an external plugin. * Resolution order: - * 1. Config-adjacent: {configDir}/plugins/{name} or {configDir}/plugins/sctx-{name} + * 1. The config root's plugins folder: /{name} or /sctx-{name} * 2. Node module: resolve from configDir using standard module resolution */ export function resolvePluginDir(pluginName: string, configDir: string): string | undefined { const normalized = normalizePluginName(pluginName); const short = shortenPluginName(pluginName); - for (const candidate of [normalized, short]) { - const localDir = resolve(configDir, CONFIG_PLUGINS_DIR, candidate); + const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); + for (const candidate of pluginsDir ? [normalized, short] : []) { + const localDir = resolve(pluginsDir!, candidate); if (existsSync(localDir)) { try { if (statSync(localDir).isDirectory()) { @@ -163,7 +165,7 @@ export function resolvePluginDir(pluginName: string, configDir: string): string * Convention: * 1. package.json "sctx": { "schemas": "..." } * 2. Convention: /schemas - * 3. Fallback for test fixtures: {configDir}/plugins/schemas + * 3. Fallback for test fixtures: /schemas */ export function resolvePluginSchemasDir(pluginName: string, configDir: string): string | undefined { const dir = resolvePluginDir(pluginName, configDir); @@ -193,9 +195,10 @@ export function resolvePluginSchemasDir(pluginName: string, configDir: string): } } - // 3. Check config-adjacent plugins/schemas as fallback (e.g. test fixtures) - const fallbackSchemas = resolve(configDir, CONFIG_PLUGINS_DIR, 'schemas'); - if (existsSync(fallbackSchemas)) { + // 3. Check the plugins folder's schemas as fallback (e.g. test fixtures) + const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); + const fallbackSchemas = pluginsDir && resolve(pluginsDir, 'schemas'); + if (fallbackSchemas && existsSync(fallbackSchemas)) { try { if (statSync(fallbackSchemas).isDirectory()) { return fallbackSchemas; diff --git a/src/schema/list-schemas.ts b/src/schema/list-schemas.ts index ab6b7956..c796ed4f 100644 --- a/src/schema/list-schemas.ts +++ b/src/schema/list-schemas.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync } from 'node:fs'; -import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; +import { basename, isAbsolute, join, resolve } from 'node:path'; import { type Config, loadConfigOrEmpty, resolveConfigPath, resolveSchemaPath } from '../config'; +import { resourceDir, SCHEMAS_RESOURCE } from '../config/layout'; import { PLUGIN_PREFIX } from '../constants'; import { CONFIG_PLUGINS_DIR, @@ -15,7 +16,7 @@ type SchemaListing = { schema: string; /** Absolute location or URL. Omitted for bundled schemas. */ path?: string; - /** `core`, a plugin's short name, or `configured`. */ + /** `core`, a plugin's short name, or `configured` (named by the config, or in its schemas folder). */ source: string; /** Names of the spaces that use it, and `global` when the top-level `schema` does. */ usedBy: string[]; @@ -80,7 +81,7 @@ export async function listSchemas( } // Relative schema references are anchored at the folder of the config that holds them - const fallbackDir = dirname(resolveConfigPath(options.startDir).path); + const fallbackDir = resolveConfigPath(options.startDir).root; const configDirs = [...new Set([fallbackDir, ...config.spaces.map((s) => s.sourceDir ?? fallbackDir)])]; // Plugins are found the way schema references resolve them, without loading their code: those declared by a @@ -96,8 +97,8 @@ export async function listSchemas( } } for (const dir of configDirs) { - const pluginsDir = join(dir, CONFIG_PLUGINS_DIR); - if (!existsSync(pluginsDir)) continue; + const pluginsDir = resourceDir(dir, CONFIG_PLUGINS_DIR); + if (!pluginsDir) continue; for (const entry of readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX))) { const schemasDir = resolvePluginSchemasDir(entry, dir); if (schemasDir && !pluginSchemaDirs.has(shortenPluginName(entry))) { @@ -122,6 +123,25 @@ export async function listSchemas( } } + for (const dir of configDirs) { + const schemasDir = resourceDir(dir, SCHEMAS_RESOURCE); + for (const file of schemasDir ? jsonFiles(schemasDir) : []) { + const location = join(schemasDir!, file); + if (byPath.has(location)) continue; + add( + { + schema: file, + path: location, + source: 'configured', + usedBy: [], + partial: file.startsWith('_'), + ...describeSchema(location), + }, + location, + ); + } + } + const references = [ ...(config.schema ? [{ user: 'global', raw: config.schema, dir: fallbackDir }] : []), ...config.spaces.flatMap((s) => diff --git a/tests/config/discovery.test.ts b/tests/config/discovery.test.ts index 85e37662..0db88e90 100644 --- a/tests/config/discovery.test.ts +++ b/tests/config/discovery.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { loadConfig, resolveConfigPath, setConfigPath } from '../../src/config'; import { LOCAL_CONFIG_FILE } from '../../src/constants'; import { useConfigEnv, writeJson as write } from '../helpers/config-env'; @@ -12,7 +12,7 @@ describe('resolveConfigPath', () => { const resolved = resolveConfigPath(join(env.vault, 'notes', 'deep')); - expect(resolved).toEqual({ path: join(env.vault, LOCAL_CONFIG_FILE), source: 'local' }); + expect(resolved).toEqual({ path: join(env.vault, LOCAL_CONFIG_FILE), source: 'local', root: env.vault }); }); it('uses the nearest of two ancestor configs without merging', () => { @@ -30,11 +30,32 @@ describe('resolveConfigPath', () => { }); it('falls back to the global config, then to none', () => { - expect(resolveConfigPath(env.vault)).toEqual({ path: env.globalFile, source: 'none' }); + expect(resolveConfigPath(env.vault)).toMatchObject({ path: env.globalFile, source: 'none' }); write(env.globalFile, { spaces: [] }); - expect(resolveConfigPath(env.vault)).toEqual({ path: env.globalFile, source: 'global' }); + expect(resolveConfigPath(env.vault)).toEqual({ + path: env.globalFile, + source: 'global', + root: dirname(env.globalFile), + }); + }); + + it('finds .sctx/config.json, rooted at the folder holding .sctx', () => { + write(join(env.vault, '.sctx', 'config.json'), { spaces: [] }); + + expect(resolveConfigPath(join(env.vault, 'notes'))).toEqual({ + path: join(env.vault, '.sctx', 'config.json'), + source: 'local', + root: env.vault, + }); + }); + + it('refuses a folder holding both .sctx.json and .sctx/config.json', () => { + write(join(env.vault, LOCAL_CONFIG_FILE), { spaces: [] }); + write(join(env.vault, '.sctx', 'config.json'), { spaces: [] }); + + expect(() => resolveConfigPath(env.vault)).toThrow(expect.objectContaining({ code: 'E_CONFIG_AMBIGUOUS' })); }); it('does not walk down into folders below the start', () => { @@ -47,11 +68,15 @@ describe('resolveConfigPath', () => { write(join(env.vault, LOCAL_CONFIG_FILE), { spaces: [] }); process.env.SCTX_CONFIG = join(env.root, 'env.json'); - expect(resolveConfigPath(env.vault)).toEqual({ path: join(env.root, 'env.json'), source: 'env' }); + expect(resolveConfigPath(env.vault)).toEqual({ path: join(env.root, 'env.json'), source: 'env', root: env.root }); setConfigPath(join(env.root, 'flag.json')); - expect(resolveConfigPath(env.vault)).toEqual({ path: join(env.root, 'flag.json'), source: 'override' }); + expect(resolveConfigPath(env.vault)).toEqual({ + path: join(env.root, 'flag.json'), + source: 'override', + root: env.root, + }); }); }); @@ -64,6 +89,21 @@ describe('loadConfig with discovery', () => { expect(config.spaces[0]?.path).toBe(env.vault); }); + it('resolves .sctx/config.json paths from the root, not from .sctx', () => { + write(join(env.vault, 'shared.json'), { spaces: [{ name: 'shared', path: 'shared' }] }); + write(join(env.vault, '.sctx', 'config.json'), { + spaces: [{ name: 'wiki', path: '.' }], + includeSpacesFrom: ['shared.json'], + }); + + const config = loadConfig({ startDir: env.vault }); + + expect(config.spaces.map((s) => [s.path, s.sourceDir])).toEqual([ + [env.vault, env.vault], + [join(env.vault, 'shared'), env.vault], + ]); + }); + it('expands ~ in includeSpacesFrom', () => { process.env.HOME = env.root; write(join(env.root, 'shared.json'), { spaces: [{ name: 'shared', path: '/shared' }] }); diff --git a/tests/config/layout.test.ts b/tests/config/layout.test.ts new file mode 100644 index 00000000..5c530a9e --- /dev/null +++ b/tests/config/layout.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'bun:test'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { resolveSchemaPath } from '../../src/config'; +import { configRoot, resourceDir } from '../../src/config/layout'; +import { useConfigEnv } from '../helpers/config-env'; + +const env = useConfigEnv(); + +describe('configRoot', () => { + it('is the folder holding .sctx.json or .sctx', () => { + expect(configRoot(join(env.vault, '.sctx.json'))).toBe(env.vault); + expect(configRoot(join(env.vault, '.sctx', 'config.json'))).toBe(env.vault); + expect(configRoot(env.globalFile)).toBe(dirname(env.globalFile)); + }); +}); + +describe('resourceDir', () => { + it('finds a resource folder in .sctx', () => { + mkdirSync(join(env.vault, '.sctx', 'plugins'), { recursive: true }); + + expect(resourceDir(env.vault, 'plugins')).toBe(join(env.vault, '.sctx', 'plugins')); + expect(resourceDir(env.vault, 'map-views')).toBeUndefined(); + }); + + it('finds the global config resources beside the config file', () => { + const globalDir = dirname(env.globalFile); + mkdirSync(join(globalDir, 'plugins'), { recursive: true }); + + expect(resourceDir(globalDir, 'plugins')).toBe(join(globalDir, 'plugins')); + }); + + it('ignores a plugins folder directly in a local root', () => { + mkdirSync(join(env.vault, 'plugins'), { recursive: true }); + + expect(resourceDir(env.vault, 'plugins')).toBeUndefined(); + }); +}); + +describe('resolveSchemaPath by file name', () => { + it('prefers .sctx/schemas over a file of the same name in the root', () => { + mkdirSync(join(env.vault, '.sctx', 'schemas'), { recursive: true }); + writeFileSync(join(env.vault, 'mine.json'), '{}'); + writeFileSync(join(env.vault, '.sctx', 'schemas', 'mine.json'), '{}'); + + expect(resolveSchemaPath('mine.json', env.vault)).toBe(join(env.vault, '.sctx', 'schemas', 'mine.json')); + }); +}); diff --git a/tests/fixtures/plugins/schemas/_test_partial.json b/tests/fixtures/.sctx/plugins/schemas/_test_partial.json similarity index 100% rename from tests/fixtures/plugins/schemas/_test_partial.json rename to tests/fixtures/.sctx/plugins/schemas/_test_partial.json diff --git a/tests/fixtures/plugins/schemas/plugin_schema.json b/tests/fixtures/.sctx/plugins/schemas/plugin_schema.json similarity index 100% rename from tests/fixtures/plugins/schemas/plugin_schema.json rename to tests/fixtures/.sctx/plugins/schemas/plugin_schema.json diff --git a/tests/fixtures/plugins/sctx-custom-plugin.ts b/tests/fixtures/.sctx/plugins/sctx-custom-plugin.ts similarity index 94% rename from tests/fixtures/plugins/sctx-custom-plugin.ts rename to tests/fixtures/.sctx/plugins/sctx-custom-plugin.ts index 78658c2b..4df9c110 100644 --- a/tests/fixtures/plugins/sctx-custom-plugin.ts +++ b/tests/fixtures/.sctx/plugins/sctx-custom-plugin.ts @@ -1,4 +1,4 @@ -import type { ParseResult, StructuredContextPlugin } from '../../../src/plugins/util'; +import type { ParseResult, StructuredContextPlugin } from '../../../../src/plugins/util'; /** A plugin that returns a fixed set of nodes (for testing first-match-wins). */ const customPlugin: StructuredContextPlugin = { diff --git a/tests/fixtures/plugins/sctx-invalid-plugin.ts b/tests/fixtures/.sctx/plugins/sctx-invalid-plugin.ts similarity index 100% rename from tests/fixtures/plugins/sctx-invalid-plugin.ts rename to tests/fixtures/.sctx/plugins/sctx-invalid-plugin.ts diff --git a/tests/fixtures/plugins/sctx-null-plugin.ts b/tests/fixtures/.sctx/plugins/sctx-null-plugin.ts similarity index 76% rename from tests/fixtures/plugins/sctx-null-plugin.ts rename to tests/fixtures/.sctx/plugins/sctx-null-plugin.ts index dce875df..c0b8151b 100644 --- a/tests/fixtures/plugins/sctx-null-plugin.ts +++ b/tests/fixtures/.sctx/plugins/sctx-null-plugin.ts @@ -1,4 +1,4 @@ -import type { StructuredContextPlugin } from '../../../src/plugins/util'; +import type { StructuredContextPlugin } from '../../../../src/plugins/util'; /** A plugin that always returns null (never handles any space). */ const nullPlugin: StructuredContextPlugin = { diff --git a/tests/fixtures/plugins/sctx-schema-plugin.ts b/tests/fixtures/.sctx/plugins/sctx-schema-plugin.ts similarity index 75% rename from tests/fixtures/plugins/sctx-schema-plugin.ts rename to tests/fixtures/.sctx/plugins/sctx-schema-plugin.ts index 639bb08b..3c09aae6 100644 --- a/tests/fixtures/plugins/sctx-schema-plugin.ts +++ b/tests/fixtures/.sctx/plugins/sctx-schema-plugin.ts @@ -1,5 +1,5 @@ import { resolve } from 'node:path'; -import type { StructuredContextPlugin } from '../../../src/plugins/util'; +import type { StructuredContextPlugin } from '../../../../src/plugins/util'; const schemaPlugin: StructuredContextPlugin = { name: 'sctx-schema-plugin', diff --git a/tests/plugins/loader.test.ts b/tests/plugins/loader.test.ts index 0042dd2f..3e8fb993 100644 --- a/tests/plugins/loader.test.ts +++ b/tests/plugins/loader.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test'; import { join } from 'node:path'; import { loadPlugins } from '../../src/plugins/loader'; -// configDir points at tests/fixtures/ so convention resolution looks in tests/fixtures/plugins/ +// configDir points at tests/fixtures/ so convention resolution looks in tests/fixtures/.sctx/plugins/ const CONFIG_DIR = join(import.meta.dir, '../fixtures'); describe('loadPlugins', () => { diff --git a/tests/plugins/plugin-schemas.test.ts b/tests/plugins/plugin-schemas.test.ts index 538cd249..177b54d1 100644 --- a/tests/plugins/plugin-schemas.test.ts +++ b/tests/plugins/plugin-schemas.test.ts @@ -9,7 +9,7 @@ import { buildFullRegistry, createValidator, readRawSchema } from '../../src/sch import { createSpaceContext } from '../../src/space-context'; const FIXTURES_DIR = join(import.meta.dir, '../fixtures'); -const PLUGIN_SCHEMAS_DIR = join(FIXTURES_DIR, 'plugins/schemas'); +const PLUGIN_SCHEMAS_DIR = join(FIXTURES_DIR, '.sctx/plugins/schemas'); describe('Plugin-contributed schemas', () => { describe('buildFullRegistry with pluginSchemas', () => { @@ -283,7 +283,7 @@ describe('Plugin-contributed schemas', () => { const configDir = mkdtempSync(join(tmpdir(), 'sctx-plugin-schemas-')); try { for (const name of ['sctx-markdown', 'sctx-mermaid', 'sctx-miro', 'sctx-extra']) { - mkdirSync(join(configDir, 'plugins', name, 'schemas'), { recursive: true }); + mkdirSync(join(configDir, '.sctx', 'plugins', name, 'schemas'), { recursive: true }); } const space = { name: 'space', @@ -293,7 +293,7 @@ describe('Plugin-contributed schemas', () => { }; const sources = resolveSpacePluginSchemas(space, { spaces: [space] }, configDir); expect(sources).toEqual([ - { pluginName: 'extra', schemasDir: join(configDir, 'plugins', 'sctx-extra', 'schemas') }, + { pluginName: 'extra', schemasDir: join(configDir, '.sctx', 'plugins', 'sctx-extra', 'schemas') }, ]); } finally { rmSync(configDir, { recursive: true, force: true }); diff --git a/tests/read/read-space.test.ts b/tests/read/read-space.test.ts index 4dc341f3..3cf0ae89 100644 --- a/tests/read/read-space.test.ts +++ b/tests/read/read-space.test.ts @@ -7,7 +7,7 @@ import { makeSpaceContext } from '../helpers/context'; const VALID_DIR = join(import.meta.dir, '../fixtures/general/valid-ost'); const VALID_PAGE = join(import.meta.dir, '../fixtures/general/on-a-page-valid.md'); -// Config written here so configDir = tests/fixtures/, resolving plugins from tests/fixtures/plugins/ +// Config written here so configDir = tests/fixtures/, resolving plugins from tests/fixtures/.sctx/plugins/ const FIXTURES_DIR = join(import.meta.dir, '../fixtures'); const TMP_CONFIG = join(FIXTURES_DIR, '_tmp-orchestrate-config.json'); diff --git a/tests/schema/list-schemas.test.ts b/tests/schema/list-schemas.test.ts index 682f8caf..0af62e61 100644 --- a/tests/schema/list-schemas.test.ts +++ b/tests/schema/list-schemas.test.ts @@ -99,13 +99,13 @@ describe('listSchemas', () => { }); it('lists schemas contributed by a plugin in the config folder, named by the plugin', async () => { - write(join(env.vault, 'plugins', 'sctx-demo', 'package.json'), { name: 'sctx-demo' }); + write(join(env.vault, '.sctx', 'plugins', 'sctx-demo', 'package.json'), { name: 'sctx-demo' }); write( - join(env.vault, 'plugins', 'sctx-demo', 'index.js'), + join(env.vault, '.sctx', 'plugins', 'sctx-demo', 'index.js'), 'module.exports = { name: "sctx-demo", configSchema: {} };', ); - write(join(env.vault, 'plugins', 'sctx-demo', 'schemas', 'demo.json'), { $id: 'sctx://demo/demo' }); - write(join(env.vault, 'plugins', 'sctx-demo', 'schemas', '_part.json'), { $id: 'sctx://demo/_part' }); + write(join(env.vault, '.sctx', 'plugins', 'sctx-demo', 'schemas', 'demo.json'), { $id: 'sctx://demo/demo' }); + write(join(env.vault, '.sctx', 'plugins', 'sctx-demo', 'schemas', '_part.json'), { $id: 'sctx://demo/_part' }); vaultConfig([{ name: 'a', path: 'a', schema: 'sctx-demo/demo.json' }]); const plugin = (await listSchemas({ startDir: env.vault })).filter((l) => l.source === 'demo'); @@ -114,7 +114,20 @@ describe('listSchemas', () => { ['demo/_part.json', true, []], ['demo/demo.json', false, ['a']], ]); - expect(plugin[0]?.path).toBe(join(env.vault, 'plugins', 'sctx-demo', 'schemas', '_part.json')); + expect(plugin[0]?.path).toBe(join(env.vault, '.sctx', 'plugins', 'sctx-demo', 'schemas', '_part.json')); + }); + + it('lists the schemas in the .sctx/schemas folder, and resolves them by file name', async () => { + write(join(env.vault, '.sctx', 'schemas', 'mine.json'), { title: 'Mine' }); + write(join(env.vault, '.sctx', 'schemas', 'spare.json'), { title: 'Spare' }); + vaultConfig([{ name: 'a', path: 'a', schema: 'mine.json' }]); + + const configured = (await listSchemas({ startDir: env.vault })).filter((l) => l.source === 'configured'); + + expect(configured.map((l) => [l.schema, l.path, l.usedBy])).toEqual([ + ['mine.json', join(env.vault, '.sctx', 'schemas', 'mine.json'), ['a']], + ['spare.json', join(env.vault, '.sctx', 'schemas', 'spare.json'), []], + ]); }); it('uses a config passed in instead of discovering one', async () => {