eidosmd 0.1.0 → 0.3.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.
- package/README.md +50 -29
- package/browser/dist/assets/index-Cc3cNWHY.css +1 -0
- package/browser/dist/assets/index-DQgCQRa5.js +46 -0
- package/browser/dist/favicon.svg +5 -0
- package/browser/dist/index.html +15 -0
- package/browser/dist/mark.svg +4 -0
- package/dist/src/cli.js +7 -0
- package/dist/src/commands/agents.js +1 -1
- package/dist/src/commands/canvas.js +77 -0
- package/dist/src/commands/check.js +10 -4
- package/dist/src/commands/configure.js +201 -0
- package/dist/src/commands/framework.js +37 -7
- package/dist/src/commands/index.js +4 -4
- package/dist/src/commands/init.js +5 -0
- package/dist/src/commands/instructions.js +1 -1
- package/dist/src/commands/list.js +8 -8
- package/dist/src/commands/migrate.js +32 -0
- package/dist/src/commands/new.js +3 -3
- package/dist/src/commands/property.js +125 -0
- package/dist/src/commands/seeds.js +5 -5
- package/dist/src/commands/setup.js +131 -0
- package/dist/src/commands/version.js +44 -0
- package/dist/src/commands/whoami.js +4 -4
- package/dist/src/context.js +5 -5
- package/dist/src/core/blueprint.js +16 -11
- package/dist/src/core/canvas-schema.js +148 -0
- package/dist/src/core/canvas.js +732 -0
- package/dist/src/core/check.js +221 -70
- package/dist/src/core/convert.js +7 -6
- package/dist/src/core/edits.js +1381 -0
- package/dist/src/core/framework-markdown.js +119 -34
- package/dist/src/core/framework-model.js +62 -10
- package/dist/src/core/framework-structured.js +222 -47
- package/dist/src/core/framework.js +13 -13
- package/dist/src/core/frontmatter.js +61 -1
- package/dist/src/core/git.js +84 -0
- package/dist/src/core/index-leaf.js +2 -2
- package/dist/src/core/links.js +87 -0
- package/dist/src/core/markdown.js +16 -9
- package/dist/src/core/me.js +16 -8
- package/dist/src/core/migrate.js +319 -0
- package/dist/src/core/naming.js +1 -1
- package/dist/src/core/regions.js +117 -0
- package/dist/src/core/root.js +2 -2
- package/dist/src/core/scaffold.js +33 -27
- package/dist/src/core/seed.js +187 -71
- package/dist/src/core/server.js +1410 -53
- package/dist/src/core/settings.js +232 -0
- package/dist/src/core/store.js +315 -0
- package/dist/src/core/template.js +32 -0
- package/dist/src/core/versions.js +84 -0
- package/dist/src/output.js +4 -1
- package/dist/src/program.js +421 -41
- package/instructions/authoring.md +15 -12
- package/instructions/configuring.md +81 -34
- package/instructions/init-required.md +4 -4
- package/instructions/overview.md +21 -9
- package/instructions/validating.md +9 -6
- package/package.json +21 -12
- package/standard/EIDOS.md +142 -259
- package/standard/seeds/README.md +12 -16
- package/standard/seeds/book/Framework.yaml +61 -0
- package/standard/seeds/book/README.md +10 -5
- package/standard/seeds/book/_gitignore +9 -3
- package/standard/seeds/book/me.md +1 -1
- package/standard/seeds/book/roles/README.md +3 -3
- package/standard/seeds/book/roles/framework-owner.md +2 -2
- package/standard/seeds/book/{shapes → templates}/chapter.full.md +0 -8
- package/standard/seeds/book/{shapes → templates}/chapter.sketch.md +0 -7
- package/standard/seeds/book/{shapes → templates}/frame.market.md +0 -6
- package/standard/seeds/book/templates/frame.premise.md +17 -0
- package/standard/seeds/book/{shapes → templates}/frame.reader.md +0 -6
- package/standard/seeds/book/{shapes → templates}/frame.voice.md +0 -7
- package/standard/seeds/research/Framework.yaml +61 -0
- package/standard/seeds/research/README.md +10 -5
- package/standard/seeds/research/_gitignore +9 -3
- package/standard/seeds/research/me.md +1 -1
- package/standard/seeds/research/roles/README.md +3 -3
- package/standard/seeds/research/roles/framework-owner.md +2 -2
- package/standard/seeds/research/{shapes → templates}/frame.ethics.md +0 -6
- package/standard/seeds/research/{shapes → templates}/frame.method.md +0 -7
- package/standard/seeds/research/{shapes → templates}/frame.prior-work.md +0 -6
- package/standard/seeds/research/{shapes → templates}/frame.question.md +0 -7
- package/standard/seeds/research/{shapes → templates}/investigation.full.md +0 -8
- package/standard/seeds/research/{shapes → templates}/investigation.note.md +0 -7
- package/standard/seeds/software/Framework.yaml +62 -0
- package/standard/seeds/software/README.md +7 -6
- package/standard/seeds/software/_gitignore +9 -3
- package/standard/seeds/software/me.md +1 -1
- package/standard/seeds/software/roles/README.md +3 -3
- package/standard/seeds/software/roles/framework-owner.md +2 -2
- package/standard/seeds/software/roles/project-manager.md +2 -2
- package/standard/seeds/software/roles/stakeholder.md +1 -1
- package/standard/seeds/software/{shapes → templates}/frame.architecture.md +0 -7
- package/standard/seeds/software/{shapes → templates}/frame.audience.md +1 -8
- package/standard/seeds/software/{shapes → templates}/frame.criteria.md +0 -8
- package/standard/seeds/software/{shapes → templates}/frame.market.md +0 -8
- package/standard/seeds/software/{shapes → templates}/spec.full.md +0 -8
- package/standard/seeds/software/{shapes → templates}/spec.micro.md +0 -9
- package/browser/index.html +0 -268
- package/dist/src/commands/convert.js +0 -30
- package/dist/src/core/shape.js +0 -26
- package/standard/seeds/book/Framework.md +0 -87
- package/standard/seeds/book/shapes/frame.premise.md +0 -24
- package/standard/seeds/research/Framework.md +0 -88
- package/standard/seeds/software/Framework.md +0 -88
- /package/standard/seeds/software/{shapes → templates}/.gitkeep +0 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// The CLI's own settings, Obsidian-style: plain YAML files in the folder the
|
|
2
|
+
// standard reserves for this tool, never a database. `settings.yaml` is the
|
|
3
|
+
// root's and travels with it (whether git is used, the people who work in
|
|
4
|
+
// the root, what every save does to a blueprint); `local.yaml` is one
|
|
5
|
+
// person's on one machine (who they are acting as, how their canvas
|
|
6
|
+
// behaves, which columns they hide, the diff viewer installed here), the
|
|
7
|
+
// personal file the standard names since 5.2.0, kept out of git by the one
|
|
8
|
+
// pattern the root's `.eidos/.gitignore` carries for every tool. Nothing
|
|
9
|
+
// the browser remembers lives anywhere else.
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { parse as parseYaml, stringify } from 'yaml';
|
|
13
|
+
import { FRAMEWORK_DIR, PLUGINS_DIR, TOOL_KEY } from './framework-model.js';
|
|
14
|
+
export const SETTINGS_DIR = `${PLUGINS_DIR}/${TOOL_KEY}`;
|
|
15
|
+
// The one line in the root's `.eidos/.gitignore` that keeps every tool's
|
|
16
|
+
// personal file out of the repository (Eidos 5.2.0), beside `me.md`.
|
|
17
|
+
export const LOCAL_IGNORE_PATTERN = 'plugins/*/local.yaml';
|
|
18
|
+
// Whether the root's `.eidos/.gitignore` already carries that line.
|
|
19
|
+
export function rootIgnoresLocal(root) {
|
|
20
|
+
const file = path.join(root, FRAMEWORK_DIR, '.gitignore');
|
|
21
|
+
if (!existsSync(file))
|
|
22
|
+
return false;
|
|
23
|
+
return readFileSync(file, 'utf8')
|
|
24
|
+
.split(/\r?\n/)
|
|
25
|
+
.some((line) => line.trim() === LOCAL_IGNORE_PATTERN);
|
|
26
|
+
}
|
|
27
|
+
export const DEFAULT_TAG_PREFIX = 'blueprints/';
|
|
28
|
+
export const DEFAULT_SHARED = { features: { git: true }, users: [], on_save: [], versions: { tag_prefix: DEFAULT_TAG_PREFIX }, check: { strict: false } };
|
|
29
|
+
export const DEFAULT_LOCAL = {
|
|
30
|
+
user: null,
|
|
31
|
+
theme: 'system',
|
|
32
|
+
canvas: { wheel_zoom: true, right_drag_pan: true, ctrl_click_menu: false, enter_commits: true, context_menu: true, resize_all: false },
|
|
33
|
+
tables: { page_size: 50, columns: {} },
|
|
34
|
+
source_mode: false,
|
|
35
|
+
hide_overview: false,
|
|
36
|
+
reader_width: 'fixed',
|
|
37
|
+
diff_viewer: null,
|
|
38
|
+
editor: null,
|
|
39
|
+
recent_searches: [],
|
|
40
|
+
};
|
|
41
|
+
const SHARED_HEADER = `eidosmd settings shared by everyone in this root. Edit by hand, with eidos setup, or from the browser.
|
|
42
|
+
features.git: whether the browser reads git (history, authors, versions) when the root is in a repository.
|
|
43
|
+
users: the people who work here: { name, alias (for @mentions, no spaces), role (from .eidos/roles/), email (ties to git) }.
|
|
44
|
+
on_save: rules applied to a blueprint's frontmatter on every save from the browser: { property, set: today | now }.
|
|
45
|
+
versions.tag_prefix: what a recorded version's git tag starts with (eidos version record --tag), blueprints/ by default.
|
|
46
|
+
check.strict: whether a warning fails eidos check as an error does; the framework owner's decision for this root (eidos check --strict / --no-strict override one run).`;
|
|
47
|
+
const LOCAL_HEADER = `eidosmd settings for this machine, ignored by git. Edit by hand, with eidos setup, or from the browser's Preferences.
|
|
48
|
+
user: who this machine acts as, by alias or name from users; absent = the git identity, else the role in me.md.
|
|
49
|
+
theme: system | light | dark. canvas: scroll-wheel zoom, right-drag pan, ctrl-click menus, whether Enter finishes an in-place edit, whether a right click opens the menu or the details, whether every node resizes. tables: page size and the columns hidden per collection. source_mode: the reader shows files as text. hide_overview: the sidebar leaves out Eidos Overview. reader_width: fixed (every page but the canvas held to about 1200px, for a wide screen) | fluid (the whole window).
|
|
50
|
+
editor: a command with {file} that opens a file in an external editor; blank = the system's default app.
|
|
51
|
+
diff_viewer: the git plugin's viewer, a command with {a} and {b} for the two files, e.g. "code --diff {a} {b}".`;
|
|
52
|
+
const isPlain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
53
|
+
export function settingsDir(root) {
|
|
54
|
+
return path.join(root, FRAMEWORK_DIR, SETTINGS_DIR);
|
|
55
|
+
}
|
|
56
|
+
function readYaml(file) {
|
|
57
|
+
if (!existsSync(file))
|
|
58
|
+
return {};
|
|
59
|
+
try {
|
|
60
|
+
const parsed = parseYaml(readFileSync(file, 'utf8'));
|
|
61
|
+
return isPlain(parsed) ? parsed : {};
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function cleanAlias(value) {
|
|
68
|
+
if (typeof value !== 'string')
|
|
69
|
+
return null;
|
|
70
|
+
const alias = value.replace(/^@/, '').replace(/\s+/g, '').trim();
|
|
71
|
+
return alias === '' ? null : alias;
|
|
72
|
+
}
|
|
73
|
+
function readUsers(value) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const entry of Array.isArray(value) ? value : []) {
|
|
76
|
+
if (!isPlain(entry) || typeof entry['name'] !== 'string' || entry['name'].trim() === '')
|
|
77
|
+
continue;
|
|
78
|
+
const text = (field) => (typeof field === 'string' && field.trim() !== '' ? field.trim() : null);
|
|
79
|
+
out.push({ name: entry['name'].trim(), alias: cleanAlias(entry['alias']), role: text(entry['role']), email: text(entry['email']) });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function readShared(value, base = DEFAULT_SHARED) {
|
|
84
|
+
const features = isPlain(value['features']) ? value['features'] : {};
|
|
85
|
+
const git = typeof features['git'] === 'boolean' ? features['git'] : base.features.git;
|
|
86
|
+
const rules = [];
|
|
87
|
+
for (const entry of Array.isArray(value['on_save']) ? value['on_save'] : []) {
|
|
88
|
+
if (isPlain(entry) && typeof entry['property'] === 'string' && entry['property'].trim() !== '' && (entry['set'] === 'today' || entry['set'] === 'now')) {
|
|
89
|
+
rules.push({ property: entry['property'].trim(), set: entry['set'] });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const versions = isPlain(value['versions']) ? value['versions'] : {};
|
|
93
|
+
const prefix = typeof versions['tag_prefix'] === 'string' ? versions['tag_prefix'].trim() : base.versions.tag_prefix;
|
|
94
|
+
const check = isPlain(value['check']) ? value['check'] : {};
|
|
95
|
+
const strict = typeof check['strict'] === 'boolean' ? check['strict'] : base.check.strict;
|
|
96
|
+
return {
|
|
97
|
+
features: { git },
|
|
98
|
+
users: 'users' in value ? readUsers(value['users']) : base.users.map((user) => ({ ...user })),
|
|
99
|
+
on_save: 'on_save' in value ? rules : base.on_save.map((rule) => ({ ...rule })),
|
|
100
|
+
versions: { tag_prefix: prefix },
|
|
101
|
+
check: { strict },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// A user by alias (case-insensitive) or by name.
|
|
105
|
+
export function findUser(users, key) {
|
|
106
|
+
if (!key)
|
|
107
|
+
return null;
|
|
108
|
+
const wanted = key.replace(/^@/, '').toLowerCase();
|
|
109
|
+
return users.find((user) => user.alias?.toLowerCase() === wanted) ?? users.find((user) => user.name.toLowerCase() === wanted) ?? null;
|
|
110
|
+
}
|
|
111
|
+
// The user this machine acts as, when local.yaml names one that exists.
|
|
112
|
+
export function actingUser(settings) {
|
|
113
|
+
return findUser(settings.shared.users, settings.local.user);
|
|
114
|
+
}
|
|
115
|
+
// A value the file does not carry, or carries wrongly, keeps `base`: the
|
|
116
|
+
// defaults on a read, the settings already held on a write.
|
|
117
|
+
function readLocal(value, base = DEFAULT_LOCAL) {
|
|
118
|
+
const out = JSON.parse(JSON.stringify(base));
|
|
119
|
+
if (value['user'] === null)
|
|
120
|
+
out.user = null;
|
|
121
|
+
else if (typeof value['user'] === 'string')
|
|
122
|
+
out.user = value['user'].trim() === '' ? null : value['user'].trim();
|
|
123
|
+
if (value['theme'] === 'light' || value['theme'] === 'dark' || value['theme'] === 'system')
|
|
124
|
+
out.theme = value['theme'];
|
|
125
|
+
const canvas = isPlain(value['canvas']) ? value['canvas'] : {};
|
|
126
|
+
for (const key of ['wheel_zoom', 'right_drag_pan', 'ctrl_click_menu', 'enter_commits', 'context_menu', 'resize_all']) {
|
|
127
|
+
if (typeof canvas[key] === 'boolean')
|
|
128
|
+
out.canvas[key] = canvas[key];
|
|
129
|
+
}
|
|
130
|
+
const tables = isPlain(value['tables']) ? value['tables'] : {};
|
|
131
|
+
if (typeof tables['page_size'] === 'number' && tables['page_size'] >= 10)
|
|
132
|
+
out.tables.page_size = Math.round(tables['page_size']);
|
|
133
|
+
if (isPlain(tables['columns'])) {
|
|
134
|
+
// a columns map given is the whole map (a write merges per collection
|
|
135
|
+
// before it gets here), so an entry it lacks is gone
|
|
136
|
+
out.tables.columns = {};
|
|
137
|
+
for (const [collection, hidden] of Object.entries(tables['columns'])) {
|
|
138
|
+
if (Array.isArray(hidden))
|
|
139
|
+
out.tables.columns[collection] = hidden.filter((name) => typeof name === 'string');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (typeof value['source_mode'] === 'boolean')
|
|
143
|
+
out.source_mode = value['source_mode'];
|
|
144
|
+
if (typeof value['hide_overview'] === 'boolean')
|
|
145
|
+
out.hide_overview = value['hide_overview'];
|
|
146
|
+
if (value['reader_width'] === 'fixed' || value['reader_width'] === 'fluid')
|
|
147
|
+
out.reader_width = value['reader_width'];
|
|
148
|
+
if (value['diff_viewer'] === null)
|
|
149
|
+
out.diff_viewer = null;
|
|
150
|
+
else if (typeof value['diff_viewer'] === 'string')
|
|
151
|
+
out.diff_viewer = value['diff_viewer'].trim() === '' ? null : value['diff_viewer'].trim();
|
|
152
|
+
if (value['editor'] === null)
|
|
153
|
+
out.editor = null;
|
|
154
|
+
else if (typeof value['editor'] === 'string')
|
|
155
|
+
out.editor = value['editor'].trim() === '' ? null : value['editor'].trim();
|
|
156
|
+
if (Array.isArray(value['recent_searches']))
|
|
157
|
+
out.recent_searches = value['recent_searches'].filter((entry) => typeof entry === 'string').slice(0, 10);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
export function readSettings(root) {
|
|
161
|
+
const dir = settingsDir(root);
|
|
162
|
+
const shared = path.join(dir, 'settings.yaml');
|
|
163
|
+
const local = path.join(dir, 'local.yaml');
|
|
164
|
+
return {
|
|
165
|
+
shared: readShared(readYaml(shared)),
|
|
166
|
+
local: readLocal(readYaml(local)),
|
|
167
|
+
files: { shared: path.relative(root, shared).split(path.sep).join('/'), local: path.relative(root, local).split(path.sep).join('/') },
|
|
168
|
+
configured: existsSync(shared),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function writeYaml(file, header, value) {
|
|
172
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
173
|
+
const comment = header
|
|
174
|
+
.split('\n')
|
|
175
|
+
.map((line) => `# ${line}`)
|
|
176
|
+
.join('\n');
|
|
177
|
+
writeFileSync(file, `${comment}\n${stringify(value)}`, 'utf8');
|
|
178
|
+
}
|
|
179
|
+
// Each file is written whole from the merged value: a partial patch keeps
|
|
180
|
+
// what it does not name. On a root whose `.eidos/.gitignore` does not yet
|
|
181
|
+
// carry the standard's pattern (one written before 5.2.0 and not migrated),
|
|
182
|
+
// a .gitignore of this tool's own is written beside the local file, so a
|
|
183
|
+
// personal setting never reaches the repository; `eidos migrate` adds the
|
|
184
|
+
// pattern and removes that file.
|
|
185
|
+
export function writeSettings(root, patch) {
|
|
186
|
+
const current = readSettings(root);
|
|
187
|
+
const dir = settingsDir(root);
|
|
188
|
+
if (isPlain(patch.shared)) {
|
|
189
|
+
const given = patch.shared;
|
|
190
|
+
const base = toPlain(current.shared);
|
|
191
|
+
const merged = { ...base, ...given };
|
|
192
|
+
if (isPlain(given['features']))
|
|
193
|
+
merged['features'] = { ...base['features'], ...given['features'] };
|
|
194
|
+
if (isPlain(given['versions']))
|
|
195
|
+
merged['versions'] = { ...base['versions'], ...given['versions'] };
|
|
196
|
+
if (isPlain(given['check']))
|
|
197
|
+
merged['check'] = { ...base['check'], ...given['check'] };
|
|
198
|
+
writeYaml(path.join(dir, 'settings.yaml'), SHARED_HEADER, readShared(merged, current.shared));
|
|
199
|
+
}
|
|
200
|
+
if (isPlain(patch.local)) {
|
|
201
|
+
const given = patch.local;
|
|
202
|
+
const base = toPlain(current.local);
|
|
203
|
+
const merged = { ...base, ...given };
|
|
204
|
+
if (isPlain(given['canvas']))
|
|
205
|
+
merged['canvas'] = { ...base['canvas'], ...given['canvas'] };
|
|
206
|
+
if (isPlain(given['tables'])) {
|
|
207
|
+
const tables = base['tables'];
|
|
208
|
+
merged['tables'] = { ...tables, ...given['tables'] };
|
|
209
|
+
if (isPlain(given['tables']['columns'])) {
|
|
210
|
+
// a collection given as null drops its entry, for a collection renamed away
|
|
211
|
+
const columns = { ...tables['columns'] };
|
|
212
|
+
for (const [collection, hidden] of Object.entries(given['tables']['columns'])) {
|
|
213
|
+
if (hidden === null)
|
|
214
|
+
delete columns[collection];
|
|
215
|
+
else
|
|
216
|
+
columns[collection] = hidden;
|
|
217
|
+
}
|
|
218
|
+
merged['tables']['columns'] = columns;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
writeYaml(path.join(dir, 'local.yaml'), LOCAL_HEADER, readLocal(merged, current.local));
|
|
222
|
+
const ignore = path.join(dir, '.gitignore');
|
|
223
|
+
if (!rootIgnoresLocal(root) && !existsSync(ignore))
|
|
224
|
+
writeFileSync(ignore, 'local.yaml\n', 'utf8');
|
|
225
|
+
}
|
|
226
|
+
return readSettings(root);
|
|
227
|
+
}
|
|
228
|
+
const toPlain = (value) => JSON.parse(JSON.stringify(value));
|
|
229
|
+
// Whether a listed user is the git identity: the same email, else the same name.
|
|
230
|
+
export function matchesIdentity(user, identity) {
|
|
231
|
+
return (user.email !== null && identity.email !== null && user.email.toLowerCase() === identity.email.toLowerCase()) || user.name.toLowerCase() === identity.name.toLowerCase();
|
|
232
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// The browser's cache. The files stay the truth: a read parses the root once
|
|
2
|
+
// and keeps it until something on disk changes, a write goes straight to the
|
|
3
|
+
// file and drops the cache. Beside the parsed root sits a search index in
|
|
4
|
+
// SQLite (node:sqlite, an FTS5 trigram table over title, summary, properties,
|
|
5
|
+
// and body) so `/` finds a phrase inside any blueprint in a keystroke. A
|
|
6
|
+
// runtime without node:sqlite (Node 20) gets a plain substring scorer with
|
|
7
|
+
// the same shape of result, so the page behaves the same everywhere.
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { readdirSync, statSync, watch } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { loadRoot } from '../context.js';
|
|
12
|
+
import { blueprintId, blueprintTitle, propertyString, resolveVariant } from './blueprint.js';
|
|
13
|
+
const IGNORED = new Set(['.git', 'node_modules', '.DS_Store']);
|
|
14
|
+
function documentOf(blueprint) {
|
|
15
|
+
const properties = Object.entries(blueprint.properties ?? {})
|
|
16
|
+
.filter(([key]) => key !== 'title' && key !== 'summary')
|
|
17
|
+
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.map((item) => String(item)).join(', ') : String(value ?? '')}`)
|
|
18
|
+
.join('\n');
|
|
19
|
+
return {
|
|
20
|
+
blueprint,
|
|
21
|
+
title: blueprintTitle(blueprint),
|
|
22
|
+
summary: propertyString(blueprint, 'summary') ?? '',
|
|
23
|
+
properties,
|
|
24
|
+
body: blueprint.body,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function hitOf(doc, snippet, field) {
|
|
28
|
+
const { variant, declared } = resolveVariant(doc.blueprint);
|
|
29
|
+
return {
|
|
30
|
+
path: doc.blueprint.rel,
|
|
31
|
+
id: blueprintId(doc.blueprint),
|
|
32
|
+
title: doc.title,
|
|
33
|
+
summary: doc.summary === '' ? null : doc.summary,
|
|
34
|
+
collection: doc.blueprint.collection.name,
|
|
35
|
+
group: doc.blueprint.group,
|
|
36
|
+
variant: variant?.name ?? declared,
|
|
37
|
+
snippet,
|
|
38
|
+
field,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// A window of text around the first occurrence of `needle`, the match in [ ].
|
|
42
|
+
function excerpt(text, needle, width = 90) {
|
|
43
|
+
const at = text.toLowerCase().indexOf(needle.toLowerCase());
|
|
44
|
+
if (at === -1) {
|
|
45
|
+
return text.slice(0, width);
|
|
46
|
+
}
|
|
47
|
+
const start = Math.max(0, at - Math.floor(width / 3));
|
|
48
|
+
const end = Math.min(text.length, at + needle.length + width);
|
|
49
|
+
const before = text.slice(start, at).replace(/\s+/g, ' ');
|
|
50
|
+
const match = text.slice(at, at + needle.length);
|
|
51
|
+
const after = text.slice(at + needle.length, end).replace(/\s+/g, ' ');
|
|
52
|
+
return `${start > 0 ? '…' : ''}${before}[${match}]${after}${end < text.length ? '…' : ''}`;
|
|
53
|
+
}
|
|
54
|
+
// The words of a query: every whitespace-separated piece, quotes stripped.
|
|
55
|
+
function terms(query) {
|
|
56
|
+
return query
|
|
57
|
+
.split(/\s+/)
|
|
58
|
+
.map((term) => term.replace(/["']/g, '').trim())
|
|
59
|
+
.filter((term) => term !== '');
|
|
60
|
+
}
|
|
61
|
+
// The fallback: every document scanned, title matches first, then summary,
|
|
62
|
+
// then a property line, then the body. Fine into the low thousands.
|
|
63
|
+
class ScanEngine {
|
|
64
|
+
docs;
|
|
65
|
+
constructor(docs = []) {
|
|
66
|
+
this.docs = docs;
|
|
67
|
+
}
|
|
68
|
+
rebuild(docs) {
|
|
69
|
+
this.docs = docs;
|
|
70
|
+
}
|
|
71
|
+
search(query, limit) {
|
|
72
|
+
const words = terms(query).map((term) => term.toLowerCase());
|
|
73
|
+
if (words.length === 0)
|
|
74
|
+
return [];
|
|
75
|
+
const hits = [];
|
|
76
|
+
for (const doc of this.docs) {
|
|
77
|
+
const fields = [
|
|
78
|
+
['title', doc.title, 0],
|
|
79
|
+
['summary', doc.summary, 1],
|
|
80
|
+
['properties', doc.properties, 2],
|
|
81
|
+
['body', doc.body, 3],
|
|
82
|
+
];
|
|
83
|
+
const all = fields.map(([, text]) => text.toLowerCase()).join('\n');
|
|
84
|
+
if (!words.every((word) => all.includes(word)))
|
|
85
|
+
continue;
|
|
86
|
+
const first = fields.find(([, text]) => words.some((word) => text.toLowerCase().includes(word)));
|
|
87
|
+
if (!first)
|
|
88
|
+
continue;
|
|
89
|
+
const word = words.find((candidate) => first[1].toLowerCase().includes(candidate)) ?? words[0] ?? '';
|
|
90
|
+
hits.push({ rank: first[2], hit: hitOf(doc, first[0] === 'title' ? doc.title : excerpt(first[1], word), first[0]) });
|
|
91
|
+
}
|
|
92
|
+
return hits.sort((a, b) => a.rank - b.rank || a.hit.title.localeCompare(b.hit.title)).slice(0, limit).map((entry) => entry.hit);
|
|
93
|
+
}
|
|
94
|
+
close() {
|
|
95
|
+
this.docs = [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// FTS5 with the trigram tokenizer: a substring anywhere in the document,
|
|
99
|
+
// case-insensitive, ranked by bm25 with the title weighted far above the body.
|
|
100
|
+
class SqliteEngine {
|
|
101
|
+
db;
|
|
102
|
+
docs = new Map();
|
|
103
|
+
constructor(db) {
|
|
104
|
+
this.db = db;
|
|
105
|
+
this.db.exec("CREATE VIRTUAL TABLE docs USING fts5(path UNINDEXED, title, summary, properties, body, tokenize='trigram')");
|
|
106
|
+
}
|
|
107
|
+
rebuild(docs) {
|
|
108
|
+
this.docs = new Map(docs.map((doc) => [doc.blueprint.rel, doc]));
|
|
109
|
+
const insert = this.db.prepare('INSERT INTO docs (path, title, summary, properties, body) VALUES (?, ?, ?, ?, ?)');
|
|
110
|
+
this.db.exec('BEGIN');
|
|
111
|
+
try {
|
|
112
|
+
this.db.exec('DELETE FROM docs');
|
|
113
|
+
for (const doc of docs) {
|
|
114
|
+
insert.run(doc.blueprint.rel, doc.title, doc.summary, doc.properties, doc.body);
|
|
115
|
+
}
|
|
116
|
+
this.db.exec('COMMIT');
|
|
117
|
+
}
|
|
118
|
+
catch (cause) {
|
|
119
|
+
this.db.exec('ROLLBACK');
|
|
120
|
+
throw cause;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
search(query, limit) {
|
|
124
|
+
const words = terms(query);
|
|
125
|
+
// The trigram tokenizer needs three characters; shorter words narrow the
|
|
126
|
+
// result in JS instead of the index.
|
|
127
|
+
const indexed = words.filter((word) => word.length >= 3);
|
|
128
|
+
const short = words.filter((word) => word.length < 3).map((word) => word.toLowerCase());
|
|
129
|
+
if (indexed.length === 0) {
|
|
130
|
+
return new ScanEngine([...this.docs.values()]).search(query, limit);
|
|
131
|
+
}
|
|
132
|
+
const match = indexed.map((word) => `"${word.replace(/"/g, '""')}"`).join(' AND ');
|
|
133
|
+
const rows = this.db
|
|
134
|
+
.prepare(`SELECT path, bm25(docs, 0, 12, 6, 3, 1) AS rank,
|
|
135
|
+
highlight(docs, 1, '[', ']') AS title_hit,
|
|
136
|
+
highlight(docs, 2, '[', ']') AS summary_hit,
|
|
137
|
+
snippet(docs, 3, '[', ']', '…', 10) AS properties_hit,
|
|
138
|
+
snippet(docs, 4, '[', ']', '…', 14) AS body_hit
|
|
139
|
+
FROM docs WHERE docs MATCH ? ORDER BY rank LIMIT ?`)
|
|
140
|
+
.all(match, Math.max(limit * 3, 30));
|
|
141
|
+
const hits = [];
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
const doc = this.docs.get(row.path);
|
|
144
|
+
if (!doc)
|
|
145
|
+
continue;
|
|
146
|
+
if (short.length > 0) {
|
|
147
|
+
const all = `${doc.title}\n${doc.summary}\n${doc.properties}\n${doc.body}`.toLowerCase();
|
|
148
|
+
if (!short.every((word) => all.includes(word)))
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (row.title_hit.includes('['))
|
|
152
|
+
hits.push(hitOf(doc, row.title_hit, 'title'));
|
|
153
|
+
else if (row.summary_hit.includes('['))
|
|
154
|
+
hits.push(hitOf(doc, row.summary_hit, 'summary'));
|
|
155
|
+
else if (row.properties_hit.includes('['))
|
|
156
|
+
hits.push(hitOf(doc, row.properties_hit.replace(/\s+/g, ' '), 'properties'));
|
|
157
|
+
else
|
|
158
|
+
hits.push(hitOf(doc, row.body_hit.replace(/\s+/g, ' '), 'body'));
|
|
159
|
+
if (hits.length >= limit)
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
return hits;
|
|
163
|
+
}
|
|
164
|
+
close() {
|
|
165
|
+
this.db.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// node:sqlite, loaded synchronously and quietly: on Node 22 the module prints
|
|
169
|
+
// an ExperimentalWarning the first time it loads, which has no place on the
|
|
170
|
+
// terminal that just printed the browser's URL.
|
|
171
|
+
function openSqlite() {
|
|
172
|
+
const previous = process.listeners('warning');
|
|
173
|
+
process.removeAllListeners('warning');
|
|
174
|
+
process.on('warning', (warning) => {
|
|
175
|
+
if (warning.name === 'ExperimentalWarning' && /sqlite/i.test(warning.message))
|
|
176
|
+
return;
|
|
177
|
+
for (const listener of previous)
|
|
178
|
+
listener(warning);
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
const sqlite = createRequire(import.meta.url)('node:sqlite');
|
|
182
|
+
return new sqlite.DatabaseSync(':memory:');
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export class Store {
|
|
189
|
+
root;
|
|
190
|
+
engine;
|
|
191
|
+
cwd;
|
|
192
|
+
cached = null;
|
|
193
|
+
dirty = true;
|
|
194
|
+
search_;
|
|
195
|
+
watcher = null;
|
|
196
|
+
poller = null;
|
|
197
|
+
pending = null;
|
|
198
|
+
// the root-relative paths that changed since the last event went out
|
|
199
|
+
changed = new Set();
|
|
200
|
+
listeners = new Set();
|
|
201
|
+
constructor(options) {
|
|
202
|
+
this.root = options.root;
|
|
203
|
+
this.cwd = options.cwd;
|
|
204
|
+
const db = openSqlite();
|
|
205
|
+
this.search_ = db ? new SqliteEngine(db) : new ScanEngine();
|
|
206
|
+
this.engine = db ? 'sqlite' : 'scan';
|
|
207
|
+
if (options.watch !== false) {
|
|
208
|
+
this.startWatching();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// The parsed root, re-read only after a change on disk or a write here.
|
|
212
|
+
context() {
|
|
213
|
+
if (this.cached === null || this.dirty) {
|
|
214
|
+
this.cached = loadRoot({ root: this.root }, this.cwd);
|
|
215
|
+
this.dirty = false;
|
|
216
|
+
this.search_.rebuild(this.cached.blueprints.map(documentOf));
|
|
217
|
+
}
|
|
218
|
+
return this.cached;
|
|
219
|
+
}
|
|
220
|
+
// After a write: the next read parses again.
|
|
221
|
+
invalidate() {
|
|
222
|
+
this.dirty = true;
|
|
223
|
+
}
|
|
224
|
+
search(query, limit = 20) {
|
|
225
|
+
this.context();
|
|
226
|
+
return this.search_.search(query, limit);
|
|
227
|
+
}
|
|
228
|
+
// Called after the disk settles, with the root-relative paths that
|
|
229
|
+
// changed, so a view can tell whether the file it holds is one of them.
|
|
230
|
+
onChange(listener) {
|
|
231
|
+
this.listeners.add(listener);
|
|
232
|
+
return () => this.listeners.delete(listener);
|
|
233
|
+
}
|
|
234
|
+
close() {
|
|
235
|
+
this.watcher?.close();
|
|
236
|
+
this.watcher = null;
|
|
237
|
+
if (this.poller)
|
|
238
|
+
clearInterval(this.poller);
|
|
239
|
+
this.poller = null;
|
|
240
|
+
if (this.pending)
|
|
241
|
+
clearTimeout(this.pending);
|
|
242
|
+
this.search_.close();
|
|
243
|
+
}
|
|
244
|
+
noticed(name) {
|
|
245
|
+
if (name.split(/[\\/]/).some((part) => IGNORED.has(part)))
|
|
246
|
+
return;
|
|
247
|
+
this.dirty = true;
|
|
248
|
+
if (name !== '')
|
|
249
|
+
this.changed.add(name.split(path.sep).join('/'));
|
|
250
|
+
if (this.pending)
|
|
251
|
+
clearTimeout(this.pending);
|
|
252
|
+
this.pending = setTimeout(() => {
|
|
253
|
+
this.pending = null;
|
|
254
|
+
const paths = [...this.changed].sort();
|
|
255
|
+
this.changed.clear();
|
|
256
|
+
for (const listener of this.listeners)
|
|
257
|
+
listener(paths);
|
|
258
|
+
}, 150);
|
|
259
|
+
}
|
|
260
|
+
// Recursive watching where the platform has it (macOS, Windows, recent
|
|
261
|
+
// Linux); elsewhere a poll of the tree's mtimes every two seconds does the
|
|
262
|
+
// same job, so the page behaves the same on every machine.
|
|
263
|
+
startWatching() {
|
|
264
|
+
try {
|
|
265
|
+
this.watcher = watch(this.root, { recursive: true }, (_event, filename) => this.noticed(typeof filename === 'string' ? filename : ''));
|
|
266
|
+
this.watcher.unref();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
this.watcher = null;
|
|
270
|
+
let last = snapshotTree(this.root);
|
|
271
|
+
this.poller = setInterval(() => {
|
|
272
|
+
const next = snapshotTree(this.root);
|
|
273
|
+
for (const [file, stamp] of next)
|
|
274
|
+
if (last.get(file) !== stamp)
|
|
275
|
+
this.noticed(file);
|
|
276
|
+
for (const file of last.keys())
|
|
277
|
+
if (!next.has(file))
|
|
278
|
+
this.noticed(file);
|
|
279
|
+
last = next;
|
|
280
|
+
}, 2000);
|
|
281
|
+
this.poller.unref();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Every file under the root with its mtime, root-relative, for the poll.
|
|
286
|
+
function snapshotTree(root) {
|
|
287
|
+
const out = new Map();
|
|
288
|
+
const walk = (dir) => {
|
|
289
|
+
let entries = [];
|
|
290
|
+
try {
|
|
291
|
+
entries = readdirSync(dir);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
for (const entry of entries) {
|
|
297
|
+
if (IGNORED.has(entry))
|
|
298
|
+
continue;
|
|
299
|
+
const full = path.join(dir, entry);
|
|
300
|
+
let stat;
|
|
301
|
+
try {
|
|
302
|
+
stat = statSync(full);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (stat.isDirectory())
|
|
308
|
+
walk(full);
|
|
309
|
+
else
|
|
310
|
+
out.set(path.relative(root, full).split(path.sep).join('/'), stat.mtimeMs);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
walk(root);
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// A template file: one body template in `.eidos/templates/`, read for the
|
|
2
|
+
// sections it declares (to validate a blueprint against) and as the body a
|
|
3
|
+
// new blueprint is rendered from.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { headings } from './markdown.js';
|
|
6
|
+
import { templatePath } from './framework.js';
|
|
7
|
+
const FRONTMATTER_BLOCK = /^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
8
|
+
// The template without the frontmatter block it should never have carried.
|
|
9
|
+
export function stripFrontmatter(text) {
|
|
10
|
+
return text.replace(FRONTMATTER_BLOCK, '').replace(/^\n+/, '');
|
|
11
|
+
}
|
|
12
|
+
export function loadTemplate(framework, variant) {
|
|
13
|
+
const file = templatePath(framework, variant);
|
|
14
|
+
if (!existsSync(file)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const text = readFileSync(file, 'utf8');
|
|
18
|
+
const all = headings(text);
|
|
19
|
+
return {
|
|
20
|
+
path: file,
|
|
21
|
+
variant,
|
|
22
|
+
text,
|
|
23
|
+
sections: all.filter((heading) => heading.level === 2).map((heading) => heading.text),
|
|
24
|
+
headings: all,
|
|
25
|
+
frontmatter: FRONTMATTER_BLOCK.test(text),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// The body a scaffolded blueprint starts from: the template with its title
|
|
29
|
+
// filled in and any leading blank lines dropped.
|
|
30
|
+
export function renderTemplate(template, title) {
|
|
31
|
+
return template.replace(/\r\n?/g, '\n').replace(/\{\{title\}\}/g, title).replace(/^\n+/, '');
|
|
32
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The root's own versions: snapshots taken on purpose, this CLI's own record
|
|
2
|
+
// under `.eidos/plugins/eidosmd/versions.yaml` (the standard reads none of a
|
|
3
|
+
// plugin's folder, and since 5.0.0 keeps no `versions` key of its own). A
|
|
4
|
+
// version is a name the owner chose and a commit that already exists in the
|
|
5
|
+
// repository the root lives in; the commit is the snapshot, nothing is copied.
|
|
6
|
+
// A tag, when wanted, is the root's tag prefix (settings.yaml
|
|
7
|
+
// `versions.tag_prefix`, `blueprints/` unless changed) before the version.
|
|
8
|
+
// Nothing here proposes a version: it is written only when asked, and it
|
|
9
|
+
// needs git.
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { parse, stringify } from 'yaml';
|
|
13
|
+
import { FRAMEWORK_DIR, TOOL_KEY } from './framework-model.js';
|
|
14
|
+
import { gitResolve, gitTag, gitTagExists } from './git.js';
|
|
15
|
+
import { DEFAULT_TAG_PREFIX, readSettings } from './settings.js';
|
|
16
|
+
export const TAG_PREFIX = DEFAULT_TAG_PREFIX;
|
|
17
|
+
// What a tag on this root starts with, as its settings say.
|
|
18
|
+
export const tagPrefix = (root) => readSettings(root).shared.versions.tag_prefix;
|
|
19
|
+
export class VersionError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'VersionError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export const versionsFile = (root) => path.join(root, FRAMEWORK_DIR, 'plugins', TOOL_KEY, 'versions.yaml');
|
|
26
|
+
const GUIDANCE = "# The root's own versions, kept by the eidos CLI (`eidos version`): snapshots taken on purpose, newest first.\n# version is the root's own number, commit the sha that is the snapshot, tag <prefix><version> when one was made.\n";
|
|
27
|
+
// Newest first, as recorded; none when the file is absent.
|
|
28
|
+
export function readVersions(root) {
|
|
29
|
+
const file = versionsFile(root);
|
|
30
|
+
if (!existsSync(file))
|
|
31
|
+
return [];
|
|
32
|
+
const data = parse(readFileSync(file, 'utf8'));
|
|
33
|
+
const rows = data && typeof data === 'object' && Array.isArray(data.versions) ? (data.versions) : [];
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const row of rows) {
|
|
36
|
+
if (!row || typeof row !== 'object')
|
|
37
|
+
continue;
|
|
38
|
+
const entry = row;
|
|
39
|
+
const version = String(entry['version'] ?? '').trim();
|
|
40
|
+
const commit = String(entry['commit'] ?? '').trim();
|
|
41
|
+
if (version === '' || commit === '')
|
|
42
|
+
continue;
|
|
43
|
+
const tag = String(entry['tag'] ?? '').trim();
|
|
44
|
+
out.push({ version, commit, tag: tag === '' ? null : tag });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
export function writeVersions(root, versions) {
|
|
49
|
+
const file = versionsFile(root);
|
|
50
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
const rows = versions.map((version) => (version.tag ? { version: version.version, commit: version.commit, tag: version.tag } : { version: version.version, commit: version.commit }));
|
|
52
|
+
writeFileSync(file, GUIDANCE + stringify({ versions: rows }, { lineWidth: 0 }), 'utf8');
|
|
53
|
+
return file;
|
|
54
|
+
}
|
|
55
|
+
export function recordVersion(root, options) {
|
|
56
|
+
const version = options.version.trim();
|
|
57
|
+
if (version === '') {
|
|
58
|
+
throw new VersionError("a version needs a name (the root's own number, e.g. 1.0.0)");
|
|
59
|
+
}
|
|
60
|
+
const existing = readVersions(root);
|
|
61
|
+
if (existing.some((entry) => entry.version === version)) {
|
|
62
|
+
throw new VersionError(`version '${version}' is already recorded`);
|
|
63
|
+
}
|
|
64
|
+
const ref = options.commit && options.commit.trim() !== '' ? options.commit.trim() : 'HEAD';
|
|
65
|
+
const sha = gitResolve(root, ref);
|
|
66
|
+
if (!sha) {
|
|
67
|
+
throw new VersionError(`'${ref}' is not a commit in the repository this root lives in; a version names a commit that exists`);
|
|
68
|
+
}
|
|
69
|
+
const short = sha.slice(0, 12);
|
|
70
|
+
const tag = options.tag ? `${options.tagPrefix ?? tagPrefix(root)}${version}` : null;
|
|
71
|
+
let tagged = false;
|
|
72
|
+
if (tag) {
|
|
73
|
+
if (gitTagExists(root, tag)) {
|
|
74
|
+
throw new VersionError(`tag '${tag}' already exists`);
|
|
75
|
+
}
|
|
76
|
+
tagged = gitTag(root, tag, sha);
|
|
77
|
+
if (!tagged) {
|
|
78
|
+
throw new VersionError(`could not create tag '${tag}'`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const row = { version, commit: short, tag };
|
|
82
|
+
writeVersions(root, [row, ...existing]);
|
|
83
|
+
return { version: row, tagged };
|
|
84
|
+
}
|
package/dist/src/output.js
CHANGED
|
@@ -20,8 +20,11 @@ export function print(...lines) {
|
|
|
20
20
|
export function printError(message) {
|
|
21
21
|
process.stderr.write(message.endsWith('\n') ? message : message + '\n');
|
|
22
22
|
}
|
|
23
|
+
// The local calendar date, YYYY-MM-DD: an on-save rule at nine in the evening
|
|
24
|
+
// writes today, not UTC's tomorrow.
|
|
23
25
|
export function today() {
|
|
24
|
-
|
|
26
|
+
const now = new Date();
|
|
27
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
25
28
|
}
|
|
26
29
|
// Left-aligned columns for a plain table; widths from the widest cell.
|
|
27
30
|
export function table(rows, gap = 2) {
|