@lowdefy/server-dev 0.0.0-experimental-20261007124348 → 0.0.0-experimental-20261009073700

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/client/recorder/createPairingBuffer.test.mjs +2 -1
  2. package/lib/docs/createDocsMcpServer.js +5 -3
  3. package/lib/docs/devToolDefinitions.js +11 -5
  4. package/lib/docs/docSources.js +21 -0
  5. package/lib/docs/docs.test.mjs +7 -0
  6. package/lib/docs/docsIndex.test.mjs +377 -0
  7. package/lib/docs/getDoc.js +100 -0
  8. package/lib/docs/getDocsIndex.js +152 -0
  9. package/lib/docs/getOverview.js +23 -4
  10. package/lib/docs/getPluginDoc.js +21 -23
  11. package/lib/docs/journeyMutantCookie.chromium.test.mjs +2 -2
  12. package/lib/docs/listModuleDocEntries.js +69 -0
  13. package/lib/docs/listPackageDocFiles.js +67 -0
  14. package/lib/docs/listPluginDocEntries.js +111 -0
  15. package/lib/docs/listPlugins.js +9 -73
  16. package/lib/docs/listTypes.js +14 -6
  17. package/lib/docs/makeModuleManifestDoc.js +174 -0
  18. package/lib/docs/parseModuleSource.js +31 -0
  19. package/lib/docs/readDocEntry.js +40 -0
  20. package/lib/docs/readDocTitle.js +26 -0
  21. package/lib/docs/readPluginPackages.js +99 -0
  22. package/lib/docs/searchDocs.js +82 -30
  23. package/lib/docs/searchDocs.test.mjs +100 -0
  24. package/lib/docs/splitSearchTerms.js +62 -0
  25. package/lib/docs/splitSearchTerms.test.mjs +50 -0
  26. package/lib/server/log/createHandleError.js +8 -3
  27. package/lib/server/log/createHandleError.test.mjs +30 -1
  28. package/lib/server/log/createLogger.js +3 -0
  29. package/lib/server/log/createLogger.test.mjs +15 -0
  30. package/package.json +38 -38
  31. package/package.original.json +38 -38
  32. package/src/app.js +9 -0
  33. package/src/routes/detached.js +5 -2
  34. package/src/routes/docs/content.js +5 -3
  35. package/src/routes/docs/search.js +15 -2
  36. package/src/routes/endpoints.js +7 -1
  37. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
@@ -18,7 +18,8 @@
18
18
  */
19
19
 
20
20
  import { jest } from '@jest/globals';
21
- import { validateTraceRecord } from '@lowdefy/node-utils';
21
+ // Only the validator: the package index loads undici, which needs Node globals jsdom lacks.
22
+ import validateTraceRecord from '@lowdefy/node-utils/journeyTrace/validateTraceRecord.js';
22
23
 
23
24
  import createPairingBuffer, { HOLD_MS } from './createPairingBuffer.js';
24
25
  import createPasswordRedactor from './createPasswordRedactor.js';
@@ -28,7 +28,7 @@ import getBuildStatus from './getBuildStatus.js';
28
28
  import getBuildStatusAfterEdits from './getBuildStatusAfterEdits.js';
29
29
  import readJourneySession from './readJourneySession.js';
30
30
  import readProxyBuildWait from './readProxyBuildWait.js';
31
- import getCoreDoc from './getCoreDoc.js';
31
+ import getDoc from './getDoc.js';
32
32
  import getExamples from './getExamples.js';
33
33
  import getOverview from './getOverview.js';
34
34
  import getPageConfig from './getPageConfig.js';
@@ -420,7 +420,7 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
420
420
  });
421
421
 
422
422
  registerDevTool('lowdefy_get_doc', ({ slug, kind, type }) => {
423
- const doc = getCoreDoc({ slug, kind, type });
423
+ const doc = getDoc({ slug, kind, type });
424
424
  if (doc === null) {
425
425
  return notFoundResult(
426
426
  `No doc found${slug ? ` for slug "${slug}"` : ''}${
@@ -431,7 +431,9 @@ function createDocsMcpServer({ origin, honoContext, version } = {}) {
431
431
  return textResult(appendHazards(doc));
432
432
  });
433
433
 
434
- registerDevTool('lowdefy_search_docs', ({ query }) => textResult(searchDocs({ query })));
434
+ registerDevTool('lowdefy_search_docs', ({ query, source }) =>
435
+ textResult(searchDocs({ query, source }))
436
+ );
435
437
 
436
438
  registerDevTool('lowdefy_search_icons', async ({ query, limit }) =>
437
439
  textResult(await searchIcons({ query, limit }))
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { z } from 'zod';
18
18
 
19
+ import docSources from './docSources.js';
19
20
  import { MAX_JOURNEY_TIMEOUT } from './validateJourneyTimeout.js';
20
21
  import { MAX_VIEWPORT_SIZE } from './validateViewport.js';
21
22
 
@@ -27,7 +28,7 @@ import { MAX_VIEWPORT_SIZE } from './validateViewport.js';
27
28
 
28
29
  const INSTRUCTIONS = `Lowdefy documentation and feedback server for this project. Lowdefy apps are YAML config composing blocks (UI), operators (logic), actions (event handlers), and connections/requests (data).
29
30
 
30
- Discovery workflow: start with lowdefy_overview. Use lowdefy_list_types with a kind to discover ALL installed blocks/operators/actions/connections/requests — never guess type names. Then lowdefy_get_schema and lowdefy_get_examples for the exact contract of a type, and lowdefy_get_doc / lowdefy_search_docs for concept documentation. Icons: use a semantic name (icon: edit); otherwise use a Lucide name in PascalCase (icon: Receipt) found with lowdefy_search_icons; never invent an icon name, never emoji; in any HTML string use <i data-icon="edit"></i>, data-tooltip="…" and data-popover="…", ClickableHtml with data-event for clicks (list each event in its dataEvents property; escape request and user data in the HTML), <a data-page-id="page" data-url-query="k=v"> for in-app links (never a hard-coded href; data-new-tab, not target), and data-tag="success" / data-status="success" for statuses (never inline-styled pills), <time datetime="…" data-time="relative">, data-format="currency" data-currency="USD" on raw numbers, data-avatar="Name" (never an avatar image service), data-copy, data-truncate="2", data-tone="secondary" (never inline grey colours) and, in ClickableHtml, confirm on destructive events in dataEvents ({ name: onDelete, confirm: "…" }); lowdefy_get_doc concepts/html-attributes lists them all. lowdefy_list_plugins and lowdefy_get_plugin_doc cover this project's local plugin packages.
31
+ Discovery workflow: start with lowdefy_overview. Use lowdefy_list_types with a kind to discover ALL installed blocks/operators/actions/connections/requests — never guess type names. Then lowdefy_get_schema and lowdefy_get_examples for the exact contract of a type, and lowdefy_get_doc / lowdefy_search_docs for concept documentation. Icons: use a semantic name (icon: edit); otherwise use a Lucide name in PascalCase (icon: Receipt) found with lowdefy_search_icons; never invent an icon name, never emoji; in any HTML string use <i data-icon="edit"></i>, data-tooltip="…" and data-popover="…", ClickableHtml with data-event for clicks (list each event in its dataEvents property; escape request and user data in the HTML), <a data-page-id="page" data-url-query="k=v"> for in-app links (never a hard-coded href; data-new-tab, not target), and data-tag="success" / data-status="success" for statuses (never inline-styled pills), <time datetime="…" data-time="relative">, data-format="currency" data-currency="USD" on raw numbers, data-avatar="Name" (never an avatar image service), data-copy, data-truncate="2", data-tone="secondary" (never inline grey colours) and, in ClickableHtml, confirm on destructive events in dataEvents ({ name: onDelete, confirm: "…" }); lowdefy_get_doc concepts/html-attributes lists them all. lowdefy_search_docs and lowdefy_get_doc also cover the docs this app's own plugins and modules ship (READMEs, docs/*.md, and modules/<id>/manifest for a module's components, exports and vars), so read those before reading plugin or module source.
31
32
 
32
33
  Push events: build results, server restarts and browser/server errors arrive as notifications/message from logger "lowdefy" (data.type is one of build, restart, client_error, server_error; a build event carries status, errors, warnings and stale). Act on them without polling — lowdefy_build_status remains the full picture.
33
34
 
@@ -457,7 +458,7 @@ const devToolDefinitions = {
457
458
 
458
459
  lowdefy_get_doc: {
459
460
  description:
460
- 'Get a core Lowdefy documentation page as markdown. Look up by slug (e.g. "concepts/lowdefy-schema", "operators/_get") or by kind + type name. Key concept slugs: concepts/lowdefy-schema, concepts/blocks, concepts/events-and-actions, concepts/connections-and-requests, concepts/operators, concepts/page-and-app-state.' +
461
+ 'Get a Lowdefy documentation page as markdown. Look up by slug (e.g. "concepts/lowdefy-schema", "operators/_get") or by kind + type name. Also returns the docs this app\'s own plugins and modules ship: "plugins/<package>" and "modules/<id>" for a README, "plugins/<package>/<file>" and "modules/<id>/<file>" for a docs/*.md file, and "modules/<id>/manifest" for the components, exports and vars a module declares. Key concept slugs: concepts/lowdefy-schema, concepts/blocks, concepts/events-and-actions, concepts/connections-and-requests, concepts/operators, concepts/page-and-app-state.' +
461
462
  HAZARDS_NOTE,
462
463
  inputSchema: {
463
464
  slug: z.string().optional().describe('Doc slug, e.g. "operators/_get".'),
@@ -470,9 +471,14 @@ const devToolDefinitions = {
470
471
  },
471
472
 
472
473
  lowdefy_search_docs: {
473
- description: 'Search the core Lowdefy docs by keyword. Returns matching slugs with snippets.',
474
+ description:
475
+ 'Search the Lowdefy docs by keywords. The query is split into words, and pages are ranked by how many match, title matches first. Returns up to 20 hits, each with its slug, snippet, source, package and version; read one with lowdefy_get_doc.',
474
476
  inputSchema: {
475
- query: z.string().describe('Search keywords.'),
477
+ query: z.string().describe('Search keywords, e.g. "Link action pathParams".'),
478
+ source: z
479
+ .enum(docSources)
480
+ .optional()
481
+ .describe('Only search docs from this source. Default: all.'),
476
482
  },
477
483
  },
478
484
 
@@ -492,7 +498,7 @@ const devToolDefinitions = {
492
498
 
493
499
  lowdefy_get_plugin_doc: {
494
500
  description:
495
- "Get markdown documentation shipped inside an installed plugin package (README, guides). Useful for this project's local custom plugins.",
501
+ 'Get all the markdown an installed plugin package ships (README, docs/*.md) as one page. lowdefy_search_docs and lowdefy_get_doc reach the same files page by page.',
496
502
  inputSchema: {
497
503
  package: z.string().describe('The package name, e.g. "@lowdefy/blocks-antd".'),
498
504
  },
@@ -0,0 +1,21 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // Where a docs index entry comes from. Search hits name it, and the search
18
+ // source filter takes one of these.
19
+ const docSources = ['core', 'plugin', 'local-plugin', 'module'];
20
+
21
+ export default docSources;
@@ -202,6 +202,13 @@ test('getPluginDoc returns null for packages without docs', () => {
202
202
  expect(getPluginDoc({ packageName: 'no-such-package' })).toBeNull();
203
203
  });
204
204
 
205
+ test('getPluginDoc reads only the app plugins, never a path out of node_modules', () => {
206
+ fs.writeFileSync(path.join(fixtureDir, 'README.md'), '# Server readme\n');
207
+ expect(getPluginDoc({ packageName: '..' })).toBeNull();
208
+ expect(getPluginDoc({ packageName: 'test-plugin/../..' })).toBeNull();
209
+ fs.rmSync(path.join(fixtureDir, 'README.md'));
210
+ });
211
+
205
212
  test('getCoreDoc returns markdown by slug from docs-content', () => {
206
213
  const doc = getCoreDoc({ slug: 'operators/_get' });
207
214
  expect(doc.title).toEqual('_get');
@@ -0,0 +1,377 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import fs from 'fs';
18
+ import os from 'os';
19
+ import path from 'path';
20
+
21
+ import { serializer } from '@lowdefy/helpers';
22
+
23
+ // A server directory whose build lists an installed plugin, a local plugin
24
+ // linked from outside node_modules, one of Lowdefy's own plugins and a local
25
+ // module. resolvePluginDir reads process.cwd() at import, so the directory is
26
+ // made and entered before the docs modules are imported.
27
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lowdefy-docs-index-test-'));
28
+ const serverDir = path.join(rootDir, 'server');
29
+ const localPluginDir = path.join(rootDir, 'plugins', 'local-tools');
30
+ const moduleRoot = path.join(rootDir, 'modules', 'contacts');
31
+
32
+ function write(filePath, data) {
33
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
34
+ fs.writeFileSync(filePath, typeof data === 'string' ? data : JSON.stringify(data));
35
+ }
36
+
37
+ function writeBuild(name, data) {
38
+ const filePath = path.join(serverDir, 'build', name);
39
+ write(filePath, data);
40
+ // A rewrite inside the same millisecond would keep the old mtime.
41
+ const later = new Date(Date.now() + Math.floor(Math.random() * 100000));
42
+ fs.utimesSync(filePath, later, later);
43
+ }
44
+
45
+ function moduleEntry(overrides = {}) {
46
+ return {
47
+ id: 'contacts',
48
+ source: 'file:../modules/contacts',
49
+ moduleRoot,
50
+ packageRoot: moduleRoot,
51
+ isLocal: true,
52
+ consumerVars: { owner_field: 'created_by' },
53
+ resolvedVarCache: {},
54
+ varDefs: {
55
+ collection: { type: 'string', description: 'Collection of contacts.', default: 'contacts' },
56
+ labels: {
57
+ description: 'Display labels.',
58
+ properties: {
59
+ title: { type: 'string', description: 'List title.', default: 'Contacts' },
60
+ },
61
+ },
62
+ owner_field: { type: 'string', required: true, description: 'Field with the owner id.' },
63
+ sort: { description: 'Sort order.', default: { '_build.env': 'SORT' } },
64
+ template: {
65
+ description: 'Card template.',
66
+ default: { '~deferred': 'contacts:vars.template' },
67
+ },
68
+ },
69
+ manifest: {
70
+ name: 'Contacts',
71
+ description: 'A contact list.',
72
+ exports: {
73
+ pages: [{ id: 'contact-list', description: 'All contacts.' }],
74
+ components: [{ id: 'contact-card', description: 'One contact as a card.' }],
75
+ },
76
+ components: [{ id: 'contact-card', component: { '~deferred': 'contacts:c.0' } }],
77
+ pages: [{ id: 'contacts/contact-list', type: 'Box' }],
78
+ },
79
+ ...overrides,
80
+ };
81
+ }
82
+
83
+ write(path.join(serverDir, 'package.json'), { name: 'server', version: '1.0.0' });
84
+ writeBuild('plugins/availableTypes.json', {
85
+ actions: {},
86
+ blocks: {
87
+ Button: { package: '@lowdefy/blocks-antd', version: '5.0.0' },
88
+ ButtonPlus: { package: 'fancy-blocks', version: '2.1.0' },
89
+ FancyCard: { package: 'fancy-blocks', version: '2.1.0' },
90
+ PlainCard: { package: 'fancy-blocks', version: '2.1.0' },
91
+ SetState: { package: 'fancy-blocks', version: '2.1.0' },
92
+ },
93
+ operators: {
94
+ client: { _shout: { package: 'local-tools', version: 'workspace:*' } },
95
+ server: { _shout: { package: 'local-tools', version: 'workspace:*' } },
96
+ },
97
+ });
98
+ writeBuild('customTypesMap.json', {
99
+ operators: { client: { _shout: { package: 'local-tools', version: 'workspace:*' } } },
100
+ });
101
+ writeBuild('installedPluginPackages.json', ['@lowdefy/blocks-antd', 'fancy-blocks', 'local-tools']);
102
+ writeBuild('modules.json', serializer.serialize({ contacts: moduleEntry() }));
103
+
104
+ write(path.join(serverDir, 'node_modules/@lowdefy/blocks-antd/package.json'), {
105
+ name: '@lowdefy/blocks-antd',
106
+ version: '5.0.0',
107
+ });
108
+ write(path.join(serverDir, 'node_modules/@lowdefy/blocks-antd/README.md'), '# Antd blocks\n');
109
+ write(path.join(serverDir, 'node_modules/fancy-blocks/package.json'), {
110
+ name: 'fancy-blocks',
111
+ version: '2.1.0',
112
+ });
113
+ write(path.join(serverDir, 'node_modules/fancy-blocks/README.md'), '# Fancy blocks\n\nCards.\n');
114
+ write(
115
+ path.join(serverDir, 'node_modules/fancy-blocks/dist/docs/FancyCard.md'),
116
+ '# FancyCard block\n\nA card with a gradient border.\n'
117
+ );
118
+ write(path.join(localPluginDir, 'package.json'), { name: 'local-tools', version: '0.3.1' });
119
+ write(path.join(localPluginDir, 'README.md'), '# Local tools\n\nShouting operators.\n');
120
+ write(path.join(localPluginDir, 'docs/_shout.md'), '# The _shout operator\n\nUppercases text.\n');
121
+ // Links out of the package, a link to nowhere and a directory named like a
122
+ // doc are never indexed.
123
+ write(path.join(rootDir, 'outside.md'), '# Outside\n\nNot part of the plugin.\n');
124
+ fs.symlinkSync(path.join(rootDir, 'outside.md'), path.join(localPluginDir, 'docs/leak.md'));
125
+ fs.symlinkSync(path.join(rootDir, 'missing.md'), path.join(localPluginDir, 'docs/broken.md'));
126
+ fs.mkdirSync(path.join(localPluginDir, 'docs/folder.md'));
127
+ fs.symlinkSync(localPluginDir, path.join(serverDir, 'node_modules/local-tools'));
128
+ write(path.join(moduleRoot, 'README.md'), '# Contacts module\n\nA list of people you work with.\n');
129
+ write(path.join(moduleRoot, 'docs/setup.md'), '# Setting up contacts\n\nCreate the collection.\n');
130
+
131
+ process.chdir(serverDir);
132
+
133
+ const { default: getDocsIndex } = await import('./getDocsIndex.js');
134
+ const { default: getDoc } = await import('./getDoc.js');
135
+ const { default: listPlugins } = await import('./listPlugins.js');
136
+ const { default: listTypes } = await import('./listTypes.js');
137
+ const { default: searchDocs } = await import('./searchDocs.js');
138
+
139
+ function appEntries() {
140
+ return getDocsIndex()
141
+ .entries.filter((entry) => entry.source !== 'core')
142
+ .map(({ slug, source, package: packageName, version, title }) => ({
143
+ slug,
144
+ source,
145
+ package: packageName,
146
+ version,
147
+ title,
148
+ }));
149
+ }
150
+
151
+ afterAll(() => {
152
+ process.chdir(os.tmpdir());
153
+ fs.rmSync(rootDir, { recursive: true, force: true });
154
+ });
155
+
156
+ test('docs index lists plugin and module docs, leaving out Lowdefy plugins', () => {
157
+ expect(appEntries()).toEqual([
158
+ {
159
+ slug: 'plugins/fancy-blocks',
160
+ source: 'plugin',
161
+ package: 'fancy-blocks',
162
+ version: '2.1.0',
163
+ title: 'fancy-blocks',
164
+ },
165
+ {
166
+ slug: 'plugins/fancy-blocks/FancyCard',
167
+ source: 'plugin',
168
+ package: 'fancy-blocks',
169
+ version: '2.1.0',
170
+ title: 'FancyCard block',
171
+ },
172
+ {
173
+ slug: 'plugins/local-tools',
174
+ source: 'local-plugin',
175
+ package: 'local-tools',
176
+ version: '0.3.1',
177
+ title: 'local-tools',
178
+ },
179
+ {
180
+ slug: 'plugins/local-tools/_shout',
181
+ source: 'local-plugin',
182
+ package: 'local-tools',
183
+ version: '0.3.1',
184
+ title: 'The _shout operator',
185
+ },
186
+ {
187
+ slug: 'modules/contacts',
188
+ source: 'module',
189
+ package: 'file:../modules/contacts',
190
+ version: 'local',
191
+ title: 'Contacts module',
192
+ },
193
+ {
194
+ slug: 'modules/contacts/setup',
195
+ source: 'module',
196
+ package: 'file:../modules/contacts',
197
+ version: 'local',
198
+ title: 'Setting up contacts',
199
+ },
200
+ {
201
+ slug: 'modules/contacts/manifest',
202
+ source: 'module',
203
+ package: 'file:../modules/contacts',
204
+ version: 'local',
205
+ title: 'contacts module: components, exports and vars',
206
+ },
207
+ ]);
208
+ });
209
+
210
+ test('a plugin type points at its own doc, else at the package README', () => {
211
+ expect(getDoc({ kind: 'block', type: 'FancyCard' }).slug).toEqual(
212
+ 'plugins/fancy-blocks/FancyCard'
213
+ );
214
+ expect(getDoc({ kind: 'block', type: 'PlainCard' }).slug).toEqual('plugins/fancy-blocks');
215
+ expect(getDoc({ type: '_shout' }).slug).toEqual('plugins/local-tools/_shout');
216
+ });
217
+
218
+ test('a plugin type named like a core type gets its plugin doc, not the core prefix match', () => {
219
+ expect(getDoc({ kind: 'block', type: 'ButtonPlus' }).slug).toEqual('plugins/fancy-blocks');
220
+ });
221
+
222
+ test('a plugin type named like a core type of another kind leaves the core page to the core type', () => {
223
+ expect(getDoc({ type: 'SetState' }).slug).toEqual('actions/setstate');
224
+ expect(getDoc({ kind: 'action', type: 'SetState' }).slug).toEqual('actions/setstate');
225
+ expect(getDoc({ kind: 'block', type: 'SetState' }).slug).toEqual('plugins/fancy-blocks');
226
+ });
227
+
228
+ test('docs that link outside the package, link nowhere or are directories are left out', () => {
229
+ const slugs = getDocsIndex().entries.map((entry) => entry.slug);
230
+ expect(slugs).toContain('plugins/local-tools/_shout');
231
+ expect(slugs).not.toContain('plugins/local-tools/leak');
232
+ expect(slugs).not.toContain('plugins/local-tools/broken');
233
+ expect(slugs).not.toContain('plugins/local-tools/folder');
234
+ expect(getDoc({ slug: 'plugins/local-tools/leak' })).toBeNull();
235
+ });
236
+
237
+ test('getDoc returns plugin and module docs by slug with their source', () => {
238
+ const readme = getDoc({ slug: 'modules/contacts' });
239
+ expect(readme).toEqual(
240
+ expect.objectContaining({
241
+ slug: 'modules/contacts',
242
+ source: 'module',
243
+ package: 'file:../modules/contacts',
244
+ version: 'local',
245
+ })
246
+ );
247
+ expect(readme.markdown).toContain('A list of people you work with.');
248
+ expect(getDoc({ slug: 'plugins/local-tools' }).markdown).toContain('Shouting operators.');
249
+ expect(getDoc({ slug: 'plugins/@lowdefy/blocks-antd' })).toBeNull();
250
+ });
251
+
252
+ test('getDoc still returns core docs first', () => {
253
+ expect(getDoc({ slug: 'actions/setstate' }).slug).toEqual('actions/setstate');
254
+ expect(getDoc({ kind: 'action', type: 'SetState' }).slug).toEqual('actions/setstate');
255
+ });
256
+
257
+ test('module manifest page lists vars, components and exports', () => {
258
+ const { markdown } = getDoc({ slug: 'modules/contacts/manifest' });
259
+ expect(markdown).toContain('A contact list.');
260
+ expect(markdown).toContain(
261
+ '- `collection` (string, default `"contacts"`): Collection of contacts.'
262
+ );
263
+ expect(markdown).toContain(' - `labels.title` (string, default `"Contacts"`): List title.');
264
+ expect(markdown).toContain('- `owner_field` (string, required): Field with the owner id.');
265
+ expect(markdown).toContain('- `sort` (default computed): Sort order.');
266
+ expect(markdown).toContain('- `template` (default computed): Card template.');
267
+ expect(markdown).toContain('- `contact-card`: One contact as a card.');
268
+ expect(markdown).toContain('- `contact-list`: All contacts.');
269
+ });
270
+
271
+ test('searchDocs finds module and local plugin docs by source', () => {
272
+ expect(searchDocs({ query: 'owner_field', source: 'module' }).map((hit) => hit.slug)).toEqual([
273
+ 'modules/contacts/manifest',
274
+ ]);
275
+ const [hit] = searchDocs({ query: 'shouting operators', source: 'local-plugin' });
276
+ expect(hit).toEqual(
277
+ expect.objectContaining({
278
+ slug: 'plugins/local-tools',
279
+ source: 'local-plugin',
280
+ package: 'local-tools',
281
+ version: '0.3.1',
282
+ })
283
+ );
284
+ });
285
+
286
+ test('listTypes and listPlugins name plugin doc slugs', () => {
287
+ const fancy = listTypes({ kind: 'blocks' }).find((item) => item.type === 'FancyCard');
288
+ expect(fancy.docSlug).toEqual('plugins/fancy-blocks/FancyCard');
289
+ const plugins = listPlugins();
290
+ expect(plugins.find((plugin) => plugin.package === 'local-tools').docSlug).toEqual(
291
+ 'plugins/local-tools'
292
+ );
293
+ expect(plugins.find((plugin) => plugin.package === '@lowdefy/blocks-antd').docSlug).toBe(
294
+ undefined
295
+ );
296
+ });
297
+
298
+ test('a config-only edit leaves the docs index alone', () => {
299
+ const before = getDocsIndex();
300
+ writeBuild(
301
+ 'modules.json',
302
+ serializer.serialize({
303
+ contacts: moduleEntry({
304
+ consumerVars: { owner_field: 'owner' },
305
+ resolvedVarCache: { 'labels.title': 'People' },
306
+ }),
307
+ })
308
+ );
309
+ writeBuild('pages/home.json', { id: 'home', type: 'Box' });
310
+ expect(getDocsIndex()).toBe(before);
311
+ });
312
+
313
+ test('a module change rebuilds the docs index', () => {
314
+ const before = getDocsIndex();
315
+ const changed = moduleEntry();
316
+ changed.varDefs.collection.description = 'Where contacts are stored.';
317
+ writeBuild('modules.json', serializer.serialize({ contacts: changed }));
318
+ const after = getDocsIndex();
319
+ expect(after).not.toBe(before);
320
+ expect(getDoc({ slug: 'modules/contacts/manifest' }).markdown).toContain(
321
+ 'Where contacts are stored.'
322
+ );
323
+ });
324
+
325
+ test('a plugin change rebuilds the docs index', () => {
326
+ const before = getDocsIndex();
327
+ writeBuild('installedPluginPackages.json', [
328
+ '@lowdefy/blocks-antd',
329
+ 'fancy-blocks',
330
+ 'local-tools',
331
+ 'x',
332
+ ]);
333
+ expect(getDocsIndex()).not.toBe(before);
334
+ });
335
+
336
+ test('a doc file added to a local plugin or module rebuilds the docs index', () => {
337
+ const before = getDocsIndex();
338
+ write(path.join(localPluginDir, 'docs/whisper.md'), '# Whispering\n\nLowercases text.\n');
339
+ const afterPlugin = getDocsIndex();
340
+ expect(afterPlugin).not.toBe(before);
341
+ expect(afterPlugin.entries.map((entry) => entry.slug)).toContain('plugins/local-tools/whisper');
342
+ write(path.join(moduleRoot, 'docs/import.md'), '# Importing contacts\n');
343
+ expect(getDocsIndex().entries.map((entry) => entry.slug)).toContain('modules/contacts/import');
344
+ });
345
+
346
+ test('a malformed module manifest names the bad parts on its page and breaks no docs call', () => {
347
+ const broken = moduleEntry({
348
+ id: 'broken',
349
+ varDefs: { good: { type: 'string' }, bad: null, nested: { properties: { inner: 'x' } } },
350
+ manifest: {
351
+ components: { card: {} },
352
+ exports: { pages: ['home', null, { id: 'list' }], api: 'all' },
353
+ },
354
+ });
355
+ writeBuild('modules.json', serializer.serialize({ contacts: moduleEntry(), broken }));
356
+ const { markdown } = getDoc({ slug: 'modules/broken/manifest' });
357
+ expect(markdown).toContain('- `good` (string)');
358
+ expect(markdown).toContain(
359
+ '- `bad`: not valid in module.lowdefy.yaml, expected an object. Received null.'
360
+ );
361
+ expect(markdown).toContain(
362
+ ' - `nested.inner`: not valid in module.lowdefy.yaml, expected an object. Received "x".'
363
+ );
364
+ expect(markdown).toContain(
365
+ '- `components`: not valid in module.lowdefy.yaml, expected a list. Received {"card":{}}.'
366
+ );
367
+ expect(markdown).toContain(
368
+ '- `exports.pages.0`: not valid in module.lowdefy.yaml, expected an object with a string id. Received "home".'
369
+ );
370
+ expect(markdown).toContain('- `list`');
371
+ expect(markdown).toContain(
372
+ '- `exports.api`: not valid in module.lowdefy.yaml, expected a list. Received "all".'
373
+ );
374
+ expect(searchDocs({ query: 'not valid', source: 'module' }).map((hit) => hit.slug)).toEqual([
375
+ 'modules/broken/manifest',
376
+ ]);
377
+ });
@@ -0,0 +1,100 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { type } from '@lowdefy/helpers';
18
+
19
+ import getCoreDoc from './getCoreDoc.js';
20
+ import getDocsIndex from './getDocsIndex.js';
21
+ import readDocEntry from './readDocEntry.js';
22
+
23
+ function singularKind({ kind }) {
24
+ return type.isNone(kind) ? null : String(kind).toLowerCase().replace(/s$/, '');
25
+ }
26
+
27
+ function findTypeSlug({ typeDocs, kind, typeName, anyKind }) {
28
+ const exactKind = singularKind({ kind });
29
+ if (!type.isNone(exactKind) && typeDocs.has(`${exactKind}:${typeName}`)) {
30
+ return typeDocs.get(`${exactKind}:${typeName}`);
31
+ }
32
+ if (!type.isNone(exactKind) && !anyKind) {
33
+ return null;
34
+ }
35
+ for (const [key, slug] of typeDocs) {
36
+ if (key.slice(key.indexOf(':') + 1) === typeName) {
37
+ return slug;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // With no kind, a core page for exactly this type name belongs to a Lowdefy
44
+ // type, unless a plugin registers the name in that same kind and so replaces
45
+ // it. A plugin type of another kind must not take that page's place.
46
+ function namesOtherCoreType({ entries, typeDocs, kind, typeName }) {
47
+ if (!type.isNone(kind)) {
48
+ return false;
49
+ }
50
+ return entries.some(
51
+ (entry) =>
52
+ entry.source === 'core' &&
53
+ entry.typeName === typeName &&
54
+ !typeDocs.has(`${entry.kind}:${typeName}`)
55
+ );
56
+ }
57
+
58
+ function makePluginDoc({ entries, slug }) {
59
+ const entry = entries.find((item) => item.source !== 'core' && item.slug === slug);
60
+ if (type.isUndefined(entry)) {
61
+ return null;
62
+ }
63
+ return {
64
+ slug: entry.slug,
65
+ title: entry.title,
66
+ section: entry.section,
67
+ source: entry.source,
68
+ package: entry.package,
69
+ version: entry.version,
70
+ markdown: readDocEntry({ entry }),
71
+ };
72
+ }
73
+
74
+ // A doc page by slug or by type: the core docs, and the docs the app's own
75
+ // plugins and modules ship. A type a plugin registers is looked up in its
76
+ // plugin's docs before the core docs, whose type lookup falls back to a
77
+ // prefix match (MongoDB covers MongoDBFind) that would claim a plugin type
78
+ // named like a core one. Plugin types the caller names under another kind
79
+ // (the kinds get_doc takes do not cover every kind) come last.
80
+ function getDoc({ slug, kind, type: typeName }) {
81
+ const { entries, typeDocs } = getDocsIndex();
82
+ if (!type.isNone(slug)) {
83
+ return getCoreDoc({ slug }) ?? makePluginDoc({ entries, slug });
84
+ }
85
+ if (type.isNone(typeName)) {
86
+ return null;
87
+ }
88
+ const pluginSlug = findTypeSlug({ typeDocs, kind, typeName, anyKind: false });
89
+ if (!type.isNone(pluginSlug) && !namesOtherCoreType({ entries, typeDocs, kind, typeName })) {
90
+ return makePluginDoc({ entries, slug: pluginSlug });
91
+ }
92
+ const coreDoc = getCoreDoc({ kind, type: typeName });
93
+ if (!type.isNone(coreDoc)) {
94
+ return coreDoc;
95
+ }
96
+ const otherKindSlug = findTypeSlug({ typeDocs, kind, typeName, anyKind: true });
97
+ return type.isNone(otherKindSlug) ? null : makePluginDoc({ entries, slug: otherKindSlug });
98
+ }
99
+
100
+ export default getDoc;