@universalcms/connect 0.13.2 → 0.13.3
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 +4 -4
- package/dist/cli.mjs +1 -1
- package/dist/guided.mjs +19 -4
- package/dist/settings-only-setup.mjs +7 -1
- package/dist/settings-only.d.mts +1 -1
- package/dist/settings-only.mjs +22 -4
- package/dist/site-discovery.mjs +130 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @universalcms/connect
|
|
2
2
|
|
|
3
3
|
Local, deterministic website setup without AI. Universal CMS stays hosted; this
|
|
4
|
-
package creates a website integration, not a CMS installation. Version 0.13.
|
|
4
|
+
package creates a website integration, not a CMS installation. Version 0.13.3 is
|
|
5
5
|
prepared locally; this repository does not establish its public npm availability.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
@@ -262,7 +262,7 @@ custom adapter still requires review, not forced overwriting.
|
|
|
262
262
|
For an operator's downloaded artifact, the installation is:
|
|
263
263
|
|
|
264
264
|
```sh
|
|
265
|
-
npm install --save-prod ./vendor/universalcms-connect-0.13.
|
|
265
|
+
npm install --save-prod ./vendor/universalcms-connect-0.13.3.tgz
|
|
266
266
|
```
|
|
267
267
|
|
|
268
268
|
Use your project's package manager. These paths are examples for downloaded
|
|
@@ -539,7 +539,7 @@ npx --no-install universalcms-connect init \
|
|
|
539
539
|
--environment 22222222-2222-4222-8222-222222222222
|
|
540
540
|
```
|
|
541
541
|
|
|
542
|
-
After a public release, `npx @universalcms/connect@0.13.
|
|
542
|
+
After a public release, `npx @universalcms/connect@0.13.3 init` is the equivalent
|
|
543
543
|
registry entry point. Copy the real IDs/API origin from **Developer setup**.
|
|
544
544
|
Missing values are prompted in a terminal; `--non-interactive` fails instead.
|
|
545
545
|
Init prints a plan. Run the same command with `--apply` after reviewing it.
|
|
@@ -589,7 +589,7 @@ The CMS button reads the website's **`/api/cms-source`** endpoint. Current Next.
|
|
|
589
589
|
prepares delivery scaffolding. React/Vite setup serves its reviewed public manifest through the normal dev command.
|
|
590
590
|
Custom exporters need their explicit adapter; a delivery check alone is insufficient.
|
|
591
591
|
|
|
592
|
-
Use the CMS's reviewed 0.13.
|
|
592
|
+
Use the CMS's reviewed 0.13.3 package for the staged commands and current source
|
|
593
593
|
discovery. Review source diagnostics and complete public
|
|
594
594
|
mappings for unsupported pages. Restart the website using its normal server command, then run:
|
|
595
595
|
|
package/dist/cli.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { WEBSITE_SECTIONS } from './website-http.mjs';
|
|
|
20
20
|
import { setupSource, applySourcePlan, syncSource, readSourceSettings } from './source-setup.mjs';
|
|
21
21
|
import { processIo, runCreate, runGuided } from './guided.mjs';
|
|
22
22
|
|
|
23
|
-
const help = `Universal CMS connector 0.13.
|
|
23
|
+
const help = `Universal CMS connector 0.13.3
|
|
24
24
|
|
|
25
25
|
Quick start (recommended) — run in your website folder:
|
|
26
26
|
npx @universalcms/connect@latest --cms https://YOUR_CMS_ADDRESS
|
package/dist/guided.mjs
CHANGED
|
@@ -392,6 +392,21 @@ export async function runGuided(directory, options, io) {
|
|
|
392
392
|
const installed = await readProjectFile(root, '.universalcms/website.integration.json') !== null;
|
|
393
393
|
if (saved && environment.keyConfigured && !options.code && await readProjectFile(root, SETTINGS_ONLY_FILE) !== null) {
|
|
394
394
|
io.write(`✔ Already connected for site settings (${saved.websiteOrigin})\n`);
|
|
395
|
+
// Bring generated files up to date with this connector version.
|
|
396
|
+
const update = await planSettingsOnly(root, Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, saved[name]])));
|
|
397
|
+
const stale = update.files.filter(file => file.state !== 'unchanged');
|
|
398
|
+
if (stale.length) {
|
|
399
|
+
await ensureConnector(root, manifest, io);
|
|
400
|
+
io.write(`\nThis connector version updates ${stale.length} Universal CMS file${stale.length === 1 ? '' : 's'} in your website (your pages and design stay the same):\n`);
|
|
401
|
+
for (const file of stale) io.write(` ${file.state}: ${file.path}\n`);
|
|
402
|
+
if (yes(await io.ask('Update them? [Y/n] '))) {
|
|
403
|
+
const again = await planSettingsOnly(root, update.connection);
|
|
404
|
+
if (JSON.stringify(again.files) !== JSON.stringify(update.files)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
|
|
405
|
+
await applyPlan(update);
|
|
406
|
+
io.write('✔ Universal CMS files updated — restart your website to load them\n');
|
|
407
|
+
return finishSettingsOnly(root, saved, await manifestOf(root), io, true);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
395
410
|
return finishSettingsOnly(root, saved, manifest, io);
|
|
396
411
|
}
|
|
397
412
|
if (saved && installed && environment.keyConfigured && !options.code) {
|
|
@@ -520,12 +535,12 @@ async function settingsReady(connection, io) {
|
|
|
520
535
|
url.search = new URLSearchParams({ check: 'connection', siteId: connection.siteId, environmentId: connection.environmentId }).toString();
|
|
521
536
|
const response = await io.fetch(url, { headers: { Origin: connection.adminOrigin, Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(10000) });
|
|
522
537
|
const body = response.ok ? await response.json() : null;
|
|
523
|
-
return body?.mode === 'settings-only' && body.siteId === connection.siteId && body.environmentId === connection.environmentId;
|
|
538
|
+
return body?.mode === 'settings-only' && body.siteId === connection.siteId && body.environmentId === connection.environmentId && body.discovered?.version === 1;
|
|
524
539
|
} catch { return false; }
|
|
525
540
|
}
|
|
526
541
|
|
|
527
|
-
async function finishSettingsOnly(root, connection, manifest, io) {
|
|
528
|
-
if (!await settingsReady(connection, io)) {
|
|
542
|
+
async function finishSettingsOnly(root, connection, manifest, io, restart = false) {
|
|
543
|
+
if (restart || !await settingsReady(connection, io)) {
|
|
529
544
|
const local = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(connection.websiteOrigin), command = await restartHint(root, manifest);
|
|
530
545
|
io.write(local
|
|
531
546
|
? `\n→ Restart your website so the changes take effect:\n 1. In the terminal where your website is running, press Ctrl+C\n 2. Start it again${command ? ` with: ${command}` : ''}\n It must run at ${connection.websiteOrigin}\n`
|
|
@@ -538,7 +553,7 @@ async function finishSettingsOnly(root, connection, manifest, io) {
|
|
|
538
553
|
}
|
|
539
554
|
const settings = `${connection.adminOrigin}/sites/${connection.siteId}#setup?environmentId=${connection.environmentId}`;
|
|
540
555
|
io.write(`✔ Your website is connected for site settings\n\n🎉 All done! Manage it from Universal CMS (this link opens the right environment):\n ${settings}\n`);
|
|
541
|
-
io.write(' • SEO,
|
|
556
|
+
io.write(' • In Developer setup, import the pages and header/footer links found on your website, so they appear in the CMS.\n • Page text stays in your website\'s code; each page\'s SEO (title, description, social image) and your menus, logo, analytics, scripts and redirects are managed in the CMS.\n');
|
|
542
557
|
io.write(' • To show the CMS menu in your own header or footer, add one line where it should appear:\n');
|
|
543
558
|
io.write(" import { CmsNavigation } from '@universalcms/connect/react-website';\n <CmsNavigation location=\"header\" /> (or location=\"footer\")\n");
|
|
544
559
|
io.write(' Other drop-ins: <CmsBranding />, <CmsSocialLinks />, and useWebsiteSettings() for anything custom.\n');
|
|
@@ -8,6 +8,7 @@ import ts from './vendor/typescript.cjs';
|
|
|
8
8
|
import { projectRoot, readProjectFile, SetupError } from './project-files.mjs';
|
|
9
9
|
import { inspectSourceRoutes } from './source-routes.mjs';
|
|
10
10
|
import { CONNECTION_FILE, validateConnection } from './staged-setup.mjs';
|
|
11
|
+
import { discoverSite, DISCOVERED_FILE } from './site-discovery.mjs';
|
|
11
12
|
|
|
12
13
|
export const SETTINGS_ONLY_FILE = '.universalcms/settings-only.json';
|
|
13
14
|
const fail = message => { throw new SetupError(message); };
|
|
@@ -45,8 +46,9 @@ const settingsRoute = (app, file) => `${MARK} Site settings served to the websit
|
|
|
45
46
|
import { createSettingsOnlyHandler } from '@universalcms/connect/settings-only';
|
|
46
47
|
import configuration from '${relative(file, SETTINGS_ONLY_FILE)}';
|
|
47
48
|
import connection from '${relative(file, CONNECTION_FILE)}';
|
|
49
|
+
import discovered from '${relative(file, DISCOVERED_FILE)}';
|
|
48
50
|
|
|
49
|
-
const handle = createSettingsOnlyHandler({ configuration: configuration as Parameters<typeof createSettingsOnlyHandler>[0]['configuration'], adminOrigin: connection.adminOrigin });
|
|
51
|
+
const handle = createSettingsOnlyHandler({ configuration: configuration as Parameters<typeof createSettingsOnlyHandler>[0]['configuration'], adminOrigin: connection.adminOrigin, discovered });
|
|
50
52
|
export const dynamic = 'force-dynamic';
|
|
51
53
|
export const GET = handle;
|
|
52
54
|
export const HEAD = handle;
|
|
@@ -84,6 +86,10 @@ export async function planSettingsOnly(directory, connectionInput) {
|
|
|
84
86
|
};
|
|
85
87
|
await add(CONNECTION_FILE, json(connection));
|
|
86
88
|
await add(SETTINGS_ONLY_FILE, json({ version: 1, apiOrigin: connection.apiOrigin, siteId: connection.siteId, environmentId: connection.environmentId, websiteOrigin: connection.websiteOrigin, routes: [] }));
|
|
89
|
+
// Public page titles and header/footer links found in the source; the CMS offers to import them.
|
|
90
|
+
const discovered = await discoverSite(root);
|
|
91
|
+
const previousDiscovery = await readProjectFile(root, DISCOVERED_FILE), discoveryContent = json(discovered);
|
|
92
|
+
files.push({ path: DISCOVERED_FILE, content: discoveryContent, state: previousDiscovery === null ? 'create' : previousDiscovery === discoveryContent ? 'unchanged' : 'update', ...(previousDiscovery !== null && previousDiscovery !== discoveryContent ? { previous: previousDiscovery } : {}) });
|
|
87
93
|
for (const name of ['settings', 'page']) { const path = `${app}/api/universalcms/${name}/route.${ext}`; await add(path, settingsRoute(app, path).replace(typed ? '' : / as Parameters<[^>]+>\[0\]\['configuration'\]/, '')); }
|
|
88
94
|
const wrapper = `${app}/universalcms.settings.${typed ? 'tsx' : 'jsx'}`;
|
|
89
95
|
await add(wrapper, wrapperTemplate(wrapper, typed));
|
package/dist/settings-only.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export const SETTINGS_ONLY_MODE: 'settings-only';
|
|
2
2
|
export type SettingsOnlyConfiguration = { version: 1; apiOrigin: string; siteId: string; environmentId: string; websiteOrigin: string; routes: [] };
|
|
3
|
-
export function createSettingsOnlyHandler(options: { configuration: SettingsOnlyConfiguration; adminOrigin: string; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): (request: Request) => Promise<Response>;
|
|
3
|
+
export function createSettingsOnlyHandler(options: { configuration: SettingsOnlyConfiguration; adminOrigin: string; discovered?: unknown; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): (request: Request) => Promise<Response>;
|
|
4
4
|
export function createSettingsOnlyRuntime(options: { configuration: SettingsOnlyConfiguration; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): { settings(): Promise<unknown> };
|
package/dist/settings-only.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createWebsiteRuntime } from './website-runtime.mjs';
|
|
8
8
|
import { WEBSITE_PAGE_PATH, WEBSITE_SECTIONS, WEBSITE_SETTINGS_PATH } from './website-http.mjs';
|
|
9
|
+
import { normalizePublicationPath, publicationRevision } from './publication.mjs';
|
|
9
10
|
|
|
10
11
|
export const SETTINGS_ONLY_MODE = 'settings-only';
|
|
11
12
|
const base = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
|
|
@@ -13,7 +14,17 @@ const base = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
|
|
|
13
14
|
/**
|
|
14
15
|
* @param {{ configuration: { version: 1, apiOrigin: string, siteId: string, environmentId: string, websiteOrigin: string, routes: [] }, adminOrigin: string, env?: () => Record<string, string | undefined>, fetcher?: typeof fetch }} options
|
|
15
16
|
*/
|
|
16
|
-
|
|
17
|
+
const short = (value, max) => typeof value === 'string' ? value.slice(0, max) : '';
|
|
18
|
+
/** Only bounded public strings from the website source reach the admin. */
|
|
19
|
+
function publicDiscovery(value) {
|
|
20
|
+
if (!value || value.version !== 1) return undefined;
|
|
21
|
+
const link = item => ({ label: short(item?.label, 80), url: short(item?.url, 200) });
|
|
22
|
+
return { version: 1, pages: (Array.isArray(value.pages) ? value.pages : []).slice(0, 100).map(page => ({ path: short(page?.path, 200), title: short(page?.title, 120), description: short(page?.description, 320) })),
|
|
23
|
+
navigation: { header: (value.navigation?.header ?? []).slice(0, 20).map(link), footer: (value.navigation?.footer ?? []).slice(0, 20).map(link) } };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createSettingsOnlyHandler({ configuration, adminOrigin, discovered, env = () => process.env, fetcher = globalThis.fetch }) {
|
|
27
|
+
const discovery = publicDiscovery(discovered);
|
|
17
28
|
const runtime = createWebsiteRuntime({ configuration, settingsOnly: true, env, fetcher });
|
|
18
29
|
const admin = new URL(adminOrigin).origin;
|
|
19
30
|
if (admin !== adminOrigin) throw new Error('Invalid CMS admin origin');
|
|
@@ -21,9 +32,16 @@ export function createSettingsOnlyHandler({ configuration, adminOrigin, env = ()
|
|
|
21
32
|
const method = request.method, url = new URL(request.url), origin = request.headers.get('origin');
|
|
22
33
|
const reply = (body, status = 200, extra = {}) => new Response(method === 'HEAD' || status === 204 ? null : JSON.stringify(body), { status, headers: { ...base, 'Content-Type': 'application/json', ...extra } });
|
|
23
34
|
if (url.pathname === WEBSITE_PAGE_PATH) {
|
|
24
|
-
//
|
|
35
|
+
// The website renders every page. A published CMS page at the same path
|
|
36
|
+
// supplies only its SEO metadata (title, description, social tags).
|
|
25
37
|
if (!['GET', 'HEAD'].includes(method)) return reply({ error: 'Method not allowed' }, 405, { Allow: 'GET, HEAD' });
|
|
26
|
-
return reply({
|
|
38
|
+
if (origin && origin !== configuration.websiteOrigin || request.headers.get('sec-fetch-site') === 'cross-site') return reply({ error: 'Request not allowed' }, 403);
|
|
39
|
+
let pathname;
|
|
40
|
+
try { pathname = normalizePublicationPath(url.searchParams.get('path') ?? ''); } catch { return reply({ state: 'missing' }, 404); }
|
|
41
|
+
const result = await runtime.page(pathname === '/' ? '/home' : pathname);
|
|
42
|
+
if (result.state !== 'published') return reply({ state: result.state === 'unavailable' ? 'unavailable' : 'missing' }, result.state === 'unavailable' ? 503 : 404);
|
|
43
|
+
const entry = { ...result.entry, path: pathname };
|
|
44
|
+
return reply({ ...result, path: pathname, entry, revision: publicationRevision(entry) });
|
|
27
45
|
}
|
|
28
46
|
if (url.pathname !== WEBSITE_SETTINGS_PATH) return reply({ error: 'Not found' }, 404);
|
|
29
47
|
const diagnostic = url.searchParams.get('check') === 'connection';
|
|
@@ -39,7 +57,7 @@ export function createSettingsOnlyHandler({ configuration, adminOrigin, env = ()
|
|
|
39
57
|
return reply({ error: 'Website scope mismatch', ...(sameSite ? { siteId: configuration.siteId, environmentId: configuration.environmentId } : {}) }, 409, cors);
|
|
40
58
|
}
|
|
41
59
|
const snapshot = await runtime.settings();
|
|
42
|
-
return reply({ version: 1, mode: SETTINGS_ONLY_MODE, siteId: configuration.siteId, environmentId: configuration.environmentId, renderedVerified: false,
|
|
60
|
+
return reply({ version: 1, mode: SETTINGS_ONLY_MODE, siteId: configuration.siteId, environmentId: configuration.environmentId, renderedVerified: false, ...(discovery ? { discovered: discovery } : {}),
|
|
43
61
|
settings: Object.fromEntries(WEBSITE_SECTIONS.map(section => [section, { installed: true, state: snapshot.sections[section]?.state ?? 'unavailable', version: snapshot.sections[section]?.version ?? 0 }])) }, 200, cors);
|
|
44
62
|
}
|
|
45
63
|
if (!['GET', 'HEAD'].includes(method)) return reply({ error: 'Method not allowed' }, 405, { Allow: 'GET, HEAD' });
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a Next.js App Router website's public structure without running it:
|
|
3
|
+
* its static pages (path, title, description) and the literal links in its
|
|
4
|
+
* header and footer. The result seeds the CMS for settings-only websites and
|
|
5
|
+
* contains only public text already present in the website source.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import ts from './vendor/typescript.cjs';
|
|
9
|
+
import { projectRoot, readProjectFile } from './project-files.mjs';
|
|
10
|
+
import { inspectSourceRoutes } from './source-routes.mjs';
|
|
11
|
+
|
|
12
|
+
export const DISCOVERED_FILE = '.universalcms/discovered.json';
|
|
13
|
+
const MAX_PAGES = 100, MAX_LINKS = 20;
|
|
14
|
+
const entities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', nbsp: ' ', ndash: '–', mdash: '—', hellip: '…', copy: '©', reg: '®', trade: '™' };
|
|
15
|
+
const decode = value => value.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (match, name) => name[0] === '#' ? String.fromCodePoint(Number.parseInt(name.slice(name[1] === 'x' || name[1] === 'X' ? 2 : 1), name[1] === 'x' || name[1] === 'X' ? 16 : 10)) : entities[name.toLowerCase()] ?? match);
|
|
16
|
+
const clean = (value, max) => typeof value === 'string' ? decode(value).replace(/\s+/g, ' ').trim().slice(0, max) : '';
|
|
17
|
+
const parse = (filename, text) => ts.createSourceFile(filename, text, ts.ScriptTarget.Latest, true, /x$/.test(filename) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
18
|
+
// Same rule as CMS navigation: plain site paths (no query or fragment) or HTTPS URLs.
|
|
19
|
+
const publicUrl = value => typeof value === 'string' && value.length <= 200 && (/^\/(?!\/)[a-zA-Z0-9/_\-]*$/.test(value) || /^https:\/\/[^\s<>"'`]+$/.test(value)) && !/^\/(api|_next)(\/|$)/.test(value);
|
|
20
|
+
|
|
21
|
+
/** Literal string of a property in `export const metadata = { ... }` (title may be a string or { default }). */
|
|
22
|
+
function metadataOf(ast) {
|
|
23
|
+
for (const statement of ast.statements) {
|
|
24
|
+
if (!ts.isVariableStatement(statement) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
25
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
26
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== 'metadata' || !declaration.initializer || !ts.isObjectLiteralExpression(declaration.initializer)) continue;
|
|
27
|
+
const read = name => {
|
|
28
|
+
const property = declaration.initializer.properties.find(item => ts.isPropertyAssignment(item) && item.name.getText(ast) === name);
|
|
29
|
+
if (!property) return '';
|
|
30
|
+
const value = property.initializer;
|
|
31
|
+
if (ts.isStringLiteralLike(value)) return value.text;
|
|
32
|
+
if (ts.isObjectLiteralExpression(value)) { const fallback = value.properties.find(item => ts.isPropertyAssignment(item) && item.name.getText(ast) === 'default'); return fallback && ts.isStringLiteralLike(fallback.initializer) ? fallback.initializer.text : ''; }
|
|
33
|
+
return '';
|
|
34
|
+
};
|
|
35
|
+
return { title: clean(read('title'), 120), description: clean(read('description'), 320) };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { title: '', description: '' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Text written directly inside a JSX element (ignores expressions and icons). */
|
|
42
|
+
function literalText(node) {
|
|
43
|
+
let text = '';
|
|
44
|
+
(function visit(child) { if (ts.isJsxText(child)) text += ` ${child.text}`; else if (ts.isJsxExpression(child) && child.expression && ts.isStringLiteralLike(child.expression)) text += ` ${child.expression.text}`; else ts.forEachChild(child, visit); })(node);
|
|
45
|
+
return clean(text, 60);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function firstHeading(ast) {
|
|
49
|
+
let found = '';
|
|
50
|
+
(function visit(node) { if (found) return; if (ts.isJsxElement(node) && node.openingElement.tagName.getText(ast) === 'h1') { found = literalText(node); return; } ts.forEachChild(node, visit); })(ast);
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Literal <Link href> / <a href> elements, labelled by their nearest <header>/<footer> ancestor. */
|
|
55
|
+
function linksOf(ast, fallbackArea) {
|
|
56
|
+
const links = [];
|
|
57
|
+
(function visit(node, area) {
|
|
58
|
+
if (ts.isJsxElement(node)) {
|
|
59
|
+
const tag = node.openingElement.tagName.getText(ast);
|
|
60
|
+
if (tag === 'header' || tag === 'nav' && !area) area = 'header';
|
|
61
|
+
if (tag === 'footer') area = 'footer';
|
|
62
|
+
if (tag === 'Link' || tag === 'a') {
|
|
63
|
+
const href = node.openingElement.attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && attribute.name.getText(ast) === 'href');
|
|
64
|
+
const value = href && ts.isJsxAttribute(href) && href.initializer && (ts.isStringLiteral(href.initializer) ? href.initializer.text : ts.isJsxExpression(href.initializer) && href.initializer.expression && ts.isStringLiteralLike(href.initializer.expression) ? href.initializer.expression.text : null);
|
|
65
|
+
const label = literalText(node);
|
|
66
|
+
if (publicUrl(value) && label && (area ?? fallbackArea)) links.push({ area: area ?? fallbackArea, label, url: value });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
ts.forEachChild(node, child => visit(child, area));
|
|
71
|
+
})(ast, null);
|
|
72
|
+
return links;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Local components imported by the root layout (one level), with their likely area. */
|
|
76
|
+
function layoutComponents(ast, layoutFile, app) {
|
|
77
|
+
const result = [];
|
|
78
|
+
for (const statement of ast.statements) {
|
|
79
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.importClause?.name && !statement.importClause?.namedBindings) continue;
|
|
80
|
+
const specifier = statement.moduleSpecifier.text, name = statement.importClause.name?.text ?? specifier.split('/').at(-1);
|
|
81
|
+
const area = /footer/i.test(name) ? 'footer' : /header|nav|menu/i.test(name) ? 'header' : null;
|
|
82
|
+
if (!area) continue;
|
|
83
|
+
const base = specifier.startsWith('@/') ? `${app.startsWith('src/') ? 'src/' : ''}${specifier.slice(2)}` : specifier.startsWith('.') ? path.posix.normalize(path.posix.join(path.posix.dirname(layoutFile), specifier)) : null;
|
|
84
|
+
if (base) result.push({ base, area });
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readFirst(root, base) {
|
|
90
|
+
for (const candidate of [base, ...['.tsx', '.jsx', '.ts', '.js', '/index.tsx', '/index.jsx'].map(suffix => base + suffix)]) {
|
|
91
|
+
try { const text = await readProjectFile(root, candidate); if (text !== null) return { file: candidate, text }; } catch { /* Not this candidate. */ }
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const titleFromPath = route => route === '/' ? 'Home' : clean(route.split('/').filter(Boolean).at(-1).replace(/[-_]+/g, ' ').replace(/\b\w/g, letter => letter.toUpperCase()), 120);
|
|
97
|
+
|
|
98
|
+
export async function discoverSite(directory) {
|
|
99
|
+
const root = await projectRoot(directory), inventory = await inspectSourceRoutes(root);
|
|
100
|
+
const files = Object.entries(inventory.files);
|
|
101
|
+
const app = files[0]?.[1].startsWith('src/app/') ? 'src/app' : 'app';
|
|
102
|
+
const pages = [];
|
|
103
|
+
for (const [route, file] of files.slice(0, MAX_PAGES)) {
|
|
104
|
+
const pathname = route === 'home' || route === '' ? '/' : `/${route}`;
|
|
105
|
+
const text = await readProjectFile(root, file).catch(() => null);
|
|
106
|
+
const ast = text ? parse(file, text) : null;
|
|
107
|
+
const meta = ast ? metadataOf(ast) : { title: '', description: '' };
|
|
108
|
+
pages.push({ path: pathname, title: meta.title || (ast && firstHeading(ast)) || titleFromPath(pathname), description: meta.description });
|
|
109
|
+
}
|
|
110
|
+
pages.sort((a, b) => a.path.localeCompare(b.path));
|
|
111
|
+
const links = [];
|
|
112
|
+
for (const ext of ['tsx', 'jsx', 'js', 'ts']) {
|
|
113
|
+
const layoutFile = `${app}/layout.${ext}`, text = await readProjectFile(root, layoutFile).catch(() => null);
|
|
114
|
+
if (!text) continue;
|
|
115
|
+
const ast = parse(layoutFile, text);
|
|
116
|
+
links.push(...linksOf(ast, null));
|
|
117
|
+
for (const component of layoutComponents(ast, layoutFile, app)) {
|
|
118
|
+
const found = await readFirst(root, component.base);
|
|
119
|
+
if (found) links.push(...linksOf(parse(found.file, found.text), component.area));
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
const navigation = { header: [], footer: [] }, seen = { header: new Set(), footer: new Set() };
|
|
124
|
+
for (const link of links) {
|
|
125
|
+
const key = `${link.label}\n${link.url}`;
|
|
126
|
+
if (seen[link.area].has(key) || navigation[link.area].length >= MAX_LINKS) continue;
|
|
127
|
+
seen[link.area].add(key); navigation[link.area].push({ label: link.label, url: link.url });
|
|
128
|
+
}
|
|
129
|
+
return { version: 1, pages, navigation };
|
|
130
|
+
}
|