420 lines
16 KiB
JavaScript
420 lines
16 KiB
JavaScript
import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const storeDir = join(__dirname, 'src', 'store');
|
||
const outputPath = join(__dirname, 'API.md');
|
||
|
||
// Modules that are exposed on the LibDashStore instance (from src/index.js).
|
||
// Order here controls the order of sections in the generated document.
|
||
const MODULE_ORDER = [
|
||
'companyuser',
|
||
'company',
|
||
'category',
|
||
'article',
|
||
'lang',
|
||
'tax',
|
||
'unit',
|
||
'companysetting',
|
||
'brand',
|
||
'customergroup',
|
||
'entity',
|
||
'entityaddress',
|
||
'entitycontact',
|
||
'supplier',
|
||
'articlesupplier',
|
||
'delivery',
|
||
'quote',
|
||
'order',
|
||
'manifestattachment',
|
||
'ledger',
|
||
'warehouse',
|
||
'stock',
|
||
'posterminal',
|
||
'printer',
|
||
'google_auth',
|
||
'pic',
|
||
'numbers',
|
||
'propertyname',
|
||
'articleproperty',
|
||
'attributename',
|
||
'attributevalue',
|
||
'articleattributevalue',
|
||
'labeltemplate',
|
||
];
|
||
|
||
// Files that are internal helpers and should not appear as public API entries.
|
||
const INTERNAL_FILES = new Set([
|
||
'request.js',
|
||
'slugify.js',
|
||
'initialize.js',
|
||
'purge.js',
|
||
'helpers.js',
|
||
'writeLines.js',
|
||
'shapeSetting.js',
|
||
'shapeUnit.js',
|
||
'ensureDefault.js',
|
||
'ensureLieferantenGroup.js',
|
||
'resolveCompanyAccess.js',
|
||
'resolveSelectedCompanyAccess.js',
|
||
'resolveCategoryAccess.js',
|
||
'resolveCustomerGroupAccess.js',
|
||
'resolveEntityAccess.js',
|
||
'resolvePicAccess.js',
|
||
'resolveArticleBySku.js',
|
||
'resolveManifestAccess.js',
|
||
'queryBySku.js',
|
||
'queryCategoryArticles.js',
|
||
'getCategoryPosterminalIds.js',
|
||
'nextSku.js',
|
||
'nextNumber.js',
|
||
'peekNumber.js',
|
||
'shape.js',
|
||
'createStore.js',
|
||
'manifestLines.js',
|
||
'index.js',
|
||
]);
|
||
|
||
/**
|
||
* Strip the outer braces from a JSDoc type (e.g. `{number}` -> `number`),
|
||
* preserving nested braces inside generics.
|
||
* @param {string} type
|
||
* @returns {string}
|
||
*/
|
||
function stripBraces(type) {
|
||
if (type.startsWith('{') && type.endsWith('}')) {
|
||
return type.slice(1, -1);
|
||
}
|
||
return type;
|
||
}
|
||
|
||
/**
|
||
* Extract the leading JSDoc block from a source string.
|
||
* @param {string} source
|
||
* @returns {{ description: string, example: string, params: Array<object>, returns: string|null, throws: string|null } | null}
|
||
*/
|
||
function extractJsDoc(source) {
|
||
const match = source.match(/\/\*\*([\s\S]*?)\*\//);
|
||
if (!match) return null;
|
||
|
||
const block = match[1];
|
||
const lines = block
|
||
.split('\n')
|
||
.map((l) => l.replace(/^\s*\*\s?/, '').replace(/^\s*\*/, ''));
|
||
|
||
const description = [];
|
||
const example = [];
|
||
const params = [];
|
||
let returns = null;
|
||
let throws = null;
|
||
let inExample = false;
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (trimmed === '@example') {
|
||
inExample = true;
|
||
continue;
|
||
}
|
||
if (trimmed.startsWith('@')) {
|
||
inExample = false;
|
||
const paramMatch = trimmed.match(/^@param\s+(\{.*\})\s+(\S+)\s*[-–]?\s*(.*)$/);
|
||
if (paramMatch) {
|
||
params.push({
|
||
type: stripBraces(paramMatch[1]),
|
||
name: paramMatch[2],
|
||
description: paramMatch[3].trim(),
|
||
});
|
||
continue;
|
||
}
|
||
const returnsMatch = trimmed.match(/^@returns\s+(\{.*\})\s*[-–]?\s*(.*)$/);
|
||
if (returnsMatch) {
|
||
returns = { type: stripBraces(returnsMatch[1]), description: returnsMatch[2].trim() };
|
||
continue;
|
||
}
|
||
const throwsMatch = trimmed.match(/^@throws\s+(\{.*\})\s*[-–]?\s*(.*)$/);
|
||
if (throwsMatch) {
|
||
throws = { type: stripBraces(throwsMatch[1]), description: throwsMatch[2].trim() };
|
||
continue;
|
||
}
|
||
continue;
|
||
}
|
||
if (inExample) {
|
||
example.push(line);
|
||
} else if (trimmed) {
|
||
description.push(trimmed);
|
||
}
|
||
}
|
||
|
||
return {
|
||
description: description.join(' ').trim(),
|
||
example: example.join('\n').trim(),
|
||
params,
|
||
returns,
|
||
throws,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Recursively collect all .js files under a directory.
|
||
* @param {string} dir
|
||
* @returns {string[]} absolute file paths
|
||
*/
|
||
function collectFiles(dir) {
|
||
const out = [];
|
||
for (const entry of readdirSync(dir)) {
|
||
const full = join(dir, entry);
|
||
if (statSync(full).isDirectory()) {
|
||
out.push(...collectFiles(full));
|
||
} else if (entry.endsWith('.js')) {
|
||
out.push(full);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Build a map of module name -> array of { name, file, description, example }.
|
||
* @returns {Map<string, Array<object>>}
|
||
*/
|
||
function buildModules() {
|
||
const modules = new Map();
|
||
|
||
for (const file of collectFiles(storeDir)) {
|
||
const rel = file.slice(storeDir.length + 1).split('/');
|
||
const moduleName = rel[0];
|
||
const fileName = rel[rel.length - 1];
|
||
|
||
if (INTERNAL_FILES.has(fileName)) continue;
|
||
|
||
const source = readFileSync(file, 'utf8');
|
||
const doc = extractJsDoc(source);
|
||
if (!doc) continue;
|
||
|
||
const fnName = fileName.replace(/\.js$/, '');
|
||
if (!modules.has(moduleName)) {
|
||
modules.set(moduleName, []);
|
||
}
|
||
modules.get(moduleName).push({
|
||
name: fnName,
|
||
file: rel.join('/'),
|
||
description: doc.description,
|
||
example: doc.example,
|
||
params: doc.params,
|
||
returns: doc.returns,
|
||
throws: doc.throws,
|
||
});
|
||
}
|
||
|
||
for (const entries of modules.values()) {
|
||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
|
||
return modules;
|
||
}
|
||
|
||
function renderSection(moduleName, entries) {
|
||
const lines = [];
|
||
lines.push(`# ${moduleName}`);
|
||
lines.push('');
|
||
lines.push(
|
||
'This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.',
|
||
);
|
||
lines.push('');
|
||
|
||
for (const entry of entries) {
|
||
lines.push(`## \`${moduleName}.${entry.name}\``);
|
||
lines.push('');
|
||
lines.push(`*Source: \`src/store/${entry.file}\`*`);
|
||
lines.push('');
|
||
if (entry.description) {
|
||
lines.push(entry.description);
|
||
lines.push('');
|
||
}
|
||
if (entry.params && entry.params.length) {
|
||
lines.push('**Parameters**');
|
||
lines.push('');
|
||
lines.push('| Name | Type | Description |');
|
||
lines.push('| --- | --- | --- |');
|
||
for (const p of entry.params) {
|
||
lines.push(`| \`${p.name}\` | \`${p.type}\` | ${p.description} |`);
|
||
}
|
||
lines.push('');
|
||
}
|
||
if (entry.returns) {
|
||
lines.push('**Returns**');
|
||
lines.push('');
|
||
lines.push(`\`${entry.returns.type}\`${entry.returns.description ? ` — ${entry.returns.description}` : ''}`);
|
||
lines.push('');
|
||
}
|
||
if (entry.throws) {
|
||
lines.push('**Throws**');
|
||
lines.push('');
|
||
lines.push(`\`${entry.throws.type}\`${entry.throws.description ? ` — ${entry.throws.description}` : ''}`);
|
||
lines.push('');
|
||
}
|
||
if (entry.example) {
|
||
lines.push('**Example**');
|
||
lines.push('');
|
||
lines.push('```js');
|
||
lines.push(entry.example);
|
||
lines.push('```');
|
||
lines.push('');
|
||
}
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function formatParamType(param, allParams) {
|
||
const cleanName = param.name.replace(/^\[|\]$/g, '').split('=')[0].trim();
|
||
const prefix = cleanName + '.';
|
||
const childParams = allParams.filter((p) => {
|
||
const pClean = p.name.replace(/^\[|\]$/g, '').split('=')[0].trim();
|
||
return pClean.startsWith(prefix) && !pClean.slice(prefix.length).includes('.');
|
||
});
|
||
|
||
if (childParams.length > 0) {
|
||
const props = childParams.map((cp) => {
|
||
const isOpt = cp.name.startsWith('[');
|
||
const propName = cp.name.replace(/^\[|\]$/g, '').split('=')[0].trim().slice(prefix.length);
|
||
const propType = formatParamType(cp, allParams);
|
||
return `${propName}${isOpt ? '?' : ''}: ${propType}`;
|
||
});
|
||
return `{${props.join(', ')}}`;
|
||
}
|
||
|
||
return param.type || 'any';
|
||
}
|
||
|
||
function formatMethodSignature(entry) {
|
||
const topLevelParams = (entry.params || []).filter((p) => !p.name.includes('.'));
|
||
const paramsStr = topLevelParams
|
||
.map((p) => {
|
||
const isOptional = p.name.startsWith('[');
|
||
const cleanName = p.name.replace(/^\[|\]$/g, '').split('=')[0].trim();
|
||
const typeStr = formatParamType(p, entry.params || []);
|
||
return `${cleanName}${isOptional ? '?' : ''}: ${typeStr}`;
|
||
})
|
||
.join(', ');
|
||
|
||
const returnType = entry.returns?.type ? `: ${entry.returns.type}` : ': void';
|
||
return `(${paramsStr})${returnType}`;
|
||
}
|
||
|
||
function render() {
|
||
const modules = buildModules();
|
||
|
||
const ordered = MODULE_ORDER.filter((m) => modules.has(m));
|
||
const extra = [...modules.keys()].filter((m) => !MODULE_ORDER.includes(m)).sort();
|
||
const moduleNames = [...ordered, ...extra];
|
||
|
||
// Write one file per section: API.<section>.md
|
||
const written = [];
|
||
for (const moduleName of moduleNames) {
|
||
const sectionPath = join(__dirname, `API.${moduleName}.md`);
|
||
const markdown = renderSection(moduleName, modules.get(moduleName));
|
||
writeFileSync(sectionPath, markdown, 'utf8');
|
||
written.push({ moduleName, sectionPath, bytes: markdown.length });
|
||
}
|
||
|
||
const SCHEMA_TABLES = [
|
||
['article', 'id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, track_stock:b=1, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_parent:b=0, parentarticle_id?->article, parentattributename_id?->attributename, parentsort:i=0, created_at:dt, updated_at:dt, UQ(parentarticle_id, parentsort)'],
|
||
['articleattributevalue', 'id PK, article_id->article, attributevalue_id->attributevalue, sort:i=0, UQ(article_id, attributevalue_id)'],
|
||
['articleean', 'id PK, article_id->article, ean:s(50) UQ'],
|
||
['articlehistory', 'id PK, article_id->article, companyuser_id?->companyuser, date:dt, text:txt, meta?:txt'],
|
||
['articlelist', 'id PK'],
|
||
['articlelistarticle', 'id PK, articlelist_id->articlelist, article_id->article, count:i=1, sort:i=0, UQ(articlelist_id, article_id), UQ(articlelist_id, sort)'],
|
||
['articlepic', 'id PK, article_id->article, pic_id->pic, sort:i=0, UQ(article_id, pic_id), UQ(article_id, sort)'],
|
||
['articleproperty', 'id PK, article_id->article, property_id->propertyname, value:txt, sort:i=0'],
|
||
['articlesupplier', 'id PK, article_id->article, entity_id->entity, net?:f, tax_id?->tax, sku?:s(100), stock?:f, pu:f=1, sort:i=0, UQ(article_id, entity_id), UQ(article_id, sort)'],
|
||
['articletext', 'id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)'],
|
||
['attributename', 'id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)'],
|
||
['attributevalue', 'id PK, attribute_id->attributename, value:s(255), sort:i=0, UQ(attribute_id, value)'],
|
||
['attributevaluepic', 'id PK, attributevalue_id->attributevalue, pic_id->pic, sort:i=0, UQ(attributevalue_id, pic_id), UQ(attributevalue_id, sort)'],
|
||
['brand', 'id PK, company_id->company, name:s(255), gsprpinfo?:txt, UQ(company_id, name)'],
|
||
['brandpic', 'id PK, brand_id->brand, pic_id->pic, sort:i=0, UQ(brand_id, pic_id), UQ(brand_id, sort)'],
|
||
['category', 'id PK, parent_id?->category, sort:i=0'],
|
||
['categoryarticle', 'id PK, category_id->category, article_id->article, UQ(category_id, article_id)'],
|
||
['categorypic', 'id PK, category_id->category, pic_id->pic, sort:i=0, UQ(category_id, pic_id), UQ(category_id, sort)'],
|
||
['categorytext', 'id PK, category_id->category, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(category_id, language_id)'],
|
||
['company', 'id PK, name:s(255) UQ, root_category_id?->category, default_language_id?->language, taxrate?->tax, reducedtaxrate?->tax, taxfreerate?->tax, poscustomergroup_id->customergroup'],
|
||
['companyhistory', 'id PK, company_id->company, companyuser_id?->companyuser, date:dt, text:txt'],
|
||
['companysetting', 'id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)'],
|
||
['companyuser', 'id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)'],
|
||
['customergroup', 'id PK, name:s(255), sort:i=0, company_id?->company'],
|
||
['entity', 'id PK, customergroup_id->customergroup, first_name?:s(255), name:s(255), number?:s(100), created_at:dt, updated_at:dt, UQ(company_id, number)'],
|
||
['entityaddress', 'id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0'],
|
||
['entitycontact', 'id PK, entity_id->entity, contacttype:s(50), contact:s(255), sort:i=0'],
|
||
['entityhistory', 'id PK, entity_id->entity, companyuser_id?->companyuser, date:dt, text:txt'],
|
||
['file', 'id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i, meta?:txt'],
|
||
['google_auth', 'id PK, email:s(320) UQ, picture?:s(2048), picturebinary?:bin, sub?:s(255) UQ, created_at:dt, selected_company?->company'],
|
||
['labeltemplate', 'id PK, company_id->company, name:s(255), type:s(50), width:f, height:f, data:txt, sort:i=0'],
|
||
['language', 'id PK, iso639:s(10) UQ'],
|
||
['ledger', 'id PK, manifestarticle_id->manifestarticle, warehouse_id?->warehouse, transaction_type:s(100), amount:f, transaction_text:txt, hash_version:i, prev_hash?:s(128) [append-only]'],
|
||
['manifest', 'id PK, company_id->company, number:s(100), type:s(100), entity_id?->entity, external_number?:s(100), date:dt, created_at:dt'],
|
||
['manifestarticle', 'id PK, manifest_id->manifest, sku?:s(100), name?:s(255), pu:f=1, amount:f, net:f, comment?:txt, rebate:f=0, taxrate:f=0'],
|
||
['manifestattachment', 'id PK, manifest_id->manifest, file_id->file, sort:i=0, UQ(manifest_id, sort)'],
|
||
['meta', 'key:s(255) PK, value:txt'],
|
||
['numbers', 'id PK, company_id->company, type:s(100), prefix:s(50)=\'\', padding:i=0, next:i=1, UQ(company_id, type)'],
|
||
['optionslist', 'id PK'],
|
||
['optionslistarticle', 'id PK, optionslist_id->optionslist, article_id->article, count:i=1, sort:i=0, mandated:b=0, UQ(optionslist_id, article_id), UQ(optionslist_id, sort)'],
|
||
['pic', 'id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), source:txt, fullbinary:bin, thumbbinary:bin'],
|
||
['posterminal', 'id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin?:s(50), root_category_id?->category, sync_root_articles:b=1, lastseen_at?:dt, updated_at:dt'],
|
||
['posterminalcategory', 'id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)'],
|
||
['price', 'id PK, tax_id->tax, article_id->article, minmultiplier:d(18,4)=1, customergroup_id->customergroup, net:f'],
|
||
['printer', 'id PK, company_id->company, address:s(255), type:s(50), model?:s(255), serial?:s(255), sort:i=0, desc?:txt'],
|
||
['propertyname', 'id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)'],
|
||
['tax', 'id PK, rate:d(18,4) UQ'],
|
||
['unit', 'id PK, company_id->company, si:s(50), basesi:s(50), basesifactor:f, UQ(company_id, si)'],
|
||
['unittext', 'id PK, unit_id->unit, language_id->language, symbol:s(50), name:s(255), UQ(unit_id, language_id)'],
|
||
['warehouse', 'id PK, company_id->company, name:s(255), sort:i=0, UQ(company_id, name)'],
|
||
];
|
||
|
||
// Write the index API.md linking to each section file.
|
||
const lines = [];
|
||
lines.push('# LibDashStore API');
|
||
lines.push('');
|
||
lines.push(
|
||
'This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.',
|
||
);
|
||
lines.push('');
|
||
lines.push(
|
||
'The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each method is an async function that takes a `pool` (mssql connection pool) at construction time and returns a callable function. Methods are grouped by the namespace they are exposed under.',
|
||
);
|
||
lines.push('');
|
||
lines.push('## Database Schema');
|
||
lines.push('');
|
||
lines.push(
|
||
'> **Convention**: `PK` primary key · `?` nullable · `->tbl` foreign key · `UQ` unique · `s(N)` string · `txt` text · `i` int · `bi` bigint · `f` float · `d` decimal · `b` bit · `dt` datetime · `bin` binary · `=val` default',
|
||
);
|
||
lines.push('');
|
||
for (const [table, cols] of SCHEMA_TABLES) {
|
||
lines.push(cols ? `- **${table}**: \`${cols}\`` : `- **${table}**`);
|
||
}
|
||
lines.push('');
|
||
lines.push('## Table of Contents');
|
||
lines.push('');
|
||
|
||
for (const { moduleName } of written) {
|
||
lines.push(`- [${moduleName}](API.${moduleName}.md)`);
|
||
const entries = modules.get(moduleName) || [];
|
||
for (const entry of entries) {
|
||
const sig = formatMethodSignature(entry);
|
||
lines.push(` - \`${entry.name}${sig}\``);
|
||
}
|
||
}
|
||
|
||
lines.push('');
|
||
|
||
return { index: lines.join('\n'), written };
|
||
}
|
||
|
||
const { index, written } = render();
|
||
writeFileSync(outputPath, index, 'utf8');
|
||
console.log(`Wrote ${outputPath} (${index.length} bytes)`);
|
||
for (const { moduleName, sectionPath, bytes } of written) {
|
||
console.log(`Wrote ${sectionPath} (${bytes} bytes)`);
|
||
}
|
||
|