mlola-ui 1.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/bin/index.js +4 -0
  4. package/package.json +44 -0
  5. package/registry/catalog.json +97 -0
  6. package/registry/index.json +2938 -0
  7. package/registry/source/packages/components/_internal/anchor.ts +54 -0
  8. package/registry/source/packages/components/_internal/dialog.ts +142 -0
  9. package/registry/source/packages/components/_internal/floating.ts +73 -0
  10. package/registry/source/packages/components/_internal/match.ts +39 -0
  11. package/registry/source/packages/components/_internal/react.ts +40 -0
  12. package/registry/source/packages/components/_internal/scroll.ts +15 -0
  13. package/registry/source/packages/components/accordion/accordion.tsx +164 -0
  14. package/registry/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
  15. package/registry/source/packages/components/activity-heatmap/calendar.ts +92 -0
  16. package/registry/source/packages/components/alert/alert.tsx +83 -0
  17. package/registry/source/packages/components/app-shell/app-shell.tsx +186 -0
  18. package/registry/source/packages/components/avatar/avatar.tsx +56 -0
  19. package/registry/source/packages/components/badge/badge.tsx +39 -0
  20. package/registry/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
  21. package/registry/source/packages/components/button/button.tsx +42 -0
  22. package/registry/source/packages/components/calendar/calendar.tsx +309 -0
  23. package/registry/source/packages/components/calendar/dates.ts +163 -0
  24. package/registry/source/packages/components/card/card.tsx +39 -0
  25. package/registry/source/packages/components/carousel/carousel.tsx +248 -0
  26. package/registry/source/packages/components/checkbox/checkbox.tsx +66 -0
  27. package/registry/source/packages/components/color-picker/color-picker.tsx +261 -0
  28. package/registry/source/packages/components/color-picker/color.ts +124 -0
  29. package/registry/source/packages/components/combobox/combobox.tsx +320 -0
  30. package/registry/source/packages/components/command-menu/command-menu.tsx +199 -0
  31. package/registry/source/packages/components/context-menu/context-menu.tsx +192 -0
  32. package/registry/source/packages/components/date-picker/date-picker.tsx +185 -0
  33. package/registry/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
  34. package/registry/source/packages/components/dropzone/dropzone.tsx +185 -0
  35. package/registry/source/packages/components/empty-state/empty-state.tsx +32 -0
  36. package/registry/source/packages/components/hover-card/hover-card.tsx +103 -0
  37. package/registry/source/packages/components/input/input.tsx +101 -0
  38. package/registry/source/packages/components/kbd/kbd.tsx +54 -0
  39. package/registry/source/packages/components/modal/modal.tsx +100 -0
  40. package/registry/source/packages/components/number-input/number-input.tsx +184 -0
  41. package/registry/source/packages/components/number-input/number.ts +50 -0
  42. package/registry/source/packages/components/otp-input/otp-input.tsx +116 -0
  43. package/registry/source/packages/components/pagination/pagination.tsx +65 -0
  44. package/registry/source/packages/components/popover/popover.tsx +107 -0
  45. package/registry/source/packages/components/priority-icon/labels.ts +13 -0
  46. package/registry/source/packages/components/priority-icon/priority-icon.tsx +60 -0
  47. package/registry/source/packages/components/progress/progress.tsx +110 -0
  48. package/registry/source/packages/components/radio-group/radio-group.tsx +106 -0
  49. package/registry/source/packages/components/rating/rating.tsx +94 -0
  50. package/registry/source/packages/components/resizable/resizable.tsx +171 -0
  51. package/registry/source/packages/components/segmented-control/segmented-control.tsx +78 -0
  52. package/registry/source/packages/components/select/select.tsx +332 -0
  53. package/registry/source/packages/components/sheet/sheet.tsx +109 -0
  54. package/registry/source/packages/components/skeleton/skeleton.tsx +84 -0
  55. package/registry/source/packages/components/slider/slider.tsx +99 -0
  56. package/registry/source/packages/components/status-icon/labels.ts +14 -0
  57. package/registry/source/packages/components/status-icon/status-icon.tsx +76 -0
  58. package/registry/source/packages/components/stepper/stepper.tsx +87 -0
  59. package/registry/source/packages/components/table/table.tsx +66 -0
  60. package/registry/source/packages/components/tabs/tabs.tsx +163 -0
  61. package/registry/source/packages/components/tag-input/tag-input.tsx +273 -0
  62. package/registry/source/packages/components/textarea/textarea.tsx +70 -0
  63. package/registry/source/packages/components/time-picker/time-picker.tsx +286 -0
  64. package/registry/source/packages/components/time-picker/times.ts +76 -0
  65. package/registry/source/packages/components/timeline/timeline.tsx +115 -0
  66. package/registry/source/packages/components/toast/toast.tsx +317 -0
  67. package/registry/source/packages/components/toggle/toggle.tsx +80 -0
  68. package/registry/source/packages/components/tooltip/tooltip.tsx +70 -0
  69. package/registry/source/packages/components/tour/tour.tsx +193 -0
  70. package/registry/source/packages/components/virtual-list/virtual-list.tsx +121 -0
  71. package/registry/source/packages/components/virtual-list/virtual.ts +33 -0
  72. package/registry/source-manifest.json +67 -0
  73. package/src/cli.js +262 -0
  74. package/src/config.js +114 -0
  75. package/src/doctor.js +156 -0
  76. package/src/installer.js +131 -0
  77. package/src/pro.js +160 -0
  78. package/src/registry.js +91 -0
  79. package/src/theme-pull.js +91 -0
package/src/doctor.js ADDED
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { interpolateTarget, loadConfig, targetRoot } from "./config.js";
4
+ import { createTransformContext, expectedFile } from "./installer.js";
5
+ import { loadRegistry, readJson, sha256, sourcePath } from "./registry.js";
6
+
7
+ const FORBIDDEN_DEPENDENCIES = [
8
+ "clsx",
9
+ "tailwind-merge",
10
+ "tailwindcss",
11
+ "@tailwindcss/postcss",
12
+ "lucide-react",
13
+ ];
14
+
15
+ function finding(level, message) {
16
+ return { level, message };
17
+ }
18
+
19
+ function projectDependencies(cwd) {
20
+ const filename = path.join(cwd, "package.json");
21
+ if (!fs.existsSync(filename)) return {};
22
+ const manifest = readJson(filename);
23
+ return {
24
+ ...manifest.dependencies,
25
+ ...manifest.devDependencies,
26
+ ...manifest.peerDependencies,
27
+ };
28
+ }
29
+
30
+ function projectContains(cwd, pattern) {
31
+ const roots = ["app", "src", "pages"]
32
+ .map((directory) => path.join(cwd, directory))
33
+ .filter((directory) => fs.existsSync(directory));
34
+ let inspected = 0;
35
+ const queue = [...roots];
36
+ while (queue.length && inspected < 500) {
37
+ const current = queue.shift();
38
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
39
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
40
+ const filename = path.join(current, entry.name);
41
+ if (entry.isDirectory()) {
42
+ queue.push(filename);
43
+ } else if (/\.(?:tsx?|jsx?|html)$/.test(entry.name)) {
44
+ inspected += 1;
45
+ if (pattern.test(fs.readFileSync(filename, "utf8"))) return true;
46
+ }
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ export function runDoctor(cwd) {
53
+ const findings = [];
54
+ let config;
55
+ let registry;
56
+ try {
57
+ config = loadConfig(cwd);
58
+ findings.push(finding("pass", "mlola.config.json is valid"));
59
+ } catch (error) {
60
+ findings.push(finding("error", error.message));
61
+ return findings;
62
+ }
63
+ try {
64
+ registry = loadRegistry();
65
+ const seen = new Set();
66
+ for (const item of registry.items) {
67
+ for (const file of item.files) {
68
+ if (seen.has(file.path)) continue;
69
+ seen.add(file.path);
70
+ const filename = sourcePath(file.path);
71
+ if (!fs.existsSync(filename) || sha256(fs.readFileSync(filename)) !== file.integrity) {
72
+ throw new Error(`Integrity mismatch for ${file.path}`);
73
+ }
74
+ }
75
+ }
76
+ findings.push(finding("pass", `bundled Registry v2 snapshot is valid (${registry.items.length} items)`));
77
+ } catch (error) {
78
+ findings.push(finding("error", error.message));
79
+ return findings;
80
+ }
81
+
82
+ const dependencies = projectDependencies(cwd);
83
+ const forbidden = FORBIDDEN_DEPENDENCIES.filter((name) => dependencies[name]);
84
+ if (forbidden.length) {
85
+ findings.push(finding("warn", `forbidden styling dependencies declared: ${forbidden.join(", ")}`));
86
+ } else {
87
+ findings.push(finding("pass", "no forbidden styling dependencies are declared"));
88
+ }
89
+
90
+ const requiredEngines = new Set();
91
+ for (const item of registry.items) {
92
+ for (const name of Object.keys(item.engineDependencies)) requiredEngines.add(name);
93
+ }
94
+ const missingEngines = [...requiredEngines].filter((name) => !dependencies[name]);
95
+ if (missingEngines.length) {
96
+ findings.push(
97
+ finding("warn", `engine packages used by the registry are not declared: ${missingEngines.join(", ")}`),
98
+ );
99
+ } else if (requiredEngines.size) {
100
+ findings.push(finding("pass", "registry engine packages are declared"));
101
+ }
102
+ if (!dependencies.react) findings.push(finding("warn", "React is not declared in package.json"));
103
+ if (!dependencies["react-dom"]) findings.push(finding("warn", "ReactDOM is not declared in package.json"));
104
+
105
+ const styleFile = path.join(cwd, targetRoot(config, "styles"), "mlola", "index.css");
106
+ if (!fs.existsSync(styleFile)) {
107
+ findings.push(finding("warn", `missing ${path.relative(cwd, styleFile)}; rerun init`));
108
+ } else {
109
+ const css = fs.readFileSync(styleFile, "utf8");
110
+ if (!css.includes(config.engine.engine ?? "@mlola-ui/engine") && !css.includes(config.engine.tokens)) {
111
+ findings.push(finding("warn", "Mlola stylesheet does not import the configured engine"));
112
+ } else {
113
+ findings.push(finding("pass", "Mlola engine stylesheet import is present"));
114
+ }
115
+ }
116
+
117
+ if (projectContains(cwd, /\bdata-theme\s*=/)) {
118
+ findings.push(finding("pass", "data-theme is present in application source"));
119
+ } else if (projectContains(cwd, /\bdata-skin\s*=/)) {
120
+ findings.push(finding("warn", "data-skin is legacy; set data-theme on the document root"));
121
+ } else {
122
+ findings.push(
123
+ finding("warn", `set data-theme="${config.theme}" on <html> or an application container`),
124
+ );
125
+ }
126
+
127
+ const context = createTransformContext(registry, config);
128
+ let installedItems = 0;
129
+ let modifiedFiles = 0;
130
+ for (const item of registry.items) {
131
+ const primary = item.files.find((file) =>
132
+ file.path.endsWith(`/${item.name}/${item.name}.tsx`),
133
+ );
134
+ if (!primary) continue;
135
+ const primaryDestination = path.join(cwd, interpolateTarget(primary.target, config));
136
+ if (!fs.existsSync(primaryDestination)) continue;
137
+ installedItems += 1;
138
+ for (const file of item.files) {
139
+ const destination = path.join(cwd, interpolateTarget(file.target, config));
140
+ if (!fs.existsSync(destination) || fs.readFileSync(destination, "utf8") !== expectedFile(file, context)) {
141
+ modifiedFiles += 1;
142
+ }
143
+ }
144
+ }
145
+ if (modifiedFiles) {
146
+ findings.push(
147
+ finding(
148
+ "warn",
149
+ `${modifiedFiles} installed registry file(s) differ from the deterministic snapshot`,
150
+ ),
151
+ );
152
+ } else {
153
+ findings.push(finding("pass", `${installedItems} installed item(s) match the registry snapshot`));
154
+ }
155
+ return findings;
156
+ }
@@ -0,0 +1,131 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { interpolateTarget } from "./config.js";
4
+ import { sha256, sourcePath } from "./registry.js";
5
+
6
+ const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
7
+ const IMPORT_PATTERN =
8
+ /(?:import|export)\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g;
9
+
10
+ function importsFrom(content) {
11
+ return [...content.matchAll(IMPORT_PATTERN)].map((match) => match[1] ?? match[2]);
12
+ }
13
+
14
+ function sourceImportCandidates(fromSource, specifier) {
15
+ const unresolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromSource), specifier));
16
+ return [
17
+ unresolved,
18
+ ...SOURCE_EXTENSIONS.map((extension) => `${unresolved}${extension}`),
19
+ ...SOURCE_EXTENSIONS.map((extension) => `${unresolved}/index${extension}`),
20
+ ];
21
+ }
22
+
23
+ function withoutSourceExtension(value) {
24
+ const extension = SOURCE_EXTENSIONS.find((candidate) => value.endsWith(candidate));
25
+ const clean = extension ? value.slice(0, -extension.length) : value;
26
+ return clean.endsWith("/index") ? clean.slice(0, -"/index".length) : clean;
27
+ }
28
+
29
+ export function createTransformContext(registry, config) {
30
+ const filesBySource = new Map();
31
+ for (const item of registry.items) {
32
+ for (const file of item.files) filesBySource.set(file.path, file);
33
+ }
34
+ return { filesBySource, config };
35
+ }
36
+
37
+ export function transformSource(content, sourceFile, context) {
38
+ const replacements = new Map();
39
+ for (const specifier of importsFrom(content)) {
40
+ if (specifier.startsWith(".")) {
41
+ const importedSource = sourceImportCandidates(sourceFile.path, specifier).find((candidate) =>
42
+ context.filesBySource.has(candidate),
43
+ );
44
+ if (importedSource) {
45
+ const importedFile = context.filesBySource.get(importedSource);
46
+ replacements.set(
47
+ specifier,
48
+ withoutSourceExtension(interpolateTarget(importedFile.target, context.config, "import")),
49
+ );
50
+ }
51
+ continue;
52
+ }
53
+
54
+ if (specifier.startsWith("@mlola-ui/")) {
55
+ const [, packageName, ...subpath] = specifier.split("/");
56
+ const configured = context.config.engine?.[packageName];
57
+ if (configured) {
58
+ replacements.set(specifier, [configured, ...subpath].join("/"));
59
+ }
60
+ }
61
+ }
62
+
63
+ if (!replacements.size) return content;
64
+ return content.replace(/(["'])([^"']+)\1/g, (match, quote, specifier) => {
65
+ const replacement = replacements.get(specifier);
66
+ return replacement ? `${quote}${replacement}${quote}` : match;
67
+ });
68
+ }
69
+
70
+ export function expectedFile(file, context) {
71
+ // Pro files arrive with their content, already checked against their integrity.
72
+ if (typeof file.content === "string") {
73
+ if (sha256(file.content) !== file.integrity) throw new Error(`Pro source failed integrity validation: ${file.path}`);
74
+ return transformSource(file.content, file, context);
75
+ }
76
+ const bundledSource = sourcePath(file.path);
77
+ if (!fs.existsSync(bundledSource)) {
78
+ throw new Error(`Bundled source is missing for ${file.path}. Reinstall mlola-ui.`);
79
+ }
80
+ const original = fs.readFileSync(bundledSource, "utf8");
81
+ if (sha256(original) !== file.integrity) {
82
+ throw new Error(`Bundled source failed integrity validation: ${file.path}`);
83
+ }
84
+ return transformSource(original, file, context);
85
+ }
86
+
87
+ export function installItems(cwd, registry, items, config, { overwrite = false } = {}) {
88
+ const context = createTransformContext(registry, config);
89
+ const written = [];
90
+ const unchanged = [];
91
+ const conflicts = [];
92
+ const seenDestinations = new Set();
93
+
94
+ for (const item of items) {
95
+ for (const file of item.files) {
96
+ const relativeDestination = interpolateTarget(file.target, config);
97
+ const destination = path.resolve(cwd, relativeDestination);
98
+ if (destination !== cwd && !destination.startsWith(`${path.resolve(cwd)}${path.sep}`)) {
99
+ throw new Error(`Refusing to write outside the project: ${relativeDestination}`);
100
+ }
101
+ if (seenDestinations.has(destination)) continue;
102
+ seenDestinations.add(destination);
103
+
104
+ const content = expectedFile(file, context);
105
+ if (fs.existsSync(destination)) {
106
+ if (fs.readFileSync(destination, "utf8") === content) {
107
+ unchanged.push(relativeDestination);
108
+ continue;
109
+ }
110
+ if (!overwrite) {
111
+ conflicts.push(relativeDestination);
112
+ continue;
113
+ }
114
+ }
115
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
116
+ fs.writeFileSync(destination, content);
117
+ written.push(relativeDestination);
118
+ }
119
+ }
120
+ return { written, unchanged, conflicts };
121
+ }
122
+
123
+ export function collectEngineDependencies(items) {
124
+ const dependencies = new Map();
125
+ for (const item of items) {
126
+ for (const [name, range] of Object.entries(item.engineDependencies)) {
127
+ dependencies.set(name, range);
128
+ }
129
+ }
130
+ return dependencies;
131
+ }
package/src/pro.js ADDED
@@ -0,0 +1,160 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { targetRoot } from "./config.js";
5
+ import { sha256 } from "./registry.js";
6
+
7
+ /**
8
+ * Mlola Pro in the CLI.
9
+ *
10
+ * Pro source is never bundled. `mlola-ui login <token>` stores a token made at
11
+ * /account (readable by the current user only); `add` sends it to the Pro
12
+ * endpoint, which returns the items and their Pro dependencies stamped with
13
+ * the licence. In CI, `MLOLA_PRO_TOKEN` takes the place of a login.
14
+ */
15
+
16
+ export const DEFAULT_HOST = "https://ui.mlola.com";
17
+ const TOKEN_PATTERN = /^mlp_[0-9a-z]{40}$/;
18
+
19
+ export function hostFrom(env, explicit) {
20
+ return (explicit ?? env.MLOLA_STUDIO_URL ?? DEFAULT_HOST).replace(/\/$/, "");
21
+ }
22
+
23
+ export function credentialsPath(env) {
24
+ const base = env.MLOLA_CONFIG_DIR ?? path.join(env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "mlola-ui");
25
+ return path.join(base, "credentials.json");
26
+ }
27
+
28
+ export function readCredentials(env) {
29
+ if (env.MLOLA_PRO_TOKEN) return { token: env.MLOLA_PRO_TOKEN.trim(), host: undefined, from: "MLOLA_PRO_TOKEN" };
30
+ const filename = credentialsPath(env);
31
+ if (!fs.existsSync(filename)) return null;
32
+ try {
33
+ const saved = JSON.parse(fs.readFileSync(filename, "utf8"));
34
+ return typeof saved.token === "string" ? { token: saved.token, host: saved.host, from: filename } : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export function saveCredentials(env, token, host) {
41
+ const filename = credentialsPath(env);
42
+ fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
43
+ fs.writeFileSync(filename, `${JSON.stringify({ token, host }, null, 2)}\n`, { mode: 0o600 });
44
+ fs.chmodSync(filename, 0o600);
45
+ return filename;
46
+ }
47
+
48
+ export function clearCredentials(env) {
49
+ const filename = credentialsPath(env);
50
+ if (!fs.existsSync(filename)) return false;
51
+ fs.rmSync(filename);
52
+ return true;
53
+ }
54
+
55
+ async function request(fetcher, url, token, init = {}) {
56
+ let response;
57
+ try {
58
+ response = await fetcher(url, { ...init, headers: { ...init.headers, authorization: `Bearer ${token}` } });
59
+ } catch (error) {
60
+ throw new Error(`Could not reach ${new URL(url).origin}: ${error.message}`);
61
+ }
62
+ const payload = await response.json().catch(() => ({}));
63
+ if (!response.ok) throw new Error(payload.error ?? `The Pro service answered ${response.status}.`);
64
+ return payload;
65
+ }
66
+
67
+ /** Checks a token with the service before it is saved, so a typo fails at login rather than at add. */
68
+ export async function verifyToken(host, token, fetcher) {
69
+ if (!TOKEN_PATTERN.test(token)) throw new Error("That is not a Mlola Pro token. Tokens start with mlp_; create one at /account.");
70
+ return request(fetcher, `${host}/api/pro/licence`, token);
71
+ }
72
+
73
+ /**
74
+ * Fetches Pro items, then checks every file and stylesheet against the
75
+ * integrity the service sent with it.
76
+ */
77
+ export async function fetchProItems(host, token, names, fetcher) {
78
+ const payload = await request(fetcher, `${host}/api/pro/items`, token, {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify({ names }),
82
+ });
83
+ if (!Array.isArray(payload.items)) throw new Error("The Pro service sent an unexpected response.");
84
+ for (const item of payload.items) {
85
+ for (const file of item.files ?? []) {
86
+ if (typeof file.content !== "string" || sha256(file.content) !== file.integrity) {
87
+ throw new Error(`Pro source failed integrity validation: ${file.path}`);
88
+ }
89
+ }
90
+ }
91
+ const styles = Array.isArray(payload.styles) ? payload.styles : [];
92
+ for (const style of styles) {
93
+ if (!/^[a-z][a-z0-9-]*$/.test(style.name) || sha256(style.css) !== style.integrity) {
94
+ throw new Error(`Pro stylesheet failed integrity validation: ${style.name}`);
95
+ }
96
+ }
97
+ const guide = payload.guide && typeof payload.guide.content === "string" ? payload.guide : null;
98
+ if (guide && sha256(guide.content) !== guide.integrity) throw new Error("The Pro agent guide failed integrity validation.");
99
+ return { items: payload.items, styles, guide };
100
+ }
101
+
102
+ export const GUIDE_FILENAME = "mlola-pro.agents.md";
103
+
104
+ /** Pro's guide for coding agents lands at the project root, beside AGENTS.md. */
105
+ export function writeProGuide(cwd, guide, { overwrite = false } = {}) {
106
+ if (!guide) return { status: "none" };
107
+ const filename = path.join(cwd, GUIDE_FILENAME);
108
+ if (fs.existsSync(filename)) {
109
+ const current = fs.readFileSync(filename, "utf8");
110
+ if (current === guide.content) return { status: "unchanged" };
111
+ if (!overwrite) return { status: "conflict" };
112
+ }
113
+ fs.writeFileSync(filename, guide.content);
114
+ return { status: "written" };
115
+ }
116
+
117
+ /**
118
+ * Pro stylesheets land in <styles>/mlola-pro/<name>.css, gathered by
119
+ * <styles>/mlola-pro.css, which the project imports once after the engine.
120
+ */
121
+ export function writeProStyles(cwd, config, styles, { overwrite = false } = {}) {
122
+ const root = targetRoot(config, "styles");
123
+ const written = [];
124
+ const unchanged = [];
125
+ const conflicts = [];
126
+ for (const style of styles) {
127
+ const relative = path.join(root, "mlola-pro", `${style.name}.css`);
128
+ const filename = path.join(cwd, relative);
129
+ if (fs.existsSync(filename)) {
130
+ if (fs.readFileSync(filename, "utf8") === style.css) {
131
+ unchanged.push(relative);
132
+ continue;
133
+ }
134
+ if (!overwrite) {
135
+ conflicts.push(relative);
136
+ continue;
137
+ }
138
+ }
139
+ fs.mkdirSync(path.dirname(filename), { recursive: true });
140
+ fs.writeFileSync(filename, style.css);
141
+ written.push(relative);
142
+ }
143
+
144
+ const indexRelative = path.join(root, "mlola-pro.css");
145
+ const indexFile = path.join(cwd, indexRelative);
146
+ let index = fs.existsSync(indexFile) ? fs.readFileSync(indexFile, "utf8") : "/* Mlola Pro styles. Import this file once, after the engine stylesheet. */\n";
147
+ let changed = false;
148
+ for (const style of styles) {
149
+ const line = `@import "./mlola-pro/${style.name}.css";`;
150
+ if (!index.includes(line)) {
151
+ index += `${line}\n`;
152
+ changed = true;
153
+ }
154
+ }
155
+ if (changed) {
156
+ fs.mkdirSync(path.dirname(indexFile), { recursive: true });
157
+ fs.writeFileSync(indexFile, index);
158
+ }
159
+ return { written, unchanged, conflicts, index: styles.length ? indexRelative : null };
160
+ }
@@ -0,0 +1,91 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ export const BUNDLED_REGISTRY_ROOT = path.join(CLI_ROOT, "registry");
8
+
9
+ export function sha256(content) {
10
+ return `sha256-${createHash("sha256").update(content).digest("base64")}`;
11
+ }
12
+
13
+ export function readJson(filename) {
14
+ return JSON.parse(fs.readFileSync(filename, "utf8"));
15
+ }
16
+
17
+ export function loadRegistry() {
18
+ const filename = path.join(BUNDLED_REGISTRY_ROOT, "index.json");
19
+ if (!fs.existsSync(filename)) {
20
+ throw new Error(
21
+ `Bundled registry is missing at ${filename}. Reinstall mlola-ui; this package is incomplete.`,
22
+ );
23
+ }
24
+ const registry = readJson(filename);
25
+ if (registry.schemaVersion !== 2 || !Array.isArray(registry.items)) {
26
+ throw new Error("Bundled registry is not a valid Registry v2 snapshot.");
27
+ }
28
+ return registry;
29
+ }
30
+
31
+ /** Names in the commercial catalog, so the CLI can say so instead of "unknown". */
32
+ export function loadCatalogNames() {
33
+ const filename = path.join(BUNDLED_REGISTRY_ROOT, "catalog.json");
34
+ if (!fs.existsSync(filename)) return new Set();
35
+ const catalog = readJson(filename);
36
+ return new Set([
37
+ ...(catalog.components ?? []),
38
+ ...(catalog.blocks ?? []),
39
+ ...(catalog.pages ?? []),
40
+ ...(catalog.templates ?? []),
41
+ ]);
42
+ }
43
+
44
+ export function sourcePath(relativePath) {
45
+ const filename = path.resolve(BUNDLED_REGISTRY_ROOT, "source", relativePath);
46
+ const sourceRoot = path.resolve(BUNDLED_REGISTRY_ROOT, "source");
47
+ if (filename !== sourceRoot && !filename.startsWith(`${sourceRoot}${path.sep}`)) {
48
+ throw new Error(`Unsafe registry source path: ${relativePath}`);
49
+ }
50
+ return filename;
51
+ }
52
+
53
+ export function itemMap(registry) {
54
+ return new Map(registry.items.map((item) => [item.name, item]));
55
+ }
56
+
57
+ export function resolveItems(registry, requestedNames, catalogNames = loadCatalogNames()) {
58
+ const byName = itemMap(registry);
59
+ const ordered = [];
60
+ const visited = new Set();
61
+ const visiting = new Set();
62
+
63
+ function visit(name, chain = []) {
64
+ const item = byName.get(name);
65
+ if (!item) {
66
+ if (catalogNames.has(name)) {
67
+ throw new Error(
68
+ `"${name}" is part of Mlola Pro, not the free registry. ` +
69
+ `See https://ui.mlola.com/pricing to get Pro.`,
70
+ );
71
+ }
72
+ const suggestions = [...byName.keys()]
73
+ .filter((candidate) => candidate.includes(name) || name.includes(candidate))
74
+ .slice(0, 4);
75
+ const hint = suggestions.length ? ` Did you mean ${suggestions.join(", ")}?` : "";
76
+ throw new Error(`Registry item "${name}" does not exist.${hint}`);
77
+ }
78
+ if (visiting.has(name)) {
79
+ throw new Error(`Registry dependency cycle: ${[...chain, name].join(" -> ")}`);
80
+ }
81
+ if (visited.has(name)) return;
82
+ visiting.add(name);
83
+ for (const dependency of item.registryDependencies) visit(dependency, [...chain, name]);
84
+ visiting.delete(name);
85
+ visited.add(name);
86
+ ordered.push(item);
87
+ }
88
+
89
+ for (const name of requestedNames) visit(name);
90
+ return ordered;
91
+ }
@@ -0,0 +1,91 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { DEFAULT_CONFIG, loadConfig, targetRoot } from "./config.js";
4
+
5
+ /**
6
+ * `mlola-ui theme pull <id | url>`: copy a Studio theme into the project.
7
+ *
8
+ * Writes the spec to mlola.theme.json (the record, and what the engine build
9
+ * reads) and the rendered stylesheet to <styles>/mlola/theme.css, imported
10
+ * from index.css. The CSS is fetched pinned to the exact version the spec
11
+ * came from, so the two files always agree.
12
+ */
13
+
14
+ export const DEFAULT_STUDIO_URL = "https://ui.mlola.com";
15
+ const THEME_ID = /^th-[0-9a-z]{12}$/;
16
+
17
+ /** Accepts `th-…`, `th-…@3`, or a hosted URL such as https://host/t/th-….css. */
18
+ export function parseThemeReference(reference, host = DEFAULT_STUDIO_URL) {
19
+ if (!reference) throw new Error("Name the theme to pull: mlola-ui theme pull <theme-id | url>");
20
+ let base = host;
21
+ let token = reference;
22
+ if (/^https?:\/\//.test(reference)) {
23
+ const url = new URL(reference);
24
+ const match = /\/t\/([^/]+)$/.exec(url.pathname);
25
+ if (!match) throw new Error(`Not a hosted theme URL: ${reference}`);
26
+ base = url.origin;
27
+ token = match[1];
28
+ }
29
+ const match = /^(th-[0-9a-z]{12})(?:@([1-9]\d*))?(?:\.(?:css|json))?$/.exec(token);
30
+ if (!match || !THEME_ID.test(match[1])) throw new Error(`Not a theme id: ${token}`);
31
+ return { base: base.replace(/\/$/, ""), id: match[1], version: match[2] ? Number(match[2]) : undefined };
32
+ }
33
+
34
+ async function fetchText(url, fetcher) {
35
+ let response;
36
+ try {
37
+ response = await fetcher(url);
38
+ } catch (error) {
39
+ throw new Error(`Could not reach ${new URL(url).origin}: ${error.message}`);
40
+ }
41
+ if (response.status === 404) {
42
+ throw new Error("No shared theme at that id. Private themes cannot be pulled; set it to Link or Public in the Studio.");
43
+ }
44
+ if (!response.ok) throw new Error(`${url} answered ${response.status}.`);
45
+ return response.text();
46
+ }
47
+
48
+ function ensureImport(indexFile, line) {
49
+ const current = fs.existsSync(indexFile) ? fs.readFileSync(indexFile, "utf8") : "";
50
+ if (current.includes(line)) return false;
51
+ fs.mkdirSync(path.dirname(indexFile), { recursive: true });
52
+ fs.writeFileSync(indexFile, `${current}${current && !current.endsWith("\n") ? "\n" : ""}${line}\n`);
53
+ return true;
54
+ }
55
+
56
+ export async function pullTheme(cwd, reference, { host, overwrite = false, fetcher = fetch, output = console } = {}) {
57
+ const { base, id, version } = parseThemeReference(reference, host);
58
+ const pinned = (extension, at) => `${base}/t/${id}@${at}.${extension}`;
59
+
60
+ const record = JSON.parse(await fetchText(version ? pinned("json", version) : `${base}/t/${id}.json`, fetcher));
61
+ if (!record?.spec || typeof record.version !== "number") throw new Error("The server returned an unexpected theme record.");
62
+ const css = await fetchText(pinned("css", record.version), fetcher);
63
+
64
+ const themeFile = path.join(cwd, "mlola.theme.json");
65
+ if (fs.existsSync(themeFile) && !overwrite) {
66
+ const existing = JSON.parse(fs.readFileSync(themeFile, "utf8"));
67
+ if (existing?.studio?.id !== id) {
68
+ throw new Error("mlola.theme.json already holds another theme. Re-run with --overwrite to replace it.");
69
+ }
70
+ }
71
+ const spec = {
72
+ $schema: "https://unpkg.com/@mlola-ui/engine/generated/theme-spec.schema.json",
73
+ ...record.spec,
74
+ id: record.selector,
75
+ studio: { id, version: record.version, source: pinned("json", record.version) },
76
+ };
77
+ fs.writeFileSync(themeFile, `${JSON.stringify(spec, null, 2)}\n`);
78
+
79
+ const config = fs.existsSync(path.join(cwd, "mlola.config.json")) ? loadConfig(cwd) : DEFAULT_CONFIG;
80
+ const stylesDirectory = path.join(cwd, targetRoot(config, "styles"), "mlola");
81
+ fs.mkdirSync(stylesDirectory, { recursive: true });
82
+ fs.writeFileSync(path.join(stylesDirectory, "theme.css"), css);
83
+ const imported = ensureImport(path.join(stylesDirectory, "index.css"), '@import "./theme.css";');
84
+
85
+ const stylesRelative = path.relative(cwd, stylesDirectory);
86
+ output.log(`✓ Pulled ${record.name} (${id}@${record.version})`);
87
+ output.log(`✓ Wrote mlola.theme.json and ${path.join(stylesRelative, "theme.css")}`);
88
+ if (imported) output.log(`✓ Imported it from ${path.join(stylesRelative, "index.css")}`);
89
+ output.log(`Set data-theme="${record.selector}" on <html> or any container.`);
90
+ return { id, version: record.version, selector: record.selector };
91
+ }