@texuli/tools 0.0.0-stage → 0.1.0-next.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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -2
  3. package/dist/a2ui/from-a2ui.d.ts +35 -0
  4. package/dist/a2ui/from-a2ui.js +180 -0
  5. package/dist/a2ui/index.d.ts +11 -0
  6. package/dist/a2ui/index.js +11 -0
  7. package/dist/a2ui/props.d.ts +10 -0
  8. package/dist/a2ui/props.js +94 -0
  9. package/dist/a2ui/schema.d.ts +26 -0
  10. package/dist/a2ui/schema.js +193 -0
  11. package/dist/a2ui/to-a2ui.d.ts +30 -0
  12. package/dist/a2ui/to-a2ui.js +119 -0
  13. package/dist/a2ui/types.d.ts +71 -0
  14. package/dist/a2ui/types.js +11 -0
  15. package/dist/cli-add/add.d.ts +21 -0
  16. package/dist/cli-add/add.js +241 -0
  17. package/dist/cli-add/cli.d.ts +25 -0
  18. package/dist/cli-add/cli.js +157 -0
  19. package/dist/cli-add/index.d.ts +12 -0
  20. package/dist/cli-add/index.js +11 -0
  21. package/dist/cli-add/paths.d.ts +4 -0
  22. package/dist/cli-add/paths.js +4 -0
  23. package/dist/cli-add/provenance.d.ts +32 -0
  24. package/dist/cli-add/provenance.js +78 -0
  25. package/dist/cli-add/registry.d.ts +19 -0
  26. package/dist/cli-add/registry.js +50 -0
  27. package/dist/cli-add/types.d.ts +71 -0
  28. package/dist/cli-add/types.js +4 -0
  29. package/dist/cli-init/cli.d.ts +25 -0
  30. package/dist/cli-init/cli.js +212 -0
  31. package/dist/cli-init/config.d.ts +10 -0
  32. package/dist/cli-init/config.js +62 -0
  33. package/dist/cli-init/detect.d.ts +13 -0
  34. package/dist/cli-init/detect.js +42 -0
  35. package/dist/cli-init/files.d.ts +18 -0
  36. package/dist/cli-init/files.js +59 -0
  37. package/dist/cli-init/index.d.ts +14 -0
  38. package/dist/cli-init/index.js +13 -0
  39. package/dist/cli-init/init.d.ts +14 -0
  40. package/dist/cli-init/init.js +157 -0
  41. package/dist/cli-init/safe-path.d.ts +2 -0
  42. package/dist/cli-init/safe-path.js +26 -0
  43. package/dist/cli-init/theme-apply.d.ts +2 -0
  44. package/dist/cli-init/theme-apply.js +69 -0
  45. package/dist/cli-init/themes.d.ts +37 -0
  46. package/dist/cli-init/themes.js +83 -0
  47. package/dist/cli-init/types.d.ts +50 -0
  48. package/dist/cli-init/types.js +1 -0
  49. package/dist/cli-init/wire.d.ts +20 -0
  50. package/dist/cli-init/wire.js +90 -0
  51. package/dist/cli-update/cli.d.ts +26 -0
  52. package/dist/cli-update/cli.js +161 -0
  53. package/dist/cli-update/index.d.ts +11 -0
  54. package/dist/cli-update/index.js +10 -0
  55. package/dist/cli-update/lines.d.ts +38 -0
  56. package/dist/cli-update/lines.js +189 -0
  57. package/dist/cli-update/provenance.d.ts +14 -0
  58. package/dist/cli-update/provenance.js +51 -0
  59. package/dist/cli-update/types.d.ts +83 -0
  60. package/dist/cli-update/types.js +1 -0
  61. package/dist/cli-update/update.d.ts +12 -0
  62. package/dist/cli-update/update.js +261 -0
  63. package/dist/elements/content.d.ts +15 -0
  64. package/dist/elements/content.js +53 -0
  65. package/dist/elements/define.d.ts +49 -0
  66. package/dist/elements/define.js +281 -0
  67. package/dist/elements/index.d.ts +6 -0
  68. package/dist/elements/index.js +6 -0
  69. package/dist/golden/axe.d.ts +32 -0
  70. package/dist/golden/axe.js +22 -0
  71. package/dist/golden/catalog.d.ts +24 -0
  72. package/dist/golden/catalog.js +85 -0
  73. package/dist/golden/cli.d.ts +9 -0
  74. package/dist/golden/cli.js +45 -0
  75. package/dist/golden/guidance.d.ts +12 -0
  76. package/dist/golden/guidance.js +171 -0
  77. package/dist/golden/index.d.ts +17 -0
  78. package/dist/golden/index.js +17 -0
  79. package/dist/golden/outline.d.ts +21 -0
  80. package/dist/golden/outline.js +68 -0
  81. package/dist/golden/paint.d.ts +21 -0
  82. package/dist/golden/paint.js +103 -0
  83. package/dist/golden/report.d.ts +28 -0
  84. package/dist/golden/report.js +64 -0
  85. package/dist/golden/score.d.ts +16 -0
  86. package/dist/golden/score.js +116 -0
  87. package/dist/golden/sinks.d.ts +8 -0
  88. package/dist/golden/sinks.js +11 -0
  89. package/dist/golden/tasks.d.ts +4 -0
  90. package/dist/golden/tasks.js +686 -0
  91. package/dist/golden/types.d.ts +54 -0
  92. package/dist/golden/types.js +9 -0
  93. package/dist/index.d.ts +8 -0
  94. package/dist/index.js +8 -0
  95. package/dist/internal/paths.d.ts +56 -0
  96. package/dist/internal/paths.js +186 -0
  97. package/dist/llms/bundle.d.ts +6 -0
  98. package/dist/llms/bundle.js +47 -0
  99. package/dist/llms/cli.d.ts +24 -0
  100. package/dist/llms/cli.js +104 -0
  101. package/dist/llms/format.d.ts +35 -0
  102. package/dist/llms/format.js +166 -0
  103. package/dist/llms/index.d.ts +9 -0
  104. package/dist/llms/index.js +8 -0
  105. package/dist/llms/types.d.ts +51 -0
  106. package/dist/llms/types.js +1 -0
  107. package/dist/llms/write.d.ts +14 -0
  108. package/dist/llms/write.js +49 -0
  109. package/dist/mcp/index.d.ts +2 -0
  110. package/dist/mcp/index.js +2 -0
  111. package/dist/mcp/server.d.ts +20 -0
  112. package/dist/mcp/server.js +326 -0
  113. package/dist/mcp/stdio.d.ts +3 -0
  114. package/dist/mcp/stdio.js +34 -0
  115. package/package.json +63 -4
@@ -0,0 +1,59 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * Contained reads and writes for `texuli init` and `texuli theme`: every
4
+ * destination resolves inside the project root and no symlink may sit on its
5
+ * path (even with `--force`).
6
+ */
7
+ import { existsSync, lstatSync, realpathSync } from 'node:fs';
8
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
9
+ import { dirname } from 'node:path';
10
+ import { assertNoSymlinkOnPath, resolveContainedOutDir } from "../internal/paths.js";
11
+ import { projectPathError } from "./safe-path.js";
12
+ export function openProject(cwd) {
13
+ if (!existsSync(cwd))
14
+ return `Project root does not exist: ${cwd}`;
15
+ return { root: realpathSync(cwd) };
16
+ }
17
+ /** Resolves a project-relative path, or explains why it is refused. */
18
+ export function resolveInProject(project, rel, name = 'Path') {
19
+ const pathError = projectPathError(name, rel);
20
+ if (pathError)
21
+ return { error: pathError };
22
+ const contained = resolveContainedOutDir(project.root, rel);
23
+ if (!contained.ok)
24
+ return { error: contained.error };
25
+ const symlink = assertNoSymlinkOnPath(contained.outDir, project.root);
26
+ if (!symlink.ok)
27
+ return { error: symlink.error };
28
+ if (existsSync(contained.outDir) && !lstatSync(contained.outDir).isFile()) {
29
+ return { error: `${name} is not a regular file: ${rel}` };
30
+ }
31
+ return contained.outDir;
32
+ }
33
+ /** Reads a project file (null when missing), refusing paths outside the project or through symlinks. */
34
+ export async function readProjectFile(project, rel, name) {
35
+ const path = resolveInProject(project, rel, name);
36
+ if (typeof path !== 'string')
37
+ return path;
38
+ return existsSync(path) ? readFile(path, 'utf8') : null;
39
+ }
40
+ /** A planned write: compare, then (unless dry run) write through the containment checks again. */
41
+ export async function writeProjectFile(project, rel, before, after, dryRun) {
42
+ const change = {
43
+ path: rel,
44
+ action: before === null ? 'create' : before === after ? 'unchanged' : 'update',
45
+ bytes: Buffer.byteLength(after),
46
+ };
47
+ if (dryRun || change.action === 'unchanged')
48
+ return change;
49
+ // Check again right before writing: the tree may have changed since planning.
50
+ const path = resolveInProject(project, rel);
51
+ if (typeof path !== 'string')
52
+ return path;
53
+ await mkdir(dirname(path), { recursive: true });
54
+ const parent = assertNoSymlinkOnPath(dirname(path), project.root);
55
+ if (!parent.ok)
56
+ return { error: parent.error };
57
+ await writeFile(path, after);
58
+ return change;
59
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `texuli init` and `texuli theme` (`@texuli/tools/cli-init`): wire tokens
3
+ * and CSS into Vite, Astro and SvelteKit projects; list, preview and apply
4
+ * themes. Node only, no network.
5
+ */
6
+ export { type InitCliArgs, parseInitArgs, runInitCli, USAGE } from './cli.js';
7
+ export { CONFIG_FILE, formatConfig, parseConfig } from './config.js';
8
+ export { DEFAULTS, detectFramework, FRAMEWORKS } from './detect.js';
9
+ export { DEFAULT_NAMESPACE, DEFAULT_THEME, initProject } from './init.js';
10
+ export { projectPathError } from './safe-path.js';
11
+ export { applyProjectTheme } from './theme-apply.js';
12
+ export { listThemes, PREVIEW_TOKENS, previewTheme, type ThemePreview, type ThemeSummary, } from './themes.js';
13
+ export type { CommandResult, FileChange, Framework, InitOptions, InitResult, TexuliConfig, ThemeApplyOptions, } from './types.js';
14
+ export { CSS_HEADER, documentTheme, importSpecifier, isGeneratedStylesheet, stylesheet, withCssImport, withDocumentWiring, withTheme, } from './wire.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `texuli init` and `texuli theme` (`@texuli/tools/cli-init`): wire tokens
3
+ * and CSS into Vite, Astro and SvelteKit projects; list, preview and apply
4
+ * themes. Node only, no network.
5
+ */
6
+ export { parseInitArgs, runInitCli, USAGE } from "./cli.js";
7
+ export { CONFIG_FILE, formatConfig, parseConfig } from "./config.js";
8
+ export { DEFAULTS, detectFramework, FRAMEWORKS } from "./detect.js";
9
+ export { DEFAULT_NAMESPACE, DEFAULT_THEME, initProject } from "./init.js";
10
+ export { projectPathError } from "./safe-path.js";
11
+ export { applyProjectTheme } from "./theme-apply.js";
12
+ export { listThemes, PREVIEW_TOKENS, previewTheme, } from "./themes.js";
13
+ export { CSS_HEADER, documentTheme, importSpecifier, isGeneratedStylesheet, stylesheet, withCssImport, withDocumentWiring, withTheme, } from "./wire.js";
@@ -0,0 +1,14 @@
1
+ import { type Project } from './files.js';
2
+ import type { CommandResult, InitOptions, InitResult } from './types.js';
3
+ export declare const DEFAULT_THEME = "enterprise-neutral";
4
+ export declare const DEFAULT_NAMESPACE = "app";
5
+ /** The edits for one project, in order: stylesheet, entry, document, config. */
6
+ interface Edit {
7
+ path: string;
8
+ before: string | null;
9
+ after: string;
10
+ }
11
+ /** Writes the planned edits (or only reports them in a dry run). */
12
+ export declare function applyEdits(project: Project, edits: Edit[], dryRun: boolean): Promise<CommandResult>;
13
+ export declare function initProject(options: InitOptions): Promise<InitResult>;
14
+ export {};
@@ -0,0 +1,157 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * `texuli init`: wires Texuli's tokens and CSS into a Vite, Astro or
4
+ * SvelteKit project. It writes the generated stylesheet (base styles and a
5
+ * theme), imports it from the framework's entry, puts the no-flash
6
+ * bootstrap and `data-tx-theme` on the document, and records the choices in
7
+ * `texuli.config.json`. Re-running it changes nothing. No network.
8
+ */
9
+ import { existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { noFlashScript } from '@texuli/tokens/no-flash';
12
+ import { CONFIG_FILE, formatConfig, isId, parseConfig } from "./config.js";
13
+ import { DEFAULTS, detectFramework } from "./detect.js";
14
+ import { openProject, readProjectFile, writeProjectFile } from "./files.js";
15
+ import { projectPathError } from "./safe-path.js";
16
+ import { themeCss, themeExists } from "./themes.js";
17
+ import { importSpecifier, isGeneratedStylesheet, stylesheet, withCssImport, withDocumentWiring, withTheme, } from "./wire.js";
18
+ export const DEFAULT_THEME = 'enterprise-neutral';
19
+ export const DEFAULT_NAMESPACE = 'app';
20
+ const failed = (errors, dryRun) => ({
21
+ ok: false,
22
+ dryRun,
23
+ files: [],
24
+ errors,
25
+ notes: [],
26
+ });
27
+ function firstExisting(project, candidates) {
28
+ return candidates.find((rel) => existsSync(join(project.root, rel))) ?? candidates[0];
29
+ }
30
+ /** Writes the planned edits (or only reports them in a dry run). */
31
+ export async function applyEdits(project, edits, dryRun) {
32
+ const files = [];
33
+ const errors = [];
34
+ for (const edit of edits) {
35
+ const change = await writeProjectFile(project, edit.path, edit.before, edit.after, dryRun);
36
+ if ('error' in change) {
37
+ errors.push(change.error);
38
+ break;
39
+ }
40
+ files.push(change);
41
+ }
42
+ return { ok: errors.length === 0, dryRun, files, errors, notes: [] };
43
+ }
44
+ export async function initProject(options) {
45
+ const dryRun = options.dryRun === true;
46
+ const force = options.force === true;
47
+ const opened = openProject(options.cwd);
48
+ if (typeof opened === 'string')
49
+ return failed([opened], dryRun);
50
+ const project = opened;
51
+ // The framework: explicit, or from package.json.
52
+ let framework = options.framework;
53
+ if (!framework) {
54
+ const pkg = await readProjectFile(project, 'package.json', 'package.json');
55
+ if (pkg !== null && typeof pkg === 'object')
56
+ return failed([pkg.error], dryRun);
57
+ if (pkg === null)
58
+ return failed(['No package.json: pass --framework vite|astro|sveltekit.'], dryRun);
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(pkg);
62
+ }
63
+ catch {
64
+ return failed(['package.json is not valid JSON.'], dryRun);
65
+ }
66
+ framework = detectFramework(parsed) ?? undefined;
67
+ if (!framework) {
68
+ return failed(['Could not detect Vite, Astro or SvelteKit in package.json: pass --framework.'], dryRun);
69
+ }
70
+ }
71
+ const defaults = DEFAULTS[framework];
72
+ const theme = options.theme ?? DEFAULT_THEME;
73
+ const namespace = options.namespace ?? DEFAULT_NAMESPACE;
74
+ const css = options.css ?? defaults.css;
75
+ const errors = [];
76
+ if (!isId(theme) || !(await themeExists(theme))) {
77
+ errors.push(`Unknown theme: ${theme}. Run \`texuli theme list\`.`);
78
+ }
79
+ if (!isId(namespace))
80
+ errors.push(`--namespace must be a kebab-case id (got: ${namespace}).`);
81
+ const cssError = projectPathError('--css', css);
82
+ if (cssError)
83
+ errors.push(cssError);
84
+ else if (!css.endsWith('.css'))
85
+ errors.push(`--css must name a .css file (got: ${css}).`);
86
+ if (errors.length > 0)
87
+ return failed(errors, dryRun);
88
+ const config = {
89
+ framework,
90
+ css,
91
+ entry: firstExisting(project, defaults.entries),
92
+ document: firstExisting(project, defaults.document),
93
+ theme,
94
+ namespace,
95
+ };
96
+ // Read everything first: any refusal stops before a single write.
97
+ const read = async (rel, name) => readProjectFile(project, rel, name);
98
+ const sources = {
99
+ css: await read(config.css, 'Stylesheet'),
100
+ entry: await read(config.entry, 'Entry'),
101
+ document: await read(config.document, 'Document'),
102
+ config: await read(CONFIG_FILE, CONFIG_FILE),
103
+ };
104
+ for (const source of Object.values(sources)) {
105
+ if (source !== null && typeof source === 'object')
106
+ errors.push(source.error);
107
+ }
108
+ if (errors.length > 0)
109
+ return failed(errors, dryRun);
110
+ const text = (value) => typeof value === 'string' ? value : null;
111
+ const compiled = await themeCss(theme);
112
+ const cssBefore = text(sources.css);
113
+ let cssAfter;
114
+ if (cssBefore === null)
115
+ cssAfter = stylesheet(theme, compiled);
116
+ else if (isGeneratedStylesheet(cssBefore))
117
+ cssAfter = withTheme(cssBefore, theme, compiled);
118
+ else if (force)
119
+ cssAfter = stylesheet(theme, compiled);
120
+ else {
121
+ return failed([
122
+ `${config.css} exists and was not generated by texuli: pass --force to replace it, or --css <path>.`,
123
+ ], dryRun);
124
+ }
125
+ const configBefore = text(sources.config);
126
+ if (configBefore !== null && 'errors' in parseConfig(configBefore) && !force) {
127
+ return failed([`${CONFIG_FILE} exists but is not a valid texuli config: pass --force to replace it.`], dryRun);
128
+ }
129
+ const entryBefore = text(sources.entry);
130
+ const entryAfter = withCssImport(entryBefore, config.entry, importSpecifier(config.entry, config.css));
131
+ const astro = framework === 'astro';
132
+ // In Astro the layout is both the entry and the document.
133
+ const documentBefore = config.document === config.entry ? entryAfter : text(sources.document);
134
+ const notes = [];
135
+ const edits = [{ path: config.css, before: cssBefore, after: cssAfter }];
136
+ const documentAfter = documentBefore === null
137
+ ? null
138
+ : withDocumentWiring(documentBefore, theme, noFlashScript(namespace, 'system'), astro);
139
+ if (config.document === config.entry) {
140
+ edits.push({ path: config.entry, before: entryBefore, after: documentAfter ?? entryAfter });
141
+ }
142
+ else {
143
+ edits.push({ path: config.entry, before: entryBefore, after: entryAfter });
144
+ if (documentAfter !== null) {
145
+ edits.push({ path: config.document, before: text(sources.document), after: documentAfter });
146
+ }
147
+ }
148
+ if (documentAfter === null) {
149
+ notes.push(`No <html> and <head> in ${config.document}: add data-tx-theme="${theme}" to <html> and the no-flash script (texuli theme apply re-checks).`);
150
+ }
151
+ if (astro && entryBefore === null) {
152
+ notes.push(`Created ${config.entry}: wrap your pages in it so they get the theme.`);
153
+ }
154
+ edits.push({ path: CONFIG_FILE, before: configBefore, after: formatConfig(config) });
155
+ const result = await applyEdits(project, edits, dryRun);
156
+ return { ...result, notes: [...notes, ...result.notes], config };
157
+ }
@@ -0,0 +1,2 @@
1
+ /** Why `value` is not an acceptable project-relative path, or `null`. */
2
+ export declare function projectPathError(name: string, value: unknown): string | null;
@@ -0,0 +1,26 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * Path rules for `texuli init` and `texuli theme`: CLI and config paths must
4
+ * be relative, normalized and inside the project. Writes use the shared
5
+ * containment in `src/internal/paths.ts`.
6
+ */
7
+ import { isAbsolute, normalize } from 'node:path';
8
+ /** Why `value` is not an acceptable project-relative path, or `null`. */
9
+ export function projectPathError(name, value) {
10
+ if (typeof value !== 'string' || value.length === 0)
11
+ return `${name} must be a non-empty path.`;
12
+ if (value.includes('\0'))
13
+ return `${name} must not contain NUL.`;
14
+ if (isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')) {
15
+ return `${name} must be relative to the project root (got absolute: ${value}).`;
16
+ }
17
+ const segments = value.split(/[/\\]/);
18
+ if (segments.some((segment) => segment === '..')) {
19
+ return `${name} must not contain '..' segments (got: ${value}).`;
20
+ }
21
+ const normalized = normalize(value).replace(/\\/g, '/');
22
+ if (normalized !== value.replace(/\\/g, '/') || segments.some((segment) => segment === '.')) {
23
+ return `${name} must be normalized (got: ${value}; use ${normalized}).`;
24
+ }
25
+ return null;
26
+ }
@@ -0,0 +1,2 @@
1
+ import type { CommandResult, ThemeApplyOptions } from './types.js';
2
+ export declare function applyProjectTheme(options: ThemeApplyOptions): Promise<CommandResult>;
@@ -0,0 +1,69 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * `texuli theme apply <id>`: switches an initialized project to another
4
+ * theme. It rewrites the theme block of the generated stylesheet and
5
+ * `data-tx-theme` on the document, then updates `texuli.config.json`.
6
+ */
7
+ import { noFlashScript } from '@texuli/tokens/no-flash';
8
+ import { CONFIG_FILE, formatConfig, isId, parseConfig } from "./config.js";
9
+ import { openProject, readProjectFile } from "./files.js";
10
+ import { applyEdits } from "./init.js";
11
+ import { themeCss, themeExists } from "./themes.js";
12
+ import { isGeneratedStylesheet, stylesheet, withDocumentWiring, withTheme } from "./wire.js";
13
+ export async function applyProjectTheme(options) {
14
+ const dryRun = options.dryRun === true;
15
+ const fail = (errors) => ({
16
+ ok: false,
17
+ dryRun,
18
+ files: [],
19
+ errors,
20
+ notes: [],
21
+ });
22
+ const opened = openProject(options.cwd);
23
+ if (typeof opened === 'string')
24
+ return fail([opened]);
25
+ if (!isId(options.theme) || !(await themeExists(options.theme))) {
26
+ return fail([`Unknown theme: ${options.theme}. Run \`texuli theme list\`.`]);
27
+ }
28
+ const configText = await readProjectFile(opened, CONFIG_FILE, CONFIG_FILE);
29
+ if (configText === null)
30
+ return fail([`No ${CONFIG_FILE}: run \`texuli init\` first.`]);
31
+ if (typeof configText === 'object')
32
+ return fail([configText.error]);
33
+ const parsed = parseConfig(configText);
34
+ if ('errors' in parsed)
35
+ return fail(parsed.errors);
36
+ const config = parsed.config;
37
+ const css = await readProjectFile(opened, config.css, 'Stylesheet');
38
+ if (css !== null && typeof css === 'object')
39
+ return fail([css.error]);
40
+ const compiled = await themeCss(options.theme);
41
+ let cssAfter;
42
+ if (css !== null && isGeneratedStylesheet(css))
43
+ cssAfter = withTheme(css, options.theme, compiled);
44
+ else if (options.force === true || css === null)
45
+ cssAfter = stylesheet(options.theme, compiled);
46
+ else
47
+ return fail([`${config.css} lost its texuli markers: pass --force to regenerate it.`]);
48
+ const document = await readProjectFile(opened, config.document, 'Document');
49
+ if (document !== null && typeof document === 'object')
50
+ return fail([document.error]);
51
+ const edits = [{ path: config.css, before: css, after: cssAfter }];
52
+ const notes = [];
53
+ const documentAfter = document === null
54
+ ? null
55
+ : withDocumentWiring(document, options.theme, noFlashScript(config.namespace, 'system'), config.framework === 'astro');
56
+ if (document !== null && documentAfter !== null) {
57
+ edits.push({ path: config.document, before: document, after: documentAfter });
58
+ }
59
+ else {
60
+ notes.push(`Set data-tx-theme="${options.theme}" on <html> in ${config.document} yourself.`);
61
+ }
62
+ edits.push({
63
+ path: CONFIG_FILE,
64
+ before: configText,
65
+ after: formatConfig({ ...config, theme: options.theme }),
66
+ });
67
+ const result = await applyEdits(opened, edits, dryRun);
68
+ return { ...result, notes };
69
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The theme side of the CLI: the full Texuli theme library (210 themes),
3
+ * compiled CSS, a token preview and the WCAG 2.x AA report. Offline: every
4
+ * theme ships inside `@texuli/tokens`.
5
+ */
6
+ export interface ThemeSummary {
7
+ id: string;
8
+ name: string;
9
+ collection: string;
10
+ contrastLevel: string;
11
+ darkFirst: boolean;
12
+ description: string;
13
+ }
14
+ export interface ThemePreview extends ThemeSummary {
15
+ mode: 'light' | 'dark';
16
+ /** Key tokens for the mode, by custom property name. */
17
+ tokens: Record<string, string>;
18
+ /** WCAG 2.x AA gate (both modes). */
19
+ aa: {
20
+ pass: boolean;
21
+ issues: number;
22
+ modes: {
23
+ mode: string;
24
+ pass: boolean;
25
+ issues: number;
26
+ }[];
27
+ };
28
+ }
29
+ /** The tokens a preview shows. */
30
+ export declare const PREVIEW_TOKENS: readonly ["--tx-background", "--tx-foreground", "--tx-primary", "--tx-primary-foreground", "--tx-secondary", "--tx-accent", "--tx-muted", "--tx-muted-foreground", "--tx-border", "--tx-ring", "--tx-destructive", "--tx-success", "--tx-warning", "--tx-radius", "--tx-font-sans"];
31
+ /** Every theme, or those matching `search` (all words must match). */
32
+ export declare function listThemes(search?: string): Promise<ThemeSummary[]>;
33
+ export declare function themeExists(id: string): Promise<boolean>;
34
+ /** Compiled CSS for a theme (light and dark rules). */
35
+ export declare function themeCss(id: string): Promise<string>;
36
+ /** Key tokens and the AA report for one theme in one mode. */
37
+ export declare function previewTheme(id: string, mode: 'light' | 'dark'): Promise<ThemePreview | null>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The theme side of the CLI: the full Texuli theme library (210 themes),
3
+ * compiled CSS, a token preview and the WCAG 2.x AA report. Offline: every
4
+ * theme ships inside `@texuli/tokens`.
5
+ */
6
+ import { compileThemeCss, themeVariables } from '@texuli/tokens';
7
+ import { runAaGateOnTheme } from '@texuli/tokens/aa-gate';
8
+ import { getFullThemeMeta, listFullThemeLibrary, loadFullLibraryTheme, searchFullThemeLibrary, } from '@texuli/tokens/library-expand';
9
+ /** The tokens a preview shows. */
10
+ export const PREVIEW_TOKENS = [
11
+ '--tx-background',
12
+ '--tx-foreground',
13
+ '--tx-primary',
14
+ '--tx-primary-foreground',
15
+ '--tx-secondary',
16
+ '--tx-accent',
17
+ '--tx-muted',
18
+ '--tx-muted-foreground',
19
+ '--tx-border',
20
+ '--tx-ring',
21
+ '--tx-destructive',
22
+ '--tx-success',
23
+ '--tx-warning',
24
+ '--tx-radius',
25
+ '--tx-font-sans',
26
+ ];
27
+ function summary(meta) {
28
+ return {
29
+ id: meta.id,
30
+ name: meta.name,
31
+ collection: meta.collection,
32
+ contrastLevel: meta.contrastLevel,
33
+ darkFirst: meta.darkFirst,
34
+ description: meta.description,
35
+ };
36
+ }
37
+ /** Every theme, or those matching `search` (all words must match). */
38
+ export async function listThemes(search) {
39
+ const metas = search?.trim()
40
+ ? await searchFullThemeLibrary(search)
41
+ : await listFullThemeLibrary();
42
+ return metas.map(summary);
43
+ }
44
+ export async function themeExists(id) {
45
+ return (await getFullThemeMeta(id)) !== undefined;
46
+ }
47
+ /** Compiled CSS for a theme (light and dark rules). */
48
+ export async function themeCss(id) {
49
+ return compileThemeCss(await loadFullLibraryTheme(id));
50
+ }
51
+ /** Key tokens and the AA report for one theme in one mode. */
52
+ export async function previewTheme(id, mode) {
53
+ const meta = await getFullThemeMeta(id);
54
+ if (!meta)
55
+ return null;
56
+ const theme = await loadFullLibraryTheme(id);
57
+ const vars = themeVariables({ theme, mode });
58
+ const tokens = {};
59
+ for (const name of PREVIEW_TOKENS)
60
+ if (vars[name] !== undefined)
61
+ tokens[name] = vars[name];
62
+ const report = runAaGateOnTheme({
63
+ id,
64
+ name: meta.name,
65
+ contrastLevel: meta.contrastLevel === 'AAA' ? 'AAA' : 'AA',
66
+ light: theme.light,
67
+ dark: theme.dark,
68
+ });
69
+ return {
70
+ ...summary(meta),
71
+ mode,
72
+ tokens,
73
+ aa: {
74
+ pass: report.pass,
75
+ issues: report.issueCount,
76
+ modes: report.modes.map((entry) => ({
77
+ mode: entry.mode,
78
+ pass: entry.pass,
79
+ issues: entry.issueCount,
80
+ })),
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,50 @@
1
+ /** Project kinds `texuli init` knows how to wire. */
2
+ export type Framework = 'vite' | 'astro' | 'sveltekit';
3
+ /** `texuli.config.json`, written by `init` and read by `theme apply`. */
4
+ export interface TexuliConfig {
5
+ framework: Framework;
6
+ /** The generated stylesheet, relative to the project root. */
7
+ css: string;
8
+ /** The file that imports the stylesheet. */
9
+ entry: string;
10
+ /** The file whose `<html>` carries `data-tx-theme` and the no-flash script. */
11
+ document: string;
12
+ /** Theme id from the Texuli theme library. */
13
+ theme: string;
14
+ /** The appearance cache namespace for the no-flash script. */
15
+ namespace: string;
16
+ }
17
+ /** What happened (or would happen) to one file. */
18
+ export interface FileChange {
19
+ /** Relative to the project root, with forward slashes. */
20
+ path: string;
21
+ action: 'create' | 'update' | 'unchanged';
22
+ bytes: number;
23
+ }
24
+ export interface CommandResult {
25
+ ok: boolean;
26
+ dryRun: boolean;
27
+ files: FileChange[];
28
+ errors: string[];
29
+ /** Short next steps for the person or agent. */
30
+ notes: string[];
31
+ }
32
+ export interface InitOptions {
33
+ cwd: string;
34
+ framework?: Framework;
35
+ theme?: string;
36
+ /** Stylesheet path relative to the project root (default per framework). */
37
+ css?: string;
38
+ namespace?: string;
39
+ force?: boolean;
40
+ dryRun?: boolean;
41
+ }
42
+ export type InitResult = CommandResult & {
43
+ config?: TexuliConfig;
44
+ };
45
+ export interface ThemeApplyOptions {
46
+ cwd: string;
47
+ theme: string;
48
+ force?: boolean;
49
+ dryRun?: boolean;
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ /** First line of every stylesheet `texuli init` generates. */
2
+ export declare const CSS_HEADER = "/* @texuli/init: generated by `texuli init` and `texuli theme apply`. Keep the markers. */";
3
+ /** The generated stylesheet: base styles, then the theme between markers. */
4
+ export declare function stylesheet(themeId: string, themeCss: string): string;
5
+ /** Whether `source` is a stylesheet `texuli init` generated (and kept its markers). */
6
+ export declare function isGeneratedStylesheet(source: string): boolean;
7
+ /** Replaces the theme block of a generated stylesheet. */
8
+ export declare function withTheme(source: string, themeId: string, themeCss: string): string;
9
+ /** The import specifier from `entry` to `css` (both project-relative). */
10
+ export declare function importSpecifier(entry: string, css: string): string;
11
+ /** Adds `import '<specifier>';` to a JS, TS, Svelte or Astro entry (or creates one). */
12
+ export declare function withCssImport(source: string | null, file: string, specifier: string): string;
13
+ /**
14
+ * Puts the no-flash bootstrap first in `<head>` (between markers, replaced
15
+ * on re-run) and sets `data-tx-theme` on `<html>`. Returns `null` when the
16
+ * document has no `<html>` or `<head>` tag.
17
+ */
18
+ export declare function withDocumentWiring(source: string, themeId: string, bootstrap: string, astro: boolean): string | null;
19
+ /** The `data-tx-theme` a document declares, if any. */
20
+ export declare function documentTheme(source: string): string | null;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Text transforms that wire Texuli into a project's files. Each is
3
+ * idempotent: applying it to its own output changes nothing. Nothing here
4
+ * touches the file system.
5
+ */
6
+ import { posix } from 'node:path';
7
+ /** First line of every stylesheet `texuli init` generates. */
8
+ export const CSS_HEADER = '/* @texuli/init: generated by `texuli init` and `texuli theme apply`. Keep the markers. */';
9
+ const THEME_START = /\/\* texuli:theme [a-z0-9-]+ \*\//;
10
+ const THEME_BLOCK = /\/\* texuli:theme [a-z0-9-]+ \*\/[\s\S]*?\/\* \/texuli:theme \*\//;
11
+ const BOOT_BLOCK = /<!-- texuli:no-flash -->[\s\S]*?<!-- \/texuli:no-flash -->/;
12
+ /** The generated stylesheet: base styles, then the theme between markers. */
13
+ export function stylesheet(themeId, themeCss) {
14
+ return `${CSS_HEADER}\n@import '@texuli/tokens/base.css';\n\n${themeBlock(themeId, themeCss)}\n`;
15
+ }
16
+ function themeBlock(themeId, themeCss) {
17
+ return `/* texuli:theme ${themeId} */\n${themeCss.trim()}\n/* /texuli:theme */`;
18
+ }
19
+ /** Whether `source` is a stylesheet `texuli init` generated (and kept its markers). */
20
+ export function isGeneratedStylesheet(source) {
21
+ return source.startsWith(CSS_HEADER) && THEME_START.test(source) && THEME_BLOCK.test(source);
22
+ }
23
+ /** Replaces the theme block of a generated stylesheet. */
24
+ export function withTheme(source, themeId, themeCss) {
25
+ return source.replace(THEME_BLOCK, () => themeBlock(themeId, themeCss));
26
+ }
27
+ /** The import specifier from `entry` to `css` (both project-relative). */
28
+ export function importSpecifier(entry, css) {
29
+ const relative = posix.relative(posix.dirname(entry), css);
30
+ return relative.startsWith('.') ? relative : `./${relative}`;
31
+ }
32
+ function hasImport(source, specifier) {
33
+ return source.includes(`import '${specifier}'`) || source.includes(`import "${specifier}"`);
34
+ }
35
+ /** Adds `import '<specifier>';` to a JS, TS, Svelte or Astro entry (or creates one). */
36
+ export function withCssImport(source, file, specifier) {
37
+ const line = `import '${specifier}';`;
38
+ if (file.endsWith('.svelte')) {
39
+ if (source === null) {
40
+ return `<script lang="ts">\n ${line}\n let { children } = $props();\n</script>\n\n{@render children()}\n`;
41
+ }
42
+ if (hasImport(source, specifier))
43
+ return source;
44
+ const script = /<script\b[^>]*>/.exec(source);
45
+ if (!script)
46
+ return `<script lang="ts">\n ${line}\n</script>\n\n${source}`;
47
+ const at = script.index + script[0].length;
48
+ return `${source.slice(0, at)}\n ${line}${source.slice(at)}`;
49
+ }
50
+ if (file.endsWith('.astro')) {
51
+ if (source === null)
52
+ return astroLayout(line);
53
+ if (hasImport(source, specifier))
54
+ return source;
55
+ if (source.startsWith('---'))
56
+ return source.replace(/^---[^\n]*\n/, (open) => `${open}${line}\n`);
57
+ return `---\n${line}\n---\n\n${source}`;
58
+ }
59
+ if (source === null)
60
+ return `${line}\n`;
61
+ return hasImport(source, specifier) ? source : `${line}\n${source}`;
62
+ }
63
+ /** A minimal Astro layout for projects that have none. */
64
+ function astroLayout(importLine) {
65
+ return `---\n${importLine}\n---\n\n<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n </head>\n <body>\n <slot />\n </body>\n</html>\n`;
66
+ }
67
+ /**
68
+ * Puts the no-flash bootstrap first in `<head>` (between markers, replaced
69
+ * on re-run) and sets `data-tx-theme` on `<html>`. Returns `null` when the
70
+ * document has no `<html>` or `<head>` tag.
71
+ */
72
+ export function withDocumentWiring(source, themeId, bootstrap, astro) {
73
+ const html = /<html\b[^>]*>/i.exec(source);
74
+ const head = /<head\b[^>]*>/i.exec(source);
75
+ if (!html || !head)
76
+ return null;
77
+ const script = `<script${astro ? ' is:inline' : ''}>${bootstrap}</script>`;
78
+ const block = `<!-- texuli:no-flash -->\n ${script}\n <!-- /texuli:no-flash -->`;
79
+ let out = BOOT_BLOCK.test(source)
80
+ ? source.replace(BOOT_BLOCK, () => block)
81
+ : `${source.slice(0, head.index + head[0].length)}\n ${block}${source.slice(head.index + head[0].length)}`;
82
+ out = out.replace(/<html\b[^>]*>/i, (tag) => /\sdata-tx-theme=/.test(tag)
83
+ ? tag.replace(/\sdata-tx-theme=(?:"[^"]*"|'[^']*'|[^\s>]+)/, ` data-tx-theme="${themeId}"`)
84
+ : tag.replace(/^<html/i, `<html data-tx-theme="${themeId}"`));
85
+ return out;
86
+ }
87
+ /** The `data-tx-theme` a document declares, if any. */
88
+ export function documentTheme(source) {
89
+ return /<html\b[^>]*\sdata-tx-theme="([^"]*)"/i.exec(source)?.[1] ?? null;
90
+ }