@assembly-js/app-foundation 0.12.0 → 0.13.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/CHANGELOG.md +5 -0
- package/bin/check-linked-names.mjs +124 -0
- package/dist/lib-client.d.ts +21 -0
- package/dist/lib-client.js +43 -0
- package/dist/record-names.d.ts +16 -0
- package/dist/record-names.js +15 -0
- package/package.json +1 -1
- package/skills/assembly-platform-links/SKILL.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,11 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.13.0
|
|
23
|
+
- [new] `<ClientName client={client} />` and `<CompanyName company={company} />` from `@assembly-js/app-foundation/lib-client` render a name that opens the record's CRM page for staff and is plain text for a client viewer.
|
|
24
|
+
- [new] `bun run build` runs `check:linked-names`, which fails when a client's or company's name is rendered by hand instead of through those components. It only runs on apps that carry the platform-links skill.
|
|
25
|
+
- [guidance] `assembly-platform-links` leads with rendering client and company names through the components.
|
|
26
|
+
|
|
22
27
|
## 0.12.0
|
|
23
28
|
- [behavior] `check:crons` on a platform update turn now only warns about a cron route the app already had without a schedule; a route added this turn, or one that lost its schedule, still fails.
|
|
24
29
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fails the build when a client's or company's name is rendered by hand in a
|
|
3
|
+
// component instead of through `<ClientName>` / `<CompanyName>` from
|
|
4
|
+
// `@assembly-js/app-foundation/lib-client`. The components link the name to the
|
|
5
|
+
// record's CRM page for staff and render plain text for a client viewer; a
|
|
6
|
+
// hand-rendered name never links, and the prose guidance to call `linkTo` was
|
|
7
|
+
// not followed. The match favours precision, because a false positive blocks the
|
|
8
|
+
// build: it only looks at a name placed directly in JSX, or a full name built
|
|
9
|
+
// from `X.givenName` and `X.familyName`, where the object is called `client`
|
|
10
|
+
// or `customer` (and `company` for the company name).
|
|
11
|
+
// Dependency free on purpose: it runs inside the generated app's sandbox before
|
|
12
|
+
// any deps beyond Node are guaranteed.
|
|
13
|
+
//
|
|
14
|
+
// Inert unless the platform-links skill was written into the app (it ships only when
|
|
15
|
+
// the deep-link flag is on) and the app depends on the foundation package, which is
|
|
16
|
+
// where the components live: a vendored app has nothing to switch to.
|
|
17
|
+
// On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
|
|
18
|
+
// update never rewrites how an app renders names.
|
|
19
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
20
|
+
import { join, relative, sep } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const ROOT = process.cwd();
|
|
23
|
+
const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
|
|
24
|
+
const PLATFORM_LINKS_SKILL = join(ROOT, '.claude', 'skills', 'assembly-platform-links', 'SKILL.md');
|
|
25
|
+
const SOURCE_FILE = /\.(tsx|jsx)$/;
|
|
26
|
+
const PACKAGE_JSON = join(ROOT, 'package.json');
|
|
27
|
+
const OPT_OUT = /linked-names:\s*plain/;
|
|
28
|
+
|
|
29
|
+
const OBJECT_PATH = String.raw`(?:[\w$]+\??\.)*`;
|
|
30
|
+
const FALLBACK = String.raw`(?:\s*(?:\?\?|\|\|)\s*(?:'[^']*'|"[^"]*"))?`;
|
|
31
|
+
// `{client.givenName}` placed in JSX, not an attribute value (`=`) or a template (`$`).
|
|
32
|
+
const CLIENT_NAME_CHILD = new RegExp(
|
|
33
|
+
String.raw`(?<!=\s*)(?<!\$)\{\s*${OBJECT_PATH}(?:client|customer)\??\.(?:givenName|familyName)${FALLBACK}\s*\}`,
|
|
34
|
+
'gi',
|
|
35
|
+
);
|
|
36
|
+
const COMPANY_NAME_CHILD = new RegExp(
|
|
37
|
+
String.raw`(?<!=\s*)(?<!\$)\{\s*${OBJECT_PATH}company\??\.name${FALLBACK}\s*\}`,
|
|
38
|
+
'gi',
|
|
39
|
+
);
|
|
40
|
+
// `${client.givenName} ${client.familyName}`, `[client.givenName, client.familyName].join(' ')`
|
|
41
|
+
// and `client.givenName + ' ' + client.familyName`; a lone `[0]` initial is not a name.
|
|
42
|
+
const FULL_NAME = new RegExp(
|
|
43
|
+
String.raw`(?:` +
|
|
44
|
+
String.raw`\$\{\s*${OBJECT_PATH}(?:client|customer)\??\.givenName\s*\}[^\`]{0,20}\$\{\s*${OBJECT_PATH}(?:client|customer)\??\.familyName\s*\}` +
|
|
45
|
+
String.raw`|\[\s*${OBJECT_PATH}(?:client|customer)\??\.givenName\s*,\s*${OBJECT_PATH}(?:client|customer)\??\.familyName\s*\]` +
|
|
46
|
+
String.raw`|${OBJECT_PATH}(?:client|customer)\??\.givenName\s*\+\s*['"\`][^'"\`]*['"\`]\s*\+\s*${OBJECT_PATH}(?:client|customer)\??\.familyName` +
|
|
47
|
+
String.raw`)`,
|
|
48
|
+
'gi',
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
function sourceFiles(dir) {
|
|
52
|
+
const out = [];
|
|
53
|
+
if (!existsSync(dir)) return out;
|
|
54
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
55
|
+
const full = join(dir, entry.name);
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
if (entry.name === 'node_modules' || entry.name === '.next') continue;
|
|
58
|
+
out.push(...sourceFiles(full));
|
|
59
|
+
} else if (SOURCE_FILE.test(entry.name)) {
|
|
60
|
+
out.push(full);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// An attribute value (`alt={...}`, `title={`...`}`) is not rendered text.
|
|
67
|
+
function inAttribute(src, index) {
|
|
68
|
+
return /=\s*\{\s*`?$/.test(src.slice(Math.max(0, index - 16), index));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A built string that is not rendered as JSX text (an object label, a filter key) cannot
|
|
72
|
+
// be a component; only a name sitting in text, or returned/assigned for display, is flagged.
|
|
73
|
+
function inStringOnlyUse(src, index) {
|
|
74
|
+
const lineStart = src.lastIndexOf('\n', index) + 1;
|
|
75
|
+
return /(?:\blabel|\bvalue|\bkey|\bsearch\w*|\bfilter\w*|\bquery)\s*[:=]\s*[^\n]*$/i.test(src.slice(lineStart, index));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function lineOf(src, index) {
|
|
79
|
+
return src.slice(0, index).split('\n').length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!existsSync(PLATFORM_LINKS_SKILL)) process.exit(0);
|
|
83
|
+
try {
|
|
84
|
+
if (!readFileSync(PACKAGE_JSON, 'utf8').includes('app-foundation')) process.exit(0);
|
|
85
|
+
} catch {
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const violations = [];
|
|
90
|
+
for (const file of [...sourceFiles(join(ROOT, 'app')), ...sourceFiles(join(ROOT, 'components'))]) {
|
|
91
|
+
const src = readFileSync(file, 'utf8');
|
|
92
|
+
if (OPT_OUT.test(src)) continue;
|
|
93
|
+
const where = relative(ROOT, file).split(sep).join('/');
|
|
94
|
+
const found = new Map();
|
|
95
|
+
for (const [re, kind] of [
|
|
96
|
+
[CLIENT_NAME_CHILD, 'client name'],
|
|
97
|
+
[FULL_NAME, 'client name'],
|
|
98
|
+
[COMPANY_NAME_CHILD, 'company name'],
|
|
99
|
+
]) {
|
|
100
|
+
for (const m of src.matchAll(re)) if (!inAttribute(src, m.index) && !(re === FULL_NAME && inStringOnlyUse(src, m.index))) found.set(m.index, `${where}:${lineOf(src, m.index)} (${kind}: ${m[0].trim()})`);
|
|
101
|
+
}
|
|
102
|
+
violations.push(...[...found.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (violations.length > 0 && UPDATE_TURN) {
|
|
106
|
+
console.warn('');
|
|
107
|
+
console.warn('check-linked-names: names rendered by hand (this update turn is not failed for it):');
|
|
108
|
+
for (const v of violations) console.warn(' - ' + v);
|
|
109
|
+
console.warn('');
|
|
110
|
+
} else if (violations.length > 0) {
|
|
111
|
+
console.error('');
|
|
112
|
+
console.error('A client or company name is rendered by hand, so it never links to its record:');
|
|
113
|
+
console.error('');
|
|
114
|
+
for (const v of violations) console.error(' - ' + v);
|
|
115
|
+
console.error('');
|
|
116
|
+
console.error("Render it with the components from '@assembly-js/app-foundation/lib-client':");
|
|
117
|
+
console.error(' <ClientName client={client} className="..." /> // a client with givenName/familyName');
|
|
118
|
+
console.error(' <CompanyName company={company} className="..." /> // a company with name');
|
|
119
|
+
console.error('Staff get a link to the CRM record; a client viewer gets plain text.');
|
|
120
|
+
console.error('A name that must stay plain (the viewer\'s own greeting, a title attribute) can add the');
|
|
121
|
+
console.error('comment `// linked-names: plain` to the file.');
|
|
122
|
+
console.error('A string-only use (a `label:` or `filter` value) is not flagged.');
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
package/dist/lib-client.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
1
2
|
import type { NavigateDestination, NavigateToOptions } from './bridge-vnext.js';
|
|
3
|
+
import { type ClientNameRecord, type CompanyNameRecord } from './record-names.js';
|
|
2
4
|
export declare function resetRenewalStateForTests(): void;
|
|
3
5
|
export declare function reexchangeBearer(): Promise<boolean>;
|
|
4
6
|
export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
@@ -28,3 +30,22 @@ export declare function handleVisibilityChangeForTests(): void;
|
|
|
28
30
|
* </button>
|
|
29
31
|
*/
|
|
30
32
|
export declare function linkTo(destination: NavigateDestination, options?: NavigateToOptions): () => void;
|
|
33
|
+
interface RecordNameProps {
|
|
34
|
+
className?: string;
|
|
35
|
+
viewer?: 'internal' | 'client';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A client's name that opens their CRM record for staff and is plain text for a
|
|
39
|
+
* client viewer, who cannot reach the destination.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* <ClientName client={client} className="font-medium" />
|
|
43
|
+
*/
|
|
44
|
+
export declare function ClientName({ client, className, viewer }: RecordNameProps & {
|
|
45
|
+
client: ClientNameRecord;
|
|
46
|
+
}): ReactElement;
|
|
47
|
+
/** A company's name that opens its CRM record for staff and is plain text for a client viewer. */
|
|
48
|
+
export declare function CompanyName({ company, className, viewer }: RecordNameProps & {
|
|
49
|
+
company: CompanyNameRecord;
|
|
50
|
+
}): ReactElement;
|
|
51
|
+
export {};
|
package/dist/lib-client.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// never be dragged into the Edge middleware bundle that imports ./client
|
|
6
6
|
// (proxy.ts pulls constants from that barrel).
|
|
7
7
|
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
8
|
+
import { createElement } from 'react';
|
|
9
|
+
import { usePathname } from 'next/navigation';
|
|
8
10
|
import { APP_CSRF_HEADER, COOKIE_PROBE_NAME, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
11
|
+
import { clientDisplayName, isClientSurfacePath, } from './record-names.js';
|
|
9
12
|
import { readBearerExpiry, readCookie } from './origin-bootstrap.js';
|
|
10
13
|
import { cookielessReexchangeHeaders, cookiesAreBlocked, getCookielessBearer, setCookielessBearer, } from './cookieless-bearer.js';
|
|
11
14
|
const REEXCHANGE_PATH = '/api/assembly-reexchange';
|
|
@@ -338,3 +341,43 @@ export function linkTo(destination, options) {
|
|
|
338
341
|
});
|
|
339
342
|
};
|
|
340
343
|
}
|
|
344
|
+
function isClientViewer(viewer, pathname) {
|
|
345
|
+
return viewer ? viewer === 'client' : isClientSurfacePath(pathname);
|
|
346
|
+
}
|
|
347
|
+
// A button so the link is keyboard reachable, reset to read as inline text; the
|
|
348
|
+
// colour is only inherited when the app gave no class, so its own colour wins.
|
|
349
|
+
// createElement keeps the package free of a jsx compiler setting.
|
|
350
|
+
function recordName(name, destination, className) {
|
|
351
|
+
if (!destination)
|
|
352
|
+
return createElement('span', { className }, name);
|
|
353
|
+
return createElement('button', {
|
|
354
|
+
type: 'button',
|
|
355
|
+
className,
|
|
356
|
+
onClick: linkTo(destination),
|
|
357
|
+
style: {
|
|
358
|
+
background: 'none',
|
|
359
|
+
border: 0,
|
|
360
|
+
padding: 0,
|
|
361
|
+
font: 'inherit',
|
|
362
|
+
textAlign: 'inherit',
|
|
363
|
+
cursor: 'pointer',
|
|
364
|
+
color: className ? undefined : 'inherit',
|
|
365
|
+
},
|
|
366
|
+
}, name);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* A client's name that opens their CRM record for staff and is plain text for a
|
|
370
|
+
* client viewer, who cannot reach the destination.
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* <ClientName client={client} className="font-medium" />
|
|
374
|
+
*/
|
|
375
|
+
export function ClientName({ client, className, viewer }) {
|
|
376
|
+
const isClient = isClientViewer(viewer, usePathname());
|
|
377
|
+
return recordName(clientDisplayName(client), isClient ? null : { to: 'client', clientId: client.id }, className);
|
|
378
|
+
}
|
|
379
|
+
/** A company's name that opens its CRM record for staff and is plain text for a client viewer. */
|
|
380
|
+
export function CompanyName({ company, className, viewer }) {
|
|
381
|
+
const isClient = isClientViewer(viewer, usePathname());
|
|
382
|
+
return recordName(company.name ?? '', isClient ? null : { to: 'company', companyId: company.id }, className);
|
|
383
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ClientNameRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
givenName?: string | null;
|
|
4
|
+
familyName?: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface CompanyNameRecord {
|
|
7
|
+
id: string;
|
|
8
|
+
name?: string | null;
|
|
9
|
+
}
|
|
10
|
+
export declare function clientDisplayName(client: ClientNameRecord): string;
|
|
11
|
+
/**
|
|
12
|
+
* Whether the page is rendered for a client in the portal. `client` and `company`
|
|
13
|
+
* are staff-only destinations, so an unknown surface also counts as a client:
|
|
14
|
+
* plain text is the safe failure, a dead link is not.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isClientSurfacePath(pathname: string): boolean;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getSurfacePrefix } from './location-prefix.js';
|
|
2
|
+
// The notification surface is staff-facing, so only the other prefixed surface is the client portal.
|
|
3
|
+
const NOTIFICATION_PREFIX = '/n';
|
|
4
|
+
export function clientDisplayName(client) {
|
|
5
|
+
return [client.givenName, client.familyName].filter(Boolean).join(' ');
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Whether the page is rendered for a client in the portal. `client` and `company`
|
|
9
|
+
* are staff-only destinations, so an unknown surface also counts as a client:
|
|
10
|
+
* plain text is the safe failure, a dead link is not.
|
|
11
|
+
*/
|
|
12
|
+
export function isClientSurfacePath(pathname) {
|
|
13
|
+
const prefix = getSurfacePrefix(pathname);
|
|
14
|
+
return prefix !== '' && prefix !== NOTIFICATION_PREFIX;
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -22,6 +22,10 @@ If it reports `unsupported`, upgrade the app in this same turn instead of skippi
|
|
|
22
22
|
4. Run `bunx tsc --noEmit` and `bun run build`, and confirm the existing header and toast bridge calls still type-check. Fix any break before continuing.
|
|
23
23
|
5. Re-run the check above, then proceed with this skill.
|
|
24
24
|
|
|
25
|
+
## Client and company names: use the components
|
|
26
|
+
|
|
27
|
+
Render every client or company name with `<ClientName client={client} />` and `<CompanyName company={company} />` from `@assembly-js/app-foundation/lib-client`, in place of `{client.givenName} {client.familyName}` or `{company.name}`. They take the record objects the app already fetches and accept `className`. Staff get a link to the record's CRM page; a client viewer gets plain text. `bun run build` fails `check:linked-names` for a name rendered by hand. The rest of this skill covers other destinations.
|
|
28
|
+
|
|
25
29
|
## When to reach for this skill
|
|
26
30
|
|
|
27
31
|
Any time the app renders a **name or reference to a platform entity** — a client name, a company badge, an invoice number, a contract, a form, a file — offer a link that opens the platform's canonical detail page, on surfaces where that destination is reachable (see the "Client portal" column in the destination catalog below). Internal/staff users can navigate to any destination. Clients in the portal can only reach the destinations marked available there — their own invoices, contracts, help articles, files, channels, and other installed apps — not `client`, `company`, `form`, or the other staff-only destinations, which the host rejects for a client viewer.
|