@websolutespa/bom-next 3.0.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 +7 -0
- package/README.md +11 -0
- package/dist/config.client-B8As5DY0.js +2 -0
- package/dist/config.edge-B8As5DY0.js +2 -0
- package/dist/config.server-B8As5DY0.js +2 -0
- package/dist/exports/client-xcFJNcG7.d.ts +979 -0
- package/dist/exports/client-xcFJNcG7.d.ts.map +1 -0
- package/dist/exports/client.js +5735 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/instrumentation-DBO6QH4d.d.ts +13 -0
- package/dist/exports/instrumentation-DBO6QH4d.d.ts.map +1 -0
- package/dist/exports/instrumentation.js +25 -0
- package/dist/exports/instrumentation.js.map +1 -0
- package/dist/exports/proxy-Cv7QFVci.d.ts +71 -0
- package/dist/exports/proxy-Cv7QFVci.d.ts.map +1 -0
- package/dist/exports/proxy.js +254 -0
- package/dist/exports/proxy.js.map +1 -0
- package/dist/exports/server-BHObJHJ6.d.ts +402 -0
- package/dist/exports/server-BHObJHJ6.d.ts.map +1 -0
- package/dist/exports/server.js +1974 -0
- package/dist/exports/server.js.map +1 -0
- package/dist/http-_2M-UFRx.js +61 -0
- package/dist/http-_2M-UFRx.js.map +1 -0
- package/dist/index-CdYcvudT.d.ts +4 -0
- package/dist/index-CtfAJ0y7.d.ts +499 -0
- package/dist/index-CtfAJ0y7.d.ts.map +1 -0
- package/dist/index.js +201 -0
- package/dist/index.js.map +1 -0
- package/dist/route-DzFwERQP.js +65 -0
- package/dist/route-DzFwERQP.js.map +1 -0
- package/dist/store-CJzqrHsl.js +73 -0
- package/dist/store-CJzqrHsl.js.map +1 -0
- package/dist/types-BatUTdbZ.d.ts +15 -0
- package/dist/types-BatUTdbZ.d.ts.map +1 -0
- package/dist/types-D29_snYY.d.ts +191 -0
- package/dist/types-D29_snYY.d.ts.map +1 -0
- package/dist/utils-Ca7xh5mm.js +78 -0
- package/dist/utils-Ca7xh5mm.js.map +1 -0
- package/dist/utils-YwteTbk7.js +23 -0
- package/dist/utils-YwteTbk7.js.map +1 -0
- package/package.json +156 -0
- package/src/client/client.tsx +18 -0
- package/src/client/extra.tsx +21 -0
- package/src/client/label.tsx +24 -0
- package/src/client/layout.tsx +22 -0
- package/src/client/local.collection.ts +33 -0
- package/src/client/metadata.tsx +217 -0
- package/src/client/page.tsx +22 -0
- package/src/client/useApi.test.tsx +203 -0
- package/src/client/useApi.ts +59 -0
- package/src/client/useCollectionApi.test.tsx +201 -0
- package/src/client/useCollectionApi.ts +66 -0
- package/src/client/useExtra.tsx +12 -0
- package/src/client/useHttp.ts +54 -0
- package/src/client/useLabel.test.tsx +76 -0
- package/src/client/useLabel.tsx +12 -0
- package/src/client/useLayout.tsx +13 -0
- package/src/client/useLink.tsx +13 -0
- package/src/client/useMetaData.tsx +12 -0
- package/src/client/usePage.tsx +12 -0
- package/src/client/useUser.tsx +12 -0
- package/src/client/user.tsx +43 -0
- package/src/exports/client.ts +20 -0
- package/src/exports/instrumentation.ts +1 -0
- package/src/exports/proxy.ts +5 -0
- package/src/exports/server.ts +19 -0
- package/src/global.d.ts +6 -0
- package/src/index.ts +17 -0
- package/src/instrumentation/client.ts +19 -0
- package/src/instrumentation/sentry/capture.requestError.ts +6 -0
- package/src/instrumentation/sentry/capture.routerTransitionStart.ts +4 -0
- package/src/instrumentation/sentry/config.client.ts +9 -0
- package/src/instrumentation/sentry/config.edge.ts +9 -0
- package/src/instrumentation/sentry/config.server.ts +9 -0
- package/src/instrumentation/server.ts +24 -0
- package/src/instrumentation/types.ts +12 -0
- package/src/lib/breadcrumb.ts +21 -0
- package/src/lib/const.ts +3 -0
- package/src/lib/http.test.ts +25 -0
- package/src/lib/http.ts +74 -0
- package/src/lib/label.ts +43 -0
- package/src/lib/layout.defaults.ts +167 -0
- package/src/lib/link.ts +19 -0
- package/src/lib/meta-data.ts +60 -0
- package/src/lib/open-graph.ts +165 -0
- package/src/lib/pagination.ts +78 -0
- package/src/lib/payload.config.ts +12 -0
- package/src/lib/route.ts +83 -0
- package/src/lib/store.ts +63 -0
- package/src/lib/structured-data.ts +24 -0
- package/src/lib/twitter-card.ts +49 -0
- package/src/lib/utils.ts +45 -0
- package/src/mock.ts +264 -0
- package/src/proxy/protected-route.test.ts +75 -0
- package/src/proxy/protected-route.ts +57 -0
- package/src/proxy/proxy.test.ts +48 -0
- package/src/proxy/proxy.ts +64 -0
- package/src/proxy/route-auto-detection.ts +126 -0
- package/src/proxy/route-proxy.ts +187 -0
- package/src/server/context.ts +147 -0
- package/src/server/handlers/auth.handler.ts +27 -0
- package/src/server/handlers/captions.handler.ts +32 -0
- package/src/server/handlers/collection.handler.test.ts +57 -0
- package/src/server/handlers/collection.handler.ts +212 -0
- package/src/server/handlers/error-response.ts +45 -0
- package/src/server/handlers/index.handler.ts +146 -0
- package/src/server/handlers/preview.handler.ts +63 -0
- package/src/server/handlers/registry.handler.test.ts +65 -0
- package/src/server/handlers/registry.handler.ts +259 -0
- package/src/server/handlers/route.handler.ts +161 -0
- package/src/server/handlers/sitemap.handler.ts +374 -0
- package/src/server/handlers/static-params.handler.ts +111 -0
- package/src/server/handlers/vars.handler.ts +167 -0
- package/src/server/layout.ts +120 -0
- package/src/server/lazy.ts +41 -0
- package/src/server/local-api.ts +63 -0
- package/src/server/metadata.ts +87 -0
- package/src/server/mixer-payload.ts +124 -0
- package/src/server/mixer-payload.type-test.ts +27 -0
- package/src/server/mixer.ts +439 -0
- package/src/server/mock/category.ts +34 -0
- package/src/server/mock/collection.ts +21 -0
- package/src/server/mock/page.ts +62 -0
- package/src/server/mock/route.ts +36 -0
- package/src/server/mock/store.ts +159 -0
- package/src/server/page.ts +86 -0
- package/src/server/session/session.ts +101 -0
- package/src/server/utils.ts +23 -0
- package/src/server/utmz.ts +11 -0
- package/src/types.ts +211 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
type StatusError = {
|
|
2
|
+
data?: unknown;
|
|
3
|
+
isPublic?: unknown;
|
|
4
|
+
message?: unknown;
|
|
5
|
+
name?: unknown;
|
|
6
|
+
status?: unknown;
|
|
7
|
+
statusText?: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type PayloadErrorItem = {
|
|
11
|
+
data?: unknown;
|
|
12
|
+
message: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const getStatus = (error: StatusError): number => {
|
|
17
|
+
return typeof error.status === 'number' && error.status >= 400 && error.status < 600 ? error.status : 500;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const isPublic = (error: StatusError, status: number): boolean => {
|
|
21
|
+
return error.isPublic === true || (error.isPublic === undefined && status >= 400 && status < 500);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Converts errors thrown by Next or Payload handlers into Payload-compatible
|
|
26
|
+
* API responses without exposing messages or data from non-public errors.
|
|
27
|
+
*/
|
|
28
|
+
export function getHandlerErrorResponse(error: unknown): Response {
|
|
29
|
+
const statusError = error && typeof error === 'object' ? error as StatusError : {};
|
|
30
|
+
const status = getStatus(statusError);
|
|
31
|
+
const publicError = isPublic(statusError, status);
|
|
32
|
+
const message = publicError && typeof statusError.message === 'string' ?
|
|
33
|
+
statusError.message :
|
|
34
|
+
publicError && typeof statusError.statusText === 'string' ? statusError.statusText : 'Something went wrong.';
|
|
35
|
+
const payloadError: PayloadErrorItem = { message };
|
|
36
|
+
|
|
37
|
+
if (publicError && typeof statusError.name === 'string') {
|
|
38
|
+
payloadError.name = statusError.name;
|
|
39
|
+
}
|
|
40
|
+
if (publicError && statusError.data !== null && statusError.data !== undefined) {
|
|
41
|
+
payloadError.data = statusError.data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return Response.json({ errors: [payloadError] }, { status });
|
|
45
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { defaultLocale, defaultMarket } from '@websolutespa/bom-core';
|
|
2
|
+
import { getPayload, SanitizedConfig } from 'payload';
|
|
3
|
+
import { getHandlerErrorResponse } from './error-response';
|
|
4
|
+
|
|
5
|
+
export function indexGetHandler(config: Promise<SanitizedConfig>) {
|
|
6
|
+
return async function (request: Request) {
|
|
7
|
+
try {
|
|
8
|
+
const payload = await getPayload({ config });
|
|
9
|
+
const keys = [
|
|
10
|
+
...payload.config.collections.map(x => x.slug),
|
|
11
|
+
...payload.config.globals.map(x => x.slug),
|
|
12
|
+
];
|
|
13
|
+
keys.sort();
|
|
14
|
+
const market = defaultMarket;
|
|
15
|
+
const locale = defaultLocale;
|
|
16
|
+
if (!Array.isArray(keys) || keys?.length === 0) {
|
|
17
|
+
console.error('apiHome.collectionGet', 'collections not found', keys);
|
|
18
|
+
return new Response('method not allowed', {
|
|
19
|
+
status: 405,
|
|
20
|
+
statusText: 'method not allowed',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return new Response(`
|
|
24
|
+
<html>
|
|
25
|
+
<head>
|
|
26
|
+
<title>REST Api</title>
|
|
27
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
|
28
|
+
<style type="text/css">
|
|
29
|
+
html {
|
|
30
|
+
padding: 0;
|
|
31
|
+
background: #09090A;
|
|
32
|
+
color: #fefefe;
|
|
33
|
+
}
|
|
34
|
+
body {
|
|
35
|
+
padding: 15px;
|
|
36
|
+
font-family: Inter,-apple-system,'Helvetica Neue','Segoe UI',Roboto,Arial,'Noto Sans',sans-serif;
|
|
37
|
+
font-size: 13px;
|
|
38
|
+
background: #09090A;
|
|
39
|
+
color: #fefefe;
|
|
40
|
+
}
|
|
41
|
+
.container {
|
|
42
|
+
margin: 0 auto;
|
|
43
|
+
max-width: 1280px;
|
|
44
|
+
}
|
|
45
|
+
a {
|
|
46
|
+
color: #fefefe;
|
|
47
|
+
text-decoration: none;
|
|
48
|
+
}
|
|
49
|
+
a:visited {
|
|
50
|
+
color: #9e9e9e;
|
|
51
|
+
}
|
|
52
|
+
a:hover {
|
|
53
|
+
text-decoration: underline;
|
|
54
|
+
}
|
|
55
|
+
table {
|
|
56
|
+
width: 100%;
|
|
57
|
+
margin: 10px 0;
|
|
58
|
+
border: none;
|
|
59
|
+
border-collapse: collapse;
|
|
60
|
+
}
|
|
61
|
+
td,
|
|
62
|
+
th {
|
|
63
|
+
text-align: left;
|
|
64
|
+
font-size: 12px;
|
|
65
|
+
padding: 3px;
|
|
66
|
+
}
|
|
67
|
+
th:not(:last-child),
|
|
68
|
+
td:not(:last-child) {
|
|
69
|
+
padding-right: 30px;
|
|
70
|
+
}
|
|
71
|
+
thead th {
|
|
72
|
+
border-bottom: 1px solid #0e0e0f;
|
|
73
|
+
}
|
|
74
|
+
#table tr:nth-child(odd) td {
|
|
75
|
+
background-color: #0e0e0f;
|
|
76
|
+
}
|
|
77
|
+
#table tbody tr:hover td {
|
|
78
|
+
background-color: #1A1A1B;
|
|
79
|
+
}
|
|
80
|
+
#table tbody tr:hover td,
|
|
81
|
+
#table tbody tr:hover td a {
|
|
82
|
+
color: #fefefe;
|
|
83
|
+
}
|
|
84
|
+
.wysiwyg {
|
|
85
|
+
margin: 0;
|
|
86
|
+
padding: 0 0 10px 0;
|
|
87
|
+
line-height: 1.2em;
|
|
88
|
+
}
|
|
89
|
+
.wysiwyg a {
|
|
90
|
+
color: #00ea4e;
|
|
91
|
+
font-weight: 600;
|
|
92
|
+
}
|
|
93
|
+
.wysiwyg a:visited {
|
|
94
|
+
color: #00ea4e;
|
|
95
|
+
}
|
|
96
|
+
</style>
|
|
97
|
+
</head>
|
|
98
|
+
<body>
|
|
99
|
+
<div class="container">
|
|
100
|
+
<h1>REST API</h1>
|
|
101
|
+
<p class="wysiwyg">
|
|
102
|
+
This is the REST Api generated by <a href="https://github.com/websolutespa/bom" target="_blank" rel="noopener">Mixer</a>.
|
|
103
|
+
</p>
|
|
104
|
+
<p class="wysiwyg">
|
|
105
|
+
It contains a list of all the generic local api CRUD handlers of Mixer app. <br />
|
|
106
|
+
All methods should be called with market and locale parameters.
|
|
107
|
+
</p>
|
|
108
|
+
<table id="table" cellpadding="3">
|
|
109
|
+
<thead>
|
|
110
|
+
<tr>
|
|
111
|
+
<th width="150px">Method</th>
|
|
112
|
+
<th width="150px">Collection</th>
|
|
113
|
+
<th>Endpoint</th>
|
|
114
|
+
</tr>
|
|
115
|
+
</thead>
|
|
116
|
+
<tbody>
|
|
117
|
+
${keys.map(c => (`
|
|
118
|
+
<tr>
|
|
119
|
+
<td>GET, POST, PATCH</td>
|
|
120
|
+
<td>${c}</td>
|
|
121
|
+
<td><a href="/api/${c}?market=${market}&locale=${locale}" target="_blank">/api/${c}</a></td>
|
|
122
|
+
</tr>
|
|
123
|
+
<tr>
|
|
124
|
+
<td>GET, DELETE</td>
|
|
125
|
+
<td>${c}</td>
|
|
126
|
+
<td><a href="/api/${c}/1?market=${market}&locale=${locale}" target="_blank">/api/${c}/{id}</a></td>
|
|
127
|
+
</tr>
|
|
128
|
+
`)).join('\n')}
|
|
129
|
+
</tbody>
|
|
130
|
+
</table>
|
|
131
|
+
</div>
|
|
132
|
+
</body>
|
|
133
|
+
`, {
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': 'text/html; charset=UTF-8',
|
|
136
|
+
'X-Frame-Options': 'SAMEORIGIN',
|
|
137
|
+
'X-Robots-Tag': 'noindex, follow',
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
} catch (error: unknown) {
|
|
141
|
+
const response = getHandlerErrorResponse(error);
|
|
142
|
+
console.error('storeHandler.indexGet.error', response.status, error);
|
|
143
|
+
return response;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { draftMode } from 'next/headers';
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
|
|
4
|
+
// http://localhost:3000/api/-preview?marketId=ww&localeId=it&templateId=homepage&documentId=6a6b33177d8f191df34b33a3
|
|
5
|
+
|
|
6
|
+
export async function previewHandler(request: NextRequest) {
|
|
7
|
+
const { searchParams } = new URL(request.url);
|
|
8
|
+
const params = Object.fromEntries(searchParams) as {
|
|
9
|
+
marketId: string;
|
|
10
|
+
localeId: string;
|
|
11
|
+
templateId: string;
|
|
12
|
+
documentId: string;
|
|
13
|
+
_status: 'published' | 'draft';
|
|
14
|
+
_live: string;
|
|
15
|
+
_token: string;
|
|
16
|
+
};
|
|
17
|
+
/*
|
|
18
|
+
if (params.secret !== process.env.MIXER_SECRET) {
|
|
19
|
+
return new Response('Invalid token', { status: 401 })
|
|
20
|
+
}
|
|
21
|
+
*/
|
|
22
|
+
/*
|
|
23
|
+
const headersList = await headers()
|
|
24
|
+
const userAgent = headersList.get('user-agent')
|
|
25
|
+
*/
|
|
26
|
+
const { marketId, localeId, templateId, documentId, _status, _live } = params;
|
|
27
|
+
const destination = `/${marketId}/${localeId}/${templateId}/${documentId}${_live ? '?_live=true' : ''}`;
|
|
28
|
+
/*
|
|
29
|
+
response.setPreviewData({ _status });
|
|
30
|
+
response.setDraftMode({ enable: true });
|
|
31
|
+
response.redirect(307, destination);
|
|
32
|
+
redirect(destination, RedirectType.replace);
|
|
33
|
+
*/
|
|
34
|
+
const draft = await draftMode();
|
|
35
|
+
draft.enable();
|
|
36
|
+
const path = `${process.env.NEXT_PUBLIC_URL}${destination}`;
|
|
37
|
+
return NextResponse.redirect(path, {
|
|
38
|
+
status: 303,
|
|
39
|
+
});
|
|
40
|
+
/*
|
|
41
|
+
return NextResponse.redirect(path, {
|
|
42
|
+
status: 303,
|
|
43
|
+
});
|
|
44
|
+
*/
|
|
45
|
+
/*
|
|
46
|
+
return new Response(null, {
|
|
47
|
+
status: 303,
|
|
48
|
+
headers: {
|
|
49
|
+
Location: path,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
*/
|
|
53
|
+
/*
|
|
54
|
+
redirect(path);
|
|
55
|
+
return;
|
|
56
|
+
*/
|
|
57
|
+
/*
|
|
58
|
+
return NextResponse.json({
|
|
59
|
+
destination: destination,
|
|
60
|
+
permanent: false
|
|
61
|
+
});
|
|
62
|
+
*/
|
|
63
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { getRegistry, getRegistryItem } from './registry.handler';
|
|
4
|
+
|
|
5
|
+
const request = new NextRequest('https://registry.example.test/r');
|
|
6
|
+
|
|
7
|
+
describe('registry handlers', () => {
|
|
8
|
+
test('builds a registry from all published UI source folders', async () => {
|
|
9
|
+
const response = await getRegistry()(request);
|
|
10
|
+
const body = await response.json();
|
|
11
|
+
const names = body.items.map((item: { name: string }) => item.name);
|
|
12
|
+
|
|
13
|
+
expect(response.headers.get('content-type')).toContain('application/json');
|
|
14
|
+
expect(body.$schema).toBe('https://ui.shadcn.com/schema/registry.json');
|
|
15
|
+
expect(names).toContain('button');
|
|
16
|
+
expect(names).toContain('box');
|
|
17
|
+
expect(names).toContain('sections-header');
|
|
18
|
+
expect(names).toContain('hooks-use-pagination');
|
|
19
|
+
expect(names).toContain('lazy-login-form');
|
|
20
|
+
expect(names).toContain('examples-example-button');
|
|
21
|
+
expect(names).toContain('theme-theme');
|
|
22
|
+
expect(names).toContain('lib-utils');
|
|
23
|
+
expect(names).not.toContain('accordion');
|
|
24
|
+
expect(names).not.toContain('hooks-use-event-listener-test');
|
|
25
|
+
expect(names).not.toContain('sections-index');
|
|
26
|
+
expect(body.items.every((item: { files: Array<{ content?: string }> }) => item.files.every(file => !('content' in file)))).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('returns rewritten source and dependencies for an item', async () => {
|
|
30
|
+
const response = await getRegistryItem()(request, { params: Promise.resolve({ slug: ['box.json'] }) });
|
|
31
|
+
const body = await response.json();
|
|
32
|
+
|
|
33
|
+
expect(body.$schema).toBe('https://ui.shadcn.com/schema/registry-item.json');
|
|
34
|
+
expect(body.name).toBe('box');
|
|
35
|
+
expect(body.files.map((file: { target: string }) => file.target)).toEqual([
|
|
36
|
+
'@ui/box.tsx',
|
|
37
|
+
]);
|
|
38
|
+
expect(body.files[0].content).toContain("@/lib/as-component");
|
|
39
|
+
expect(body.registryDependencies).toEqual([
|
|
40
|
+
'https://registry.example.test/r/lib-as-component.json',
|
|
41
|
+
'https://registry.example.test/r/lib-utils.json',
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('returns source, dependencies, and assets for non-component items', async () => {
|
|
46
|
+
const handler = getRegistryItem();
|
|
47
|
+
const sectionResponse = await handler(request, { params: Promise.resolve({ slug: ['sections-header.json'] }) });
|
|
48
|
+
const section = await sectionResponse.json();
|
|
49
|
+
const themeResponse = await handler(request, { params: Promise.resolve({ slug: ['theme-theme.json'] }) });
|
|
50
|
+
const theme = await themeResponse.json();
|
|
51
|
+
|
|
52
|
+
expect(sectionResponse.status).toBe(200);
|
|
53
|
+
expect(section.files.map((file: { target: string }) => file.target)).toContain('@components/sections/header.tsx');
|
|
54
|
+
expect(section.registryDependencies).toContain('https://registry.example.test/r/sections-main-nav.json');
|
|
55
|
+
expect(themeResponse.status).toBe(200);
|
|
56
|
+
expect(theme.files.map((file: { type: string }) => file.type)).toContain('registry:style');
|
|
57
|
+
expect(theme.files.map((file: { target: string }) => file.target)).toContain('@components/theme/theme.css');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('returns 404 for excluded and unknown components', async () => {
|
|
61
|
+
const handler = getRegistryItem();
|
|
62
|
+
expect((await handler(request, { params: Promise.resolve({ slug: ['accordion.json'] }) })).status).toBe(404);
|
|
63
|
+
expect((await handler(request, { params: Promise.resolve({ slug: ['missing.json'] }) })).status).toBe(404);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const REGISTRY_SCHEMA = 'https://ui.shadcn.com/schema/registry.json';
|
|
6
|
+
const REGISTRY_ITEM_SCHEMA = 'https://ui.shadcn.com/schema/registry-item.json';
|
|
7
|
+
const SOURCE_EXTENSIONS = new Set(['.css', '.json', '.ts', '.tsx']);
|
|
8
|
+
const PUBLISHED_DIRECTORIES = new Set(['components', 'examples', 'hooks', 'lazy', 'lib', 'sections', 'theme']);
|
|
9
|
+
const SHADCN_COMPONENTS = new Set([
|
|
10
|
+
'accordion', 'alert-dialog', 'alert', 'aspect-ratio', 'avatar', 'badge', 'breadcrumb', 'calendar', 'card',
|
|
11
|
+
'carousel', 'chart', 'checkbox', 'collapsible', 'combobox', 'command', 'context-menu', 'dialog', 'drawer',
|
|
12
|
+
'dropdown-menu', 'form', 'hover-card', 'input', 'input-otp', 'label', 'menubar', 'navigation-menu',
|
|
13
|
+
'pagination', 'popover', 'progress', 'radio-group', 'resizable', 'scroll-area', 'select', 'separator', 'sheet',
|
|
14
|
+
'sidebar', 'skeleton', 'slider', 'sonner', 'switch', 'table', 'tabs', 'textarea', 'toast', 'toggle',
|
|
15
|
+
'toggle-group', 'tooltip',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
type RegistryFile = {
|
|
19
|
+
path: string;
|
|
20
|
+
content: string;
|
|
21
|
+
type: 'registry:ui' | 'registry:lib' | 'registry:hook' | 'registry:style' | 'registry:file';
|
|
22
|
+
target: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type RegistryCatalogFile = Omit<RegistryFile, 'content'>;
|
|
26
|
+
|
|
27
|
+
type RegistryItem = {
|
|
28
|
+
$schema: typeof REGISTRY_ITEM_SCHEMA;
|
|
29
|
+
name: string;
|
|
30
|
+
type: 'registry:ui';
|
|
31
|
+
title: string;
|
|
32
|
+
dependencies?: string[];
|
|
33
|
+
registryDependencies?: string[];
|
|
34
|
+
files: RegistryFile[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type RegistryCatalogItem = Omit<RegistryItem, 'files'> & {
|
|
38
|
+
files: RegistryCatalogFile[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type SourceFile = { absolutePath: string; relativePath: string };
|
|
42
|
+
|
|
43
|
+
function toKebabCase(value: string) {
|
|
44
|
+
return value.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/[._\s]+/g, '-').toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function titleFromName(name: string) {
|
|
48
|
+
return name.replace(/(^|[-_])(\w)/g, (_, __, character: string) => character.toUpperCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function packageName(specifier: string) {
|
|
52
|
+
return specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getOrigin(request: NextRequest) {
|
|
56
|
+
return process.env.NEXT_PUBLIC_URL?.replace(/\/$/, '') || request.nextUrl.origin;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function directoryExists(directory: string) {
|
|
60
|
+
try {
|
|
61
|
+
return (await stat(directory)).isDirectory();
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function getUiSourceDirectory() {
|
|
68
|
+
if (process.env.UI_REGISTRY_SOURCE_DIR) {
|
|
69
|
+
const configuredPath = resolve(process.env.UI_REGISTRY_SOURCE_DIR);
|
|
70
|
+
if (await directoryExists(configuredPath)) return configuredPath;
|
|
71
|
+
throw new Error(`UI_REGISTRY_SOURCE_DIR does not exist: ${configuredPath}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let currentDirectory = process.cwd();
|
|
75
|
+
while (dirname(currentDirectory) !== currentDirectory) {
|
|
76
|
+
const candidate = join(currentDirectory, 'packages', 'ui', 'src');
|
|
77
|
+
if (await directoryExists(candidate)) return candidate;
|
|
78
|
+
currentDirectory = dirname(currentDirectory);
|
|
79
|
+
}
|
|
80
|
+
throw new Error('Unable to find packages/ui/src. Set UI_REGISTRY_SOURCE_DIR to its absolute path.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function getSourceFiles(directory: string, sourceDirectory = directory): Promise<SourceFile[]> {
|
|
84
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
85
|
+
const files = await Promise.all(entries.map(async entry => {
|
|
86
|
+
const absolutePath = join(directory, entry.name);
|
|
87
|
+
if (entry.isDirectory()) return getSourceFiles(absolutePath, sourceDirectory);
|
|
88
|
+
if (!SOURCE_EXTENSIONS.has(extname(entry.name)) || entry.name.endsWith('.d.ts') || /\.(test|spec)\.[^.]+$/.test(entry.name)) return [];
|
|
89
|
+
return [{ absolutePath, relativePath: entry.name }];
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
return files.flat().map(file => ({
|
|
93
|
+
...file,
|
|
94
|
+
relativePath: relative(sourceDirectory, file.absolutePath).replaceAll('\\', '/'),
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getImportSpecifiers(source: string) {
|
|
99
|
+
return [...source.matchAll(/(?:import|export)\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g)]
|
|
100
|
+
.map(match => match[1]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveSourceImport(sourceDirectory: string, sourceFile: string, specifier: string, sourceFiles: Map<string, SourceFile>) {
|
|
104
|
+
let candidate: string | undefined;
|
|
105
|
+
if (specifier.startsWith('@app/ui/')) candidate = specifier.slice('@app/ui/'.length);
|
|
106
|
+
if (specifier.startsWith('.')) candidate = relative(sourceDirectory, resolve(dirname(sourceFile), specifier)).replaceAll('\\', '/');
|
|
107
|
+
if (!candidate) return undefined;
|
|
108
|
+
|
|
109
|
+
const normalizedCandidate = candidate.replace(/^\.\//, '');
|
|
110
|
+
return sourceFiles.get(normalizedCandidate) ||
|
|
111
|
+
sourceFiles.get(`${normalizedCandidate}.ts`) || sourceFiles.get(`${normalizedCandidate}.tsx`) ||
|
|
112
|
+
sourceFiles.get(`${normalizedCandidate}/index.ts`) || sourceFiles.get(`${normalizedCandidate}/index.tsx`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function rewriteUiImports(source: string, sourceFile: SourceFile, sourceDirectory: string, sourceFiles: Map<string, SourceFile>) {
|
|
116
|
+
return source.replace(/(['"])(@app\/ui\/[^'"]+|\.\.?\/[^'"]+)\1/g, (match, quote: string, specifier: string) => {
|
|
117
|
+
const importedFile = resolveSourceImport(sourceDirectory, sourceFile.absolutePath, specifier, sourceFiles);
|
|
118
|
+
return importedFile ? `${quote}${getImportPath(importedFile.relativePath)}${quote}` : match;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getImportPath(relativePath: string) {
|
|
123
|
+
const extensionlessPath = relativePath.replace(/\.(tsx?|mts|cts)$/, '');
|
|
124
|
+
if (extensionlessPath.startsWith('components/')) return `@/components/ui/${extensionlessPath.slice('components/'.length)}`;
|
|
125
|
+
if (extensionlessPath.startsWith('hooks/')) return `@/hooks/${extensionlessPath.slice('hooks/'.length)}`;
|
|
126
|
+
if (extensionlessPath.startsWith('lib/')) return `@/lib/${extensionlessPath.slice('lib/'.length)}`;
|
|
127
|
+
return `@/components/${extensionlessPath}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getTargetPath(relativePath: string) {
|
|
131
|
+
if (relativePath.startsWith('components/')) return `@ui/${relativePath.slice('components/'.length)}`;
|
|
132
|
+
if (relativePath.startsWith('hooks/')) return `@hooks/${relativePath.slice('hooks/'.length)}`;
|
|
133
|
+
if (relativePath.startsWith('lib/')) return `@lib/${relativePath.slice('lib/'.length)}`;
|
|
134
|
+
return `@components/${relativePath}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getRegistryFileType(relativePath: string): RegistryFile['type'] {
|
|
138
|
+
if (relativePath.startsWith('components/')) return 'registry:ui';
|
|
139
|
+
if (relativePath.startsWith('hooks/')) return 'registry:hook';
|
|
140
|
+
if (relativePath.startsWith('theme/')) return 'registry:style';
|
|
141
|
+
if (extname(relativePath) === '.json') return 'registry:file';
|
|
142
|
+
return 'registry:lib';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isPublishedFile(file: SourceFile) {
|
|
146
|
+
const [directory] = file.relativePath.split('/');
|
|
147
|
+
return PUBLISHED_DIRECTORIES.has(directory) && !/(^|\/)index\.[^.]+$/.test(file.relativePath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getRegistryItemName(file: SourceFile) {
|
|
151
|
+
const [directory, ...path] = file.relativePath.split('/');
|
|
152
|
+
const filename = path.join('/').replace(extname(file.relativePath), '');
|
|
153
|
+
const name = toKebabCase(filename.replaceAll('/', '-'));
|
|
154
|
+
return directory === 'components' ? name : `${directory}-${name}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function createRegistryCatalog(sourceDirectory: string, origin: string) {
|
|
158
|
+
const files = (await getSourceFiles(sourceDirectory)).filter(file => PUBLISHED_DIRECTORIES.has(file.relativePath.split('/')[0]));
|
|
159
|
+
const filesByRelativePath = new Map(files.map(file => [file.relativePath, file]));
|
|
160
|
+
const entries = new Map(files.filter(isPublishedFile).map(file => [getRegistryItemName(file), file]));
|
|
161
|
+
const entryNamesByPath = new Map([...entries].map(([name, file]) => [file.relativePath, name]));
|
|
162
|
+
const includedItems = new Set([...entries].filter(([name, file]) =>
|
|
163
|
+
!file.relativePath.startsWith('components/') || name === 'button' || !SHADCN_COMPONENTS.has(name)
|
|
164
|
+
).map(([name]) => name));
|
|
165
|
+
|
|
166
|
+
async function getItem(name: string): Promise<RegistryItem | undefined> {
|
|
167
|
+
const entry = entries.get(name);
|
|
168
|
+
if (!entry || !includedItems.has(name)) return undefined;
|
|
169
|
+
|
|
170
|
+
const filesToInclude = new Map<string, SourceFile>();
|
|
171
|
+
const externalDependencies = new Set<string>();
|
|
172
|
+
const registryDependencies = new Set<string>();
|
|
173
|
+
const pendingFiles = [entry];
|
|
174
|
+
|
|
175
|
+
while (pendingFiles.length) {
|
|
176
|
+
const file = pendingFiles.pop()!;
|
|
177
|
+
if (filesToInclude.has(file.relativePath)) continue;
|
|
178
|
+
filesToInclude.set(file.relativePath, file);
|
|
179
|
+
const source = await readFile(file.absolutePath, 'utf8');
|
|
180
|
+
|
|
181
|
+
for (const specifier of getImportSpecifiers(source)) {
|
|
182
|
+
const localFile = resolveSourceImport(sourceDirectory, file.absolutePath, specifier, filesByRelativePath);
|
|
183
|
+
const dependencyName = localFile && entryNamesByPath.get(localFile.relativePath);
|
|
184
|
+
if (localFile?.relativePath.startsWith('components/')) {
|
|
185
|
+
const componentName = toKebabCase(basename(localFile.relativePath, extname(localFile.relativePath)));
|
|
186
|
+
if (dependencyName !== name) {
|
|
187
|
+
registryDependencies.add(includedItems.has(componentName) ? `${origin}/r/${componentName}.json` : componentName);
|
|
188
|
+
}
|
|
189
|
+
} else if (localFile && dependencyName && dependencyName !== name && includedItems.has(dependencyName)) {
|
|
190
|
+
registryDependencies.add(`${origin}/r/${dependencyName}.json`);
|
|
191
|
+
} else if (localFile) {
|
|
192
|
+
pendingFiles.push(localFile);
|
|
193
|
+
} else if (!specifier.startsWith('.') && !specifier.startsWith('@app/ui/') && !specifier.startsWith('node:') && specifier !== 'react' && specifier !== 'react-dom') {
|
|
194
|
+
externalDependencies.add(packageName(specifier));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const registryFiles = await Promise.all([...filesToInclude.values()].sort((left, right) => left.relativePath.localeCompare(right.relativePath)).map(async file => ({
|
|
200
|
+
path: file.relativePath,
|
|
201
|
+
content: rewriteUiImports(await readFile(file.absolutePath, 'utf8'), file, sourceDirectory, filesByRelativePath),
|
|
202
|
+
type: getRegistryFileType(file.relativePath),
|
|
203
|
+
target: getTargetPath(file.relativePath),
|
|
204
|
+
})));
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
$schema: REGISTRY_ITEM_SCHEMA,
|
|
208
|
+
name,
|
|
209
|
+
type: 'registry:ui',
|
|
210
|
+
title: titleFromName(name),
|
|
211
|
+
...(externalDependencies.size && { dependencies: [...externalDependencies].sort() }),
|
|
212
|
+
...(registryDependencies.size && { registryDependencies: [...registryDependencies].sort() }),
|
|
213
|
+
files: registryFiles,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function getCatalogItem(name: string): Promise<RegistryCatalogItem | undefined> {
|
|
218
|
+
const item = await getItem(name);
|
|
219
|
+
if (!item) return undefined;
|
|
220
|
+
return {
|
|
221
|
+
...item,
|
|
222
|
+
files: item.files.map(file => ({
|
|
223
|
+
path: file.path,
|
|
224
|
+
type: file.type,
|
|
225
|
+
target: file.target,
|
|
226
|
+
})),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { items: includedItems, getCatalogItem, getItem };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function jsonResponse(body: unknown, status = 200) {
|
|
234
|
+
return Response.json(body, {
|
|
235
|
+
status,
|
|
236
|
+
headers: { 'X-Frame-Options': 'SAMEORIGIN', 'X-Robots-Tag': 'noindex, follow' },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function getRegistry() {
|
|
241
|
+
return async function (request: NextRequest) {
|
|
242
|
+
const sourceDirectory = await getUiSourceDirectory();
|
|
243
|
+
const catalog = await createRegistryCatalog(sourceDirectory, getOrigin(request));
|
|
244
|
+
const items = (await Promise.all([...catalog.items].sort().map(name => catalog.getCatalogItem(name)))).filter(Boolean);
|
|
245
|
+
return jsonResponse({ $schema: REGISTRY_SCHEMA, name: 'bom-ui', homepage: getOrigin(request), items });
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function getRegistryItem() {
|
|
250
|
+
return async function (request: NextRequest, { params: getParams }: { params: Promise<{ slug: string[] }> }) {
|
|
251
|
+
const { slug } = await getParams;
|
|
252
|
+
const name = slug.at(-1)?.replace(/\.json$/, '');
|
|
253
|
+
if (!name) return jsonResponse({ error: 'Registry item name is required.' }, 404);
|
|
254
|
+
|
|
255
|
+
const sourceDirectory = await getUiSourceDirectory();
|
|
256
|
+
const item = await (await createRegistryCatalog(sourceDirectory, getOrigin(request))).getItem(name);
|
|
257
|
+
return item ? jsonResponse(item) : jsonResponse({ error: `Registry item not found: ${name}` }, 404);
|
|
258
|
+
};
|
|
259
|
+
}
|