Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions config/defaults/development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Init Secure SSL if can be used
configHelper.initSecureMode(config);
// Print a warning if config.domain is not set
Expand Down
33 changes: 33 additions & 0 deletions lib/helpers/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -238,5 +270,6 @@ export default {
initSecureMode,
initGlobalConfigFiles,
filterByActivation,
filterByDocExclusion,
CORE_MODULES,
};
104 changes: 104 additions & 0 deletions lib/helpers/tests/config.filterByDocExclusion.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading