diff --git a/docs/astro.config.ts b/docs/astro.config.ts index ff6aab5..e3f1e75 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -19,7 +19,16 @@ export default defineConfig({ label: "Deutsch", }, }, - plugins: [starlightSpellChecker()], + plugins: [ + starlightSpellChecker({ + usage: { + enabled: true, + }, + spell: { + ignore: ["astro.config.mjs"], + }, + }), + ], sidebar: [ { label: "Start Here", diff --git a/docs/src/content/docs/de/test.mdx b/docs/src/content/docs/de/test.mdx deleted file mode 100644 index d194236..0000000 --- a/docs/src/content/docs/de/test.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: starlight-spell-checker -description: Check your documentation for spelling mistakes; multilingual support. -head: - - tag: title - content: starlight-spell-checker -template: splash -editUrl: false -hero: - tagline: Check your documentation for spelling mistakes; multilingual support. - image: - file: ../../../assets/houston.webp - actions: - - text: Get Started - link: /getting-started/ - icon: right-arrow -draft: true ---- - -import { Card, CardGrid } from '@astrojs/starlight/components' - -## Next steps - - - - In der [Startanleitung](/getting-started/) findest du Anweisungen zur Installation. - - - Bearbeite deine Konfiguration in der Datei `astro.config.mjs`. - - diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx index 78cf602..4225139 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -6,7 +6,7 @@ Check your documentation for spelling mistakes; multilingual support. ## Prerequisites -You will need to have a Starlight websit set up. +You will need to have a Starlight website set up. If you don't have one yet, you can follow the ["Getting Started"](https://starlight.astro.build/getting-started) guide in the Starlight docs to create one. ## Installation diff --git a/packages/starlight-spell-checker/index.ts b/packages/starlight-spell-checker/index.ts index c791a88..a1fcf32 100644 --- a/packages/starlight-spell-checker/index.ts +++ b/packages/starlight-spell-checker/index.ts @@ -5,9 +5,10 @@ import { type StarlightSpellCheckerConfig, type StarlightSpellCheckerUserConfig, } from "./libs/config"; -import { logErrors, validateTexts } from "./libs/validation"; +import { logErrors, logWarnings, validateTexts } from "./libs/validation"; import { clearContentLayerCache } from "./libs/astro"; import { remarkStarlightSpellChecker } from "./libs/remark"; +import { green } from "kleur/colors"; export { type StarlightSpellCheckerConfig }; @@ -42,7 +43,7 @@ export default function starlightSpellChecker( }); }, "astro:build:done": async ({ dir, pages }) => { - const misspellings = await validateTexts( + const { warnings, errors } = await validateTexts( pages, dir, astroConfig, @@ -50,9 +51,14 @@ export default function starlightSpellChecker( config ); - logErrors(logger, misspellings); + logWarnings(logger, warnings); + logErrors(logger, errors); - if (misspellings.size > 0) { + if (warnings.size <= 0 && errors.size <= 0) { + logger.info(green("✓ All words spelled correctly.\n")); + } + + if (errors.size > 0) { throwPluginError("Spelling validation failed."); } }, diff --git a/packages/starlight-spell-checker/libs/config.ts b/packages/starlight-spell-checker/libs/config.ts index 6aed826..8c1854d 100644 --- a/packages/starlight-spell-checker/libs/config.ts +++ b/packages/starlight-spell-checker/libs/config.ts @@ -13,36 +13,467 @@ const configSchema = z exclude: z.array(z.string()).default([]), /** - * Defines a list of words that should be ignored by the spell checker. - * - * The words in this list will be ignored by the spell checker and will not be considered as misspelled. - * - * @default [] + * Configuration for the assuming plugin. */ - ignore: z.array(z.string()).default([]), + assuming: z + .object({ + /** + * Whether to enable the assuming plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if unhelpful phrases are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + + /** + * Defines a list of phrases that should be checked against. + * + * The plugin has an internal list used by default. + */ + phrases: z.array(z.string()).optional(), + + /** + * Defines a list of words that should be ignored by the spell checker. + * + * The words in this list will be ignored by the spell checker and will not be considered as misspelled. + * + * @default [] + */ + ignore: z.array(z.string()).default([]), + + /** + * Defines whether the plugin should also check for inverted assumptions that are probably fine. + * + * @default false + */ + verbose: z.boolean().default(false), + }) + .default({}), /** - * Whether to ignore [literal words](https://github.com/syntax-tree/nlcst-is-literal). - * - * @default true + * Configuration for the casePolice plugin. + */ + casePolice: z + .object({ + /** + * Whether to enable the casePolice plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if popular names casings are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the contractions plugin. */ - ignoreLiterals: z.boolean().default(true), + contractions: z + .object({ + /** + * Whether to enable the contractions plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if apostrophe use in contractions are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), /** - * Whether to ignore “words” that contain digits or times such as `123456` or `2:41pm`. - * - * @default true + * Configuration for the diacritics plugin. */ - ignoreDigits: z.boolean().default(true), + diacritics: z + .object({ + /** + * Whether to enable the diacritics plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if diacritics are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), /** - * Number of times to suggest. - * - * Further misspellings do not get suggestions. - * - * @default 30 + * Configuration for the equality plugin. + */ + equality: z + .object({ + /** + * Whether to enable the equality plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if insensitive, inconsiderate language are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the indefiniteArticle plugin. + */ + indefiniteArticle: z + .object({ + /** + * Whether to enable the indefiniteArticle plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if indefinite articles are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the intensify plugin. + */ + intensify: z + .object({ + /** + * Whether to enable the intensify plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if weak, mitigaing wordings are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the overuse plugin. + */ + overuse: z + .object({ + /** + * Whether to enable the overuse plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if overused words are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the passive plugin. + */ + passive: z + .object({ + /** + * Whether to enable the passive plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if passive voice are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the profanities plugin. + */ + profanities: z + .object({ + /** + * Whether to enable the profanities plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if profane, vulgar wordings are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the readability plugin. + */ + readability: z + .object({ + /** + * Whether to enable the readability plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if readability issues are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the redundantAcronyms plugin. + */ + redundantAcronyms: z + .object({ + /** + * Whether to enable the redundantAcronyms plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if redundant acronyms are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the repeatedWords plugin. + */ + repeatedWords: z + .object({ + /** + * Whether to enable the repeatedWords plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if repeated words are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the simplify plugin. */ - max: z.number().default(30), + simplify: z + .object({ + /** + * Whether to enable the simplify plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if simplifiable words are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the usage plugin. + */ + usage: z + .object({ + /** + * Whether to enable the usage plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if incorrect English usage issues are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the quotes plugin. + */ + quotes: z + .object({ + /** + * Whether to enable the quotes plugin. + * + * @default false + */ + enabled: z.boolean().default(false), + + /** + * Defines whether the plugin should throw an error if quote and apostrophe usage issuges are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + }) + .default({}), + + /** + * Configuration for the spell checker plugin. + */ + spell: z + .object({ + /** + * Whether to enable the spell checker plugin. + * + * @default true + */ + enabled: z.boolean().default(true), + + /** + * Defines whether the plugin should throw an error if misspellings are found. + * + * When set to `false`, the plugin will log warnings and continue the build process. + * When set to `true`, the plugin will throw an error and stop the build process. + * + * @default false + */ + throwError: z.boolean().default(false), + + /** + * Defines a list of words that should be ignored by the spell checker. + * + * The words in this list will be ignored by the spell checker and will not be considered as misspelled. + * + * @default [] + */ + ignore: z.array(z.string()).default([]), + + /** + * Whether to ignore [literal words](https://github.com/syntax-tree/nlcst-is-literal). + * + * @default true + */ + ignoreLiterals: z.boolean().default(true), + + /** + * Whether to ignore “words” that contain digits or times such as `123456` or `2:41pm`. + * + * @default true + */ + ignoreDigits: z.boolean().default(true), + + /** + * Number of times to suggest. + * + * Further misspellings do not get suggestions. + * + * @default 30 + */ + max: z.number().default(30), + }) + .default({}), }) .default({}); diff --git a/packages/starlight-spell-checker/libs/validation.ts b/packages/starlight-spell-checker/libs/validation.ts index 6b79778..ade738f 100644 --- a/packages/starlight-spell-checker/libs/validation.ts +++ b/packages/starlight-spell-checker/libs/validation.ts @@ -3,7 +3,7 @@ import { statSync } from "node:fs"; import type { StarlightUserConfig as StarlightUserConfigWithPlugins } from "@astrojs/starlight/types"; import type { AstroConfig, AstroIntegrationLogger } from "astro"; -import { $, bgGreen, black, blue, dim, green, red } from "kleur/colors"; +import { $, bgGreen, black, blue, dim, green, red, yellow } from "kleur/colors"; import type { StarlightSpellCheckerConfig } from "../libs/config"; @@ -11,6 +11,7 @@ import { retext } from "retext"; import { getLocaleDictionary } from "./i18n"; import { stripLeadingSlash } from "./path"; import { getValidationData } from "./remark"; +import picomatch from "picomatch"; import retextAssuming from "retext-assuming"; import retextCasePolice from "retext-case-police"; @@ -65,52 +66,131 @@ export async function validateTexts( const { contents } = getValidationData(); const errors: ValidationErrors = new Map(); + const warnings: ValidationErrors = new Map(); for (const [filePath, content] of contents) { + if (isExcludedPage(filePath, options.exclude)) { + continue; + } + let dictionary = getLocaleDictionary(filePath, starlightConfig); - let retextProcessor = retext() - .use(retextAssuming) + let retextProcessor = createProcessor(retext()) + .use(retextAssuming, options.assuming.enabled, { + ...(options.assuming.phrases !== undefined && { + phrases: options.assuming.phrases, + }), + ignore: options.assuming.ignore, + verbose: options.assuming.verbose, + }) // .use(retextCliches) - .use(retextContractions) - .use(retextDiacritics) - .use(retextEquality) - .use(retextIndefiniteArticle) - .use(retextIntensify) - // .use(retextOveruse) - .use(retextPassive) - .use(retextProfanities) - .use(retextReadability) - .use(retextRedundantAcronyms) - .use(retextRepeatedWords) - .use(retextSimplify) - .use(retextSpell, { + .use(retextContractions, options.contractions.enabled) + .use(retextDiacritics, options.diacritics.enabled) + .use(retextEquality, options.equality.enabled) + .use(retextIndefiniteArticle, options.indefiniteArticle.enabled) + .use(retextIntensify, options.intensify.enabled) + // .use(retextOveruse, options.overuse.enabled) + .use(retextPassive, options.passive.enabled) + .use(retextProfanities, options.profanities.enabled) + .use(retextReadability, options.readability.enabled) + .use(retextRedundantAcronyms, options.redundantAcronyms.enabled) + .use(retextRepeatedWords, options.repeatedWords.enabled) + .use(retextSimplify, options.simplify.enabled) + .use(retextSpell, options.spell.enabled, { dictionary, + ignore: options.spell.ignore, + ignoreLiterals: options.spell.ignoreLiterals, + ignoreDigits: options.spell.ignoreDigits, + max: options.spell.max, }) - .use(retextUsage) - .use(retextQuotes) - .use(retextCasePolice); + .use(retextUsage, options.usage.enabled) + .use(retextQuotes, options.quotes.enabled) + .use(retextCasePolice, options.casePolice.enabled) + .build(); try { const file = await retextProcessor.process(content); let fileErrors: ValidationError[] = []; + let fileWarnings: ValidationError[] = []; for (const error of file.messages.values()) { - fileErrors.push({ - word: error.actual ?? "", - type: validationErrorTypeMapper[error.source ?? "other"], - suggestions: error.expected ?? [], - }); + const throwError = getThrowErrorForType( + validationErrorTypeMapper[error.source ?? "other"], + options + ); + + if (throwError) { + fileErrors.push({ + word: error.actual ?? "", + type: validationErrorTypeMapper[error.source ?? "other"], + suggestions: error.expected ?? [], + }); + } else { + fileWarnings.push({ + word: error.actual ?? "", + type: validationErrorTypeMapper[error.source ?? "other"], + suggestions: error.expected ?? [], + }); + } } - errors.set(filePath, fileErrors); + if (fileErrors.length > 0) { + errors.set(filePath, fileErrors); + } + if (fileWarnings.length > 0) { + warnings.set(filePath, fileWarnings); + } } catch (err) { console.error(`Error processing file ${filePath}:`, err); } } - return errors; + return { warnings, errors }; +} + +export function logWarnings( + pluginLogger: AstroIntegrationLogger, + warnings: Map +) { + const logger = pluginLogger.fork(""); + + if (warnings.size === 0) { + return; + } + + const warningCount = [...warnings.values()].reduce( + (acc, links) => acc + links.length, + 0 + ); + + logger.warn( + yellow( + `✗ Found ${warningCount} ${pluralize(warningCount, "warning")} in ${ + warnings.size + } ${pluralize(warnings.size, "file")}.` + ) + ); + + for (const [file, validationWarnings] of warnings) { + logger.info(`${yellow("▶")} ${blue(file)}`); + + for (const [index, validationWarning] of validationWarnings.entries()) { + logger.info( + ` ${blue(`${index < validationWarnings.length - 1 ? "├" : "└"}─`)} ${ + validationWarning.word + }${dim(` - ${validationWarning.type}`)}${ + validationWarning.suggestions + ? validationWarning.suggestions.length > 0 + ? ` (${validationWarning.suggestions.join(", ")})` + : " no suggestions" + : "" + }` + ); + } + } + + process.stdout.write("\n"); } export function logErrors( @@ -120,7 +200,6 @@ export function logErrors( const logger = pluginLogger.fork(""); if (errors.size === 0) { - logger.info(green("✓ All words spelled correctly.\n")); return; } @@ -131,7 +210,7 @@ export function logErrors( logger.error( red( - `✗ Found ${errorCount} misspelled ${pluralize(errorCount, "word")} in ${ + `✗ Found ${errorCount} ${pluralize(errorCount, "error")} in ${ errors.size } ${pluralize(errors.size, "file")}.` ) @@ -159,28 +238,36 @@ export function logErrors( } /** - * Check if a link is a valid asset in the build output directory. + * A wrapper around a retext processor to allow conditional plugin chaining. + * + * @param {Processor} processor - The retext processor instance. + * @returns {Object} An object with a `use` method for conditional chaining and a `build` method to finalize. */ -function isValidAsset(path: string, astroConfig: AstroConfig, outputDir: URL) { - if (astroConfig.base !== "/") { - const base = stripLeadingSlash(astroConfig.base); - - if (path.startsWith(base)) { - path = path.replace(new RegExp(`^${stripLeadingSlash(base)}/?`), ""); - } else { - return false; - } - } - - try { - const filePath = fileURLToPath(new URL(path, outputDir)); - const stats = statSync(filePath); - console.log(filePath); - - return stats.isFile(); - } catch { - return false; - } +function createProcessor(processor) { + return { + /** + * Conditionally adds a plugin to the processor. + * + * @param {Function} plugin - The plugin to add (e.g., retextAssuming). + * @param {boolean} condition - Determines whether to apply the plugin. + * @param {Object} [options] - Optional options to pass to the plugin. + * @returns {Object} The same wrapper for chaining. + */ + use(plugin, condition, options = {}) { + if (condition) { + processor = processor.use(plugin, options); + } + return this; + }, + /** + * Finalizes and returns the processor. + * + * @returns {Processor} The built retext processor instance. + */ + build() { + return processor; + }, + }; } function pluralize(count: number, singular: string) { @@ -190,9 +277,44 @@ function pluralize(count: number, singular: string) { /** * Check if a page is excluded from validation by the user. */ -// function isExcludedPage(page: string, exclude: string[]) { -// return picomatch(exclude)(page); -// } +function isExcludedPage(page: string, exclude: string[]) { + return picomatch(exclude)(page); +} + +function getThrowErrorForType( + errorType: ValidationErrorType, + options: Record // The validated options object from your config +): boolean | undefined { + // Create a mapping between ValidationErrorType and option keys + const errorTypeToOptionKey: Record = { + [ValidationErrorType.Assuming]: "assuming", + [ValidationErrorType.CasePolice]: "casePolice", + [ValidationErrorType.Cliches]: "cliches", + [ValidationErrorType.Contractions]: "contractions", + [ValidationErrorType.Diacritics]: "diacritics", + [ValidationErrorType.Equality]: "equality", + [ValidationErrorType.IndefiniteArticle]: "indefiniteArticle", + [ValidationErrorType.Intensify]: "intensify", + [ValidationErrorType.Overuse]: "overuse", + [ValidationErrorType.Passive]: "passive", + [ValidationErrorType.Profanities]: "profanities", + [ValidationErrorType.Readability]: "readability", + [ValidationErrorType.RedundantAcronyms]: "redundantAcronyms", + [ValidationErrorType.RepeatedWords]: "repeatedWords", + [ValidationErrorType.Simplify]: "simplify", + [ValidationErrorType.Spell]: "spell", + [ValidationErrorType.Usage]: "usage", + [ValidationErrorType.Quotes]: "quotes", + [ValidationErrorType.Other]: "other", + }; + + // Find the corresponding option key for the given errorType + const optionKey = errorTypeToOptionKey[errorType]; + if (!optionKey) return undefined; // Return undefined if no mapping exists + + // Access the options dynamically to get the `throwError` value + return options[optionKey]?.throwError ?? undefined; +} type ValidationErrors = Map; diff --git a/packages/starlight-spell-checker/package.json b/packages/starlight-spell-checker/package.json index bdbf510..31d53da 100644 --- a/packages/starlight-spell-checker/package.json +++ b/packages/starlight-spell-checker/package.json @@ -48,6 +48,7 @@ "mdast-util-mdx-jsx": "^3.1.3", "mdast-util-to-string": "^4.0.0", "no-cliches": "^0.3.6", + "picomatch": "^4.0.2", "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42038d2..a2be133 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: no-cliches: specifier: ^0.3.6 version: 0.3.6 + picomatch: + specifier: ^4.0.2 + version: 4.0.2 rehype-stringify: specifier: ^10.0.1 version: 10.0.1