Initial commit

This commit is contained in:
Andres Santana
2026-09-25 09:37:15 -04:00
commit 324464e9de
115 changed files with 22403 additions and 0 deletions
@@ -0,0 +1,323 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import traverseBabel from '@babel/traverse';
import * as t from '@babel/types';
const ECOMMERCE_API_RELATIVE_PATH = '../../src/api/EcommerceApi.js';
const HOME_ROUTE_LABEL = 'Main';
const PRODUCT_PAGE_FETCH_LIMIT = 100;
const MAX_PRODUCT_PAGES = 50;
/**
* Resolves the tag name of a JSX element, unwrapping member expressions (e.g. `Foo.Bar` → `Bar`).
*
* @param {import('@babel/types').JSXOpeningElement['name']} nameNode
* @returns {string|null}
*/
function getJsxElementName(nameNode) {
if (t.isJSXIdentifier(nameNode)) {
return nameNode.name;
}
if (t.isJSXMemberExpression(nameNode) && t.isJSXIdentifier(nameNode.property)) {
return nameNode.property.name;
}
return null;
}
/**
* Reads a string literal from an attribute value, including one wrapped in a JSX expression container.
*
* @param {import('@babel/types').Node|null|undefined} value
* @returns {string|null}
*/
function getStringLiteral(value) {
if (!value) return null;
if (t.isStringLiteral(value)) return value.value;
if (t.isJSXExpressionContainer(value) && t.isStringLiteral(value.expression)) {
return value.expression.value;
}
return null;
}
/**
* Reads a JSX element from an attribute value, including one wrapped in a JSX expression container.
*
* @param {import('@babel/types').Node|null|undefined} value
* @returns {import('@babel/types').JSXElement|null}
*/
function getJsxElement(value) {
if (t.isJSXElement(value)) return value;
if (t.isJSXExpressionContainer(value) && t.isJSXElement(value.expression)) {
return value.expression;
}
return null;
}
/**
* Extracts the `path` and element component name from a `<Route>` opening element.
*
* @param {import('@babel/types').JSXOpeningElement} openingNode
* @returns {{ routePath: string, componentName: string }|null}
*/
function parseRoute(openingNode) {
if (getJsxElementName(openingNode.name) !== 'Route') return null;
const pathAttr = openingNode.attributes.find((a) => t.isJSXAttribute(a) && a.name?.name === 'path');
const elementAttr = openingNode.attributes.find((a) => t.isJSXAttribute(a) && a.name?.name === 'element');
const routePath = getStringLiteral(pathAttr?.value);
if (routePath === null) return null;
const element = getJsxElement(elementAttr?.value);
const componentName = element && getJsxElementName(element.openingElement.name);
if (!componentName) return null;
return { routePath, componentName };
}
/**
* Maps default import local names to their module source for one import declaration.
*
* @param {import('@babel/types').ImportDeclaration} importNode
* @returns {Record<string, string>}
*/
function parseImports(importNode) {
const source = importNode.source.value;
return Object.fromEntries(
importNode.specifiers
.filter(t.isImportDefaultSpecifier)
.map((specifier) => [specifier.local.name, source]),
);
}
/**
* Reads the text of the `<title>` inside a `<Helmet>` element, if present.
*
* @param {import('@babel/types').JSXElement} jsxElement
* @returns {string|null}
*/
function parseHelmetTitle(jsxElement) {
if (getJsxElementName(jsxElement.openingElement.name) !== 'Helmet') return null;
for (const child of jsxElement.children) {
if (!t.isJSXElement(child) || getJsxElementName(child.openingElement.name) !== 'title') continue;
const titleChild = child.children[0];
if (t.isJSXText(titleChild)) return titleChild.value.trim();
if (t.isJSXExpressionContainer(titleChild) && t.isStringLiteral(titleChild.expression)) {
return titleChild.expression.value;
}
}
return null;
}
/**
* Scans one file's AST. Router files yield routes + imports; page files yield helmetTitle.
*
* @param {import('@babel/types').File} ast
* @returns {{ routes: Array<{ routePath: string, componentName: string }>, imports: Record<string, string>, helmetTitle: string|null, usesEcommerceApi: boolean }}
*/
export function scanFile(ast) {
const routes = [];
const imports = {};
let helmetTitle = null;
let usesEcommerceApi = false;
(traverseBabel.default)(ast, {
enter(nodePath) {
if (nodePath.isJSXOpeningElement()) {
const route = parseRoute(nodePath.node);
if (route) routes.push(route);
return;
}
if (nodePath.isImportDeclaration()) {
Object.assign(imports, parseImports(nodePath.node));
if (nodePath.node.source.value.includes('EcommerceApi')) {
usesEcommerceApi = true;
}
return;
}
if (nodePath.isJSXElement()) {
const title = parseHelmetTitle(nodePath.node);
if (title) helmetTitle = title;
}
},
});
return { routes, imports, helmetTitle, usesEcommerceApi };
}
/**
* Merges one file's scan result into the running index, replacing any previous
* entries contributed by that same file.
*
* @param {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }} index
* @param {string} fileId
* @param {{ routes: Array<{ routePath: string, componentName: string }>, imports: Record<string, string>, helmetTitle: string|null, usesEcommerceApi: boolean }} scan
* @returns {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }}
*/
export function upsertFile(index, fileId, scan) {
const routerEntries = [
...index.routerEntries.filter((entry) => entry.routerFileId !== fileId),
...scan.routes.map(({ routePath, componentName }) => ({
routePath,
componentName,
routerFileId: fileId,
imports: scan.imports,
})),
];
const titlesByPageFile = Object.fromEntries(
Object.entries(index.titlesByPageFile).filter(([key]) => key !== fileId),
);
if (scan.helmetTitle) {
titlesByPageFile[fileId] = scan.helmetTitle;
}
const ecommercePageFiles = Object.fromEntries(
Object.entries(index.ecommercePageFiles).filter(([key]) => key !== fileId),
);
if (scan.usesEcommerceApi) {
ecommercePageFiles[fileId] = true;
}
return { routerEntries, titlesByPageFile, ecommercePageFiles };
}
/**
* Chooses a display label for a route: the home label for `/`, otherwise the
* helmet title when available, falling back to the component name.
*
* @param {string} routePath
* @param {string} componentName
* @param {string|null} helmetTitle
* @returns {string}
*/
export function resolvePageLabel(routePath, componentName, helmetTitle) {
if (routePath === '/') return HOME_ROUTE_LABEL;
return helmetTitle ?? componentName;
}
/**
* Substitutes the first dynamic segment of a route with a concrete value.
* The param name is derived from the route (e.g. ":slug", ":productId"), not
* assumed to be ":id", since route params are authored by generated code.
*
* @param {string} routePath
* @param {string} productId
* @returns {string}
*/
export function buildProductPath(routePath, productId) {
const paramToken = routePath.match(/:[^/]+/)?.[0];
return paramToken ? routePath.replace(paramToken, productId) : routePath;
}
/**
* Expands a dynamic product route into one concrete page entry per product.
*
* @param {Array<{ id: string, title: string }>} products
* @param {string} routePath
* @param {string} componentName
* @returns {Array<{ path: string, label: string, helmetTitle: string, component: string, dynamic: true }>}
*/
export function mapProductsToPages(products, routePath, componentName) {
return products.map((product) => ({
path: buildProductPath(routePath, product.id),
label: product.title,
helmetTitle: product.title,
component: componentName,
dynamic: true,
}));
}
/**
* Fetches every product by paging the store API until a short page is returned,
* bounded by MAX_PRODUCT_PAGES. EcommerceApi is imported lazily so non-ecommerce
* sites never resolve a module that does not exist for them.
*
* Import via absolute file URL: after Vite bundles this plugin into
* vite.config.js.timestamp-*.mjs, relative imports resolve against the bundle
* path instead of the original plugin location.
*
* @param {string} ecommerceApiPath Absolute filesystem path to EcommerceApi.js
* @returns {Promise<Array<{ id: string, title: string }>>}
*/
async function fetchAllProducts(ecommerceApiPath) {
const { getProducts } = await import(pathToFileURL(ecommerceApiPath).href);
const collected = [];
for (let page = 0; page < MAX_PRODUCT_PAGES; page++) {
const { products = [] } = await getProducts({
limit: PRODUCT_PAGE_FETCH_LIMIT,
offset: page * PRODUCT_PAGE_FETCH_LIMIT,
});
collected.push(...products);
if (products.length < PRODUCT_PAGE_FETCH_LIMIT) {
break;
}
}
return collected;
}
/**
* Builds the flat list of site pages from the scanned index, resolving each route's
* page module and expanding ecommerce product routes. Returns only routes actually
* detected by the scan (no synthetic home fallback).
*
* @param {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }} index
* @param {(specifier: string, importer: string) => Promise<{ id: string }|null>} resolveModule
* @param {string} projectRoot
* @returns {Promise<Array<{ path: string, label: string, helmetTitle: string|null, component: string|null, file?: string|null, dynamic?: true }>>}
*/
export async function buildPageList(index, resolveModule, projectRoot) {
const groups = await Promise.all(
index.routerEntries.map(async ({ routePath, componentName, routerFileId, imports }) => {
const moduleSpecifier = imports[componentName];
const resolved = moduleSpecifier
? await resolveModule(moduleSpecifier, routerFileId)
: null;
const pageFileId = resolved?.id ?? null;
if (routePath.includes(':')) {
// Only ecommerce product routes are expanded into concrete pages.
// Other dynamic routes (e.g. blog/CMS detail pages) are intentionally
// skipped since we have no data source to enumerate their instances.
if (!pageFileId || !index.ecommercePageFiles[pageFileId]) {
return [];
}
const ecommerceApiPath = fileURLToPath(new URL(ECOMMERCE_API_RELATIVE_PATH, import.meta.url));
if (!existsSync(ecommerceApiPath)) {
return [];
}
try {
const products = await fetchAllProducts(ecommerceApiPath);
return mapProductsToPages(products, routePath, componentName);
} catch {
return [];
}
}
const helmetTitle = pageFileId ? (index.titlesByPageFile[pageFileId] ?? null) : null;
return [{
path: routePath,
label: resolvePageLabel(routePath, componentName, helmetTitle),
helmetTitle,
component: componentName,
file: pageFileId
? path.relative(projectRoot, pageFileId).split(path.sep).join('/')
: null,
}];
}),
);
return groups.flat();
}