asljs-appbase 0.0.0-stage → 0.1.5
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/LICENSE.md +51 -0
- package/README.md +81 -2
- package/dist/browser-modules.d.ts +36 -0
- package/dist/browser-modules.js +148 -0
- package/dist/editor-app.d.ts +232 -0
- package/dist/editor-app.js +268 -0
- package/dist/files.d.ts +25 -0
- package/dist/files.js +48 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/markdown.d.ts +27 -0
- package/dist/markdown.js +84 -0
- package/dist/post-process.d.ts +50 -0
- package/dist/post-process.js +84 -0
- package/dist/server.d.ts +152 -0
- package/dist/server.js +354 -0
- package/dist/state-folder.d.ts +20 -0
- package/dist/state-folder.js +21 -0
- package/docs/appbase.md +193 -0
- package/package.json +72 -4
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { loadConfig } from 'asljs-cmdbase';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createImportMap, MODULES_PREFIX } from './browser-modules.js';
|
|
4
|
+
import { listFiles } from './files.js';
|
|
5
|
+
import { createHandler, mountHandlers, page, scriptJson, send, serve, serverUrl, untilStopped } from './server.js';
|
|
6
|
+
/**
|
|
7
|
+
* The path an application serves its plugin's folder at.
|
|
8
|
+
*/
|
|
9
|
+
const PLUGIN_PREFIX = '/_/plugin/';
|
|
10
|
+
/**
|
|
11
|
+
* The projects of an application in `folder`, by its configuration file
|
|
12
|
+
* `name`, e.g. `kb.json`: the folder itself when it has the file; else every
|
|
13
|
+
* folder below it that has one, by id; else the folder itself, with the
|
|
14
|
+
* nearest file of a parent, or with none (`loadConfig`). The files are
|
|
15
|
+
* `files`, the files of `folder` as `listFiles` gives them, so that a server
|
|
16
|
+
* of several applications lists them once; `listFiles(folder)` when absent.
|
|
17
|
+
*
|
|
18
|
+
* With `required`, a project needs the file: when the folder, the folders
|
|
19
|
+
* below it and those above it have none, it throws, for an application whose
|
|
20
|
+
* projects cannot be told without it, e.g. which artefacts a part project
|
|
21
|
+
* supports.
|
|
22
|
+
*/
|
|
23
|
+
export async function findProjects(folder, name, files, options = {}) {
|
|
24
|
+
const root = path.resolve(folder);
|
|
25
|
+
const listed = files
|
|
26
|
+
?? await listFiles(root);
|
|
27
|
+
if (listed.includes(name)) {
|
|
28
|
+
return [await toProject(root, root, name)];
|
|
29
|
+
}
|
|
30
|
+
const suffix = `/${name}`;
|
|
31
|
+
const folders = listed
|
|
32
|
+
.filter(file => file.endsWith(suffix))
|
|
33
|
+
.map(file => path.join(root, file.slice(0, -suffix.length)));
|
|
34
|
+
if (folders.length > 0) {
|
|
35
|
+
return (await Promise.all(folders.map(found => toProject(root, found, name))))
|
|
36
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
37
|
+
}
|
|
38
|
+
const project = await toProject(root, root, name);
|
|
39
|
+
if (options.required === true
|
|
40
|
+
&& project.config.file === null) {
|
|
41
|
+
throw new Error(`No ${name} in ${root}, in a folder below it, or in a folder above it.`);
|
|
42
|
+
}
|
|
43
|
+
return [project];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The projects of an application in the folders of `context` by its
|
|
47
|
+
* configuration file `name`: `findProjects` of `cwd` and `files` alone, or of
|
|
48
|
+
* each of `folders` when there are several. Then the id of a project starts
|
|
49
|
+
* with the name of its folder, `docs/notes` for `notes` of `docs`, the name
|
|
50
|
+
* alone for the folder itself, and a name that two folders share gets `-2`,
|
|
51
|
+
* `-3` and so on after the first. With `required`, a folder without the file
|
|
52
|
+
* has no project, and an error is thrown only when no folder has one.
|
|
53
|
+
*/
|
|
54
|
+
export async function findAppProjects(context, name, options = {}) {
|
|
55
|
+
const folders = context.folders
|
|
56
|
+
?? [{ cwd: context.cwd,
|
|
57
|
+
files: context.files }];
|
|
58
|
+
if (folders.length === 1) {
|
|
59
|
+
return await findProjects(folders[0].cwd, name, folders[0].files, options);
|
|
60
|
+
}
|
|
61
|
+
const labels = folderLabels(folders.map(folder => folder.cwd));
|
|
62
|
+
const projects = [];
|
|
63
|
+
for (const [index, folder] of folders.entries()) {
|
|
64
|
+
for (const project of await findProjects(folder.cwd, name, folder.files)) {
|
|
65
|
+
if (options.required === true
|
|
66
|
+
&& project.config.file === null) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const id = project.id === '.'
|
|
70
|
+
? labels[index]
|
|
71
|
+
: `${labels[index]}/${project.id}`;
|
|
72
|
+
projects.push({ ...project,
|
|
73
|
+
id,
|
|
74
|
+
title: id });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (projects.length === 0) {
|
|
78
|
+
throw new Error(`No ${name} in ${folders
|
|
79
|
+
.map(folder => path.resolve(folder.cwd))
|
|
80
|
+
.join(', ')}, in a folder below them, or in a folder above them.`);
|
|
81
|
+
}
|
|
82
|
+
return projects;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The name of each folder, by its base name, `-2`, `-3` and so on added to a
|
|
86
|
+
* name an earlier folder has.
|
|
87
|
+
*/
|
|
88
|
+
function folderLabels(folders) {
|
|
89
|
+
const labels = [];
|
|
90
|
+
for (const folder of folders) {
|
|
91
|
+
const resolved = path.resolve(folder);
|
|
92
|
+
const base = path.basename(resolved)
|
|
93
|
+
|| resolved;
|
|
94
|
+
let label = base;
|
|
95
|
+
for (let n = 2; labels.includes(label); n++) {
|
|
96
|
+
label = `${base}-${n}`;
|
|
97
|
+
}
|
|
98
|
+
labels.push(label);
|
|
99
|
+
}
|
|
100
|
+
return labels;
|
|
101
|
+
}
|
|
102
|
+
async function toProject(root, folder, name) {
|
|
103
|
+
const id = path.relative(root, folder)
|
|
104
|
+
.split(path.sep)
|
|
105
|
+
.join('/')
|
|
106
|
+
|| '.';
|
|
107
|
+
return { id,
|
|
108
|
+
title: id === '.'
|
|
109
|
+
? path.basename(root)
|
|
110
|
+
: id,
|
|
111
|
+
folder,
|
|
112
|
+
config: await loadConfig(folder, name) };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The segment of a project in its path: its id, `~` for `.`.
|
|
116
|
+
*/
|
|
117
|
+
export function projectSegment(id) {
|
|
118
|
+
return id === '.'
|
|
119
|
+
? '~'
|
|
120
|
+
: encodeURIComponent(id);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* An application served as an editor plugin; see `EditorApp`.
|
|
124
|
+
*/
|
|
125
|
+
export function createEditorApp(options) {
|
|
126
|
+
const base = options.base ?? '/';
|
|
127
|
+
const handlers = new Map();
|
|
128
|
+
const closers = [];
|
|
129
|
+
const modules = createHandler({ folder: '/',
|
|
130
|
+
modules: options.modules,
|
|
131
|
+
index: async () => '',
|
|
132
|
+
allow: () => false });
|
|
133
|
+
const style = options.style ?? '';
|
|
134
|
+
const pluginFiles = createHandler({ folder: path.dirname(options.plugin),
|
|
135
|
+
methods: ['GET',
|
|
136
|
+
'HEAD'],
|
|
137
|
+
allow: relative => /\.(?:m?js|map)$/.test(relative) });
|
|
138
|
+
const descriptor = () => ({ id: options.id,
|
|
139
|
+
title: options.title,
|
|
140
|
+
icon: options.icon,
|
|
141
|
+
plugin: `${base}${PLUGIN_PREFIX.slice(1)}${encodeURIComponent(path.basename(options.plugin))}`,
|
|
142
|
+
projects: options.projects.map(project => ({ id: project.id,
|
|
143
|
+
title: project.title,
|
|
144
|
+
base: `${base}p/${projectSegment(project.id)}/` })) });
|
|
145
|
+
const handler = async (request, response, url) => {
|
|
146
|
+
if (url.pathname === '/') {
|
|
147
|
+
send(response, 200, 'text/html; charset=utf-8', editorPage({ title: options.title,
|
|
148
|
+
apps: [`${base}_/app.json`],
|
|
149
|
+
modules: options.modules,
|
|
150
|
+
style }));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (url.pathname === '/_/app.json') {
|
|
154
|
+
send(response, 200, 'application/json; charset=utf-8', JSON.stringify(descriptor()));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (url.pathname.startsWith(PLUGIN_PREFIX)) {
|
|
158
|
+
const inner = new URL(url);
|
|
159
|
+
inner.pathname =
|
|
160
|
+
url.pathname.slice(PLUGIN_PREFIX.length - 1);
|
|
161
|
+
await pluginFiles(request, response, inner);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (url.pathname.startsWith(MODULES_PREFIX)) {
|
|
165
|
+
await modules(request, response, url);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const match = /^\/p\/([^/]+)(\/.*)?$/.exec(url.pathname);
|
|
169
|
+
const id = match === null
|
|
170
|
+
? undefined
|
|
171
|
+
: match[1] === '~'
|
|
172
|
+
? '.'
|
|
173
|
+
: decodeURIComponent(match[1]);
|
|
174
|
+
const project = options.projects.find(candidate => candidate.id === id);
|
|
175
|
+
if (match === null
|
|
176
|
+
|| project === undefined) {
|
|
177
|
+
send(response, 404, 'text/plain; charset=utf-8', `Not found: ${url.pathname}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (match[2] === undefined) {
|
|
181
|
+
response.writeHead(302, { location: `${base}p/${match[1]}/${url.search}` });
|
|
182
|
+
response.end();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
let made = handlers.get(project.id);
|
|
186
|
+
if (made === undefined) {
|
|
187
|
+
made =
|
|
188
|
+
Promise.resolve(options.project(project, { base: `${base}p/${match[1]}/`,
|
|
189
|
+
onClose: action => closers.push(action) }));
|
|
190
|
+
handlers.set(project.id, made);
|
|
191
|
+
}
|
|
192
|
+
const inner = new URL(url);
|
|
193
|
+
inner.pathname = match[2];
|
|
194
|
+
await (await made)(request, response, inner);
|
|
195
|
+
};
|
|
196
|
+
return { id: options.id,
|
|
197
|
+
title: options.title,
|
|
198
|
+
base,
|
|
199
|
+
style,
|
|
200
|
+
modules: options.modules,
|
|
201
|
+
handler,
|
|
202
|
+
close: async () => {
|
|
203
|
+
for (const close of closers.splice(0)) {
|
|
204
|
+
await close();
|
|
205
|
+
}
|
|
206
|
+
} };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A handler for several applications, each mounted at `/<id>/`, which is the
|
|
210
|
+
* `base` it was made with: `/` is the editor with every application as a
|
|
211
|
+
* plugin, and `/modules/...` the modules of them all.
|
|
212
|
+
*/
|
|
213
|
+
export function combineEditorApps(title, apps) {
|
|
214
|
+
const modules = [...new Map(apps
|
|
215
|
+
.flatMap(app => app.modules)
|
|
216
|
+
.map(module => [module.name,
|
|
217
|
+
module]))
|
|
218
|
+
.values()];
|
|
219
|
+
const files = createHandler({ folder: '/',
|
|
220
|
+
modules,
|
|
221
|
+
index: async () => '',
|
|
222
|
+
allow: () => false });
|
|
223
|
+
const style = apps
|
|
224
|
+
.map(app => app.style)
|
|
225
|
+
.join('\n');
|
|
226
|
+
return mountHandlers(Object.fromEntries(apps.map(app => [app.id,
|
|
227
|
+
app.handler])), async (request, response, url) => {
|
|
228
|
+
if (url.pathname === '/') {
|
|
229
|
+
send(response, 200, 'text/html; charset=utf-8', editorPage({ title,
|
|
230
|
+
apps: apps.map(app => `${app.base}_/app.json`),
|
|
231
|
+
modules,
|
|
232
|
+
style }));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
await files(request, response, url);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* An editor page whose plugins are the applications of `apps`, the URLs of
|
|
240
|
+
* their `/_/app.json`; see `mountEditorApps` of `asljs-ui-editor`.
|
|
241
|
+
*/
|
|
242
|
+
export function editorPage(options) {
|
|
243
|
+
return page(options.title, `<script type="importmap">${scriptJson(createImportMap(options.modules, MODULES_PREFIX))}</script>
|
|
244
|
+
<asljs-editor id="editor"></asljs-editor>
|
|
245
|
+
<script type="module">
|
|
246
|
+
import { mountEditorApps } from 'asljs-ui-editor';
|
|
247
|
+
await mountEditorApps(
|
|
248
|
+
document.getElementById('editor'),
|
|
249
|
+
${scriptJson(options.apps)},
|
|
250
|
+
{ storageKey: ${scriptJson(`asljs-editor:${options.title}`)} });
|
|
251
|
+
</script>`, options.style ?? '');
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Serves an editor application, as `<app>-srv` does: prints where it serves
|
|
255
|
+
* `cwd`, serves until SIGINT or SIGTERM, then closes the application.
|
|
256
|
+
*/
|
|
257
|
+
export async function serveApp(app, options) {
|
|
258
|
+
const server = await serve(app.handler, { port: options.port,
|
|
259
|
+
host: options.host,
|
|
260
|
+
logger: options.loggerProvider.getLogger(`${app.id}.srv`) });
|
|
261
|
+
options.io.stdout.write(`Serving ${options.cwd} at ${serverUrl(server)}\n`);
|
|
262
|
+
try {
|
|
263
|
+
await untilStopped(server);
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
await app.close();
|
|
267
|
+
}
|
|
268
|
+
}
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface FindOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Names of folders to skip besides those starting with `.` and
|
|
4
|
+
* `node_modules`, e.g. `Archive`.
|
|
5
|
+
*/
|
|
6
|
+
skip?: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Every markdown file of a folder and its subfolders, sorted, by absolute
|
|
10
|
+
* path, as the `LocationResolver` of `asljs-locator` finds them: skipping
|
|
11
|
+
* folders whose name starts with `.`, `node_modules`, and the `skip` names.
|
|
12
|
+
*/
|
|
13
|
+
export declare function findMarkdownFiles(folder: string, options?: FindOptions): Promise<string[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Every file of a folder and its subfolders, relative to it with `/`
|
|
16
|
+
* separators, sorted, as the `LocationResolver` of `asljs-locator` finds them:
|
|
17
|
+
* what `.gitignore` files ignore is left out, and so are `.git` and
|
|
18
|
+
* `node_modules`. One list serves every search of the folder's files, such as
|
|
19
|
+
* each application's `findProjects`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function listFiles(folder: string): Promise<string[]>;
|
|
22
|
+
/**
|
|
23
|
+
* A path relative to `folder`, with `/` separators.
|
|
24
|
+
*/
|
|
25
|
+
export declare function toDisplayPath(folder: string, file: string): string;
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { LocationResolver } from 'asljs-locator';
|
|
2
|
+
import { NullLogger } from 'asljs-logging';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Every markdown file of a folder and its subfolders, sorted, by absolute
|
|
6
|
+
* path, as the `LocationResolver` of `asljs-locator` finds them: skipping
|
|
7
|
+
* folders whose name starts with `.`, `node_modules`, and the `skip` names.
|
|
8
|
+
*/
|
|
9
|
+
export async function findMarkdownFiles(folder, options = {}) {
|
|
10
|
+
const root = path.resolve(folder);
|
|
11
|
+
return (await new LocationResolver(new NullLogger(), root)
|
|
12
|
+
.resolve(root,
|
|
13
|
+
// Any case of the extension, as `.MD`.
|
|
14
|
+
{ patterns: ['**/*.md',
|
|
15
|
+
'**/*.MD',
|
|
16
|
+
'**/*.Md',
|
|
17
|
+
'**/*.mD'],
|
|
18
|
+
exclude: ['**/.*/**',
|
|
19
|
+
'**/node_modules/**',
|
|
20
|
+
...(options.skip ?? []).map(name => `**/${name}/**`)] }))
|
|
21
|
+
.sort();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Every file of a folder and its subfolders, relative to it with `/`
|
|
25
|
+
* separators, sorted, as the `LocationResolver` of `asljs-locator` finds them:
|
|
26
|
+
* what `.gitignore` files ignore is left out, and so are `.git` and
|
|
27
|
+
* `node_modules`. One list serves every search of the folder's files, such as
|
|
28
|
+
* each application's `findProjects`.
|
|
29
|
+
*/
|
|
30
|
+
export async function listFiles(folder) {
|
|
31
|
+
const root = path.resolve(folder);
|
|
32
|
+
const files = await new LocationResolver(new NullLogger(), root)
|
|
33
|
+
.resolve(root, { pattern: '**/*',
|
|
34
|
+
exclude: ['.git/**',
|
|
35
|
+
'**/node_modules/**'],
|
|
36
|
+
filters: [{ name: 'GitIgnore' }] });
|
|
37
|
+
return files
|
|
38
|
+
.map(file => toDisplayPath(root, file))
|
|
39
|
+
.sort();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A path relative to `folder`, with `/` separators.
|
|
43
|
+
*/
|
|
44
|
+
export function toDisplayPath(folder, file) {
|
|
45
|
+
return path.relative(folder, file)
|
|
46
|
+
.split(path.sep)
|
|
47
|
+
.join('/');
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createImportMap, findBrowserModules, findModuleFile, MODULES_PREFIX, type BrowserModule } from './browser-modules.js';
|
|
2
|
+
export { commandLine, configBoolean, configLogging, configPath, configPaths, configPort, configString, configStrings, loadCommandLineConfig, loadConfig, messageOf, packageVersion, parsePort, readOptionValue, runMain, workingFolder, type CommandIo, type Config } from 'asljs-cmdbase';
|
|
3
|
+
export { combineEditorApps, createEditorApp, editorPage, findAppProjects, findProjects, projectSegment, serveApp, type EditorApp, type EditorAppContext, type EditorAppDescriptor, type EditorAppOptions, type Project, type ProjectContext, type WorkingFolder } from './editor-app.js';
|
|
4
|
+
export { findMarkdownFiles, listFiles, toDisplayPath, type FindOptions } from './files.js';
|
|
5
|
+
export { findSectionHeading, formatUrl, getSection, parseMarkdown, plainText, splitLocalUrl, type MarkdownNode } from './markdown.js';
|
|
6
|
+
export { findConfig, postProcess, takeWritten, writeMarkdown, type PostProcessIo, type ToolConfig } from './post-process.js';
|
|
7
|
+
export { createHandler, DEFAULT_PORT, escapeHtml, jsonReply, mountHandlers, page, scriptJson, send, serve, serverUrl, startServer, untilStopped, versionConflict, type HandlerOptions, type JsonReply, type ListenOptions, type PostReply, type RequestHandler, type ServerOptions } from './server.js';
|
|
8
|
+
export { stateFolder, type GivenStateFolder } from './state-folder.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createImportMap, findBrowserModules, findModuleFile, MODULES_PREFIX } from './browser-modules.js';
|
|
2
|
+
export { commandLine, configBoolean, configLogging, configPath, configPaths, configPort, configString, configStrings, loadCommandLineConfig, loadConfig, messageOf, packageVersion, parsePort, readOptionValue, runMain, workingFolder } from 'asljs-cmdbase';
|
|
3
|
+
export { combineEditorApps, createEditorApp, editorPage, findAppProjects, findProjects, projectSegment, serveApp } from './editor-app.js';
|
|
4
|
+
export { findMarkdownFiles, listFiles, toDisplayPath } from './files.js';
|
|
5
|
+
export { findSectionHeading, formatUrl, getSection, parseMarkdown, plainText, splitLocalUrl } from './markdown.js';
|
|
6
|
+
export { findConfig, postProcess, takeWritten, writeMarkdown } from './post-process.js';
|
|
7
|
+
export { createHandler, DEFAULT_PORT, escapeHtml, jsonReply, mountHandlers, page, scriptJson, send, serve, serverUrl, startServer, untilStopped, versionConflict } from './server.js';
|
|
8
|
+
export { stateFolder } from './state-folder.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Heading, type Root, type RootContent } from 'mdast';
|
|
2
|
+
export type MarkdownNode = Root | RootContent;
|
|
3
|
+
export declare function parseMarkdown(text: string): Root;
|
|
4
|
+
/**
|
|
5
|
+
* The level 2 heading with the given text.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findSectionHeading(root: Root, name: string): Heading | null;
|
|
8
|
+
/**
|
|
9
|
+
* The nodes between a level 2 heading and the next heading of level 1 or 2,
|
|
10
|
+
* or `null` when there is no such heading.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getSection(root: Root, name: string): RootContent[] | null;
|
|
13
|
+
export declare function plainText(node: MarkdownNode): string;
|
|
14
|
+
/**
|
|
15
|
+
* Splits a link target that points at a local file into the path, with
|
|
16
|
+
* percent-encoding decoded, and the `#` fragment; `null` for a target with a
|
|
17
|
+
* scheme, a protocol-relative, root-relative or fragment-only target.
|
|
18
|
+
*/
|
|
19
|
+
export declare function splitLocalUrl(url: string): {
|
|
20
|
+
path: string;
|
|
21
|
+
fragment: string;
|
|
22
|
+
} | null;
|
|
23
|
+
/**
|
|
24
|
+
* A link target in the form written by `rq`: in angle brackets when it has
|
|
25
|
+
* spaces or parentheses.
|
|
26
|
+
*/
|
|
27
|
+
export declare function formatUrl(url: string): string;
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import remarkParse from 'remark-parse';
|
|
2
|
+
import { unified } from 'unified';
|
|
3
|
+
export function parseMarkdown(text) {
|
|
4
|
+
return unified()
|
|
5
|
+
.use(remarkParse)
|
|
6
|
+
.parse(text);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The level 2 heading with the given text.
|
|
10
|
+
*/
|
|
11
|
+
export function findSectionHeading(root, name) {
|
|
12
|
+
return root.children.find((node) => node.type === 'heading'
|
|
13
|
+
&& node.depth === 2
|
|
14
|
+
&& plainText(node) === name)
|
|
15
|
+
?? null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The nodes between a level 2 heading and the next heading of level 1 or 2,
|
|
19
|
+
* or `null` when there is no such heading.
|
|
20
|
+
*/
|
|
21
|
+
export function getSection(root, name) {
|
|
22
|
+
const heading = findSectionHeading(root, name);
|
|
23
|
+
if (!heading) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const nodes = [];
|
|
27
|
+
for (const node of root.children.slice(root.children.indexOf(heading) + 1)) {
|
|
28
|
+
if (node.type === 'heading'
|
|
29
|
+
&& node.depth <= 2) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
nodes.push(node);
|
|
33
|
+
}
|
|
34
|
+
return nodes;
|
|
35
|
+
}
|
|
36
|
+
export function plainText(node) {
|
|
37
|
+
if ('value' in node) {
|
|
38
|
+
return node.value;
|
|
39
|
+
}
|
|
40
|
+
if ('children' in node) {
|
|
41
|
+
return node.children
|
|
42
|
+
.map(child => plainText(child))
|
|
43
|
+
.join('');
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Splits a link target that points at a local file into the path, with
|
|
49
|
+
* percent-encoding decoded, and the `#` fragment; `null` for a target with a
|
|
50
|
+
* scheme, a protocol-relative, root-relative or fragment-only target.
|
|
51
|
+
*/
|
|
52
|
+
export function splitLocalUrl(url) {
|
|
53
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url)
|
|
54
|
+
|| url.startsWith('/')
|
|
55
|
+
|| url.startsWith('#')
|
|
56
|
+
|| url === '') {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const hash = url.indexOf('#');
|
|
60
|
+
const encoded = hash < 0
|
|
61
|
+
? url
|
|
62
|
+
: url.slice(0, hash);
|
|
63
|
+
let decoded;
|
|
64
|
+
try {
|
|
65
|
+
decoded =
|
|
66
|
+
decodeURIComponent(encoded);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
decoded = encoded;
|
|
70
|
+
}
|
|
71
|
+
return { path: decoded,
|
|
72
|
+
fragment: hash < 0
|
|
73
|
+
? ''
|
|
74
|
+
: url.slice(hash) };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A link target in the form written by `rq`: in angle brackets when it has
|
|
78
|
+
* spaces or parentheses.
|
|
79
|
+
*/
|
|
80
|
+
export function formatUrl(url) {
|
|
81
|
+
return /[\s()]/.test(url)
|
|
82
|
+
? `<${url}>`
|
|
83
|
+
: url;
|
|
84
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Logger } from 'asljs-logging';
|
|
2
|
+
/**
|
|
3
|
+
* What a tool's configuration file, e.g. `rq.json`, says.
|
|
4
|
+
*/
|
|
5
|
+
export interface ToolConfig {
|
|
6
|
+
/**
|
|
7
|
+
* A command line run, in the folder of the configuration file, with the
|
|
8
|
+
* markdown files a command wrote as arguments, e.g. a project's formatter
|
|
9
|
+
* and linter.
|
|
10
|
+
*/
|
|
11
|
+
markdownPostProcessing?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Where `postProcess` reports a failure.
|
|
15
|
+
*/
|
|
16
|
+
export interface PostProcessIo {
|
|
17
|
+
cwd: string;
|
|
18
|
+
stderr: {
|
|
19
|
+
write(text: string): unknown;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Where the command line and its exit code are logged; nothing is logged
|
|
23
|
+
* when absent.
|
|
24
|
+
*/
|
|
25
|
+
logger?: Logger;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Writes a markdown file and remembers it for `postProcess`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function writeMarkdown(file: string, text: string): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* The markdown files written since the last call, and forgets them.
|
|
33
|
+
*/
|
|
34
|
+
export declare function takeWritten(): string[];
|
|
35
|
+
/**
|
|
36
|
+
* The nearest configuration file named `name`, e.g. `rq.json`, of `folder` or
|
|
37
|
+
* a parent, and its folder; `null` when there is none.
|
|
38
|
+
*/
|
|
39
|
+
export declare function findConfig(folder: string, name: string): Promise<{
|
|
40
|
+
folder: string;
|
|
41
|
+
config: ToolConfig;
|
|
42
|
+
} | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Runs the `markdownPostProcessing` command of the nearest configuration file
|
|
45
|
+
* named `config`, e.g. `rq.json`, with the markdown files a command wrote and
|
|
46
|
+
* that still exist, relative to the folder of the configuration file, where it
|
|
47
|
+
* runs. Returns 1 and reports the output when the command fails, 0 otherwise
|
|
48
|
+
* or when there is nothing to do.
|
|
49
|
+
*/
|
|
50
|
+
export declare function postProcess(io: PostProcessIo, files: readonly string[], config: string): Promise<number>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { runCommand } from 'asljs-agents';
|
|
2
|
+
import { loadConfig } from 'asljs-cmdbase';
|
|
3
|
+
import { NullLogger } from 'asljs-logging';
|
|
4
|
+
import { stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
const written = new Set();
|
|
7
|
+
/**
|
|
8
|
+
* Writes a markdown file and remembers it for `postProcess`.
|
|
9
|
+
*/
|
|
10
|
+
export async function writeMarkdown(file, text) {
|
|
11
|
+
await writeFile(file, text, 'utf8');
|
|
12
|
+
written.add(path.resolve(file));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The markdown files written since the last call, and forgets them.
|
|
16
|
+
*/
|
|
17
|
+
export function takeWritten() {
|
|
18
|
+
const files = [...written];
|
|
19
|
+
written.clear();
|
|
20
|
+
return files;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The nearest configuration file named `name`, e.g. `rq.json`, of `folder` or
|
|
24
|
+
* a parent, and its folder; `null` when there is none.
|
|
25
|
+
*/
|
|
26
|
+
export async function findConfig(folder, name) {
|
|
27
|
+
const config = await loadConfig(folder, name);
|
|
28
|
+
if (config.folder === null) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const command = config.values.markdownPostProcessing;
|
|
32
|
+
if (command !== undefined
|
|
33
|
+
&& typeof command
|
|
34
|
+
!== 'string') {
|
|
35
|
+
throw new Error(`${config.file}: "markdownPostProcessing" must be a command line.`);
|
|
36
|
+
}
|
|
37
|
+
return { folder: config.folder,
|
|
38
|
+
config: config.values };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Runs the `markdownPostProcessing` command of the nearest configuration file
|
|
42
|
+
* named `config`, e.g. `rq.json`, with the markdown files a command wrote and
|
|
43
|
+
* that still exist, relative to the folder of the configuration file, where it
|
|
44
|
+
* runs. Returns 1 and reports the output when the command fails, 0 otherwise
|
|
45
|
+
* or when there is nothing to do.
|
|
46
|
+
*/
|
|
47
|
+
export async function postProcess(io, files, config) {
|
|
48
|
+
const existing = [];
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
if ((await stat(file).catch(() => null))?.isFile()) {
|
|
51
|
+
existing.push(file);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (existing.length === 0) {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
const found = await findConfig(io.cwd, config);
|
|
58
|
+
const command = found?.config.markdownPostProcessing?.trim();
|
|
59
|
+
if (found === null
|
|
60
|
+
|| command === undefined
|
|
61
|
+
|| command === '') {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
const line = [command,
|
|
65
|
+
...existing.map(file => JSON.stringify(path.relative(found.folder, file)
|
|
66
|
+
.split(path.sep)
|
|
67
|
+
.join('/')))]
|
|
68
|
+
.join(' ');
|
|
69
|
+
const logger = io.logger ?? new NullLogger();
|
|
70
|
+
logger.debug({ command: line,
|
|
71
|
+
cwd: found.folder }, 'post-processing');
|
|
72
|
+
const run = await runCommand(line, found.folder)
|
|
73
|
+
.catch((error) => ({ code: -1,
|
|
74
|
+
stdout: '',
|
|
75
|
+
stderr: error instanceof Error
|
|
76
|
+
? error.message
|
|
77
|
+
: String(error) }));
|
|
78
|
+
logger.debug({ code: run.code }, 'post-processing exited');
|
|
79
|
+
if (run.code === 0) {
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
io.stderr.write(`Post-processing failed: ${command} exited with code ${run.code}\n${`${run.stdout}${run.stderr}`.trimEnd()}\n`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|