@valancex/cli 0.0.0-stage → 0.11.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 (84) hide show
  1. package/README.md +263 -2
  2. package/bin/vlx.js +4 -0
  3. package/dist/add.d.ts +20 -0
  4. package/dist/add.js +143 -0
  5. package/dist/args.d.ts +20 -0
  6. package/dist/args.js +78 -0
  7. package/dist/author.d.ts +4 -0
  8. package/dist/author.js +30 -0
  9. package/dist/build.d.ts +14 -0
  10. package/dist/build.js +70 -0
  11. package/dist/check.d.ts +8 -0
  12. package/dist/check.js +37 -0
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +280 -0
  15. package/dist/compile.d.ts +51 -0
  16. package/dist/compile.js +217 -0
  17. package/dist/config.d.ts +20 -0
  18. package/dist/config.js +99 -0
  19. package/dist/content.d.ts +12 -0
  20. package/dist/content.js +193 -0
  21. package/dist/contract.d.ts +31 -0
  22. package/dist/contract.js +45 -0
  23. package/dist/delegate.d.ts +2 -0
  24. package/dist/delegate.js +23 -0
  25. package/dist/dev.d.ts +21 -0
  26. package/dist/dev.js +250 -0
  27. package/dist/diagnostics.d.ts +16 -0
  28. package/dist/diagnostics.js +16 -0
  29. package/dist/emit.d.ts +13 -0
  30. package/dist/emit.js +107 -0
  31. package/dist/init.d.ts +69 -0
  32. package/dist/init.js +185 -0
  33. package/dist/markdown.d.ts +20 -0
  34. package/dist/markdown.js +185 -0
  35. package/dist/node.d.ts +10 -0
  36. package/dist/node.js +6 -0
  37. package/dist/outline.d.ts +17 -0
  38. package/dist/outline.js +88 -0
  39. package/dist/project.d.ts +8 -0
  40. package/dist/project.js +20 -0
  41. package/dist/targets.d.ts +9 -0
  42. package/dist/targets.js +2 -0
  43. package/dist/tree.d.ts +47 -0
  44. package/dist/tree.js +149 -0
  45. package/dist/tty.d.ts +13 -0
  46. package/dist/tty.js +4 -0
  47. package/dist/ui/Spinner.d.ts +3 -0
  48. package/dist/ui/Spinner.js +16 -0
  49. package/dist/ui/init.d.ts +22 -0
  50. package/dist/ui/init.js +137 -0
  51. package/dist/ui/prompts.d.ts +39 -0
  52. package/dist/ui/prompts.js +98 -0
  53. package/dist/ui/report.d.ts +31 -0
  54. package/dist/ui/report.js +30 -0
  55. package/dist/ui/run.d.ts +15 -0
  56. package/dist/ui/run.js +75 -0
  57. package/dist/vite.d.ts +7 -0
  58. package/dist/vite.js +49 -0
  59. package/dist/wiring.d.ts +108 -0
  60. package/dist/wiring.js +330 -0
  61. package/package.json +69 -3
  62. package/schemas/valance.schema.json +14 -0
  63. package/schemas/valance.web.schema.json +14 -0
  64. package/templates/web/README.md +14 -0
  65. package/templates/web/content/404.md +7 -0
  66. package/templates/web/content/guide/getting-started.md +33 -0
  67. package/templates/web/content/guide/writing-pages.md +21 -0
  68. package/templates/web/content/index.md +16 -0
  69. package/templates/web/gitignore +4 -0
  70. package/templates/web/public/logo.svg +1 -0
  71. package/templates/web/src/layout.mprx +27 -0
  72. package/templates/web/src/main.ts +20 -0
  73. package/templates/web/src/styles.css +28 -0
  74. package/templates/web/src/theme-toggle.mprx +1 -0
  75. package/templates/web/src/theme-toggle.ts +10 -0
  76. package/templates/web/tsconfig.json +18 -0
  77. package/views/block.mprx +3 -0
  78. package/views/blocks.mprx +3 -0
  79. package/views/heading.mprx +3 -0
  80. package/views/inline.mprx +3 -0
  81. package/views/layout.mprx +26 -0
  82. package/views/list-block.mprx +5 -0
  83. package/views/list-items.mprx +3 -0
  84. package/views/runs.mprx +3 -0
@@ -0,0 +1,217 @@
1
+ // Build time, in Node: the templates → one MESH program. Templates are found, never listed: every `.mprx` file under `src/` is a component named by its file (`src/callout.mprx` is
2
+ // `<callout>`), and the framework's own (`views/` in this package) fill in what the author did not write. A template with the same name as a built-in replaces it. The manifest MESH checks
3
+ // the templates against is derived (Valance's `manifest`) from the tag table, the layout's scope and the command table, so there is no manifest file to keep in step.
4
+ import { compileProgram } from "@valancex/mesh-compiler";
5
+ import { manifest } from "@valancex/valance/web/build";
6
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
7
+ import { basename, join, relative, sep } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { commandTable, scopeSchema } from "@valancex/common/internal";
10
+ import { AppError, diagnostic } from "./diagnostics.js";
11
+ import { declarationsOf, linked, nameOf, treeOf } from "./tree.js";
12
+ import { tagsOf } from "./outline.js";
13
+ import { entryOf } from "./contract.js";
14
+ import { locateKey } from "./wiring.js";
15
+ import { primitives } from "@valancex/common/internal";
16
+ import { ENTRY_FILE } from "./project.js";
17
+ export const SOURCE_DIR = "src";
18
+ /** The component the page is drawn by. */
19
+ export const ROOT = "layout";
20
+ export const BUILT_IN = fileURLToPath(new URL("../views/", import.meta.url));
21
+ /** The package directory: where its README, which documents the framework's own names, is. */
22
+ export const PACKAGE_DIR = fileURLToPath(new URL("../", import.meta.url));
23
+ const mprxIn = (dir) => existsSync(dir)
24
+ ? readdirSync(dir, { recursive: true, encoding: "utf8" })
25
+ .filter((file) => file.endsWith(".mprx"))
26
+ .sort()
27
+ : [];
28
+ const entryText = (root) => {
29
+ try {
30
+ return readFileSync(join(root, ENTRY_FILE), "utf8");
31
+ }
32
+ catch {
33
+ return "";
34
+ }
35
+ };
36
+ /**
37
+ * Built-in templates, then the author's. The author's are exactly the ones `src/main.ts` declares: `layout`, `components` and `override`. A file under `src/` that is not declared is an error, not
38
+ * a guess; a registered component that no template writes is allowed (it is not part of the program until one does). Which components a template uses is read from the template. A name that is a built-in's replaces it only through `override`.
39
+ */
40
+ export const templatesOf = (root, author) => {
41
+ const found = new Map();
42
+ for (const file of mprxIn(BUILT_IN)) {
43
+ const component = basename(file, ".mprx");
44
+ found.set(component, { component, path: `@valancex/cli/views/${file}`, source: readFileSync(join(BUILT_IN, file), "utf8"), builtIn: true });
45
+ }
46
+ const layout = author?.layout;
47
+ const overrides = author?.override ?? [];
48
+ const declared = treeOf(layout, overrides, author?.components ?? []);
49
+ const declarations = declarationsOf(root);
50
+ const problems = [];
51
+ /** Where a template's component is defined: its `component(…)` call, else where it is imported, else the top of the entry file. */
52
+ const at = (file) => {
53
+ const site = declarations.get(file);
54
+ return site?.call ?? site?.imported ?? { file: ENTRY_FILE, line: 1, column: 1 };
55
+ };
56
+ const problem = (place, code, message) => {
57
+ problems.push(diagnostic(place.file, place.line, code, message, place.column));
58
+ };
59
+ // The layout's inputs are the state and the commands, so it has no props or events of its own to declare.
60
+ if (layout !== undefined && (layout.props !== undefined || layout.events !== undefined)) {
61
+ problem(at(layout.template.mprx), "layout-contract", "the layout takes no props and raises no events: its inputs are the state and the commands. Declare state fields and commands in defineApp instead");
62
+ }
63
+ if (layout !== undefined && nameOf(layout.template.mprx) !== ROOT) {
64
+ problem(at(layout.template.mprx), "layout-name", `the layout's template must be named ${ROOT}.mprx, not ${basename(layout.template.mprx)}`);
65
+ }
66
+ const builtInNames = new Set(found.keys());
67
+ for (const each of overrides) {
68
+ const name = nameOf(each.template.mprx);
69
+ if (!builtInNames.has(name) && name !== ROOT) {
70
+ problem(at(each.template.mprx), "override-unknown", `<${name}> is not a built-in component, so there is nothing to override; list it in the \`uses\` of the component that uses it`);
71
+ }
72
+ }
73
+ const files = new Map();
74
+ for (const node of declared) {
75
+ const place = at(node.file);
76
+ if (files.has(node.name) && files.get(node.name) !== node.file) {
77
+ problem(place, "template-duplicate", `${files.get(node.name)} is also <${node.name}>: a component is named by its file, so the name must be unique`);
78
+ continue;
79
+ }
80
+ if (node.name in primitives) {
81
+ problem(place, "template-primitive", `<${node.name}> is already a tag the framework draws as an element; name the file something else`);
82
+ continue;
83
+ }
84
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(node.name)) {
85
+ problem(place, "template-name", `"${node.name}" cannot be a tag: use lowercase words joined by - (for example callout or post-card)`);
86
+ continue;
87
+ }
88
+ if (!existsSync(join(root, node.file))) {
89
+ problem(place, "template-missing", `${node.file} does not exist`);
90
+ continue;
91
+ }
92
+ files.set(node.name, node.file);
93
+ found.set(node.name, { component: node.name, path: node.file, source: readFileSync(join(root, node.file), "utf8"), builtIn: false });
94
+ }
95
+ // Every template under src/ must be somewhere in the tree.
96
+ for (const file of mprxIn(join(root, SOURCE_DIR))) {
97
+ const path = `${SOURCE_DIR}/${file}`;
98
+ if (![...files.values()].includes(path)) {
99
+ problems.push(diagnostic(path, 0, "template-unused", `${path} is not in the component tree: import it in ${ENTRY_FILE} and ${basename(file) === `${ROOT}.mprx` ? "pass it as `layout`" : "list it in `components`"}`));
100
+ }
101
+ }
102
+ // Who uses whom is what the templates write: a component's uses are the declared components whose tags its template has.
103
+ const tree = linked(declared, layout, overrides, (name) => [...tagsOf(found.get(name)?.source ?? "").keys()]);
104
+ if (problems.length > 0) {
105
+ throw new AppError(problems);
106
+ }
107
+ return { all: [...found.values()], tree };
108
+ };
109
+ /** The templates the root reaches, following the tags each uses. A template nothing uses is not part of the program (the compiler refuses one). */
110
+ const reachable = (all) => {
111
+ const byName = new Map(all.map((template) => [template.component, template]));
112
+ const seen = new Set();
113
+ const queue = [ROOT];
114
+ while (queue.length > 0) {
115
+ const name = queue.pop();
116
+ const template = byName.get(name);
117
+ if (template === undefined || seen.has(name)) {
118
+ continue;
119
+ }
120
+ seen.add(name);
121
+ for (const tag of tagsOf(template.source).keys()) {
122
+ queue.push(tag);
123
+ }
124
+ }
125
+ return all.filter((template) => seen.has(template.component));
126
+ };
127
+ export const manifestOf = (author) => manifest({
128
+ primitives,
129
+ scopes: { [ROOT]: scopeSchema(author) },
130
+ commands: commandTable(author),
131
+ });
132
+ const isManifestError = (error) => error instanceof Error && error._tag === "ManifestError";
133
+ /**
134
+ * The model the templates are checked against: the derived one (the tags, the layout's scope, the commands), and an entry for each component that declares its interface. A declared interface is
135
+ * exactly what was written, an omitted side being none, and it overrides inference; a component that declares neither props nor events keeps the contract MESH infers from its template and its
136
+ * uses. MESH checks every use against it (an undeclared prop, a missing one, a wrong type, an undeclared event in a handler), at the use or in the template. A schema with no model type is a
137
+ * diagnostic at the declaration, not a crash.
138
+ */
139
+ const modelOf = (root, author, tree) => {
140
+ const problems = [];
141
+ const mainText = entryText(root);
142
+ let derived;
143
+ try {
144
+ derived = manifestOf(author);
145
+ }
146
+ catch (error) {
147
+ if (!isManifestError(error)) {
148
+ throw error;
149
+ }
150
+ // `layout.theme.deep`: a state field (the layout's scope) names where; anything else is the commands.
151
+ const field = /^layout\.([^.[]+)/.exec(error.path)?.[1];
152
+ const place = field === undefined ? { file: ENTRY_FILE, line: 1, column: 1 } : locateKey(mainText, field);
153
+ problems.push(diagnostic(place.file, place.line, "state-type", `${field === undefined ? error.path : `the state field "${field}"`} has no type the model can hold: ${error.message.replace(/^[\w.[\]]+: /, "")}`, place.column));
154
+ }
155
+ const declarations = declarationsOf(root);
156
+ const entries = {};
157
+ for (const node of tree) {
158
+ if (node.interface === undefined || node.name === ROOT) {
159
+ continue;
160
+ }
161
+ const { entry, problems: bad } = entryOf(node.interface);
162
+ const site = declarations.get(node.file);
163
+ for (const each of bad) {
164
+ const place = site?.member(each.kind, each.name) ?? site?.call ?? site?.imported ?? { file: ENTRY_FILE, line: 1, column: 1 };
165
+ problems.push(diagnostic(place.file, place.line, "component-schema", `${node.name}'s ${each.kind === "props" ? "prop" : "event"} "${each.name}" has no type the model can hold: ${each.reason}`, place.column));
166
+ }
167
+ entries[node.name] = entry;
168
+ }
169
+ if (problems.length > 0 || derived === undefined) {
170
+ throw new AppError(problems);
171
+ }
172
+ return { ...derived, components: { ...derived.components, ...entries } };
173
+ };
174
+ /** Compiles the application's templates. Fails with every diagnostic, each pointing into the file it is about. */
175
+ export const compileViews = async (root, author) => {
176
+ const { all, tree } = templatesOf(root, author);
177
+ const used = reachable(all);
178
+ const model = modelOf(root, author, tree);
179
+ if (!used.some((template) => template.component === ROOT)) {
180
+ throw new AppError([diagnostic(`${SOURCE_DIR}/${ROOT}.mprx`, 0, "layout-missing", "there is no layout")]);
181
+ }
182
+ const result = await compileProgram({
183
+ model: { manifest: JSON.stringify(model), path: ".valance/manifest.json" },
184
+ root: ROOT,
185
+ components: used.map((template) => ({ component: template.component, source: template.source, path: template.path })),
186
+ });
187
+ if (result.program === undefined) {
188
+ const found = [
189
+ ...(result.components ?? []).flatMap((entry) => entry.diagnostics.diagnostics
190
+ .filter((each) => each.severity === "error")
191
+ .map((each) => diagnostic(each.path, each.span.start.line, each.code, each.message + (each.suggestions.length > 0 ? ` (did you mean ${each.suggestions.map((suggestion) => `"${suggestion.replacement}"`).join(" or ")}?)` : ""), each.span.start.column))),
192
+ ...(result.assembly?.diagnostics ?? []).map((each) => diagnostic(each.path ?? "program", each.span?.start.line ?? 0, each.code, each.message, each.span?.start.column ?? 0)),
193
+ ];
194
+ throw new AppError(found.map((each) => relocate(each, root)));
195
+ }
196
+ const program = result.program;
197
+ const enriched = program.model;
198
+ const manifestOfProgram = typeof enriched === "string" ? JSON.parse(enriched) : enriched;
199
+ return {
200
+ program,
201
+ manifest: manifestOfProgram,
202
+ tree,
203
+ files: Object.fromEntries(used.filter((template) => !template.builtIn).map((template) => [template.path, template.component])),
204
+ components: used.map((template) => template.component),
205
+ templates: used.map((template) => ({
206
+ component: template.component,
207
+ file: template.builtIn
208
+ ? relative(root, join(BUILT_IN, `${template.component}.mprx`))
209
+ .split(sep)
210
+ .join("/")
211
+ : template.path,
212
+ source: template.source,
213
+ builtIn: template.builtIn,
214
+ })),
215
+ };
216
+ };
217
+ const relocate = (each, root) => ({ ...each, file: each.file.startsWith(root) ? relative(root, each.file) : each.file });
@@ -0,0 +1,20 @@
1
+ import type { SiteInfo } from "@valancex/common/internal";
2
+ export declare const PROJECT_FILE = "valance.json";
3
+ export declare const WEB_FILE = "valance.web.json";
4
+ /** The only version of both files there is. */
5
+ export declare const CONFIG_VERSION = 1;
6
+ export interface Config {
7
+ readonly name: string;
8
+ readonly web: {
9
+ readonly base: string;
10
+ readonly url: string;
11
+ };
12
+ }
13
+ /** Reads and validates the project's configuration. Every problem in both files is reported together. */
14
+ export declare const loadConfig: (root: string) => Config;
15
+ export declare const siteInfo: (config: Config) => SiteInfo;
16
+ /**
17
+ * The nearest directory at or above `from` that holds a `valance.json`, as its real path. The bundler names a file by its real path, so a project reached through a symlink, or by a short
18
+ * Windows name (`RUNNER~1`), must be called the same thing here or a file is not the file the bundler means.
19
+ */
20
+ export declare const findRoot: (from: string) => string;
package/dist/config.js ADDED
@@ -0,0 +1,99 @@
1
+ // Project configuration. `valance.json` is the project's (what it is called); `valance.web.json` is the web target's (where it is served). Nothing else is configurable, on purpose: a setting
2
+ // is added when something needs it. Unknown keys are errors with the line, because a misspelt key that is ignored is a setting the author thinks they have. The JSON Schemas in
3
+ // `schemas/` describe the same files for editors; this is the check the tools make.
4
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { AppError, diagnostic, lineOf } from "./diagnostics.js";
7
+ export const PROJECT_FILE = "valance.json";
8
+ export const WEB_FILE = "valance.web.json";
9
+ /** The only version of both files there is. */
10
+ export const CONFIG_VERSION = 1;
11
+ const read = (root, file) => {
12
+ const path = join(root, file);
13
+ if (!existsSync(path)) {
14
+ return undefined;
15
+ }
16
+ const text = readFileSync(path, "utf8");
17
+ try {
18
+ return { text, value: JSON.parse(text) };
19
+ }
20
+ catch (error) {
21
+ throw new AppError([diagnostic(file, 1, "config-syntax", `not valid JSON: ${error instanceof Error ? error.message : String(error)}`)]);
22
+ }
23
+ };
24
+ const check = (file, text, value, keys, required) => {
25
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
26
+ return [diagnostic(file, 1, "config-shape", "must be a JSON object")];
27
+ }
28
+ const found = [];
29
+ const record = value;
30
+ for (const key of required) {
31
+ if (!(key in record)) {
32
+ found.push(diagnostic(file, 1, "config-missing", `"${key}" is required`));
33
+ }
34
+ }
35
+ for (const [key, given] of Object.entries(record)) {
36
+ const rule = keys[key];
37
+ if (rule === undefined) {
38
+ found.push(diagnostic(file, lineOf(text, `"${key}"`), "config-unknown", `unknown setting "${key}" (known: ${Object.keys(keys).join(", ")})`));
39
+ continue;
40
+ }
41
+ const problem = rule(given);
42
+ if (problem !== undefined) {
43
+ found.push(diagnostic(file, lineOf(text, `"${key}"`), "config-value", `"${key}" ${problem}`));
44
+ }
45
+ }
46
+ return found;
47
+ };
48
+ const text = (value) => (typeof value === "string" && value.trim() !== "" ? undefined : "must be a non-empty string");
49
+ const version = (value) => (value === CONFIG_VERSION ? undefined : `must be ${CONFIG_VERSION} (the only version there is)`);
50
+ const schema = (value) => (typeof value === "string" ? undefined : "must be a string");
51
+ const base = (value) => typeof value === "string" && /^\/([\w.~-]+\/)*$/.test(value) ? undefined : 'must start and end with "/" (for example "/" or "/docs/")';
52
+ const url = (value) => {
53
+ if (typeof value !== "string") {
54
+ return "must be a string";
55
+ }
56
+ try {
57
+ return new URL(value).protocol.startsWith("http") && !value.endsWith("/") ? undefined : 'must be an http(s) address without a trailing "/"';
58
+ }
59
+ catch {
60
+ return "must be an absolute address such as https://example.com";
61
+ }
62
+ };
63
+ /** Reads and validates the project's configuration. Every problem in both files is reported together. */
64
+ export const loadConfig = (root) => {
65
+ const project = read(root, PROJECT_FILE);
66
+ if (project === undefined) {
67
+ throw new AppError([diagnostic(PROJECT_FILE, 0, "config-missing", `there is no ${PROJECT_FILE} in ${root}`)]);
68
+ }
69
+ const web = read(root, WEB_FILE);
70
+ const problems = [
71
+ ...check(PROJECT_FILE, project.text, project.value, { $schema: schema, version, name: text }, ["name"]),
72
+ ...(web === undefined ? [] : check(WEB_FILE, web.text, web.value, { $schema: schema, version, base, url }, [])),
73
+ ];
74
+ if (problems.length > 0) {
75
+ throw new AppError(problems);
76
+ }
77
+ const given = (web?.value ?? {});
78
+ return { name: project.value.name, web: { base: given.base ?? "/", url: given.url ?? "" } };
79
+ };
80
+ export const siteInfo = (config) => ({ name: config.name, base: config.web.base, url: config.web.url });
81
+ /**
82
+ * The nearest directory at or above `from` that holds a `valance.json`, as its real path. The bundler names a file by its real path, so a project reached through a symlink, or by a short
83
+ * Windows name (`RUNNER~1`), must be called the same thing here or a file is not the file the bundler means.
84
+ */
85
+ export const findRoot = (from) => {
86
+ let at = from;
87
+ for (;;) {
88
+ if (existsSync(join(at, PROJECT_FILE))) {
89
+ return realpathSync.native(at);
90
+ }
91
+ const parent = dirname(at);
92
+ if (parent === at) {
93
+ throw new AppError([
94
+ diagnostic(PROJECT_FILE, 0, "no-project", `no ${PROJECT_FILE} in ${from} or any directory above it. Run this from your project, or create one with {"name": "…"}`),
95
+ ]);
96
+ }
97
+ at = parent;
98
+ }
99
+ };
@@ -0,0 +1,12 @@
1
+ import type { Config } from "./config.js";
2
+ import type { Content } from "@valancex/common/internal";
3
+ export declare const CONTENT_DIR = "content";
4
+ export declare const PUBLIC_DIR = "public";
5
+ /** `guide/getting-started.md` → `/guide/getting-started`; `index.md` → `/`; `guide/index.md` → `/guide`. */
6
+ export declare const pathOfFile: (relative: string) => string;
7
+ /** An address under the site's base: `/guide/x#y` with base `/docs/` is `/docs/guide/x#y`. */
8
+ export declare const underBase: (base: string, address: string) => string;
9
+ export declare const publicFiles: (root: string) => ReadonlySet<string>;
10
+ /** Reads `content/` under `root`. Every mistake in every file is reported together, not the first only. */
11
+ export declare const readContent: (root: string, config: Config) => Content;
12
+ export declare const sectionTitle: (value: string) => string;
@@ -0,0 +1,193 @@
1
+ // Build time, in Node: `content/` → the content model. One Markdown file is one page; its path is its address. This is the whole of the content pipeline for an ordinary application:
2
+ // discovery, front matter, Markdown, navigation order, and a check of every link and image against the pages and `public/` that exist.
3
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { join, posix } from "node:path";
5
+ import { AppError, diagnostic } from "./diagnostics.js";
6
+ import { parseMarkdown, plain } from "./markdown.js";
7
+ export const CONTENT_DIR = "content";
8
+ export const PUBLIC_DIR = "public";
9
+ const FRONT_KEYS = ["title", "description", "order"];
10
+ const frontMatter = (source, file, problems) => {
11
+ const lines = source.split("\n");
12
+ if (lines[0]?.trim() !== "---") {
13
+ return { values: {}, lines: 0, body: source };
14
+ }
15
+ const end = lines.findIndex((line, at) => at > 0 && line.trim() === "---");
16
+ if (end < 0) {
17
+ problems.push(diagnostic(file, 1, "front-unclosed", 'the front matter starts with "---" and is never closed'));
18
+ return { values: {}, lines: 0, body: source };
19
+ }
20
+ const values = {};
21
+ lines.slice(1, end).forEach((line, at) => {
22
+ if (line.trim() === "" || line.trim().startsWith("#")) {
23
+ return;
24
+ }
25
+ const found = /^([A-Za-z][\w-]*):\s*(.*?)\s*$/.exec(line);
26
+ if (found === null) {
27
+ problems.push(diagnostic(file, at + 2, "front-syntax", `expected "key: value", found "${line.trim()}"`));
28
+ return;
29
+ }
30
+ if (!FRONT_KEYS.includes(found[1])) {
31
+ problems.push(diagnostic(file, at + 2, "front-unknown", `unknown front matter key "${found[1]}" (known: ${FRONT_KEYS.join(", ")})`));
32
+ return;
33
+ }
34
+ values[found[1]] = found[2].replace(/^(["'])(.*)\1$/, "$2");
35
+ });
36
+ return { values, lines: end + 1, body: lines.slice(end + 1).join("\n") };
37
+ };
38
+ const filesUnder = (dir, prefix = "") => !existsSync(dir)
39
+ ? []
40
+ : readdirSync(dir)
41
+ .sort()
42
+ .flatMap((name) => (statSync(join(dir, name)).isDirectory() ? filesUnder(join(dir, name), `${prefix}${name}/`) : [`${prefix}${name}`]));
43
+ /** `guide/getting-started.md` → `/guide/getting-started`; `index.md` → `/`; `guide/index.md` → `/guide`. */
44
+ export const pathOfFile = (relative) => {
45
+ const without = relative.replace(/\.md$/, "");
46
+ const path = `/${without}`.replace(/\/index$/, "");
47
+ return path === "" ? "/" : path;
48
+ };
49
+ const titleCase = (value) => value.replace(/[-_]+/g, " ").replace(/^./, (first) => first.toUpperCase());
50
+ /** `/a/b#x` → `["/a/b", "x"]`; a trailing slash is not part of the path. */
51
+ const splitAddress = (href) => {
52
+ const hash = href.indexOf("#");
53
+ const path = (hash < 0 ? href : href.slice(0, hash)).replace(/(.)\/$/, "$1");
54
+ return [path, hash < 0 ? "" : href.slice(hash + 1)];
55
+ };
56
+ /** An address under the site's base: `/guide/x#y` with base `/docs/` is `/docs/guide/x#y`. */
57
+ export const underBase = (base, address) => `${base}${address.replace(/^\//, "")}`;
58
+ export const publicFiles = (root) => new Set(filesUnder(join(root, PUBLIC_DIR)).map((file) => `/${file}`));
59
+ /** Reads `content/` under `root`. Every mistake in every file is reported together, not the first only. */
60
+ export const readContent = (root, config) => {
61
+ const problems = [];
62
+ const dir = join(root, CONTENT_DIR);
63
+ if (!existsSync(dir)) {
64
+ throw new AppError([diagnostic(CONTENT_DIR, 0, "content-missing", `there is no ${CONTENT_DIR}/ directory: each Markdown file in it becomes a page`)]);
65
+ }
66
+ const base = config.web.base;
67
+ const assets = publicFiles(root);
68
+ const raw = new Map();
69
+ let notFound;
70
+ for (const relative of filesUnder(dir)) {
71
+ const file = `${CONTENT_DIR}/${relative}`;
72
+ if (!relative.endsWith(".md")) {
73
+ problems.push(diagnostic(file, 0, "content-file", "only .md files belong in content/; put other files in public/"));
74
+ continue;
75
+ }
76
+ if (!/^[\w.\-/]+$/.test(relative)) {
77
+ problems.push(diagnostic(file, 0, "content-name", "a file name becomes an address: use letters, digits, - _ and ."));
78
+ continue;
79
+ }
80
+ const here = [];
81
+ const front = frontMatter(readFileSync(join(dir, relative), "utf8"), file, here);
82
+ let parsed;
83
+ try {
84
+ parsed = parseMarkdown(front.body, file, front.lines);
85
+ }
86
+ catch (error) {
87
+ if (error instanceof AppError) {
88
+ here.push(...error.diagnostics);
89
+ }
90
+ else {
91
+ throw error;
92
+ }
93
+ }
94
+ problems.push(...here);
95
+ if (parsed === undefined || here.length > 0) {
96
+ continue;
97
+ }
98
+ const heading = parsed.blocks.find((block) => block.kind === "heading" && block.level === 1);
99
+ const title = front.values["title"] ?? (heading === undefined ? "" : plain(heading.spans));
100
+ const order = front.values["order"];
101
+ if (title === "") {
102
+ problems.push(diagnostic(file, 1, "page-title", 'a page needs a title: add "title: …" to its front matter, or start it with a # heading'));
103
+ continue;
104
+ }
105
+ if (order !== undefined && !/^-?\d+$/.test(order)) {
106
+ problems.push(diagnostic(file, 1, "front-order", `"order" must be a whole number, found "${order}"`));
107
+ continue;
108
+ }
109
+ const path = pathOfFile(relative);
110
+ const entry = {
111
+ page: {
112
+ path,
113
+ file,
114
+ title,
115
+ description: front.values["description"] ?? "",
116
+ order: order === undefined ? 1000 : Number(order),
117
+ section: relative.includes("/") ? relative.split("/")[0] : "",
118
+ blocks: parsed.blocks,
119
+ outline: parsed.outline,
120
+ },
121
+ links: parsed.links,
122
+ anchors: parsed.anchors,
123
+ front,
124
+ };
125
+ if (path === "/404") {
126
+ notFound = entry;
127
+ continue;
128
+ }
129
+ if (raw.has(path)) {
130
+ problems.push(diagnostic(file, 0, "content-duplicate", `${path} is also the address of ${raw.get(path).page.file}`));
131
+ continue;
132
+ }
133
+ raw.set(path, entry);
134
+ }
135
+ if (!raw.has("/") && problems.length === 0) {
136
+ problems.push(diagnostic(CONTENT_DIR, 0, "content-home", "there is no content/index.md: it is the page at the site's address"));
137
+ }
138
+ const resolve = (entry, link) => {
139
+ const from = entry.page.file;
140
+ const href = link.href;
141
+ const bad = (code, message) => {
142
+ problems.push(diagnostic(from, link.line, code, message));
143
+ return href;
144
+ };
145
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
146
+ return /^(https?:|mailto:)/i.test(href) ? href : bad("link-scheme", `"${href}" is not an address a page may link to (http, https and mailto are)`);
147
+ }
148
+ if (link.image) {
149
+ if (!href.startsWith("/")) {
150
+ return bad("image-path", `an image address starts with "/" and names a file in ${PUBLIC_DIR}/ (found "${href}")`);
151
+ }
152
+ return assets.has(href) ? underBase(base, href) : bad("image-missing", `${PUBLIC_DIR}${href} does not exist`);
153
+ }
154
+ // A relative address is relative to the file it is written in, and names another .md file.
155
+ const absolute = href.startsWith("#") || href.startsWith("/")
156
+ ? href
157
+ : `${pathOfFile(posix.join(posix.dirname(from.slice(CONTENT_DIR.length + 1)), href.replace(/#.*$/, "")))}${href.includes("#") ? href.slice(href.indexOf("#")) : ""}`;
158
+ const [target, fragment] = absolute.startsWith("#") ? [entry.page.path, absolute.slice(1)] : splitAddress(absolute);
159
+ const page = target === entry.page.path ? entry : raw.get(target);
160
+ if (page === undefined) {
161
+ return assets.has(target) && fragment === "" ? underBase(base, target) : bad("link-broken", `${href} leads to no page (and no file in ${PUBLIC_DIR}/)`);
162
+ }
163
+ if (fragment !== "" && !page.anchors.has(fragment)) {
164
+ return bad("link-fragment", `${page.page.file} has no heading with the address #${fragment} (it has ${[...page.anchors].map((anchor) => `#${anchor}`).join(", ") || "none"})`);
165
+ }
166
+ return underBase(base, target) + (fragment === "" ? "" : `#${fragment}`);
167
+ };
168
+ // The parser recorded the links in document order, and a page's blocks hold them in that same order.
169
+ const rewrite = (entry) => {
170
+ const resolved = entry.links.map((link) => resolve(entry, link));
171
+ let n = 0;
172
+ const take = () => resolved[n++];
173
+ const spansOf = (spans) => spans.map((span) => (span.kind === "link" ? { ...span, href: take() } : span));
174
+ const blocks = entry.page.blocks.map((block) => ({
175
+ ...block,
176
+ ...(block.kind === "image" ? { src: take() } : {}),
177
+ spans: spansOf(block.spans),
178
+ items: block.items.map((item) => ({ ...item, spans: spansOf(item.spans) })),
179
+ }));
180
+ if (n !== resolved.length) {
181
+ throw new Error(`internal: ${entry.page.file} has ${resolved.length} links and the model holds ${n}`);
182
+ }
183
+ return { ...entry.page, blocks };
184
+ };
185
+ const pages = [...raw.values()].map(rewrite);
186
+ const fallback = notFound === undefined ? undefined : rewrite(notFound);
187
+ if (problems.length > 0) {
188
+ throw new AppError(problems);
189
+ }
190
+ const ordered = [...pages].sort((a, b) => (a.section === "" ? 0 : 1) - (b.section === "" ? 0 : 1) || a.section.localeCompare(b.section) || a.order - b.order || a.path.localeCompare(b.path));
191
+ return { site: { name: config.name, base: config.web.base, url: config.web.url }, pages: ordered, ...(fallback === undefined ? {} : { notFound: fallback }) };
192
+ };
193
+ export const sectionTitle = titleCase;
@@ -0,0 +1,31 @@
1
+ import type { MeshType } from "@valancex/valance/web/build";
2
+ import { Schema } from "effect";
3
+ import type { Node } from "./tree.js";
4
+ type SchemaOf = Schema.Schema<any, any, never>;
5
+ /** The model's entry for a component: exactly the interface it declares. */
6
+ export interface Entry {
7
+ readonly props: Readonly<Record<string, {
8
+ readonly type: MeshType;
9
+ readonly required: boolean;
10
+ }>>;
11
+ readonly events: Readonly<Record<string, {
12
+ readonly payload?: MeshType;
13
+ }>>;
14
+ readonly commands: Readonly<Record<string, never>>;
15
+ /** Every prop is a name the template reads: the same name and type, absent when the prop may be. */
16
+ readonly scope: Readonly<Record<string, MeshType>>;
17
+ }
18
+ /** A schema with no model type, said at the declaration. */
19
+ export interface Problem {
20
+ readonly kind: "props" | "events";
21
+ readonly name: string;
22
+ readonly reason: string;
23
+ }
24
+ /** What a schema is in the model: put in a struct, so the rules are state's. A field that may be `undefined` comes back optional. */
25
+ export declare const typeOf: (schema: SchemaOf) => MeshType;
26
+ /** The entry a declared interface makes, and every schema in it that has no model type. */
27
+ export declare const entryOf: (declared: NonNullable<Node["interface"]>) => {
28
+ readonly entry: Entry;
29
+ readonly problems: ReadonlyArray<Problem>;
30
+ };
31
+ export {};
@@ -0,0 +1,45 @@
1
+ import { manifest } from "@valancex/valance/web/build";
2
+ import { Schema } from "effect";
3
+ /** What a schema is in the model: put in a struct, so the rules are state's. A field that may be `undefined` comes back optional. */
4
+ export const typeOf = (schema) => {
5
+ const derived = manifest({ primitives: {}, scopes: { contract: Schema.Struct({ value: schema }) }, commands: () => ({}) });
6
+ return derived.components["contract"].scope["value"];
7
+ };
8
+ const reasonOf = (error) => {
9
+ const message = error instanceof Error ? error.message : String(error);
10
+ return message.replace(/^[\w.]+: /, "");
11
+ };
12
+ /** No payload: the schema of nothing. */
13
+ const isEmpty = (schema) => ["VoidKeyword", "UndefinedKeyword"].includes(schema.ast._tag);
14
+ /** The entry a declared interface makes, and every schema in it that has no model type. */
15
+ export const entryOf = (declared) => {
16
+ const problems = [];
17
+ const props = {};
18
+ const scope = {};
19
+ const events = {};
20
+ for (const [name, schema] of Object.entries(declared.props)) {
21
+ try {
22
+ const type = typeOf(schema);
23
+ const optional = type.kind === "optional";
24
+ props[name] = { type: optional ? type.type : type, required: !optional };
25
+ scope[name] = type;
26
+ }
27
+ catch (error) {
28
+ problems.push({ kind: "props", name, reason: reasonOf(error) });
29
+ }
30
+ }
31
+ for (const [name, schema] of Object.entries(declared.events)) {
32
+ try {
33
+ if (isEmpty(schema)) {
34
+ events[name] = {};
35
+ continue;
36
+ }
37
+ const type = typeOf(schema);
38
+ events[name] = { payload: type.kind === "optional" ? type.type : type };
39
+ }
40
+ catch (error) {
41
+ problems.push({ kind: "events", name, reason: reasonOf(error) });
42
+ }
43
+ }
44
+ return { entry: { props, events, commands: {}, scope }, problems };
45
+ };
@@ -0,0 +1,2 @@
1
+ /** The `dist/cli.js` of the `@valancex/cli` installed in the project that contains `cwd`, if it is not this package. */
2
+ export declare const localCli: (cwd: string) => string | undefined;
@@ -0,0 +1,23 @@
1
+ // The command on your PATH is not the version your project uses. Like `ng`, a global `vlx` hands `dev`, `build` and `check` to the `@valancex/cli` installed in the project (the one its
2
+ // package.json names, with the MESH compiler, runtime and VALANCE that go with it), so a project builds the same wherever it is run from. `init` and `--help` need no project and are the
3
+ // global command's own. With no local install (or the local one is this one) it runs as it is.
4
+ import { existsSync, realpathSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { findRoot } from "./config.js";
9
+ const SELF = realpathSync(fileURLToPath(new URL("../", import.meta.url)));
10
+ /** The `dist/cli.js` of the `@valancex/cli` installed in the project that contains `cwd`, if it is not this package. */
11
+ export const localCli = (cwd) => {
12
+ try {
13
+ const root = findRoot(cwd);
14
+ const manifest = createRequire(join(root, "package.json")).resolve("@valancex/cli/package.json");
15
+ const dir = realpathSync(manifest.replace(/[\\/]package\.json$/, ""));
16
+ const entry = join(dir, "dist", "cli.js");
17
+ return dir !== SELF && existsSync(entry) ? entry : undefined;
18
+ }
19
+ catch {
20
+ // No project here, or nothing installed in it: the command runs as it is.
21
+ return undefined;
22
+ }
23
+ };