@arcforgelabs/flow 2026.10.35 → 2026.10.37
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/README.md +1 -0
- package/npm-shrinkwrap.json +81 -50
- package/package.json +6 -5
- package/server/api/invoicing.js +23 -2
- package/server/auth/clerk.js +35 -0
- package/server/auth/routes.js +45 -6
- package/server/core/host.js +24 -0
- package/server/index.js +9 -3
- package/server/integrations/seazona/index.js +1 -1
- package/server/integrations/seazona/settings.js +5 -2
- package/server/integrations/sentry/index.js +5 -2
- package/server/integrations/xero/index.js +3 -3
- package/server/integrations/xero/settings.js +6 -0
- package/server/ui/app.js +88 -23
- package/server/ui/bulk-bar.js +99 -0
- package/server/ui/custom-fields.js +223 -56
- package/server/ui/flow.css +158 -11
- package/server/ui/invoicing.js +291 -45
- package/server/ui/job-board.js +58 -34
- package/server/ui/jobs.js +27 -13
- package/server/ui/routes.js +11 -1
package/server/ui/job-board.js
CHANGED
|
@@ -76,66 +76,82 @@ const capital = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
|
|
|
76
76
|
const sentence = (text) => capital(String(text).trim().replace(/\.?$/, '.'));
|
|
77
77
|
|
|
78
78
|
// One ledger reason (server/core/ledger.js) as a fix: a short label, what is
|
|
79
|
-
// wrong, and
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
// wrong, what to do, and where to go to do it (`go`, see GO). A job's fix
|
|
80
|
+
// speaks of Flow's own invoice only. How that invoice stands in an
|
|
81
|
+
// accounting system is the invoice's business, on the invoice page.
|
|
82
|
+
function reasonFix(reason) {
|
|
83
|
+
const fix = (label, todo, go = null) => ({ label, text: sentence(reason), todo, go });
|
|
82
84
|
if (reason === 'the accounting system was not checked') {
|
|
83
|
-
return { label:
|
|
85
|
+
return { label: 'Not checked yet', text: 'Flow did not check for an invoice for this job yet.', todo: 'Wait for the next check, a few minutes. If it stays, check Settings, Integrations.', go: 'integrations' };
|
|
84
86
|
}
|
|
85
87
|
if (reason === 'a line does not map to a price item') {
|
|
86
|
-
return { label: 'No price code', text: 'A line has a code that is not in the price list.', todo: 'Add the code to the price list, or correct the code in the work system.' };
|
|
88
|
+
return { label: 'No price code', text: 'A line has a code that is not in the price list.', todo: 'Add the code to the price list, or correct the code in the work system.', go: 'price-list' };
|
|
87
89
|
}
|
|
88
90
|
if (reason === 'the job has no customer contact') {
|
|
89
|
-
return { label: 'Client not linked', text: 'The client is not linked to a customer in Flow.', todo: 'Link the client to a customer in Flow.' };
|
|
91
|
+
return { label: 'Client not linked', text: 'The client is not linked to a customer in Flow.', todo: 'Link the client to a customer in Flow.', go: null };
|
|
90
92
|
}
|
|
91
93
|
if (reason === 'the work system report is out of date') {
|
|
92
|
-
return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.' };
|
|
94
|
+
return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.', go: 'integrations' };
|
|
95
|
+
}
|
|
96
|
+
if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Open the invoice to see what changed. Fix the job in the work system, or adopt its changes on the draft.', 'invoice');
|
|
97
|
+
if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.', 'work');
|
|
98
|
+
if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.', 'work');
|
|
99
|
+
// The matcher found invoices that may be this job's, but no exact link.
|
|
100
|
+
// Flow holds the job so that it is not invoiced two times.
|
|
101
|
+
if (/^the accounting evidence is ambiguous/.test(reason)) {
|
|
102
|
+
return { label: 'Invoice unclear', text: 'Flow cannot tell if this job has an invoice already.', todo: 'Check the invoice number on the job in the work system. Flow holds the job so it is not invoiced two times.', go: 'work' };
|
|
103
|
+
}
|
|
104
|
+
if (/^the accounting evidence is conflict/.test(reason)) {
|
|
105
|
+
return { label: 'Invoice link changed', text: 'A second invoice now answers for this job, or its invoice is gone.', todo: 'Check the invoice number on the job in the work system. Void an extra invoice.', go: 'work' };
|
|
106
|
+
}
|
|
107
|
+
if (/^invoice .* was (voided|deleted)$/.test(reason)) {
|
|
108
|
+
const voided = /voided$/.test(reason);
|
|
109
|
+
return { label: `Invoice ${voided ? 'voided' : 'deleted'}`, text: `The invoice for this job was ${voided ? 'voided' : 'deleted'}.`, todo: 'Make a new invoice, or close the job.', go: 'invoice' };
|
|
110
|
+
}
|
|
111
|
+
if (/^the accounting system reports /.test(reason)) {
|
|
112
|
+
return { label: 'May be invoiced', text: 'Flow found an invoice that may be for this job.', todo: 'Check the invoice number on the job in the work system.', go: 'work' };
|
|
93
113
|
}
|
|
94
|
-
if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Open the invoice to see what changed. Fix the job in the work system, or adopt its changes on the draft.');
|
|
95
|
-
if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.');
|
|
96
|
-
if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.');
|
|
97
|
-
if (/^the accounting evidence is ambiguous/.test(reason)) return fix(`${acct} match unclear`, `Find the invoice for this job in ${acct}, then link it.`);
|
|
98
|
-
if (/^the accounting evidence is conflict/.test(reason)) return fix(`Two ${acct} invoices`, `Find the right invoice for this job in ${acct}. Void the other.`);
|
|
99
|
-
if (/^invoice .* was (voided|deleted)$/.test(reason)) return fix(`Invoice ${/voided$/.test(reason) ? 'voided' : 'deleted'}`, 'Make a new invoice, or close the job.');
|
|
100
|
-
if (/^the accounting system reports /.test(reason)) return fix(`Already in ${acct}`, `Check the invoice in ${acct}. Link it to this job if it is the right one.`);
|
|
101
114
|
if (/^the job is held$/.test(reason)) return fix('On hold', 'Release the hold when the job can be invoiced.');
|
|
102
|
-
if (/^the job is review$/.test(reason)) return fix('Needs review', 'Check the job and its invoice.');
|
|
115
|
+
if (/^the job is review$/.test(reason)) return fix('Needs review', 'Check the job and its invoice.', 'invoice');
|
|
103
116
|
if (reason === 'held') return fix('On hold', 'Release the hold when the job can be invoiced.');
|
|
104
|
-
return fix('Needs review', 'Check the job and its invoice.');
|
|
117
|
+
return fix('Needs review', 'Check the job and its invoice.', 'invoice');
|
|
105
118
|
}
|
|
106
119
|
|
|
107
|
-
// One
|
|
108
|
-
|
|
120
|
+
// One problem on the job's invoice (server/core/sending.js, and an accounting
|
|
121
|
+
// system's) as a job fix. Flow's own invoice problems keep their words. A
|
|
122
|
+
// problem an accounting system found stays on the invoice page; the job only
|
|
123
|
+
// says that its invoice needs a fix.
|
|
124
|
+
function draftFix(problem) {
|
|
109
125
|
const code = problem.code ?? '';
|
|
110
|
-
const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo });
|
|
126
|
+
const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo, go: 'invoice' });
|
|
111
127
|
if (code === 'no_lines') return fix('No lines', 'Open the invoice and add the lines.');
|
|
112
128
|
if (code === 'no_amount') return fix('Total is $0', 'Open the invoice and correct the amounts.');
|
|
113
129
|
if (code === 'line_no_item') return fix('No price code', 'Open the invoice and choose a price item for each line.');
|
|
114
|
-
if (code.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (code.startsWith('send_') || code.endsWith('_push_failed') || code.endsWith('_outcome_unknown')) return fix('Not sent', 'Open the invoice and send it again.');
|
|
119
|
-
return fix('Invoice problem', 'Open the invoice to correct it.');
|
|
130
|
+
if (code.startsWith('send_') || code.endsWith('_push_failed') || code.endsWith('_outcome_unknown')) {
|
|
131
|
+
return { label: 'Not sent', text: 'The invoice for this job was not sent.', todo: 'Open the invoice and send it again.', go: 'invoice' };
|
|
132
|
+
}
|
|
133
|
+
return { label: 'Invoice problem', text: 'The invoice for this job needs a fix before it can be sent.', todo: 'Open the invoice for the steps.', go: 'invoice' };
|
|
120
134
|
}
|
|
121
135
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
// Where a fix's link goes: Flow's invoice, the job in the work system, or a
|
|
137
|
+
// Flow settings page (jobs.js draws the link).
|
|
138
|
+
export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations'];
|
|
139
|
+
|
|
140
|
+
// What a person must fix on a job, in order: [{ label, text, todo, go }].
|
|
141
|
+
// Empty unless invoiceStage is Problem.
|
|
142
|
+
export function fixes(o) {
|
|
126
143
|
if (invoiceStage(o) !== 'problem') return [];
|
|
127
|
-
const acct = accountingName ?? 'the accounting system';
|
|
128
144
|
const found = [];
|
|
129
|
-
if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r
|
|
145
|
+
if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
|
|
130
146
|
if (o.view === 'ready') {
|
|
131
147
|
found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
|
|
132
|
-
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings,
|
|
148
|
+
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Invoice rules.', go: 'invoicing-settings',
|
|
133
149
|
})));
|
|
134
150
|
}
|
|
135
151
|
if (o.invoiceStatus === 'void') {
|
|
136
|
-
found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.' });
|
|
152
|
+
found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
|
|
137
153
|
}
|
|
138
|
-
found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p
|
|
154
|
+
found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p)));
|
|
139
155
|
return found;
|
|
140
156
|
}
|
|
141
157
|
|
|
@@ -146,6 +162,14 @@ export function problemSummary(found) {
|
|
|
146
162
|
return labels.length > 1 ? `${labels[0]} +${labels.length - 1}` : labels[0];
|
|
147
163
|
}
|
|
148
164
|
|
|
165
|
+
// A system's own record id (a UUID) is not a case number a person reads.
|
|
166
|
+
// Search still finds the job by it (job-board.js matches).
|
|
167
|
+
const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
168
|
+
export function caseNumber(job) {
|
|
169
|
+
const ref = job.caseRef ?? job.sourceJobRef ?? null;
|
|
170
|
+
return ref && !RECORD_ID.test(ref) ? ref : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
149
173
|
// Does a job match the search text? Case, client, practitioner or invoice
|
|
150
174
|
// number; the case is not shown on the board, but a person can find it.
|
|
151
175
|
export function matches(job, q) {
|
package/server/ui/jobs.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
16
16
|
import {
|
|
17
|
-
FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
17
|
+
caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
18
18
|
} from '/assets/ui/job-board.js';
|
|
19
19
|
import {
|
|
20
20
|
DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, filterCount, filterJobs, groupJobs, GROUPS, laneOf, normaliseJobsView, NOT_SET, sortJobs, SORTS,
|
|
@@ -74,7 +74,7 @@ export async function renderJobs(main, ui) {
|
|
|
74
74
|
const saved = ui.me?.views?.jobs;
|
|
75
75
|
const state = {
|
|
76
76
|
view: normaliseJobsView(saved ?? { ...DEFAULT_VIEW, layout: readLayout() }),
|
|
77
|
-
q: asked.trim().toLowerCase(), jobs: [], statuses: [],
|
|
77
|
+
q: asked.trim().toLowerCase(), jobs: [], statuses: [], canDraft: false,
|
|
78
78
|
// Custom fields (docs/CUSTOM_FIELDS.md): the lab's fields, the work
|
|
79
79
|
// systems' stage fields, the people a person field can name, and each
|
|
80
80
|
// job's values by job and field.
|
|
@@ -99,7 +99,7 @@ export async function renderJobs(main, ui) {
|
|
|
99
99
|
drawToggle();
|
|
100
100
|
search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
|
|
101
101
|
|
|
102
|
-
const problemsOf = (job) => fixes(job.obligation
|
|
102
|
+
const problemsOf = (job) => fixes(job.obligation);
|
|
103
103
|
const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
|
|
104
104
|
const valueOf = (job, fieldId) => state.values.get(valueKey(job, fieldId)) ?? null;
|
|
105
105
|
const groupable = () => state.fields.filter((f) => f.groupable);
|
|
@@ -232,7 +232,6 @@ export async function renderJobs(main, ui) {
|
|
|
232
232
|
]);
|
|
233
233
|
state.jobs = body.jobs;
|
|
234
234
|
state.statuses = body.statuses;
|
|
235
|
-
state.accountingName = body.accountingName;
|
|
236
235
|
state.canDraft = context.canIssue;
|
|
237
236
|
state.fields = fields?.fields ?? [];
|
|
238
237
|
state.systemFields = fields?.systemFields ?? [];
|
|
@@ -495,7 +494,7 @@ export async function renderJobs(main, ui) {
|
|
|
495
494
|
try {
|
|
496
495
|
const { move } = await flow('/api/work/moves', { method: 'POST', body: JSON.stringify({ ...ref, toStatus: status }) });
|
|
497
496
|
job.pendingMove = { to: move.to, from: move.from, requestedAt: move.requestedAt, expiresAt: move.expiresAt, openUrl: move.openUrl, openExact: move.openExact };
|
|
498
|
-
ui.toast?.(`Waiting for ${system}. Set case ${move
|
|
497
|
+
ui.toast?.(`Waiting for ${system}. Set ${caseNumber(move) ? `case ${caseNumber(move)}` : 'the case'} to ${move.to} there.`, 'success');
|
|
499
498
|
} catch (error) {
|
|
500
499
|
job.pendingMove = before;
|
|
501
500
|
ui.toast?.(error.message, 'danger');
|
|
@@ -512,13 +511,14 @@ export async function renderJobs(main, ui) {
|
|
|
512
511
|
// system's web app. An exact link opens the order itself; otherwise it
|
|
513
512
|
// opens the lab's home page there, and the person finds the case.
|
|
514
513
|
function openStep(job, system) {
|
|
515
|
-
const caseRef = job
|
|
514
|
+
const caseRef = caseNumber(job);
|
|
515
|
+
const named = caseRef ? `case ${caseRef}` : `the case for ${clientOf(job)}`;
|
|
516
516
|
const url = job.link?.url ?? null;
|
|
517
|
-
if (!url) return `Open
|
|
517
|
+
if (!url) return `Open ${named} in ${system}.`;
|
|
518
518
|
const out = (text) => h('a', { class: 'fx-task__link', href: url, target: '_blank', rel: 'noopener' }, text);
|
|
519
519
|
return job.link.exact
|
|
520
|
-
? h('span', {}, out(`Open case ${caseRef} in ${system} ↗`))
|
|
521
|
-
: h('span', {}, out(`Open ${system} ↗`), ` and find
|
|
520
|
+
? h('span', {}, out(caseRef ? `Open case ${caseRef} in ${system} ↗` : `Open the case in ${system} ↗`))
|
|
521
|
+
: h('span', {}, out(`Open ${system} ↗`), ` and find ${named}.`);
|
|
522
522
|
}
|
|
523
523
|
|
|
524
524
|
// The job panel's custom fields: one control each, saved when it changes.
|
|
@@ -681,7 +681,20 @@ export async function renderJobs(main, ui) {
|
|
|
681
681
|
: stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
|
|
682
682
|
const fixBox = found.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
|
|
683
683
|
h('h3', {}, 'Fix'),
|
|
684
|
-
found.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, `${f.text} ${f.todo}`)))) : null;
|
|
684
|
+
found.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, `${f.text} ${f.todo}`), fixLink(f.go)))) : null;
|
|
685
|
+
// Each fix's link: Flow's own invoice, the job in the work system, or
|
|
686
|
+
// the Flow page where the fix is made (job-board.js GO).
|
|
687
|
+
function fixLink(go) {
|
|
688
|
+
const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, onclick: (event) => { close(); ui.go(event); } }, text);
|
|
689
|
+
if (go === 'invoice' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
|
|
690
|
+
if (go === 'work' && job.link?.url) {
|
|
691
|
+
return h('a', { class: 'fx-task__link', href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`);
|
|
692
|
+
}
|
|
693
|
+
if (go === 'price-list') return inFlow('/price-list', 'Open price list');
|
|
694
|
+
if (go === 'invoicing-settings') return inFlow('/invoicing-settings', 'Open invoice rules');
|
|
695
|
+
if (go === 'integrations' && ui.me?.person?.role === 'owner') return inFlow('/integrations', 'Open integrations');
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
685
698
|
const lines = job.lines ?? [];
|
|
686
699
|
const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
|
|
687
700
|
element.append(h('div', { class: 'fx-task__grid' },
|
|
@@ -697,13 +710,14 @@ export async function renderJobs(main, ui) {
|
|
|
697
710
|
field(job.dueOn ? 'Due' : 'Ordered', job.dueOn ? fullDate(job.dueOn) : job.orderedOn ? fullDate(job.orderedOn) : null, late ? { class: 'fx-task__late' } : {}),
|
|
698
711
|
job.dueOn ? field('Ordered', job.orderedOn ? fullDate(job.orderedOn) : null) : null,
|
|
699
712
|
field('Total', job.totalCents == null ? null : formatCents(job.totalCents)),
|
|
700
|
-
field('Case number', job
|
|
713
|
+
field('Case number', caseNumber(job), { class: 'fx-mono' }),
|
|
701
714
|
job.link?.url ? field(system, link({ href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`)) : null,
|
|
702
715
|
field(`${system} status`, job.status),
|
|
703
716
|
field(`${system} client`, job.workClientName && job.workClientName !== job.clientName ? job.workClientName : null),
|
|
704
717
|
field('Department', job.department),
|
|
705
|
-
|
|
706
|
-
|
|
718
|
+
// The job links to Flow's invoice only. The invoice page shows its
|
|
719
|
+
// link to an accounting system (its Linked card).
|
|
720
|
+
field('Invoice', invoiceText)),
|
|
707
721
|
fixBox,
|
|
708
722
|
state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
|
|
709
723
|
h('h3', {}, 'Fields'),
|
package/server/ui/routes.js
CHANGED
|
@@ -11,6 +11,7 @@ import { themeStylesheet } from '../theme/themes.js';
|
|
|
11
11
|
// /assets/theme/<file>.js the theme engine itself, as browser modules
|
|
12
12
|
// /assets/ui/<file> Flow's components (flow.css) and page scripts
|
|
13
13
|
// /assets/fonts/<pkg>/… the families' faces (server/theme/font-families.js)
|
|
14
|
+
// /assets/icons/<name>.svg one Lucide icon (lucide-static, ISC), for any page
|
|
14
15
|
// Only the files named here are served. They change only with a release, so
|
|
15
16
|
// browsers may keep them for an hour.
|
|
16
17
|
|
|
@@ -20,7 +21,9 @@ const themeDir = join(here, '..', 'theme');
|
|
|
20
21
|
const logoDir = join(here, 'logos');
|
|
21
22
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
22
23
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
23
|
-
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
|
|
24
|
+
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'flow-mark.svg']);
|
|
25
|
+
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
26
|
+
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
24
27
|
const MAX_AGE = '1h';
|
|
25
28
|
|
|
26
29
|
// Send one named file from dir. Passing dir as root limits the dotfile check
|
|
@@ -49,6 +52,13 @@ export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
|
|
|
49
52
|
// Each integration's logo, the maker's own mark (Integrations).
|
|
50
53
|
router.get('/assets/ui/logos/:file', sendFrom(logoDir, LOGO_FILES));
|
|
51
54
|
|
|
55
|
+
// The whole Lucide set: a page names any icon, and only the icons a page
|
|
56
|
+
// uses are fetched. A name outside the set falls through to the 404.
|
|
57
|
+
router.get('/assets/icons/:file', (req, res, next) => {
|
|
58
|
+
if (!ICON_FILE.test(req.params.file)) return next();
|
|
59
|
+
sendNamed(res, next, iconDir, req.params.file);
|
|
60
|
+
});
|
|
61
|
+
|
|
52
62
|
for (const name of FONT_PACKAGES) {
|
|
53
63
|
const dir = dirname(require.resolve(`@fontsource-variable/${name}/LICENSE`));
|
|
54
64
|
router.get(`/assets/fonts/${name}/index.css`, (_req, res, next) => sendNamed(res, next, dir, 'index.css'));
|