dbtour 0.0.0-stage → 0.2.0

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/bin/dbtour.mjs ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // Node 22.18+ runs the TypeScript as it is; an older Node fails here with a syntax error, so say why first.
3
+ const [major, minor] = process.versions.node.split('.').map(Number);
4
+ if (major < 22 || (major === 22 && minor < 18)) {
5
+ console.error(`dbtour needs Node 22.18 or newer (this is ${process.versions.node}). Upgrade Node, then run it again.`);
6
+ process.exit(2);
7
+ }
8
+ const { main } = await import('../src/cli.ts');
9
+ process.exitCode = await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "dbtour",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Tour your database and its API: searchable docs, relations and diagrams, generated from the schema, kept in the repo.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Victor Saly",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/victorsaly/dbtour.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "engines": {
14
+ "node": ">=22.18"
15
+ },
16
+ "bin": {
17
+ "dbtour": "bin/dbtour.mjs"
18
+ },
19
+ "exports": {
20
+ ".": "./src/index.ts",
21
+ "./model": "./src/model.ts",
22
+ "./markdown": "./src/render/markdown.ts"
23
+ },
24
+ "files": [
25
+ "bin",
26
+ "src"
27
+ ],
28
+ "keywords": [
29
+ "database",
30
+ "documentation",
31
+ "schema",
32
+ "erd",
33
+ "mssql",
34
+ "markdown",
35
+ "mermaid"
36
+ ],
37
+ "dependencies": {
38
+ "yaml": "^2.8.1"
39
+ }
40
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,156 @@
1
+ // The command. Reads dbtour.yaml in the working directory, writes under its `out`. Exit 1 for what was
2
+ // asked and failed, 2 for what is missing, 0 otherwise.
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { parseArgs } from 'node:util';
7
+ import { configFile, defaultConfigText, modelFile, parseConfig, resolveConnection, type AddonConfig, type Config, type SourceConfig } from './config.ts';
8
+ import { emptyModel, validate, type Api, type Database, type Model } from './model.ts';
9
+ import { applyNotes, parseNotes } from './notes.ts';
10
+ import { loadAddon, loadSource, type Addon, type Importer, type Source } from './plugins.ts';
11
+ import { renderMarkdown } from './render/markdown.ts';
12
+
13
+ export interface Io {
14
+ cwd: string;
15
+ log: (line: string) => void;
16
+ error: (line: string) => void;
17
+ env?: Record<string, string | undefined>;
18
+ /** How a source is loaded; tests hand in their own. */
19
+ load?: (source: SourceConfig) => Promise<Source>;
20
+ /** How an add-on is loaded; tests hand in their own. */
21
+ loadAddon?: (addon: AddonConfig) => Promise<Addon>;
22
+ }
23
+
24
+ const help = `Usage: dbtour <command>
25
+
26
+ init [--source mssql] write ${configFile} here
27
+ extract read every source in ${configFile}, write <out>/${modelFile}
28
+ render read <out>/${modelFile} and the notes, write the Markdown and what the
29
+ add-ons in ${configFile} render (explorer: index.html) under <out>
30
+
31
+ Set the connection string in the environment variable ${configFile} names, never in the file.`;
32
+
33
+ export async function main(argv: string[], io: Io = { cwd: process.cwd(), log: console.log, error: console.error }): Promise<number> {
34
+ const { values, positionals } = parseArgs({ args: argv, options: { help: { type: 'boolean', short: 'h' }, source: { type: 'string' } }, allowPositionals: true, strict: false });
35
+ const command = positionals[0];
36
+ if (values.help || command === undefined) {
37
+ io.log(help);
38
+ return 0;
39
+ }
40
+ try {
41
+ switch (command) {
42
+ case 'init':
43
+ return init(io, typeof values.source === 'string' ? values.source : 'mssql');
44
+ case 'extract':
45
+ return await extract(io);
46
+ case 'render':
47
+ return await render(io);
48
+ default:
49
+ io.error(`"${command}" is not a dbtour command.\n\n${help}`);
50
+ return 1;
51
+ }
52
+ } catch (error) {
53
+ io.error(error instanceof Error ? error.message : String(error));
54
+ return (error as { exitCode?: number }).exitCode ?? 1;
55
+ }
56
+ }
57
+
58
+ function missing(message: string): Error {
59
+ return Object.assign(new Error(message), { exitCode: 2 });
60
+ }
61
+
62
+ function init(io: Io, source: string): number {
63
+ const path = join(io.cwd, configFile);
64
+ if (existsSync(path)) {
65
+ io.error(`${configFile} already exists here. Edit it, or delete it and run dbtour init again.`);
66
+ return 1;
67
+ }
68
+ writeFileSync(path, defaultConfigText(source));
69
+ io.log(`Wrote ${configFile}. Set the connection string in DBTOUR_${source.toUpperCase()}, then run dbtour extract.`);
70
+ return 0;
71
+ }
72
+
73
+ function readConfig(io: Io): Config {
74
+ const path = join(io.cwd, configFile);
75
+ if (!existsSync(path)) throw missing(`No ${configFile} here. Run dbtour init to write one.`);
76
+ return parseConfig(readFileSync(path, 'utf8'));
77
+ }
78
+
79
+ async function extract(io: Io): Promise<number> {
80
+ const config = readConfig(io);
81
+ const env = io.env ?? process.env;
82
+ const load = io.load ?? ((source: SourceConfig) => loadSource(source, importer(io.cwd)));
83
+ const model: Model = { ...emptyModel(), generatedAt: new Date().toISOString() };
84
+ for (const source of config.sources) {
85
+ const connection = resolveConnection(source.connection, env);
86
+ const { extract } = await load(source);
87
+ let result: Database | Api;
88
+ try {
89
+ result = await extract({ ...source, connection });
90
+ } catch (error) {
91
+ io.error(`${source.name} (${source.type}): ${error instanceof Error ? error.message : String(error)}`);
92
+ return 1;
93
+ }
94
+ if ('schemas' in result) {
95
+ model.databases.push(result);
96
+ const tables = result.schemas.reduce((n, s) => n + s.tables.length, 0);
97
+ io.log(`${result.name}: ${tables} tables, ${result.relations.length} relations`);
98
+ } else {
99
+ model.apis.push(result);
100
+ io.log(`${result.name}: ${result.endpoints.length} endpoints`);
101
+ }
102
+ }
103
+ const checked = validate(model);
104
+ if (!checked.ok) {
105
+ io.error(`A source returned something that is not a model:\n ${checked.errors.join('\n ')}`);
106
+ return 1;
107
+ }
108
+ const path = join(io.cwd, config.out, modelFile);
109
+ mkdirSync(dirname(path), { recursive: true });
110
+ writeFileSync(path, JSON.stringify(model, null, 2) + '\n');
111
+ io.log(`Wrote ${join(config.out, modelFile)}. Run dbtour render to write the docs.`);
112
+ return 0;
113
+ }
114
+
115
+ /** A module given as a path is relative to the project, not to dbtour. */
116
+ function importer(cwd: string): Importer {
117
+ return (specifier) => import(specifier.startsWith('.') || specifier.startsWith('/') ? pathToFileURL(resolve(cwd, specifier)).href : specifier);
118
+ }
119
+
120
+ async function render(io: Io): Promise<number> {
121
+ const config = readConfig(io);
122
+ const modelPath = join(io.cwd, config.out, modelFile);
123
+ if (!existsSync(modelPath)) throw missing(`No ${join(config.out, modelFile)} yet. Run dbtour extract to write it.`);
124
+ const checked = validate(JSON.parse(readFileSync(modelPath, 'utf8')));
125
+ if (!checked.ok) {
126
+ io.error(`${join(config.out, modelFile)} is not a model:\n ${checked.errors.join('\n ')}`);
127
+ return 1;
128
+ }
129
+ let model = checked.model;
130
+ const notesPath = join(io.cwd, config.notes);
131
+ if (existsSync(notesPath)) {
132
+ const applied = applyNotes(model, parseNotes(readFileSync(notesPath, 'utf8')));
133
+ for (const warning of applied.warnings) io.error(warning);
134
+ model = applied.model;
135
+ }
136
+ // Every add-on is loaded before anything is written, so a missing one stops the run with nothing half done.
137
+ const load = io.loadAddon ?? ((addon: AddonConfig) => loadAddon(addon, importer(io.cwd)));
138
+ const addons: Addon[] = [];
139
+ for (const addon of config.addons) {
140
+ try {
141
+ addons.push(await load(addon));
142
+ } catch (error) {
143
+ throw missing(error instanceof Error ? error.message : String(error));
144
+ }
145
+ }
146
+ for (const addon of addons) if (addon.enrich) model = await addon.enrich(model);
147
+ const files = renderMarkdown(model);
148
+ for (const addon of addons) if (addon.render) Object.assign(files, await addon.render(model));
149
+ for (const [name, text] of Object.entries(files)) {
150
+ const path = join(io.cwd, config.out, name);
151
+ mkdirSync(dirname(path), { recursive: true });
152
+ writeFileSync(path, text);
153
+ }
154
+ io.log(`Wrote ${Object.keys(files).length} files to ${config.out}.`);
155
+ return 0;
156
+ }
package/src/config.ts ADDED
@@ -0,0 +1,93 @@
1
+ // dbtour.yaml: where the model goes, where it comes from, where the notes are.
2
+ import { parse } from 'yaml';
3
+
4
+ export interface SourceConfig {
5
+ type: string;
6
+ name: string;
7
+ /** `${NAME}`: the environment variable holding the connection string. Never the string itself. */
8
+ connection?: string;
9
+ /** A module to import instead of dbtour-source-<type>. */
10
+ module?: string;
11
+ [option: string]: unknown;
12
+ }
13
+
14
+ export interface AddonConfig {
15
+ type: string;
16
+ /** A module to import instead of dbtour-addon-<type>. */
17
+ module?: string;
18
+ [option: string]: unknown;
19
+ }
20
+
21
+ export interface Config {
22
+ out: string;
23
+ notes: string;
24
+ sources: SourceConfig[];
25
+ addons: AddonConfig[];
26
+ }
27
+
28
+ export const configFile = 'dbtour.yaml';
29
+ export const modelFile = 'schema.json';
30
+
31
+ export function parseConfig(text: string): Config {
32
+ const raw: unknown = parse(text) ?? {};
33
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new Error(`${configFile} must be a map with out, notes and sources`);
34
+ const doc = raw as Record<string, unknown>;
35
+ if (!Array.isArray(doc.sources) || doc.sources.length === 0) throw new Error(`${configFile} needs a sources list with at least one source`);
36
+ const sources = doc.sources.map((value, i): SourceConfig => {
37
+ if (typeof value !== 'object' || value === null) throw new Error(`sources[${i}] must be a map with type and name`);
38
+ const entry = value as Record<string, unknown>;
39
+ if (typeof entry.type !== 'string' || entry.type === '') throw new Error(`sources[${i}].type is required: mssql, for instance`);
40
+ if (typeof entry.name !== 'string' || entry.name === '') throw new Error(`sources[${i}].name is required: the name the docs call this database`);
41
+ return { ...entry, type: entry.type, name: entry.name };
42
+ });
43
+ return {
44
+ out: typeof doc.out === 'string' ? doc.out : 'docs/dbtour',
45
+ notes: typeof doc.notes === 'string' ? doc.notes : 'dbtour.notes.yaml',
46
+ sources,
47
+ addons: parseAddons(doc.addons),
48
+ };
49
+ }
50
+
51
+ /** `addons: [explorer]` or `addons: [{ type: explorer, module: ./page.ts }]`; none when absent. */
52
+ function parseAddons(value: unknown): AddonConfig[] {
53
+ if (value === undefined) return [];
54
+ if (!Array.isArray(value)) throw new Error('addons must be a list of add-on names, like [explorer]');
55
+ return value.map((entry, i): AddonConfig => {
56
+ if (typeof entry === 'string' && entry !== '') return { type: entry };
57
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw new Error(`addons[${i}] must be a name or a map with type`);
58
+ const map = entry as Record<string, unknown>;
59
+ if (typeof map.type !== 'string' || map.type === '') throw new Error(`addons[${i}].type is required: explorer, for instance`);
60
+ return { ...map, type: map.type };
61
+ });
62
+ }
63
+
64
+ export function defaultConfigText(type: string): string {
65
+ const variable = `DBTOUR_${type.toUpperCase()}`;
66
+ return `# dbtour: see https://github.com/victorsaly/dbtour
67
+ out: docs/dbtour
68
+ sources:
69
+ - type: ${type}
70
+ name: ${type === 'mssql' ? 'MyDatabase' : 'my-database'}
71
+ # The name of the environment variable holding the connection string. Never the string itself.
72
+ connection: \${${variable}}
73
+ # Descriptions, relations and groups the database cannot say. Optional.
74
+ notes: dbtour.notes.yaml
75
+ # What else render writes. explorer is the page: npm install --save-dev dbtour-addon-explorer
76
+ addons:
77
+ - explorer
78
+ `;
79
+ }
80
+
81
+ const variablePattern = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
82
+
83
+ export function resolveConnection(value: string | undefined, env: Record<string, string | undefined>): string | undefined {
84
+ if (value === undefined) return undefined;
85
+ const match = variablePattern.exec(value);
86
+ if (!match) throw new Error(`connection must name an environment variable, like \${DBTOUR_MSSQL}: never write the connection string into ${configFile}`);
87
+ const resolved = env[match[1]];
88
+ if (resolved === undefined || resolved === '') {
89
+ // Exit 2: what is missing, not what failed.
90
+ throw Object.assign(new Error(`Set ${match[1]} to the connection string, then run it again`), { exitCode: 2 });
91
+ }
92
+ return resolved;
93
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './model.ts';
2
+ export { parseNotes, applyNotes, type Notes, type TableNotes, type RelationNote, type Applied } from './notes.ts';
3
+ export { parseConfig, defaultConfigText, resolveConnection, configFile, modelFile, type Config, type SourceConfig, type AddonConfig } from './config.ts';
4
+ export { loadSource, sourceModuleName, loadAddon, addonModuleName, type Source, type Extract, type Importer, type Addon, type Files } from './plugins.ts';
5
+ export { renderMarkdown, renderTableMarkdown, driverName, wholeDiagramLimit } from './render/markdown.ts';
6
+ export { erDiagram, neighbourhood, entityName } from './render/mermaid.ts';
package/src/model.ts ADDED
@@ -0,0 +1,318 @@
1
+ // The model: what every source writes and everything else reads. Kept to plain JSON so it can be committed,
2
+ // diffed and handed to a page or an agent. Refs name tables as `schema.table` and columns as
3
+ // `schema.table.column`.
4
+
5
+ export const MODEL_VERSION = 1;
6
+
7
+ export interface Column {
8
+ name: string;
9
+ /** As the database spells it, with its size: nvarchar(320), decimal(18,2). */
10
+ type: string;
11
+ nullable: boolean;
12
+ default?: string;
13
+ /** The expression of a computed column. */
14
+ computed?: string;
15
+ identity?: boolean;
16
+ description?: string;
17
+ }
18
+
19
+ export interface Index {
20
+ name: string;
21
+ columns: string[];
22
+ unique: boolean;
23
+ included?: string[];
24
+ }
25
+
26
+ export interface Constraint {
27
+ name: string;
28
+ kind: 'primary' | 'unique' | 'check' | 'foreign';
29
+ columns: string[];
30
+ definition?: string;
31
+ }
32
+
33
+ export interface Table {
34
+ name: string;
35
+ kind: 'table' | 'view';
36
+ description?: string;
37
+ columns: Column[];
38
+ primaryKey?: string[];
39
+ indexes: Index[];
40
+ constraints: Constraint[];
41
+ /** The SQL of a view. */
42
+ definition?: string;
43
+ }
44
+
45
+ export interface Schema {
46
+ name: string;
47
+ tables: Table[];
48
+ }
49
+
50
+ export interface RelationEnd {
51
+ table: string;
52
+ columns: string[];
53
+ }
54
+
55
+ export interface Relation {
56
+ name?: string;
57
+ from: RelationEnd;
58
+ to: RelationEnd;
59
+ /** Declared in the notes, not in the database. */
60
+ virtual?: boolean;
61
+ onDelete?: string;
62
+ onUpdate?: string;
63
+ }
64
+
65
+ export interface Database {
66
+ name: string;
67
+ driver: string;
68
+ version?: string;
69
+ description?: string;
70
+ schemas: Schema[];
71
+ relations: Relation[];
72
+ }
73
+
74
+ export interface Endpoint {
75
+ method: string;
76
+ path: string;
77
+ summary?: string;
78
+ description?: string;
79
+ tags?: string[];
80
+ }
81
+
82
+ export interface Api {
83
+ name: string;
84
+ description?: string;
85
+ endpoints: Endpoint[];
86
+ }
87
+
88
+ /** An endpoint and the tables it touches. */
89
+ export interface Link {
90
+ api: string;
91
+ method: string;
92
+ path: string;
93
+ tables: string[];
94
+ }
95
+
96
+ export interface Group {
97
+ name: string;
98
+ description?: string;
99
+ tables: string[];
100
+ }
101
+
102
+ export interface Model {
103
+ dbtour: typeof MODEL_VERSION;
104
+ generatedAt?: string;
105
+ databases: Database[];
106
+ apis: Api[];
107
+ links: Link[];
108
+ groups: Group[];
109
+ }
110
+
111
+ export function emptyModel(): Model {
112
+ return { dbtour: MODEL_VERSION, databases: [], apis: [], links: [], groups: [] };
113
+ }
114
+
115
+ export interface TableRef {
116
+ schema: string;
117
+ table: string;
118
+ column?: string;
119
+ }
120
+
121
+ export function tableRef(schema: string, table: string): string {
122
+ return `${schema}.${table}`;
123
+ }
124
+
125
+ export function parseRef(ref: string): TableRef {
126
+ const parts = ref.split('.');
127
+ if (parts.length < 2 || parts.length > 3 || parts.some((p) => p === '')) {
128
+ throw new Error(`"${ref}" is not a ref: write schema.table, or schema.table.column`);
129
+ }
130
+ const [schema, table, column] = parts;
131
+ return column === undefined ? { schema, table } : { schema, table, column };
132
+ }
133
+
134
+ export interface Found {
135
+ database: Database;
136
+ schema: Schema;
137
+ table: Table;
138
+ }
139
+
140
+ export function findTable(model: Model, ref: string): Found | undefined {
141
+ const { schema: schemaName, table: tableName } = parseRef(ref);
142
+ for (const database of model.databases) {
143
+ for (const schema of database.schemas) {
144
+ if (schema.name !== schemaName) continue;
145
+ const table = schema.tables.find((t) => t.name === tableName);
146
+ if (table) return { database, schema, table };
147
+ }
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ /** Unique on its own, by a constraint or an index, and not the primary key, which says so already. */
153
+ export function isUniqueColumn(table: Table, column: string): boolean {
154
+ if (table.primaryKey?.includes(column)) return false;
155
+ return (
156
+ table.constraints.some((c) => c.kind === 'unique' && c.columns.length === 1 && c.columns[0] === column) ||
157
+ table.indexes.some((i) => i.unique && i.columns.length === 1 && i.columns[0] === column)
158
+ );
159
+ }
160
+
161
+ export function relationsOf(model: Model, ref: string): { out: Relation[]; in: Relation[] } {
162
+ const out: Relation[] = [];
163
+ const inward: Relation[] = [];
164
+ for (const database of model.databases) {
165
+ for (const relation of database.relations) {
166
+ if (relation.from.table === ref) out.push(relation);
167
+ if (relation.to.table === ref) inward.push(relation);
168
+ }
169
+ }
170
+ return { out, in: inward };
171
+ }
172
+
173
+ export type Validation = { ok: true; model: Model } | { ok: false; errors: string[] };
174
+
175
+ /** Checks the shape, then that every relation points at a table in its database. */
176
+ export function validate(value: unknown): Validation {
177
+ const errors: string[] = [];
178
+ const check = new Checker(errors);
179
+ if (!check.object(value, 'model')) return { ok: false, errors };
180
+ const model = value as Record<string, unknown>;
181
+ if (model.dbtour !== MODEL_VERSION) errors.push(`model.dbtour must be ${MODEL_VERSION}, got ${JSON.stringify(model.dbtour)}`);
182
+ check.optionalString(model.generatedAt, 'generatedAt');
183
+ check.list(model.databases, 'databases', (d, p) => checkDatabase(check, d, p));
184
+ check.list(model.apis, 'apis', (a, p) => {
185
+ if (!check.object(a, p)) return;
186
+ check.string(a.name, `${p}.name`);
187
+ check.optionalString(a.description, `${p}.description`);
188
+ check.list(a.endpoints, `${p}.endpoints`, (e, ep) => {
189
+ if (!check.object(e, ep)) return;
190
+ check.string(e.method, `${ep}.method`);
191
+ check.string(e.path, `${ep}.path`);
192
+ check.optionalString(e.summary, `${ep}.summary`);
193
+ check.optionalString(e.description, `${ep}.description`);
194
+ if (e.tags !== undefined) check.strings(e.tags, `${ep}.tags`);
195
+ });
196
+ });
197
+ check.list(model.links, 'links', (l, p) => {
198
+ if (!check.object(l, p)) return;
199
+ check.string(l.api, `${p}.api`);
200
+ check.string(l.method, `${p}.method`);
201
+ check.string(l.path, `${p}.path`);
202
+ check.strings(l.tables, `${p}.tables`);
203
+ });
204
+ check.list(model.groups, 'groups', (g, p) => {
205
+ if (!check.object(g, p)) return;
206
+ check.string(g.name, `${p}.name`);
207
+ check.optionalString(g.description, `${p}.description`);
208
+ check.strings(g.tables, `${p}.tables`);
209
+ });
210
+ if (errors.length > 0) return { ok: false, errors };
211
+
212
+ const typed = value as Model;
213
+ typed.databases.forEach((database, i) => {
214
+ const refs = new Set(database.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name))));
215
+ database.relations.forEach((relation, j) => {
216
+ for (const end of ['from', 'to'] as const) {
217
+ if (!refs.has(relation[end].table)) {
218
+ errors.push(`databases[${i}].relations[${j}].${end}.table "${relation[end].table}" is not a table of ${database.name}`);
219
+ }
220
+ }
221
+ });
222
+ });
223
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, model: typed };
224
+ }
225
+
226
+ function checkDatabase(check: Checker, d: unknown, p: string): void {
227
+ if (!check.object(d, p)) return;
228
+ check.string(d.name, `${p}.name`);
229
+ check.string(d.driver, `${p}.driver`);
230
+ check.optionalString(d.version, `${p}.version`);
231
+ check.optionalString(d.description, `${p}.description`);
232
+ check.list(d.schemas, `${p}.schemas`, (s, sp) => {
233
+ if (!check.object(s, sp)) return;
234
+ check.string(s.name, `${sp}.name`);
235
+ check.list(s.tables, `${sp}.tables`, (t, tp) => checkTable(check, t, tp));
236
+ });
237
+ check.list(d.relations, `${p}.relations`, (r, rp) => {
238
+ if (!check.object(r, rp)) return;
239
+ check.optionalString(r.name, `${rp}.name`);
240
+ for (const end of ['from', 'to']) {
241
+ const e = r[end];
242
+ if (!check.object(e, `${rp}.${end}`)) continue;
243
+ check.string(e.table, `${rp}.${end}.table`);
244
+ check.strings(e.columns, `${rp}.${end}.columns`);
245
+ }
246
+ check.optionalBoolean(r.virtual, `${rp}.virtual`);
247
+ });
248
+ }
249
+
250
+ function checkTable(check: Checker, t: unknown, p: string): void {
251
+ if (!check.object(t, p)) return;
252
+ check.string(t.name, `${p}.name`);
253
+ if (t.kind !== 'table' && t.kind !== 'view') check.errors.push(`${p}.kind must be "table" or "view"`);
254
+ check.optionalString(t.description, `${p}.description`);
255
+ check.optionalString(t.definition, `${p}.definition`);
256
+ if (t.primaryKey !== undefined) check.strings(t.primaryKey, `${p}.primaryKey`);
257
+ check.list(t.columns, `${p}.columns`, (c, cp) => {
258
+ if (!check.object(c, cp)) return;
259
+ check.string(c.name, `${cp}.name`);
260
+ check.string(c.type, `${cp}.type`);
261
+ if (typeof c.nullable !== 'boolean') check.errors.push(`${cp}.nullable must be true or false`);
262
+ check.optionalString(c.default, `${cp}.default`);
263
+ check.optionalString(c.computed, `${cp}.computed`);
264
+ check.optionalBoolean(c.identity, `${cp}.identity`);
265
+ check.optionalString(c.description, `${cp}.description`);
266
+ });
267
+ check.list(t.indexes, `${p}.indexes`, (x, xp) => {
268
+ if (!check.object(x, xp)) return;
269
+ check.string(x.name, `${xp}.name`);
270
+ check.strings(x.columns, `${xp}.columns`);
271
+ if (typeof x.unique !== 'boolean') check.errors.push(`${xp}.unique must be true or false`);
272
+ });
273
+ check.list(t.constraints, `${p}.constraints`, (c, cp) => {
274
+ if (!check.object(c, cp)) return;
275
+ check.string(c.name, `${cp}.name`);
276
+ if (!['primary', 'unique', 'check', 'foreign'].includes(String(c.kind))) check.errors.push(`${cp}.kind is not a constraint kind`);
277
+ check.strings(c.columns, `${cp}.columns`);
278
+ check.optionalString(c.definition, `${cp}.definition`);
279
+ });
280
+ }
281
+
282
+ class Checker {
283
+ errors: string[];
284
+
285
+ constructor(errors: string[]) {
286
+ this.errors = errors;
287
+ }
288
+
289
+ object(value: unknown, path: string): value is Record<string, any> { // eslint-disable-line @typescript-eslint/no-explicit-any
290
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) return true;
291
+ this.errors.push(`${path} must be an object`);
292
+ return false;
293
+ }
294
+
295
+ string(value: unknown, path: string): void {
296
+ if (typeof value !== 'string' || value === '') this.errors.push(`${path} must be a non-empty string`);
297
+ }
298
+
299
+ optionalString(value: unknown, path: string): void {
300
+ if (value !== undefined && typeof value !== 'string') this.errors.push(`${path} must be a string when given`);
301
+ }
302
+
303
+ optionalBoolean(value: unknown, path: string): void {
304
+ if (value !== undefined && typeof value !== 'boolean') this.errors.push(`${path} must be true or false when given`);
305
+ }
306
+
307
+ strings(value: unknown, path: string): void {
308
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) this.errors.push(`${path} must be a list of strings`);
309
+ }
310
+
311
+ list(value: unknown, path: string, each: (item: unknown, path: string) => void): void {
312
+ if (!Array.isArray(value)) {
313
+ this.errors.push(`${path} must be a list`);
314
+ return;
315
+ }
316
+ value.forEach((item, i) => each(item, `${path}[${i}]`));
317
+ }
318
+ }
package/src/notes.ts ADDED
@@ -0,0 +1,130 @@
1
+ // The notes file: what people add that the database cannot say. Merged into the model at render time and
2
+ // winning over it, so a production database nobody may alter still gets documented.
3
+ import { parse } from 'yaml';
4
+ import { findTable, parseRef, tableRef, type Group, type Model, type Relation } from './model.ts';
5
+
6
+ export interface TableNotes {
7
+ description?: string;
8
+ columns?: Record<string, string>;
9
+ }
10
+
11
+ export interface RelationNote {
12
+ from: string;
13
+ to: string;
14
+ }
15
+
16
+ export interface Notes {
17
+ tables: Record<string, TableNotes>;
18
+ relations: RelationNote[];
19
+ groups: Group[];
20
+ }
21
+
22
+ export function parseNotes(text: string): Notes {
23
+ const raw: unknown = parse(text) ?? {};
24
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new Error('the notes file must be a map with tables, relations and groups');
25
+ const doc = raw as Record<string, unknown>;
26
+ const notes: Notes = { tables: {}, relations: [], groups: [] };
27
+
28
+ if (doc.tables !== undefined) {
29
+ if (typeof doc.tables !== 'object' || doc.tables === null || Array.isArray(doc.tables)) throw new Error('tables must be a map of schema.table to its notes');
30
+ for (const [ref, value] of Object.entries(doc.tables as Record<string, unknown>)) {
31
+ parseRef(ref);
32
+ if (typeof value !== 'object' || value === null) throw new Error(`tables.${ref} must be a map with description and columns`);
33
+ const entry = value as Record<string, unknown>;
34
+ const table: TableNotes = {};
35
+ if (entry.description !== undefined) table.description = String(entry.description);
36
+ if (entry.columns !== undefined) {
37
+ if (typeof entry.columns !== 'object' || entry.columns === null || Array.isArray(entry.columns)) throw new Error(`tables.${ref}.columns must be a map of column to description`);
38
+ table.columns = Object.fromEntries(Object.entries(entry.columns as Record<string, unknown>).map(([k, v]) => [k, String(v)]));
39
+ }
40
+ notes.tables[ref] = table;
41
+ }
42
+ }
43
+
44
+ if (doc.relations !== undefined) {
45
+ if (!Array.isArray(doc.relations)) throw new Error('relations must be a list of { from, to }');
46
+ doc.relations.forEach((value, i) => {
47
+ if (typeof value !== 'object' || value === null) throw new Error(`relations[${i}] must be { from, to }`);
48
+ const { from, to } = value as Record<string, unknown>;
49
+ for (const [end, ref] of [['from', from], ['to', to]] as const) {
50
+ if (typeof ref !== 'string' || parseRef(ref).column === undefined) throw new Error(`relations[${i}].${end} must be schema.table.column`);
51
+ }
52
+ notes.relations.push({ from: from as string, to: to as string });
53
+ });
54
+ }
55
+
56
+ if (doc.groups !== undefined) {
57
+ if (!Array.isArray(doc.groups)) throw new Error('groups must be a list of { name, description, tables }');
58
+ doc.groups.forEach((value, i) => {
59
+ if (typeof value !== 'object' || value === null) throw new Error(`groups[${i}] must be { name, description, tables }`);
60
+ const entry = value as Record<string, unknown>;
61
+ if (typeof entry.name !== 'string' || entry.name === '') throw new Error(`groups[${i}].name is required`);
62
+ if (!Array.isArray(entry.tables) || entry.tables.some((t) => typeof t !== 'string')) throw new Error(`groups[${i}].tables must be a list of schema.table`);
63
+ entry.tables.forEach((t) => parseRef(t));
64
+ const group: Group = { name: entry.name, tables: entry.tables as string[] };
65
+ if (entry.description !== undefined) group.description = String(entry.description);
66
+ notes.groups.push(group);
67
+ });
68
+ }
69
+ return notes;
70
+ }
71
+
72
+ export interface Applied {
73
+ model: Model;
74
+ /** What the notes name that the model does not have; said, then skipped. */
75
+ warnings: string[];
76
+ }
77
+
78
+ export function applyNotes(input: Model, notes: Notes): Applied {
79
+ const model = structuredClone(input);
80
+ const warnings: string[] = [];
81
+
82
+ for (const [ref, tableNotes] of Object.entries(notes.tables)) {
83
+ const found = findTable(model, ref);
84
+ if (!found) {
85
+ warnings.push(`notes: ${ref} is not a table in the model`);
86
+ continue;
87
+ }
88
+ if (tableNotes.description !== undefined) found.table.description = tableNotes.description;
89
+ for (const [name, description] of Object.entries(tableNotes.columns ?? {})) {
90
+ const column = found.table.columns.find((c) => c.name === name);
91
+ if (!column) {
92
+ warnings.push(`notes: ${ref}.${name} is not a column in the model`);
93
+ continue;
94
+ }
95
+ column.description = description;
96
+ }
97
+ }
98
+
99
+ for (const note of notes.relations) {
100
+ const from = parseRef(note.from);
101
+ const to = parseRef(note.to);
102
+ const relation: Relation = {
103
+ from: { table: tableRef(from.schema, from.table), columns: [from.column!] },
104
+ to: { table: tableRef(to.schema, to.table), columns: [to.column!] },
105
+ virtual: true,
106
+ };
107
+ const fromTable = findTable(model, relation.from.table);
108
+ const toTable = findTable(model, relation.to.table);
109
+ if (!fromTable || !toTable) {
110
+ warnings.push(`notes: relation ${note.from} → ${note.to} names a table that is not in the model`);
111
+ continue;
112
+ }
113
+ const exists = fromTable.database.relations.some((r) => sameEnd(r.from, relation.from) && sameEnd(r.to, relation.to));
114
+ if (!exists) fromTable.database.relations.push(relation);
115
+ }
116
+
117
+ for (const group of notes.groups) {
118
+ const tables = group.tables.filter((ref) => {
119
+ if (findTable(model, ref)) return true;
120
+ warnings.push(`notes: group ${group.name} lists ${ref}, which is not a table in the model`);
121
+ return false;
122
+ });
123
+ model.groups.push({ ...group, tables });
124
+ }
125
+ return { model, warnings };
126
+ }
127
+
128
+ function sameEnd(a: { table: string; columns: string[] }, b: { table: string; columns: string[] }): boolean {
129
+ return a.table === b.table && a.columns.join(',') === b.columns.join(',');
130
+ }
package/src/plugins.ts ADDED
@@ -0,0 +1,63 @@
1
+ // Sources and add-ons are packages named after their type: `type: mssql` loads dbtour-source-mssql, the
2
+ // add-on `explorer` loads dbtour-addon-explorer. A `module` in the config loads that instead, for one kept
3
+ // in the project.
4
+ import type { AddonConfig, SourceConfig } from './config.ts';
5
+ import type { Api, Database, Model } from './model.ts';
6
+
7
+ export type Extract = (config: SourceConfig) => Promise<Database | Api>;
8
+
9
+ export interface Source {
10
+ extract: Extract;
11
+ }
12
+
13
+ export type Importer = (specifier: string) => Promise<unknown>;
14
+
15
+ export function sourceModuleName(source: SourceConfig): string {
16
+ return source.module ?? `dbtour-source-${source.type}`;
17
+ }
18
+
19
+ export async function loadSource(source: SourceConfig, importer: Importer = (s) => import(s)): Promise<Source> {
20
+ const name = sourceModuleName(source);
21
+ let loaded: unknown;
22
+ try {
23
+ loaded = await importer(name);
24
+ } catch (error) {
25
+ const reason = error instanceof Error ? error.message : String(error);
26
+ throw new Error(`Source "${source.type}" is not installed: npm install ${name} (${reason})`, { cause: error });
27
+ }
28
+ const extract = (loaded as { extract?: unknown } | null)?.extract;
29
+ if (typeof extract !== 'function') throw new Error(`${name} does not export extract(config), so it is not a dbtour source`);
30
+ return { extract: extract as Extract };
31
+ }
32
+
33
+ /** Files to write under `out`, keyed by path, like renderMarkdown returns them. */
34
+ export type Files = Record<string, string>;
35
+
36
+ /** An add-on changes the model, writes files beside the Markdown, or both. */
37
+ export interface Addon {
38
+ enrich?: (model: Model) => Model | Promise<Model>;
39
+ render?: (model: Model) => Files | Promise<Files>;
40
+ }
41
+
42
+ export function addonModuleName(addon: AddonConfig): string {
43
+ return addon.module ?? `dbtour-addon-${addon.type}`;
44
+ }
45
+
46
+ export async function loadAddon(addon: AddonConfig, importer: Importer = (s) => import(s)): Promise<Addon> {
47
+ const name = addonModuleName(addon);
48
+ let loaded: unknown;
49
+ try {
50
+ loaded = await importer(name);
51
+ } catch (error) {
52
+ const reason = error instanceof Error ? error.message : String(error);
53
+ throw new Error(`Add-on "${addon.type}" is not installed: npm install ${name} (${reason})`, { cause: error });
54
+ }
55
+ const { enrich, render } = (loaded as { enrich?: unknown; render?: unknown } | null) ?? {};
56
+ if (typeof enrich !== 'function' && typeof render !== 'function') {
57
+ throw new Error(`${name} exports neither enrich(model) or render(model), so it is not a dbtour add-on`);
58
+ }
59
+ const result: Addon = {};
60
+ if (typeof enrich === 'function') result.enrich = enrich as Addon['enrich'];
61
+ if (typeof render === 'function') result.render = render as Addon['render'];
62
+ return result;
63
+ }
@@ -0,0 +1,147 @@
1
+ // Markdown GitHub shows: an index with every table, and one page per table with its columns, keys,
2
+ // relations both ways, indexes, constraints and the diagram of its neighbourhood.
3
+ import { findTable, isUniqueColumn, relationsOf, tableRef, type Database, type Model, type Table } from '../model.ts';
4
+ import { erDiagram, neighbourhood } from './mermaid.ts';
5
+
6
+ // Mermaid lays out a whole database only while it is small; above this many tables the index shows the
7
+ // groups and each table page shows its own neighbourhood instead.
8
+ export const wholeDiagramLimit = 40;
9
+
10
+ export function renderMarkdown(model: Model): Record<string, string> {
11
+ const files: Record<string, string> = { 'README.md': renderIndex(model) };
12
+ for (const database of model.databases) {
13
+ for (const schema of database.schemas) {
14
+ for (const table of schema.tables) {
15
+ const ref = tableRef(schema.name, table.name);
16
+ files[`tables/${ref}.md`] = renderTable(model, ref, table);
17
+ }
18
+ }
19
+ }
20
+ return files;
21
+ }
22
+
23
+ function renderIndex(model: Model): string {
24
+ const parts: string[] = [];
25
+ const single = model.databases.length === 1;
26
+ if (!single) parts.push('# Databases\n');
27
+ for (const database of model.databases) {
28
+ parts.push(`${single ? '#' : '##'} ${database.name}\n`);
29
+ if (database.description) parts.push(`${database.description}\n`);
30
+ parts.push(`${driverLine(database)}\n`);
31
+ parts.push('| Table | Kind | Columns | Description |\n|---|---|---|---|');
32
+ for (const schema of database.schemas) {
33
+ for (const table of schema.tables) {
34
+ const ref = tableRef(schema.name, table.name);
35
+ parts.push(`| [${ref}](tables/${ref}.md) | ${table.kind} | ${table.columns.length} | ${cell(table.description)} |`);
36
+ }
37
+ }
38
+ parts.push('');
39
+ }
40
+ for (const group of model.groups) {
41
+ parts.push(`## ${group.name}\n`);
42
+ if (group.description) parts.push(`${group.description}\n`);
43
+ parts.push(group.tables.map((ref) => `- [${ref}](tables/${ref}.md)`).join('\n') + '\n');
44
+ parts.push(mermaid(erDiagram(model, group.tables)));
45
+ }
46
+ const refs = model.databases.flatMap((d) => d.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name))));
47
+ if (refs.length <= wholeDiagramLimit) {
48
+ parts.push('## Diagram\n');
49
+ parts.push(mermaid(erDiagram(model, refs)));
50
+ } else {
51
+ parts.push(`## Diagram\n\n${refs.length} tables is too many for one diagram: each table's page draws its own neighbourhood.\n`);
52
+ }
53
+ return parts.join('\n');
54
+ }
55
+
56
+ /** The product's name for a driver: mssql is SQL Server. */
57
+ export function driverName(driver: string): string {
58
+ const names: Record<string, string> = { mssql: 'SQL Server', postgres: 'PostgreSQL', mysql: 'MySQL', sqlite: 'SQLite' };
59
+ return names[driver] ?? driver;
60
+ }
61
+
62
+ function driverLine(database: Database): string {
63
+ const name = driverName(database.driver);
64
+ return database.version ? `${name} ${database.version}.` : `${name}.`;
65
+ }
66
+
67
+ /** One table's page on its own: what the page hands to an agent. */
68
+ export function renderTableMarkdown(model: Model, ref: string): string {
69
+ const found = findTable(model, ref);
70
+ if (!found) throw new Error(`${ref} is not a table in the model`);
71
+ return renderTable(model, ref, found.table);
72
+ }
73
+
74
+ function renderTable(model: Model, ref: string, table: Table): string {
75
+ const parts: string[] = [`# ${ref}\n`];
76
+ if (table.description) parts.push(`${table.description}\n`);
77
+ const relations = relationsOf(model, ref);
78
+
79
+ parts.push('| Column | Type | Nullable | Default | Key | Description |\n|---|---|---|---|---|---|');
80
+ for (const column of table.columns) {
81
+ const keys: string[] = [];
82
+ if (table.primaryKey?.includes(column.name)) keys.push('PK');
83
+ for (const relation of relations.out) {
84
+ const i = relation.from.columns.indexOf(column.name);
85
+ if (i === -1) continue;
86
+ const { table: toTable, columns } = relation.to;
87
+ keys.push(`FK → [${toTable}](${toTable}.md).${columns[i] ?? columns.join(',')}${relation.virtual ? ' (notes)' : ''}`);
88
+ }
89
+ if (isUniqueColumn(table, column.name)) keys.push('unique');
90
+ if (column.identity) keys.push('identity');
91
+ if (column.computed !== undefined) keys.push(`computed \`${column.computed}\``);
92
+ const dflt = column.default === undefined ? '' : `\`${column.default}\``;
93
+ parts.push(`| ${cell(column.name)} | ${cell(column.type)} | ${column.nullable ? 'yes' : 'no'} | ${cell(dflt)} | ${keys.join(', ')} | ${cell(column.description)} |`);
94
+ }
95
+ parts.push('');
96
+
97
+ if (relations.in.length > 0) {
98
+ parts.push('## Referenced by\n');
99
+ const lines = relations.in
100
+ .map((r) => `- [${r.from.table}](${r.from.table}.md).${r.from.columns.join(', ')}${r.virtual ? ' (notes)' : ''}`)
101
+ .sort();
102
+ parts.push(lines.join('\n') + '\n');
103
+ }
104
+
105
+ if (table.indexes.length > 0) {
106
+ parts.push('## Indexes\n');
107
+ parts.push('| Name | Columns | Unique |\n|---|---|---|');
108
+ for (const index of table.indexes) {
109
+ const columns = index.columns.join(', ') + (index.included?.length ? ` (includes ${index.included.join(', ')})` : '');
110
+ parts.push(`| ${cell(index.name)} | ${cell(columns)} | ${index.unique ? 'yes' : 'no'} |`);
111
+ }
112
+ parts.push('');
113
+ }
114
+
115
+ if (table.constraints.length > 0) {
116
+ parts.push('## Constraints\n');
117
+ parts.push('| Name | Kind | Columns | Definition |\n|---|---|---|---|');
118
+ for (const constraint of table.constraints) {
119
+ const definition = constraint.definition === undefined ? '' : `\`${constraint.definition}\``;
120
+ parts.push(`| ${cell(constraint.name)} | ${constraint.kind} | ${cell(constraint.columns.join(', '))} | ${cell(definition)} |`);
121
+ }
122
+ parts.push('');
123
+ }
124
+
125
+ if (table.definition !== undefined) {
126
+ parts.push('## Definition\n');
127
+ parts.push('```sql\n' + table.definition.trim() + '\n```\n');
128
+ }
129
+
130
+ const around = neighbourhood(model, ref);
131
+ if (around.length > 1) {
132
+ parts.push('## Diagram\n');
133
+ parts.push(mermaid(erDiagram(model, around)));
134
+ }
135
+ return parts.join('\n');
136
+ }
137
+
138
+ function mermaid(diagram: string): string {
139
+ return '```mermaid\n' + diagram + '```\n';
140
+ }
141
+
142
+ /** A table cell: pipes and line breaks would end it. */
143
+ function cell(text: string | undefined): string {
144
+ return (text ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
145
+ }
146
+
147
+ export { findTable };
@@ -0,0 +1,75 @@
1
+ // Mermaid erDiagram text: GitHub draws it in the Markdown, no image service needed.
2
+ import { findTable, relationsOf, type Model, type Relation, type Table } from '../model.ts';
3
+
4
+ /** Mermaid takes letters, digits and underscores in an entity name. */
5
+ export function entityName(ref: string): string {
6
+ return ref.replace(/[^A-Za-z0-9_]/g, '_');
7
+ }
8
+
9
+ /** The table and every table one relation away, in the order the relations are listed. */
10
+ export function neighbourhood(model: Model, ref: string): string[] {
11
+ const refs = [ref];
12
+ for (const relation of model.databases.flatMap((d) => d.relations)) {
13
+ const other = relation.from.table === ref ? relation.to.table : relation.to.table === ref ? relation.from.table : undefined;
14
+ if (other !== undefined && !refs.includes(other)) refs.push(other);
15
+ }
16
+ return refs;
17
+ }
18
+
19
+ export function erDiagram(model: Model, refs: string[]): string {
20
+ const lines = ['erDiagram'];
21
+ const shown = new Set(refs);
22
+ for (const ref of refs) {
23
+ const found = findTable(model, ref);
24
+ if (!found) continue;
25
+ const foreign = new Set(relationsOf(model, ref).out.flatMap((r) => r.from.columns));
26
+ lines.push(` ${entityName(ref)} {`);
27
+ for (const column of found.table.columns) {
28
+ const keys = keyMarks(found.table, column.name, foreign);
29
+ lines.push(` ${attributeType(column.type)} ${attributeName(column.name)}${keys ? ' ' + keys : ''}`);
30
+ }
31
+ lines.push(' }');
32
+ }
33
+ for (const relation of model.databases.flatMap((d) => d.relations)) {
34
+ if (!shown.has(relation.from.table) || !shown.has(relation.to.table)) continue;
35
+ lines.push(` ${entityName(relation.from.table)} ${cardinality(model, relation)} ${entityName(relation.to.table)} : ${label(relation)}`);
36
+ }
37
+ return lines.join('\n') + '\n';
38
+ }
39
+
40
+ function keyMarks(table: Table, column: string, foreign: Set<string>): string {
41
+ const marks: string[] = [];
42
+ if (table.primaryKey?.includes(column)) marks.push('PK');
43
+ if (foreign.has(column)) marks.push('FK');
44
+ if (!marks.includes('PK') && table.constraints.some((c) => c.kind === 'unique' && c.columns.length === 1 && c.columns[0] === column)) marks.push('UK');
45
+ return marks.join(',');
46
+ }
47
+
48
+ // Mermaid 11 reads a type as one word of letters, digits, underscores, hyphens, brackets and the commas
49
+ // inside them (decimal(18,2) is fine), and a name as one word; it does not take quotes on either, so anything
50
+ // else becomes an underscore.
51
+ function attributeType(type: string): string {
52
+ return type.replace(/^[^A-Za-z_]|[^A-Za-z0-9_,()[\]-]/g, '_');
53
+ }
54
+
55
+ function attributeName(name: string): string {
56
+ return name.replace(/^[^A-Za-z_]|[^A-Za-z0-9_-]/g, '_');
57
+ }
58
+
59
+ /** many-to-one unless the referencing columns are themselves a key, then one-to-one. Dotted when virtual. */
60
+ function cardinality(model: Model, relation: Relation): string {
61
+ const from = findTable(model, relation.from.table)?.table;
62
+ const columns = relation.from.columns.join(',');
63
+ const isKey =
64
+ from !== undefined &&
65
+ (from.primaryKey?.join(',') === columns ||
66
+ from.constraints.some((c) => c.kind === 'unique' && c.columns.join(',') === columns) ||
67
+ from.indexes.some((i) => i.unique && i.columns.join(',') === columns));
68
+ const line = relation.virtual ? '..' : '--';
69
+ return `${isKey ? '|o' : '}o'}${line}||`;
70
+ }
71
+
72
+ function label(relation: Relation): string {
73
+ const text = relation.from.columns.join(', ') + (relation.virtual ? ' (notes)' : '');
74
+ return /^[A-Za-z0-9_]+$/.test(text) ? text : `"${text.replace(/"/g, "'")}"`;
75
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.