@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.
- package/client/recorder/createPairingBuffer.test.mjs +2 -1
- package/lib/docs/createDocsMcpServer.js +5 -3
- package/lib/docs/devToolDefinitions.js +11 -5
- package/lib/docs/docSources.js +21 -0
- package/lib/docs/docs.test.mjs +7 -0
- package/lib/docs/docsIndex.test.mjs +377 -0
- package/lib/docs/getDoc.js +100 -0
- package/lib/docs/getDocsIndex.js +152 -0
- package/lib/docs/getOverview.js +23 -4
- package/lib/docs/getPluginDoc.js +21 -23
- package/lib/docs/journeyMutantCookie.chromium.test.mjs +2 -2
- package/lib/docs/listModuleDocEntries.js +69 -0
- package/lib/docs/listPackageDocFiles.js +67 -0
- package/lib/docs/listPluginDocEntries.js +111 -0
- package/lib/docs/listPlugins.js +9 -73
- package/lib/docs/listTypes.js +14 -6
- package/lib/docs/makeModuleManifestDoc.js +174 -0
- package/lib/docs/parseModuleSource.js +31 -0
- package/lib/docs/readDocEntry.js +40 -0
- package/lib/docs/readDocTitle.js +26 -0
- package/lib/docs/readPluginPackages.js +99 -0
- package/lib/docs/searchDocs.js +82 -30
- package/lib/docs/searchDocs.test.mjs +100 -0
- package/lib/docs/splitSearchTerms.js +62 -0
- package/lib/docs/splitSearchTerms.test.mjs +50 -0
- package/lib/server/log/createHandleError.js +8 -3
- package/lib/server/log/createHandleError.test.mjs +30 -1
- package/lib/server/log/createLogger.js +3 -0
- package/lib/server/log/createLogger.test.mjs +15 -0
- package/package.json +38 -38
- package/package.original.json +38 -38
- package/src/app.js +9 -0
- package/src/routes/detached.js +5 -2
- package/src/routes/docs/content.js +5 -3
- package/src/routes/docs/search.js +15 -2
- package/src/routes/endpoints.js +7 -1
- package/src/routes/endpointsTextAnswer.test.mjs +77 -0
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { jest } from '@jest/globals';
|
|
21
|
-
|
|
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
|
|
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 =
|
|
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 }) =>
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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;
|
package/lib/docs/docs.test.mjs
CHANGED
|
@@ -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;
|