diff --git a/src/configure/editor-links.ts b/src/configure/editor-links.ts new file mode 100644 index 0000000..3518854 --- /dev/null +++ b/src/configure/editor-links.ts @@ -0,0 +1,357 @@ +const VALID_EDITORS = new Set(['visual', 'content', 'data', 'source', 'preview']); +const EDITOR_ALIASES: Record = { + explore: 'content', + update: 'visual', + browser: 'source', + code: 'source', +}; + +const FILE_PREFIXES = new Set(['collections', 'content', 'data']); +const LEGACY_PREFIXES = new Set(['explore', 'browser', 'update', 'source']); +const DEAD_PREFIXES = new Set(['visual', 'preview']); +const APP_ROUTE_PREFIXES = new Set([ + 'dashboard', + 'settings', + 'status', + 'setup', + 'usage', + 'reports', + 'debug', + 'publish', + 'inbox', + 'assets', +]); + +export interface CollectionInfo { + key: string; + path?: string; + schemas: string[]; + enabledEditors?: string[]; +} + +export interface LinkContext { + collections: Map; + source: string; + fileExists: (repoPath: string) => boolean; +} + +export type FindingLevel = 'error' | 'warning' | 'info'; + +export interface LinkFinding { + level: FindingLevel; + message: string; +} + +function decodeEntities(value: string): string { + let previous: string; + let result = value; + do { + previous = result; + result = result + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + } while (result !== previous); + return result; +} + +function findBodyEnd(value: string, bodyStart: number, precedingChar: string): number { + if (precedingChar === '"' || precedingChar === "'") { + const close = value.indexOf(precedingChar, bodyStart); + return close === -1 ? value.length : close; + } + + if (precedingChar === '<') { + for (let i = bodyStart; i < value.length; i++) { + if (value[i] === '>' || /\s/.test(value[i])) { + return i; + } + } + return value.length; + } + + if (precedingChar === '(') { + let depth = 0; + for (let i = bodyStart; i < value.length; i++) { + const c = value[i]; + if (/\s/.test(c)) { + return i; + } + if (c === '(') { + depth++; + } else if (c === ')') { + if (depth === 0) { + return i; + } + depth--; + } + } + return value.length; + } + + for (let i = bodyStart; i < value.length; i++) { + if (/\s/.test(value[i]) || value[i] === '>') { + return i; + } + } + return value.length; +} + +function trimTrailing(link: string): string { + let end = link.length; + while (end > 0) { + const c = link[end - 1]; + if ('.,;:!?'.includes(c)) { + end--; + continue; + } + if (c === ')' || c === ']') { + const open = c === ')' ? '(' : '['; + const body = link.slice(0, end); + let opens = 0; + let closes = 0; + for (const ch of body) { + if (ch === open) opens++; + else if (ch === c) closes++; + } + if (closes > opens) { + end--; + continue; + } + } + break; + } + return link.slice(0, end); +} + +export function extractEditorLinks(value: string): string[] { + if (typeof value !== 'string' || !/cloudcannon:/i.test(value)) { + return []; + } + + const results: string[] = []; + const scheme = /(? 0 ? value[match.index - 1] : ''); + const link = trimTrailing(`cloudcannon:${value.slice(bodyStart, end)}`); + if (link.length > 'cloudcannon:'.length) { + results.push(link); + } + scheme.lastIndex = end; + } + + return results; +} + +function stripLeadingSlash(value: string): string { + return value.replace(/^\/+/, ''); +} + +function decodeCollectionKey(value: string): string { + return value.replace(/–/g, '/'); +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value.replace(/\+/g, ' ')); + } catch { + return value; + } +} + +function parseQuery(query: string): Record { + const params: Record = {}; + for (const pair of query.split('&')) { + if (!pair) { + continue; + } + const eq = pair.indexOf('='); + const key = safeDecode(eq === -1 ? pair : pair.slice(0, eq)); + params[key] = safeDecode(eq === -1 ? '' : pair.slice(eq + 1)); + } + return params; +} + +function resolveEditRoute( + route: string, + editorPart: string, + mainFragment: string, + ctx: LinkContext +): LinkFinding | undefined { + const [, query = ''] = editorPart.split('?'); + const params = parseQuery(query); + + const mainKey = stripLeadingSlash(mainFragment).split('/').slice(1).join('/'); + const collectionKey = decodeCollectionKey(params.collection || mainKey); + const collection = collectionKey ? ctx.collections.get(collectionKey) : undefined; + + if (collectionKey && !collection) { + return { + level: 'error', + message: `unknown collection ${q(collectionKey)} (not defined in collections_config)`, + }; + } + + if (!params.path) { + if (route === 'create' || params.schema || params.base_path || params.default_content_file) { + return undefined; + } + return { + level: 'warning', + message: 'edit link has no path parameter, so its target file cannot be verified', + }; + } + + if (!ctx.fileExists(params.path)) { + return { level: 'error', message: `file not found: ${q(params.path)}` }; + } + + if (params.schema && collection && collection.schemas.length > 0) { + if (!collection.schemas.includes(params.schema)) { + return { + level: 'error', + message: `unknown schema ${q(params.schema)} for collection ${q(collection.key)} (available: ${collection.schemas.map(q).join(', ')})`, + }; + } + } + + if (params.editor) { + const editor = EDITOR_ALIASES[params.editor] ?? params.editor; + if (!VALID_EDITORS.has(editor)) { + return { + level: 'error', + message: `invalid editor ${q(params.editor)} (expected one of ${[...VALID_EDITORS].map(q).join(', ')})`, + }; + } + if (collection?.enabledEditors) { + const enabled = collection.enabledEditors.map((e) => EDITOR_ALIASES[e] ?? e); + if (!enabled.includes(editor)) { + return { + level: 'warning', + message: `editor ${q(params.editor)} is not in _enabled_editors for collection ${q(collection.key)} (${collection.enabledEditors.map(q).join(', ')})`, + }; + } + } + if (EDITOR_ALIASES[params.editor]) { + return { + level: 'info', + message: `editor ${q(params.editor)} is a legacy alias; use ${q(editor)}`, + }; + } + } + + return undefined; +} + +function resolveFileRoute(prefix: string, rest: string, ctx: LinkContext): LinkFinding | undefined { + if (rest === '') { + return { + level: 'error', + message: + prefix === 'collections' + ? 'link is missing a collection key or file path' + : 'link is missing a file path', + }; + } + + if (prefix === 'collections' && !rest.includes('/')) { + const collection = ctx.collections.get(decodeCollectionKey(rest)); + if (collection?.path) { + return undefined; + } + } + + if (ctx.fileExists(rest)) { + return undefined; + } + + if (prefix === 'collections') { + const firstSegment = decodeCollectionKey(rest.split('/')[0]); + if (ctx.collections.has(firstSegment)) { + return { + level: 'error', + message: `${q(firstSegment)} is a collection, but ${q(rest)} is not a file in your repository. Editor links resolve the path as a real repository path, not relative to the collection — use the real path, or the ${q(`collections/${firstSegment}:/edit?path=/…`)} form`, + }; + } + return { + level: 'error', + message: `no collection named ${q(rest)} and no file at that path`, + }; + } + + return { level: 'error', message: `no file at ${q(rest)}` }; +} + +function q(value: string): string { + return `"${value}"`; +} + +function hasPlaceholder(body: string): boolean { + return /:source\b|:collections_dir\b|:base_path|:extension\b|:editor\b|\[[^\]]*\]|\{/.test(body); +} + +export function resolveEditorLink(rawLink: string, ctx: LinkContext): LinkFinding | undefined { + if (!rawLink.startsWith('cloudcannon:')) { + return undefined; + } + + let body = decodeEntities(rawLink.slice('cloudcannon:'.length)); + + if (body === '' || body.startsWith('#') || body.startsWith('!')) { + return undefined; + } + + body = stripLeadingSlash(body); + + if (body === '' || hasPlaceholder(body)) { + return undefined; + } + + const bareRoute = body.match(/^(edit|create)(\?|$)/); + if (bareRoute) { + return resolveEditRoute(bareRoute[1], body, '', ctx); + } + + const splitIndex = body.indexOf(':'); + if (splitIndex !== -1) { + const editorPart = body.slice(splitIndex + 1); + const routeMatch = editorPart.match(/^\/?(edit|create)(\?|$)/); + if (routeMatch) { + return resolveEditRoute(routeMatch[1], editorPart, body.slice(0, splitIndex), ctx); + } + } + + const prefix = body.split('/')[0]; + + if (FILE_PREFIXES.has(prefix)) { + return resolveFileRoute(prefix, body.split('/').slice(1).join('/'), ctx); + } + + if (DEAD_PREFIXES.has(prefix)) { + return { + level: 'error', + message: `${q(`${prefix}/…`)} is not a valid editor-link route (the app cannot open it)`, + }; + } + + if (LEGACY_PREFIXES.has(prefix)) { + return { + level: 'info', + message: `${q(`${prefix}/…`)} is a legacy editor-route prefix; prefer the ${q('collections/:/edit?path=/…')} form`, + }; + } + + if (APP_ROUTE_PREFIXES.has(prefix)) { + return undefined; + } + + return { + level: 'warning', + message: `unrecognised editor link route ${q(body)} — could not verify`, + }; +} diff --git a/src/configure/validate-editor-links.ts b/src/configure/validate-editor-links.ts new file mode 100644 index 0000000..bacd160 --- /dev/null +++ b/src/configure/validate-editor-links.ts @@ -0,0 +1,298 @@ +import { readdir, readFile, realpath, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { loadConfiguration } from '@cloudcannon/configuration-loader'; +import { parse as parseYaml } from 'yaml'; +import { + type CollectionInfo, + extractEditorLinks, + type FindingLevel, + type LinkContext, + type LinkFinding, + resolveEditorLink, +} from './editor-links.ts'; +import { text } from './utility.ts'; + +const README_PATHS = ['.cloudcannon/README.md', '.cloudcannon/readme.md']; + +const IGNORED_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + '_site', + 'build', + '.cache', + '.next', + '.svelte-kit', +]); + +async function listRepositoryFiles(targetPath: string): Promise> { + const files = new Set(); + const root = await realpath(targetPath).catch(() => targetPath); + const visited = new Set([root]); + + async function walk(dir: string, prefix: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => null); + if (!entries) { + return; + } + + for (const entry of entries) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + const fullPath = join(dir, entry.name); + + let isDirectory = entry.isDirectory(); + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + const stats = await stat(fullPath).catch(() => null); + if (!stats) { + continue; + } + isDirectory = stats.isDirectory(); + isFile = stats.isFile(); + } + + if (isDirectory) { + if (IGNORED_DIRS.has(entry.name)) { + continue; + } + const real = await realpath(fullPath).catch(() => fullPath); + if (!real.startsWith(root) || visited.has(real)) { + continue; + } + visited.add(real); + await walk(fullPath, relativePath); + } else if (isFile) { + files.add(relativePath); + } + } + } + + await walk(targetPath, ''); + return files; +} + +interface LinkOccurrence { + location: string; + link: string; +} + +function stripLeadingSlash(value: string): string { + return value.replace(/^\/+/, ''); +} + +function buildFileExists(filePaths: Set, source: string) { + return (repoPath: string): boolean => { + const stripped = stripLeadingSlash(repoPath); + if (filePaths.has(stripped)) { + return true; + } + return source ? filePaths.has(`${stripLeadingSlash(source)}/${stripped}`) : false; + }; +} + +function buildCollections(config: Record): Map { + const collections = new Map(); + const collectionsConfig = config.collections_config; + if (!collectionsConfig || typeof collectionsConfig !== 'object') { + return collections; + } + + for (const [key, raw] of Object.entries(collectionsConfig as Record)) { + const value = (raw ?? {}) as Record; + const schemas = value.schemas; + collections.set(key, { + key, + path: typeof value.path === 'string' ? value.path : undefined, + schemas: + schemas && typeof schemas === 'object' + ? Object.keys(schemas as Record) + : [], + enabledEditors: Array.isArray(value._enabled_editors) + ? (value._enabled_editors as string[]) + : undefined, + }); + } + + return collections; +} + +function collectFromObject( + value: unknown, + path: string, + origin: string, + sink: LinkOccurrence[] +): void { + if (typeof value === 'string') { + for (const link of extractEditorLinks(value)) { + sink.push({ location: `${origin}: ${path}`, link }); + } + return; + } + + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + collectFromObject(value[i], `${path}[${i}]`, origin, sink); + } + return; + } + + if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + collectFromObject(child, path ? `${path}.${key}` : key, origin, sink); + } + } +} + +function collectSchemaPaths(value: unknown, found: Set): void { + if (Array.isArray(value)) { + for (const child of value) { + collectSchemaPaths(child, found); + } + return; + } + + if (value && typeof value === 'object') { + const record = value as Record; + const schemas = record.schemas; + if (schemas && typeof schemas === 'object' && !Array.isArray(schemas)) { + for (const schema of Object.values(schemas as Record)) { + const schemaPath = (schema as Record | null)?.path; + if (typeof schemaPath === 'string') { + found.add(stripLeadingSlash(schemaPath)); + } + } + } + for (const child of Object.values(record)) { + collectSchemaPaths(child, found); + } + } +} + +function parseFrontMatter(contents: string): Record | undefined { + const match = contents.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) { + return undefined; + } + try { + const parsed = parseYaml(match[1]); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +async function readFileOrUndefined(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8'); + } catch { + return undefined; + } +} + +async function gatherOccurrences( + targetPath: string, + config: Record +): Promise { + const occurrences: LinkOccurrence[] = []; + + collectFromObject(config, '', 'config', occurrences); + + for (const readmePath of README_PATHS) { + const contents = await readFileOrUndefined(resolve(targetPath, readmePath)); + if (contents) { + for (const link of extractEditorLinks(contents)) { + occurrences.push({ location: readmePath, link }); + } + break; + } + } + + const schemaPaths = new Set(); + collectSchemaPaths(config, schemaPaths); + for (const schemaPath of schemaPaths) { + const contents = await readFileOrUndefined(resolve(targetPath, schemaPath)); + const frontMatter = contents ? parseFrontMatter(contents) : undefined; + if (frontMatter) { + collectFromObject(frontMatter, '', schemaPath, occurrences); + } + } + + return occurrences; +} + +export async function validateEditorLinks( + targetPath: string, + configPath: string +): Promise { + let findings: Array; + let occurrenceCount: number; + try { + const loaded = await loadConfiguration(configPath, { + parseFile: (contents: string, filePath: string) => + filePath.endsWith('.json') ? JSON.parse(contents) : parseYaml(contents), + }); + const config = loaded.config as Record; + const filePaths = await listRepositoryFiles(targetPath); + const source = typeof config.source === 'string' ? config.source : ''; + + const ctx: LinkContext = { + collections: buildCollections(config), + source, + fileExists: buildFileExists(filePaths, source), + }; + + const occurrences = await gatherOccurrences(targetPath, config); + occurrenceCount = occurrences.length; + findings = []; + for (const occurrence of occurrences) { + const finding = resolveEditorLink(occurrence.link, ctx); + if (finding) { + findings.push({ ...occurrence, ...finding }); + } + } + } catch (err) { + console.error( + `Could not validate editor links: ${err instanceof Error ? err.message : String(err)}` + ); + return false; + } + + const counts: Record = { error: 0, warning: 0, info: 0 }; + for (const finding of findings) { + counts[finding.level]++; + } + + if (occurrenceCount === 0) { + console.log(`${text.good('✓ valid')}: ${text.em('editor links')} (none found)`); + return true; + } + + if (findings.length === 0) { + console.log(`${text.good('✓ valid')}: ${text.em('editor links')} (${occurrenceCount} checked)`); + return true; + } + + const marker = + counts.error > 0 + ? text.bad('✗ invalid') + : counts.warning > 0 + ? text.secondary('! warnings') + : text.secondary('ℹ notices'); + console.log(`${marker}: ${text.em('editor links')} (${occurrenceCount} checked)`); + + for (const finding of findings) { + const label = + finding.level === 'error' + ? text.bad('error') + : finding.level === 'warning' + ? text.secondary('warning') + : text.secondary('notice'); + console.log(` ${label} ${text.em(finding.location)}`); + console.log(` ${text.value(finding.link)}`); + console.log(` ${finding.message}`); + } + + return counts.error === 0; +} diff --git a/src/configure/validate.ts b/src/configure/validate.ts index 864aa71..1190f4c 100644 --- a/src/configure/validate.ts +++ b/src/configure/validate.ts @@ -1,6 +1,7 @@ import { relative, resolve } from 'node:path'; import { type CommandContext, defineCommand } from 'citty'; import { pathArg, text } from './utility.ts'; +import { validateEditorLinks } from './validate-editor-links.ts'; import { CONFIG_FILENAMES, checkFile, @@ -34,6 +35,11 @@ const args = { default: false, description: `Validate only ${text.em(ROUTING_PATH)}`, }, + 'editor-links': { + type: 'boolean', + default: false, + description: 'Validate only the CloudCannon editor links in the config and README', + }, 'configuration-path': { type: 'string', description: `Path to the CloudCannon configuration file, overrides ${text.em('PATH')} search`, @@ -55,6 +61,13 @@ export const validateCommand = defineCommand({ async run(ctx: CommandContext): Promise { const targetPath = resolve(ctx.args.path ?? '.'); + if (ctx.args['editor-links'] && ctx.args.stdin) { + console.error( + `${text.em('--editor-links')} cannot be combined with ${text.em('--stdin')} — it needs the README and files on disk.` + ); + process.exit(1); + } + if (ctx.args.stdin) { const explicit = [ ctx.args.configuration, @@ -82,22 +95,30 @@ export const validateCommand = defineCommand({ return; } - const none = !ctx.args.configuration && !ctx.args['initial-site-settings'] && !ctx.args.routing; + const none = + !ctx.args.configuration && + !ctx.args['initial-site-settings'] && + !ctx.args.routing && + !ctx.args['editor-links']; const doConfig = ctx.args.configuration || none; const doSettings = ctx.args['initial-site-settings'] || none; const doRouting = ctx.args.routing || none; + const doLinks = ctx.args['editor-links'] || none; let allValid = true; - if (doConfig) { - const configPath = await findConfigFile(targetPath, ctx.args['configuration-path']); + let configPath: string | undefined; + if (doConfig || doLinks) { + configPath = await findConfigFile(targetPath, ctx.args['configuration-path']); if (!configPath) { const searched = ctx.args['configuration-path'] ?? CONFIG_FILENAMES.map(text.em).join(', '); console.error(`No CloudCannon configuration file found. Searched: ${searched}`); process.exit(1); } + } + if (doConfig && configPath) { const configDisplayName = relative(targetPath, configPath); const parsedConfig = await readAndParseFile(configPath, configDisplayName); if (!parsedConfig) { @@ -127,6 +148,10 @@ export const validateCommand = defineCommand({ allValid = (await checkFile(routingPath, validateRouting, targetPath, none)) && allValid; } + if (doLinks && configPath) { + allValid = (await validateEditorLinks(targetPath, configPath)) && allValid; + } + if (!allValid) { process.exit(1); } diff --git a/tests/configure/editor-links.test.ts b/tests/configure/editor-links.test.ts new file mode 100644 index 0000000..888b4b1 --- /dev/null +++ b/tests/configure/editor-links.test.ts @@ -0,0 +1,361 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + extractEditorLinks, + type LinkContext, + resolveEditorLink, +} from '../../src/configure/editor-links.ts'; + +function context(overrides: Partial = {}): LinkContext { + return { + collections: new Map([ + [ + 'pages', + { + key: 'pages', + path: 'src/content/pages', + schemas: ['default'], + enabledEditors: ['visual'], + }, + ], + ['data', { key: 'data', path: 'data', schemas: [] }], + ]), + source: '', + fileExists: (repoPath: string): boolean => + ['src/content/pages/index.md', 'data/pricing.json'].includes(repoPath.replace(/^\/+/, '')), + ...overrides, + }; +} + +describe('extractEditorLinks', () => { + it('returns nothing for strings without the protocol', () => { + assert.deepEqual(extractEditorLinks('just some help text'), []); + assert.deepEqual(extractEditorLinks(''), []); + }); + + it('extracts a bare href value', () => { + assert.deepEqual(extractEditorLinks('cloudcannon:collections/pages'), [ + 'cloudcannon:collections/pages', + ]); + }); + + it('extracts links embedded in markdown, stopping at the closing paren', () => { + assert.deepEqual( + extractEditorLinks('See [the home page](cloudcannon:collections/pages/index.md) now'), + ['cloudcannon:collections/pages/index.md'] + ); + }); + + it('keeps entity-encoded query strings intact', () => { + assert.deepEqual( + extractEditorLinks( + '[x](cloudcannon:collections/pages:/edit?collection=pages&path=%2Fa.md)' + ), + ['cloudcannon:collections/pages:/edit?collection=pages&path=%2Fa.md'] + ); + }); + + it('extracts multiple links from one string', () => { + assert.equal( + extractEditorLinks('[a](cloudcannon:collections/pages) and [b](cloudcannon:collections/data)') + .length, + 2 + ); + }); + + it('keeps bracketed (dynamic-route) filenames intact', () => { + assert.deepEqual(extractEditorLinks('Edit cloudcannon:collections/pages/[slug].md now'), [ + 'cloudcannon:collections/pages/[slug].md', + ]); + }); + + it('trims trailing prose punctuation from a bare link', () => { + assert.deepEqual(extractEditorLinks('Browse the team at cloudcannon:collections/staff.'), [ + 'cloudcannon:collections/staff', + ]); + }); + + it('preserves spaces inside a quoted href (bounded by the closing quote)', () => { + assert.deepEqual( + extractEditorLinks(''), + ['cloudcannon:collections/posts:/edit?path=/a (1).md'] + ); + }); + + it('does not emit anything for the bare word "cloudcannon:" in prose', () => { + assert.deepEqual(extractEditorLinks('The cloudcannon: protocol is neat'), []); + }); + + it('detects the scheme case-insensitively but normalises it', () => { + assert.deepEqual(extractEditorLinks('Cloudcannon:collections/pages'), [ + 'cloudcannon:collections/pages', + ]); + }); + + it('keeps balanced parens inside a markdown link', () => { + assert.deepEqual(extractEditorLinks('[x](cloudcannon:edit?path=/data/list(1).json)'), [ + 'cloudcannon:edit?path=/data/list(1).json', + ]); + }); + + it('does not match the scheme mid-word', () => { + assert.deepEqual(extractEditorLinks('xcloudcannon:collections/foo'), []); + }); + + it('terminates an unquoted attribute at the closing angle bracket', () => { + assert.deepEqual(extractEditorLinks(''), [ + 'cloudcannon:collections/foo', + ]); + }); +}); + +describe('resolveEditorLink — collection/file routes', () => { + it('accepts browsing a collection by key', () => { + assert.equal(resolveEditorLink('cloudcannon:collections/pages', context()), undefined); + }); + + it('accepts a link to a real repository path', () => { + assert.equal( + resolveEditorLink('cloudcannon:collections/src/content/pages/index.md', context()), + undefined + ); + }); + + it('flags the collection-relative-path mistake (the folder-listing bug)', () => { + const finding = resolveEditorLink('cloudcannon:collections/pages/index.md', context()); + assert.equal(finding?.level, 'error'); + assert.match(finding?.message ?? '', /is a collection, but/); + }); + + it('flags an unknown collection with no matching file', () => { + const finding = resolveEditorLink('cloudcannon:collections/blog', context()); + assert.equal(finding?.level, 'error'); + assert.match(finding?.message ?? '', /no collection named/); + }); +}); + +describe('resolveEditorLink — explicit edit routes', () => { + const good = + 'cloudcannon:collections/pages:/edit?collection=pages&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md&schema=default&editor=visual&url=%2F'; + + it('accepts a well-formed edit link', () => { + assert.equal(resolveEditorLink(good, context()), undefined); + }); + + it('accepts the entity-encoded (&) form', () => { + assert.equal(resolveEditorLink(good.replace(/&/g, '&'), context()), undefined); + }); + + it('errors on an unknown collection param', () => { + const finding = resolveEditorLink( + 'cloudcannon:collections/x:/edit?collection=nope&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md', + context() + ); + assert.equal(finding?.level, 'error'); + assert.match(finding?.message ?? '', /unknown collection/); + }); + + it('errors on a missing target file', () => { + const finding = resolveEditorLink( + 'cloudcannon:collections/pages:/edit?collection=pages&path=%2Fsrc%2Fcontent%2Fpages%2Fmissing.md', + context() + ); + assert.equal(finding?.level, 'error'); + assert.match(finding?.message ?? '', /file not found/); + }); + + it('errors on an unknown schema when the collection defines schemas', () => { + const finding = resolveEditorLink( + 'cloudcannon:collections/pages:/edit?collection=pages&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md&schema=nope', + context() + ); + assert.equal(finding?.level, 'error'); + assert.match(finding?.message ?? '', /unknown schema/); + }); + + it('errors on an invalid editor value', () => { + const finding = resolveEditorLink( + 'cloudcannon:collections/pages:/edit?collection=pages&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md&editor=wysiwyg', + context() + ); + assert.equal(finding?.level, 'error'); + assert.match(finding?.message ?? '', /invalid editor/); + }); + + it('warns when the editor is valid but not enabled for the collection', () => { + const finding = resolveEditorLink( + 'cloudcannon:collections/pages:/edit?collection=pages&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md&editor=data', + context() + ); + assert.equal(finding?.level, 'warning'); + assert.match(finding?.message ?? '', /not in _enabled_editors/); + }); + + it('warns when an edit link has no path to verify', () => { + const finding = resolveEditorLink( + 'cloudcannon:collections/pages:/edit?collection=pages&editor=visual', + context() + ); + assert.equal(finding?.level, 'warning'); + assert.match(finding?.message ?? '', /no path parameter/); + }); + + it('does not warn about a missing path on a create route', () => { + assert.equal( + resolveEditorLink('cloudcannon:collections/pages:/create?collection=pages', context()), + undefined + ); + }); + + it('accepts the app’s legacy editor aliases but flags them as deprecated (info)', () => { + for (const editor of ['explore', 'update', 'browser', 'code']) { + const finding = resolveEditorLink( + `cloudcannon:collections/data:/edit?collection=data&path=%2Fdata%2Fpricing.json&editor=${editor}`, + context() + ); + assert.equal(finding?.level, 'info', `editor=${editor} should be an info notice`); + assert.match(finding?.message ?? '', /legacy alias/); + } + }); + + it('does not flag a canonical (non-alias) editor', () => { + assert.equal( + resolveEditorLink( + 'cloudcannon:collections/data:/edit?collection=data&path=%2Fdata%2Fpricing.json&editor=data', + context() + ), + undefined + ); + }); + + it('picks the last value when a query param is repeated (matches the app)', () => { + assert.match( + resolveEditorLink( + 'cloudcannon:collections/pages:/edit?collection=pages&collection=bogus&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md', + context() + )?.message ?? '', + /unknown collection "bogus"/ + ); + assert.equal( + resolveEditorLink( + 'cloudcannon:collections/pages:/edit?collection=bogus&collection=pages&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md', + context() + ), + undefined + ); + }); + + it('handles a bare edit? route (no collections/ prefix)', () => { + assert.equal( + resolveEditorLink( + 'cloudcannon:edit?collection=pages&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md', + context() + ), + undefined + ); + const finding = resolveEditorLink('cloudcannon:edit?collection=nope&path=%2Fx.md', context()); + assert.equal(finding?.level, 'error'); + assert.match(finding?.message ?? '', /unknown collection/); + }); + + it('decodes + to space in query values', () => { + const ctx = context({ fileExists: (p: string): boolean => p.replace(/^\/+/, '') === 'a b.md' }); + assert.equal( + resolveEditorLink('cloudcannon:collections/pages:/edit?collection=pages&path=%2Fa+b.md', ctx), + undefined + ); + }); +}); + +describe('resolveEditorLink — skipped and warned forms', () => { + it('skips the empty body from a bare "cloudcannon:" in prose', () => { + assert.equal(resolveEditorLink('cloudcannon:', context()), undefined); + }); + + it('skips template-placeholder links', () => { + assert.equal( + resolveEditorLink('cloudcannon:collections/[collection]:/edit?path=x', context()), + undefined + ); + assert.equal( + resolveEditorLink('cloudcannon:collections/posts/{{ post.path }}', context()), + undefined + ); + }); + + it('flags legacy never-dead route prefixes as deprecated (info)', () => { + for (const prefix of ['explore', 'browser', 'update', 'source']) { + const finding = resolveEditorLink(`cloudcannon:${prefix}/whatever/missing.md`, context()); + assert.equal(finding?.level, 'info', `${prefix}/ should be an info notice`); + assert.match(finding?.message ?? '', /legacy editor-route prefix/); + } + }); + + it('resolves content/ and data/ as real file paths (dead-link on a miss)', () => { + assert.equal( + resolveEditorLink('cloudcannon:content/src/content/pages/index.md', context()), + undefined + ); + assert.equal(resolveEditorLink('cloudcannon:data/data/pricing.json', context()), undefined); + assert.equal(resolveEditorLink('cloudcannon:content/nope.md', context())?.level, 'error'); + }); + + it('errors on visual/ and preview/ prefixes, which the app cannot open', () => { + assert.equal(resolveEditorLink('cloudcannon:visual/src/index.html', context())?.level, 'error'); + assert.equal(resolveEditorLink('cloudcannon:preview/src/x.md', context())?.level, 'error'); + }); +}); + +describe('resolveEditorLink — unverifiable and source-relative forms', () => { + it('skips front-matter references', () => { + assert.equal(resolveEditorLink('cloudcannon:#title', context()), undefined); + assert.equal(resolveEditorLink('cloudcannon:#object.array[0]', context()), undefined); + }); + + it('skips app-root (!) and known app routes', () => { + assert.equal(resolveEditorLink('cloudcannon:!support', context()), undefined); + assert.equal(resolveEditorLink('cloudcannon:status', context()), undefined); + }); + + it('skips links containing unresolved placeholders', () => { + assert.equal( + resolveEditorLink('cloudcannon:collections/posts/{{ post.path }}', context()), + undefined + ); + }); + + it('treats the injected fileExists probe as authoritative for path resolution', () => { + const present = context({ fileExists: () => true }); + assert.equal(resolveEditorLink('cloudcannon:collections/anything/here.md', present), undefined); + + const absent = context({ fileExists: () => false }); + const finding = resolveEditorLink('cloudcannon:collections/anything/here.md', absent); + assert.equal(finding?.level, 'error'); + }); +}); + +describe('resolveEditorLink — collection key resolution', () => { + const nested = (): LinkContext => ({ + collections: new Map([['blog/posts', { key: 'blog/posts', path: 'blog/_posts', schemas: [] }]]), + source: '', + fileExists: () => false, + }); + + it('browses a collection only when it has a path', () => { + const pathless: LinkContext = { + collections: new Map([['staff', { key: 'staff', schemas: [] }]]), + source: '', + fileExists: () => false, + }; + assert.equal(resolveEditorLink('cloudcannon:collections/staff', pathless)?.level, 'error'); + }); + + it('browses a nested key via its –-encoded form', () => { + assert.equal(resolveEditorLink('cloudcannon:collections/blog–posts', nested()), undefined); + }); + + it('rejects the un-encoded nested form collections/blog/posts', () => { + const finding = resolveEditorLink('cloudcannon:collections/blog/posts', nested()); + assert.equal(finding?.level, 'error'); + }); +}); diff --git a/toolproof-tests/validate/default-missing-settings.toolproof.yml b/toolproof-tests/validate/default-missing-settings.toolproof.yml index ebd43f4..38b6d46 100644 --- a/toolproof-tests/validate/default-missing-settings.toolproof.yml +++ b/toolproof-tests/validate/default-missing-settings.toolproof.yml @@ -13,3 +13,4 @@ steps: - snapshot: stdout snapshot_content: |- ╎✓ valid: cloudcannon.config.yml + ╎✓ valid: editor links (none found) diff --git a/toolproof-tests/validate/editor-links/build-output-ignored.toolproof.yml b/toolproof-tests/validate/editor-links/build-output-ignored.toolproof.yml new file mode 100644 index 0000000..5af3a5f --- /dev/null +++ b/toolproof-tests/validate/editor-links/build-output-ignored.toolproof.yml @@ -0,0 +1,24 @@ +name: cloudcannon validate --editor-links [build output ignored] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + collections_config: + pages: + path: src/content/pages + - step: I have a "dist/pages/index.html" file with the content {content} + content: |- + + - step: I have a ".cloudcannon/README.md" file with the content {content} + content: |- + - [x](cloudcannon:collections/pages:/edit?collection=pages&path=%2Fdist%2Fpages%2Findex.html) + - ref: ../../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --editor-links 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✗ invalid: editor links (1 checked) + ╎ error .cloudcannon/README.md + ╎ cloudcannon:collections/pages:/edit?collection=pages&path=%2Fdist%2Fpages%2Findex.html + ╎ file not found: "/dist/pages/index.html" diff --git a/toolproof-tests/validate/editor-links/editor-not-enabled-warning.toolproof.yml b/toolproof-tests/validate/editor-links/editor-not-enabled-warning.toolproof.yml new file mode 100644 index 0000000..3469837 --- /dev/null +++ b/toolproof-tests/validate/editor-links/editor-not-enabled-warning.toolproof.yml @@ -0,0 +1,28 @@ +name: cloudcannon validate --editor-links [editor not enabled warning] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + collections_config: + pages: + path: src/content/pages + _enabled_editors: + - visual + - step: I have a "src/content/pages/index.md" file with the content {content} + content: |- + --- + title: Home + --- + - step: I have a ".cloudcannon/README.md" file with the content {content} + content: |- + - [Home](cloudcannon:collections/pages:/edit?collection=pages&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md&editor=data) + - ref: ../../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --editor-links 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎! warnings: editor links (1 checked) + ╎ warning .cloudcannon/README.md + ╎ cloudcannon:collections/pages:/edit?collection=pages&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md&editor=data + ╎ editor "data" is not in _enabled_editors for collection "pages" ("visual") diff --git a/toolproof-tests/validate/editor-links/folder-listing-invalid.toolproof.yml b/toolproof-tests/validate/editor-links/folder-listing-invalid.toolproof.yml new file mode 100644 index 0000000..7573bdf --- /dev/null +++ b/toolproof-tests/validate/editor-links/folder-listing-invalid.toolproof.yml @@ -0,0 +1,26 @@ +name: cloudcannon validate --editor-links [folder-listing mistake] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + collections_config: + pages: + path: src/content/pages + - step: I have a "src/content/pages/index.md" file with the content {content} + content: |- + --- + title: Home + --- + - step: I have a ".cloudcannon/README.md" file with the content {content} + content: |- + - [Home](cloudcannon:collections/pages/index.md) + - ref: ../../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --editor-links 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✗ invalid: editor links (1 checked) + ╎ error .cloudcannon/README.md + ╎ cloudcannon:collections/pages/index.md + ╎ "pages" is a collection, but "pages/index.md" is not a file in your repository. Editor links resolve the path as a real repository path, not relative to the collection — use the real path, or the "collections/pages:/edit?path=/…" form diff --git a/toolproof-tests/validate/editor-links/legacy-prefix-notice.toolproof.yml b/toolproof-tests/validate/editor-links/legacy-prefix-notice.toolproof.yml new file mode 100644 index 0000000..3172ba0 --- /dev/null +++ b/toolproof-tests/validate/editor-links/legacy-prefix-notice.toolproof.yml @@ -0,0 +1,21 @@ +name: cloudcannon validate --editor-links [legacy prefix notice] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + collections_config: + pages: + path: src/pages + - step: I have a ".cloudcannon/README.md" file with the content {content} + content: |- + - [old](cloudcannon:explore/src/pages/index.md) + - ref: ../../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --editor-links 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎ℹ notices: editor links (1 checked) + ╎ notice .cloudcannon/README.md + ╎ cloudcannon:explore/src/pages/index.md + ╎ "explore/…" is a legacy editor-route prefix; prefer the "collections/:/edit?path=/…" form diff --git a/toolproof-tests/validate/editor-links/schema-front-matter-invalid.toolproof.yml b/toolproof-tests/validate/editor-links/schema-front-matter-invalid.toolproof.yml new file mode 100644 index 0000000..ad40333 --- /dev/null +++ b/toolproof-tests/validate/editor-links/schema-front-matter-invalid.toolproof.yml @@ -0,0 +1,31 @@ +name: cloudcannon validate --editor-links [schema front matter] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + collections_config: + pages: + path: src/content/pages + schemas: + default: + path: .cloudcannon/schemas/page.md + - step: I have a "src/content/pages/index.md" file with the content {content} + content: |- + --- + title: Home + --- + - step: I have a ".cloudcannon/schemas/page.md" file with the content {content} + content: |- + --- + link: cloudcannon:collections/pages/index.md + --- + - ref: ../../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --editor-links 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✗ invalid: editor links (1 checked) + ╎ error .cloudcannon/schemas/page.md: link + ╎ cloudcannon:collections/pages/index.md + ╎ "pages" is a collection, but "pages/index.md" is not a file in your repository. Editor links resolve the path as a real repository path, not relative to the collection — use the real path, or the "collections/pages:/edit?path=/…" form diff --git a/toolproof-tests/validate/editor-links/source-relative-valid.toolproof.yml b/toolproof-tests/validate/editor-links/source-relative-valid.toolproof.yml new file mode 100644 index 0000000..3eda958 --- /dev/null +++ b/toolproof-tests/validate/editor-links/source-relative-valid.toolproof.yml @@ -0,0 +1,24 @@ +name: cloudcannon validate --editor-links [source-relative path] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + source: site + collections_config: + pages: + path: content/pages + - step: I have a "site/content/pages/index.md" file with the content {content} + content: |- + --- + title: Home + --- + - step: I have a ".cloudcannon/README.md" file with the content {content} + content: |- + - [Home](cloudcannon:collections/pages:/edit?collection=pages&path=%2Fcontent%2Fpages%2Findex.md) + - ref: ../../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --editor-links 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✓ valid: editor links (1 checked) diff --git a/toolproof-tests/validate/editor-links/valid.toolproof.yml b/toolproof-tests/validate/editor-links/valid.toolproof.yml new file mode 100644 index 0000000..ba8d423 --- /dev/null +++ b/toolproof-tests/validate/editor-links/valid.toolproof.yml @@ -0,0 +1,29 @@ +name: cloudcannon validate --editor-links [valid] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + collections_config: + pages: + path: src/content/pages + _enabled_editors: + - visual + schemas: + default: + path: .cloudcannon/schemas/page.md + - step: I have a "src/content/pages/index.md" file with the content {content} + content: |- + --- + title: Home + --- + - step: I have a ".cloudcannon/README.md" file with the content {content} + content: |- + - [Home](cloudcannon:collections/pages:/edit?collection=pages&path=%2Fsrc%2Fcontent%2Fpages%2Findex.md&schema=default&editor=visual) + - [Browse](cloudcannon:collections/pages) + - ref: ../../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --editor-links 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✓ valid: editor links (2 checked) diff --git a/toolproof-tests/validate/help.toolproof.yml b/toolproof-tests/validate/help.toolproof.yml index 21a0903..41740a7 100644 --- a/toolproof-tests/validate/help.toolproof.yml +++ b/toolproof-tests/validate/help.toolproof.yml @@ -18,6 +18,7 @@ steps: ╎ --configuration Validate only the CloudCannon configuration file and any split configuration files (Default: false) ╎ --initial-site-settings Validate only .cloudcannon/initial-site-settings.json (Default: false) ╎ --routing Validate only .cloudcannon/routing.json (Default: false) + ╎ --editor-links Validate only the CloudCannon editor links in the config and README (Default: false) ╎ --configuration-path= Path to the CloudCannon configuration file, overrides PATH search ╎ --stdin Read from stdin instead of files on disk (Default: false) ╎ diff --git a/toolproof-tests/validate/initial-site-settings-invalid.toolproof.yml b/toolproof-tests/validate/initial-site-settings-invalid.toolproof.yml index 4fa972b..d3d9bba 100644 --- a/toolproof-tests/validate/initial-site-settings-invalid.toolproof.yml +++ b/toolproof-tests/validate/initial-site-settings-invalid.toolproof.yml @@ -32,3 +32,4 @@ steps: ╎ $.build.environment_variables[0]: must have required property value ╎ $.build.environment_variables[0]: unexpected property pickle ╎ $.build.environment_variables[0].key: unexpected type array, allowed types: string + ╎✓ valid: editor links (none found) diff --git a/toolproof-tests/validate/initial-site-settings-valid.toolproof.yml b/toolproof-tests/validate/initial-site-settings-valid.toolproof.yml index 4e95524..1e8efff 100644 --- a/toolproof-tests/validate/initial-site-settings-valid.toolproof.yml +++ b/toolproof-tests/validate/initial-site-settings-valid.toolproof.yml @@ -26,3 +26,4 @@ steps: snapshot_content: |- ╎✓ valid: cloudcannon.config.yml ╎✓ valid: .cloudcannon/initial-site-settings.json + ╎✓ valid: editor links (none found) diff --git a/toolproof-tests/validate/invalid.toolproof.yml b/toolproof-tests/validate/invalid.toolproof.yml index e5948f1..c801fda 100644 --- a/toolproof-tests/validate/invalid.toolproof.yml +++ b/toolproof-tests/validate/invalid.toolproof.yml @@ -37,3 +37,4 @@ steps: ╎ $.collections_config.data: must have required property path ╎ $.collections_config.data.icon: unexpected value date_usage, allowed values: 3584 values (closest: data_usage) ╎ $._inputs.date.instance_value: unexpected value now, allowed values: UUID, NOW + ╎✓ valid: editor links (none found) diff --git a/toolproof-tests/validate/valid.toolproof.yml b/toolproof-tests/validate/valid.toolproof.yml index 49d80ad..5127f98 100644 --- a/toolproof-tests/validate/valid.toolproof.yml +++ b/toolproof-tests/validate/valid.toolproof.yml @@ -13,3 +13,4 @@ steps: - snapshot: stdout snapshot_content: |- ╎✓ valid: cloudcannon.config.yml + ╎✓ valid: editor links (none found)