diff --git a/MIGRATIONS.md b/MIGRATIONS.md index b1fae563c..7b1238690 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -4,6 +4,31 @@ Breaking changes and upgrade notes for downstream projects. --- +## Config: `docs.excludeModules` — doc-only module exclusion (2026-06-29) + +New opt-in `config.docs.excludeModules` (default `[]` → **no behavior change**). It drops a module's `doc/*.yml` (OpenAPI) + `doc/guides/*.md` (guide tree) from the public spec (`/api/spec.json`) and guide tree (`/api/public/docs`), **independent of module runtime activation** — so it works even for **core** modules (`core`/`auth`/`users`/`home`), which `filterByActivation` never filters. + +Why: a module's `activated` flag gates both its routes/models **and** its doc contribution, and core modules bypass that filter entirely. So a core module's sample docs/guides were always served, with no opt-out. A project whose own guides reuse the sample slugs (e.g. `welcome`/`quickstart`, shipped by `home`) collided on duplicate slugs and could only fix it by deleting stack files — which conflicts with keeping stack files byte-identical and recurs on every sync. + +### What changed (this repo) + +- **New config knob** `config.docs.excludeModules: []` (`config/defaults/development.config.js`, in the existing `docs:` block). +- **New helper** `filterByDocExclusion(files, config)` (`lib/helpers/config.js`, next to `filterByActivation`) — drops doc files of listed modules, **no `CORE_MODULES` bypass**; missing/empty/non-array list = no-op. +- **`config/index.js`** — second filter pass applies `filterByDocExclusion` to the `openapi` + `guides` file keys only, after the activation filter. Runtime file keys (routes/models/policies/...) are unaffected. + +### Action required for downstream projects (`/update-stack`) + +- All changes are devkit-owned stack files → arrive via `/update-stack`. Default `[]`: **no action = no behavior change** (sample guides remain a working tutorial). +- A project that keeps a module runtime-active but does **not** want its sample docs/guides in the public spec/tree (e.g. it ships its own guides under the same slugs) sets it in `config/defaults/{project}.config.js`: + + ```js + docs: { excludeModules: ['home'] } + ``` + + Non-core demo modules can instead be dropped wholesale via `config.{module}.activated = false` (existing mechanism); `excludeModules` is for modules that must stay active. + +--- + ## Config rename: `swagger` → `openapi` (2026-06-16) The mis-named `config.swagger` namespace is renamed to `config.openapi`: it gates the OpenAPI JSON spec served at `/api/spec.json`, and there is no Swagger-UI (the Redoc UI was decommissioned earlier). Pure rename, no behavior change. diff --git a/config/defaults/development.config.js b/config/defaults/development.config.js index acdd74e87..badc71e93 100644 --- a/config/defaults/development.config.js +++ b/config/defaults/development.config.js @@ -25,6 +25,12 @@ const config = { guideSections: [ { title: 'Get Started', prefixMin: 0, prefixMax: 9 }, ], + // Modules whose doc/*.yml (OpenAPI) + doc/guides/*.md are excluded from the + // public spec (/api/spec.json) and guide tree (/api/public/docs), independent + // of module activation — works even on core modules (core/auth/users/home). + // Empty = include all (the sample guides stay a working tutorial). A project + // overrides this (e.g. ['home']) when its own guides reuse the sample slugs. + excludeModules: [], }, api: { protocol: 'http', diff --git a/config/index.js b/config/index.js index 0a29d4e29..47a88be3f 100644 --- a/config/index.js +++ b/config/index.js @@ -164,6 +164,15 @@ const initGlobalConfig = async () => { config.files[key] = configHelper.filterByActivation(config.files[key], config); } } + // Exclude doc files (OpenAPI YAML + guides) for modules listed in + // config.docs.excludeModules — independent of runtime activation, so it works + // even for core modules. Empty/missing list = no-op. + const docFileKeys = ['openapi', 'guides']; + for (const key of docFileKeys) { + if (config.files[key]) { + config.files[key] = configHelper.filterByDocExclusion(config.files[key], config); + } + } // Init Secure SSL if can be used configHelper.initSecureMode(config); // Print a warning if config.domain is not set diff --git a/lib/helpers/config.js b/lib/helpers/config.js index 51ddd53eb..f1322235d 100644 --- a/lib/helpers/config.js +++ b/lib/helpers/config.js @@ -203,6 +203,38 @@ const filterByActivation = (files, config) => files.filter((file) => { return moduleConfig.activated !== false; }); +/** + * Filter documentation file paths by `config.docs.excludeModules`. + * + * Drops a module's `doc/*.yml` (OpenAPI) and `doc/guides/*.md` files from the + * resolved file lists, INDEPENDENT of runtime module activation — so it works + * even for core modules (`core`/`auth`/`users`/`home`), which `filterByActivation` + * never filters out. This lets a project keep a module running (e.g. `home` + * serves `/api/health`) while dropping only its sample docs/guides — typically + * to avoid a guide-slug collision with the project's own guides. + * + * Applied only to the `openapi` + `guides` file keys (see `config/index.js`); + * runtime file keys (routes/models/policies/...) are unaffected. As a safeguard + * the filter only ever drops files under a module's `doc/` directory, so even if + * called with a broader list it never removes a module's runtime files. A missing + * or empty `config.docs.excludeModules` is a no-op. + * + * @param {string[]} files - array of file paths + * @param {object} config - merged configuration object + * @returns {string[]} filtered file paths + */ +const filterByDocExclusion = (files, config) => { + const excluded = config.docs?.excludeModules; + if (!Array.isArray(excluded) || excluded.length === 0) return files; // no-op + const excludedSet = new Set(excluded); + return files.filter((file) => { + if (!file.includes('/doc/')) return true; // only ever drop doc files, never runtime files + const moduleName = extractModuleName(file); + if (!moduleName) return true; // not a module file, keep it + return !excludedSet.has(moduleName); // drop listed modules' doc files (incl. core) + }); +}; + /** * Initialize global configuration files by resolving asset glob patterns. * @@ -238,5 +270,6 @@ export default { initSecureMode, initGlobalConfigFiles, filterByActivation, + filterByDocExclusion, CORE_MODULES, }; diff --git a/lib/helpers/tests/config.filterByDocExclusion.unit.tests.js b/lib/helpers/tests/config.filterByDocExclusion.unit.tests.js new file mode 100644 index 000000000..fd936d50e --- /dev/null +++ b/lib/helpers/tests/config.filterByDocExclusion.unit.tests.js @@ -0,0 +1,104 @@ +/** + * Unit tests for filterByDocExclusion in config helper. + * + * filterByDocExclusion drops a module's doc files (OpenAPI YAML + guides) from + * the resolved file lists based on `config.docs.excludeModules`, independent of + * runtime module activation — so it works even for core modules. + */ +import configHelper from '../config.js'; + +const { filterByDocExclusion } = configHelper; + +describe('filterByDocExclusion', () => { + const files = [ + 'modules/core/doc/index.yml', + 'modules/home/doc/openapi.yml', + 'modules/home/doc/guides/00-welcome.md', + 'modules/home/doc/guides/01-quickstart.md', + 'modules/tasks/doc/tasks.yml', + 'modules/tasks/doc/guides/00-tasks.md', + 'modules/billing/doc/billing.yml', + ]; + + it('should return all files unchanged when excludeModules is missing (no docs config)', () => { + expect(filterByDocExclusion(files, {})).toEqual(files); + }); + + it('should return all files unchanged when docs exists but excludeModules is missing', () => { + expect(filterByDocExclusion(files, { docs: { guideSections: [] } })).toEqual(files); + }); + + it('should return all files unchanged when excludeModules is empty (default)', () => { + expect(filterByDocExclusion(files, { docs: { excludeModules: [] } })).toEqual(files); + }); + + it('should drop OpenAPI yml + guides of an excluded CORE module (no activation bypass)', () => { + const config = { docs: { excludeModules: ['home'] } }; + const result = filterByDocExclusion(files, config); + expect(result).not.toContain('modules/home/doc/openapi.yml'); + expect(result).not.toContain('modules/home/doc/guides/00-welcome.md'); + expect(result).not.toContain('modules/home/doc/guides/01-quickstart.md'); + // Other modules' docs untouched + expect(result).toContain('modules/core/doc/index.yml'); + expect(result).toContain('modules/tasks/doc/tasks.yml'); + expect(result).toContain('modules/billing/doc/billing.yml'); + }); + + it('should drop docs of multiple excluded modules', () => { + const config = { docs: { excludeModules: ['home', 'tasks'] } }; + const result = filterByDocExclusion(files, config); + expect(result).not.toContain('modules/home/doc/openapi.yml'); + expect(result).not.toContain('modules/home/doc/guides/00-welcome.md'); + expect(result).not.toContain('modules/tasks/doc/tasks.yml'); + expect(result).not.toContain('modules/tasks/doc/guides/00-tasks.md'); + expect(result).toEqual([ + 'modules/core/doc/index.yml', + 'modules/billing/doc/billing.yml', + ]); + }); + + it('should keep non-module files (no modules/ in path)', () => { + const mixedFiles = [ + 'config/defaults/development.config.js', + 'lib/helpers/config.js', + 'modules/home/doc/guides/00-welcome.md', + ]; + const config = { docs: { excludeModules: ['home'] } }; + const result = filterByDocExclusion(mixedFiles, config); + expect(result).toContain('config/defaults/development.config.js'); + expect(result).toContain('lib/helpers/config.js'); + expect(result).not.toContain('modules/home/doc/guides/00-welcome.md'); + }); + + it('should be a no-op when excludeModules is not an array (defensive)', () => { + expect(filterByDocExclusion(files, { docs: { excludeModules: 'home' } })).toEqual(files); + expect(filterByDocExclusion(files, { docs: { excludeModules: null } })).toEqual(files); + }); + + it('should not drop a module whose name is a prefix of an excluded name', () => { + const prefixFiles = ['modules/home-extras/doc/home-extras.yml']; + const config = { docs: { excludeModules: ['home'] } }; + expect(filterByDocExclusion(prefixFiles, config)).toEqual(prefixFiles); + }); + + it('should only ever drop files under a module doc/ dir, never runtime files of an excluded module', () => { + const runtimeFiles = [ + 'modules/home/routes/home.route.js', + 'modules/home/models/home.mongoose.js', + 'modules/home/doc/openapi.yml', + 'modules/home/doc/guides/00-welcome.md', + ]; + const config = { docs: { excludeModules: ['home'] } }; + const result = filterByDocExclusion(runtimeFiles, config); + // runtime files of the excluded module are kept + expect(result).toContain('modules/home/routes/home.route.js'); + expect(result).toContain('modules/home/models/home.mongoose.js'); + // only its doc files are dropped + expect(result).not.toContain('modules/home/doc/openapi.yml'); + expect(result).not.toContain('modules/home/doc/guides/00-welcome.md'); + }); + + it('should handle empty file array', () => { + expect(filterByDocExclusion([], { docs: { excludeModules: ['home'] } })).toEqual([]); + }); +});