@ai-ecoverse/slicc-man 0.0.0 → 1.1.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/lib/render.mjs ADDED
@@ -0,0 +1,214 @@
1
+ import { BOLD, inline, parse, plain, UNDERLINE } from './markdown.mjs';
2
+
3
+ const BODY = 7;
4
+ const SUB = 3;
5
+
6
+ function paint(cells, styled) {
7
+ let out = '';
8
+ let current = 0;
9
+ for (const cell of cells) {
10
+ const style = styled ? cell.style : 0;
11
+ if (style !== current) {
12
+ if (current !== 0) out += '\x1b[0m';
13
+ if (style & BOLD) out += '\x1b[1m';
14
+ if (style & UNDERLINE) out += '\x1b[4m';
15
+ current = style;
16
+ }
17
+ out += cell.ch;
18
+ }
19
+ if (current !== 0) out += '\x1b[0m';
20
+ return out;
21
+ }
22
+
23
+ function words(runs) {
24
+ const lines = [[]];
25
+ let word = [];
26
+ const end = () => {
27
+ if (word.length > 0) lines.at(-1).push(word);
28
+ word = [];
29
+ };
30
+ for (const run of runs) {
31
+ if (run.br) {
32
+ end();
33
+ lines.push([]);
34
+ continue;
35
+ }
36
+ for (const ch of run.text) {
37
+ if (/\s/u.test(ch) && ch !== '\u00a0') end();
38
+ else word.push({ ch, style: run.style });
39
+ }
40
+ }
41
+ end();
42
+ return lines;
43
+ }
44
+
45
+ function wrap(runs, indent, width, styled, lead = '') {
46
+ const hang = ' '.repeat(indent + lead.length);
47
+ const room = Math.max(width - indent - lead.length, 1);
48
+ const out = [];
49
+ let first = true;
50
+ for (const line of words(runs)) {
51
+ let cur = [];
52
+ let size = 0;
53
+ const emit = () => {
54
+ const text = cur.map((w) => paint(w, styled)).join(' ');
55
+ out.push(`${first ? ' '.repeat(indent) + lead : hang}${text}`.trimEnd());
56
+ first = false;
57
+ cur = [];
58
+ size = 0;
59
+ };
60
+ for (const w of line) {
61
+ if (cur.length > 0 && size + 1 + w.length > room) emit();
62
+ size += (cur.length > 0 ? 1 : 0) + w.length;
63
+ cur.push(w);
64
+ }
65
+ if (cur.length > 0 || out.length === 0 || line.length === 0) emit();
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function upper(runs) {
71
+ return runs.map((run) => ({ ...run, text: run.text.toUpperCase() }));
72
+ }
73
+
74
+ function table(block, indent, styled) {
75
+ const rows = block.rows.map((row) => {
76
+ const out = [];
77
+ for (let c = 0; c < block.columns; c++) {
78
+ const runs = inline(row[c] ?? '');
79
+ const cells = [];
80
+ for (const line of words(runs)) {
81
+ for (const w of line) {
82
+ if (cells.length > 0) cells.push({ ch: ' ', style: 0 });
83
+ cells.push(...w);
84
+ }
85
+ }
86
+ out.push(cells);
87
+ }
88
+ return out;
89
+ });
90
+ const widths = [];
91
+ for (let c = 0; c < block.columns; c++) {
92
+ widths.push(Math.max(3, ...rows.map((row) => row[c].length)));
93
+ }
94
+ const line = (row) =>
95
+ `${' '.repeat(indent)}| ${row
96
+ .map((cells, c) => paint(cells, styled) + ' '.repeat(widths[c] - cells.length))
97
+ .join(' | ')} |`;
98
+ const rule = `${' '.repeat(indent)}| ${widths.map((w) => '-'.repeat(w)).join(' | ')} |`;
99
+ return [line(rows[0]), rule, ...rows.slice(1).map(line)];
100
+ }
101
+
102
+ function emit(block, indent, ctx, out) {
103
+ const { width, styled } = ctx;
104
+ switch (block.type) {
105
+ case 'paragraph':
106
+ out.push(...wrap(inline(block.text), indent, width, styled));
107
+ break;
108
+ case 'code':
109
+ out.push(...block.lines.map((line) => `${' '.repeat(indent)}${line}`.trimEnd()));
110
+ break;
111
+ case 'grid':
112
+ out.push(...block.lines.map((line) => `${' '.repeat(indent)}${line}`));
113
+ break;
114
+ case 'table':
115
+ out.push(...table(block, indent, styled));
116
+ break;
117
+ case 'quote':
118
+ sequence(block.blocks, indent + 4, ctx, out);
119
+ break;
120
+ case 'list':
121
+ for (const [index, entry] of block.items.entries()) {
122
+ if (index > 0 && block.loose) out.push('');
123
+ const lead = `${entry.marker} `;
124
+ const [head, ...rest] = entry.blocks;
125
+ if (head?.type === 'paragraph') {
126
+ out.push(...wrap(inline(head.text), indent, width, styled, lead));
127
+ } else {
128
+ out.push(`${' '.repeat(indent)}${entry.marker}`);
129
+ if (head) rest.unshift(head);
130
+ }
131
+ for (const inner of rest) {
132
+ if (block.loose || inner.type !== 'list') out.push('');
133
+ emit(inner, indent + lead.length, ctx, out);
134
+ }
135
+ }
136
+ break;
137
+ case 'heading': {
138
+ const runs = inline(block.text);
139
+ if (block.level <= 2) out.push(paint(cellsOf(upper(runs)), styled));
140
+ else out.push(' '.repeat(SUB) + paint(cellsOf(runs), styled));
141
+ break;
142
+ }
143
+ }
144
+ }
145
+
146
+ function cellsOf(runs) {
147
+ const cells = [];
148
+ for (const line of words(runs)) {
149
+ for (const w of line) {
150
+ if (cells.length > 0) cells.push({ ch: ' ', style: BOLD });
151
+ cells.push(...w.map((cell) => ({ ...cell, style: cell.style | BOLD })));
152
+ }
153
+ }
154
+ return cells;
155
+ }
156
+
157
+ function sequence(list, indent, ctx, out) {
158
+ let after = true;
159
+ for (const block of list) {
160
+ if (block.type === 'rule') continue;
161
+ if (block.type === 'heading') {
162
+ if (out.length > 0) out.push('');
163
+ emit(block, indent, ctx, out);
164
+ after = true;
165
+ continue;
166
+ }
167
+ if (!after) out.push('');
168
+ emit(block, indent, ctx, out);
169
+ after = false;
170
+ }
171
+ }
172
+
173
+ function metadata(block) {
174
+ if (block?.type === 'grid') return block.first.join(' ').trim() === 'Metadata';
175
+ if (block?.type === 'table') return block.rows[0].join(' ').trim() === 'Metadata';
176
+ return false;
177
+ }
178
+
179
+ function header(topic, width) {
180
+ const side = `${topic.toUpperCase()}(1)`;
181
+ const middle = 'SLICC Manual';
182
+ const free = width - side.length * 2 - middle.length;
183
+ if (free < 2) return `${side} ${middle} ${side}`;
184
+ const left = Math.max(1, Math.floor((width - middle.length) / 2) - side.length);
185
+ const right = free - left;
186
+ if (right < 1) return `${side}${' '.repeat(free - 1)}${middle} ${side}`;
187
+ return `${side}${' '.repeat(left)}${middle}${' '.repeat(right)}${side}`;
188
+ }
189
+
190
+ export function render(markdown, { topic, width = 80, styled = false }) {
191
+ const list = parse(markdown);
192
+ while (list.length > 0 && list.at(-1).type === 'rule') list.pop();
193
+ if (metadata(list.at(-1))) list.pop();
194
+ const ctx = { width, styled };
195
+ const out = [header(topic, width), ''];
196
+ out.push(paint(cellsOf([{ text: 'NAME', style: 0 }]), styled));
197
+ const name = [{ text: topic, style: 0 }];
198
+ if (list[0]?.type === 'paragraph') {
199
+ name.push({ text: ' - ', style: 0 }, ...inline(list.shift().text));
200
+ }
201
+ out.push(...wrap(name, BODY, width, styled));
202
+ const body = [];
203
+ sequence(list, BODY, ctx, body);
204
+ if (body.length > 0) {
205
+ if (body[0] !== '') out.push('');
206
+ out.push(...body);
207
+ }
208
+ return `${out.join('\n')}\n`;
209
+ }
210
+
211
+ export function describe(markdown) {
212
+ const first = parse(markdown)[0];
213
+ return first?.type === 'paragraph' ? plain(inline(first.text)) : '';
214
+ }
package/lib/search.mjs ADDED
@@ -0,0 +1,83 @@
1
+ const special = /[.*+?^${}()|[\]\\/-]/g;
2
+
3
+ function bracket(pattern, i) {
4
+ let j = i + 1;
5
+ if (pattern[j] === '!' || pattern[j] === '^') j++;
6
+ if (pattern[j] === ']') j++;
7
+ j = pattern.indexOf(']', j);
8
+ if (j === -1) return { text: '\\[', next: i + 1 };
9
+ let body = pattern.slice(i + 1, j);
10
+ const not = body[0] === '!' || body[0] === '^';
11
+ if (not) body = body.slice(1);
12
+ return { text: `[${not ? '^' : ''}${body.replace(/[\\\]^]/g, '\\$&')}]`, next: j + 1 };
13
+ }
14
+
15
+ function glob(pattern) {
16
+ let out = '';
17
+ let i = 0;
18
+ while (i < pattern.length) {
19
+ const ch = pattern[i];
20
+ if (ch === '[') {
21
+ const found = bracket(pattern, i);
22
+ out += found.text;
23
+ i = found.next;
24
+ continue;
25
+ }
26
+ if (ch === '*') out += '.*';
27
+ else if (ch === '?') out += '.';
28
+ else if (ch === '\\' && i + 1 < pattern.length) out += pattern[++i].replace(special, '\\$&');
29
+ else out += ch.replace(special, '\\$&');
30
+ i++;
31
+ }
32
+ return new RegExp(`^(?:${out})$`, 'i');
33
+ }
34
+
35
+ export function words(text) {
36
+ return text.split(/[^\p{L}\p{N}_]+/u).filter(Boolean);
37
+ }
38
+
39
+ export function matcher(kind, mode, keyword) {
40
+ if (mode === 'regex') {
41
+ const re = new RegExp(keyword, 'i');
42
+ return kind === 'whatis'
43
+ ? (entry) => re.test(entry.topic)
44
+ : (entry) => re.test(entry.topic) || re.test(entry.description);
45
+ }
46
+ if (mode === 'wildcard') {
47
+ const re = glob(keyword);
48
+ return kind === 'whatis'
49
+ ? (entry) => re.test(entry.topic)
50
+ : (entry) => re.test(entry.topic) || words(entry.description).some((w) => re.test(w));
51
+ }
52
+ const want = keyword.toLowerCase();
53
+ return kind === 'whatis'
54
+ ? (entry) => entry.topic.toLowerCase() === want
55
+ : (entry) =>
56
+ entry.topic.toLowerCase() === want ||
57
+ words(entry.description).some((w) => w.toLowerCase() === want);
58
+ }
59
+
60
+ export function line(entry) {
61
+ return `${`${entry.topic} (1)`.padEnd(20)} - ${entry.description}`.trimEnd();
62
+ }
63
+
64
+ export function cut(text, width) {
65
+ const chars = Array.from(text);
66
+ if (chars.length <= width) return text;
67
+ return `${chars.slice(0, Math.max(width - 3, 0)).join('')}...`;
68
+ }
69
+
70
+ export function search(entries, matchers, all) {
71
+ const hits = matchers.map(() => false);
72
+ const found = [];
73
+ for (const entry of entries) {
74
+ const each = matchers.map((match) => match(entry));
75
+ const ok = all ? each.every(Boolean) : each.some(Boolean);
76
+ if (!ok) continue;
77
+ found.push(entry);
78
+ each.forEach((hit, n) => {
79
+ if (hit) hits[n] = true;
80
+ });
81
+ }
82
+ return { found, hits };
83
+ }
package/lib/site.mjs ADDED
@@ -0,0 +1,205 @@
1
+ import { describe } from './render.mjs';
2
+
3
+ export const DEFAULT_BASE = 'https://www.sliccy.com';
4
+ export const FRESH = 4 * 60 * 60;
5
+ const TIMEOUT = 30000;
6
+ const PARALLEL = 8;
7
+
8
+ export function valid(topic) {
9
+ return /^[A-Za-z0-9_+-][A-Za-z0-9._+-]*$/.test(topic);
10
+ }
11
+
12
+ export function base(env) {
13
+ return (env.SLICC_MAN_URL || DEFAULT_BASE).replace(/\/+$/, '');
14
+ }
15
+
16
+ export function folder(url) {
17
+ const parsed = new URL(url);
18
+ const host = parsed.hostname.replace(/[^A-Za-z0-9.-]/g, '_');
19
+ return parsed.port ? `${host}-${parsed.port}` : host;
20
+ }
21
+
22
+ export function key(topic) {
23
+ return `page-${topic.replace(/[A-Z_]/g, (ch) => (ch === '_' ? '__' : `_${ch.toLowerCase()}`))}`;
24
+ }
25
+
26
+ function age(headers) {
27
+ const match = /(?:^|[,\s])max-age\s*=\s*"?(\d+)"?/i.exec(headers.get('cache-control') ?? '');
28
+ return match ? Number(match[1]) : undefined;
29
+ }
30
+
31
+ function reason(error) {
32
+ if (error?.name === 'TimeoutError') return 'timed out';
33
+ const cause = error?.cause;
34
+ return cause?.message || cause?.code || error?.message || String(error);
35
+ }
36
+
37
+ function stamp(time) {
38
+ return new Date(time).toISOString().replace(/\.\d+Z$/, 'Z');
39
+ }
40
+
41
+ export function topics(xml) {
42
+ const seen = new Set();
43
+ const out = [];
44
+ for (const match of String(xml).matchAll(/<loc>\s*([^<]*?)\s*<\/loc>/g)) {
45
+ const text = match[1]
46
+ .replace(/&lt;/g, '<')
47
+ .replace(/&gt;/g, '>')
48
+ .replace(/&quot;/g, '"')
49
+ .replace(/&apos;/g, "'")
50
+ .replace(/&amp;/g, '&');
51
+ let path;
52
+ try {
53
+ path = new URL(text, 'https://localhost').pathname;
54
+ } catch {
55
+ continue;
56
+ }
57
+ const found = /^\/man\/([^/]+)$/.exec(path);
58
+ if (!found) continue;
59
+ let topic;
60
+ try {
61
+ topic = decodeURIComponent(found[1]);
62
+ } catch {
63
+ continue;
64
+ }
65
+ if (!valid(topic) || seen.has(topic)) continue;
66
+ seen.add(topic);
67
+ out.push(topic);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ export function site({ url, store, fetch, now = Date.now, name }) {
73
+ const root = url.replace(/\/+$/, '');
74
+
75
+ async function load(id) {
76
+ try {
77
+ const text = await store.read(id);
78
+ if (text === undefined) return undefined;
79
+ const entry = JSON.parse(text);
80
+ return entry && typeof entry === 'object' ? entry : undefined;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ async function save(id, entry) {
87
+ try {
88
+ await store.write(id, JSON.stringify(entry));
89
+ } catch {}
90
+ }
91
+
92
+ async function drop(id) {
93
+ try {
94
+ await store.remove(id);
95
+ } catch {}
96
+ }
97
+
98
+ const fresh = (entry) =>
99
+ typeof entry?.fetched === 'number' && now() - entry.fetched < (entry.maxAge ?? FRESH) * 1000;
100
+
101
+ async function request(address, id, entry) {
102
+ const headers = {};
103
+ if (entry?.lastModified) headers['if-modified-since'] = entry.lastModified;
104
+ const response = await fetch(address, { headers, signal: AbortSignal.timeout(TIMEOUT) });
105
+ if (response.status === 304 && typeof entry?.body === 'string') {
106
+ await response.body?.cancel();
107
+ await save(id, { ...entry, fetched: now(), maxAge: age(response.headers) ?? entry.maxAge });
108
+ return { status: 'ok', body: entry.body };
109
+ }
110
+ if (response.status === 200) {
111
+ const body = await response.text();
112
+ const lastModified = response.headers.get('last-modified') ?? undefined;
113
+ const maxAge = age(response.headers) ?? FRESH;
114
+ await save(id, { fetched: now(), maxAge, lastModified, body });
115
+ return { status: 'ok', body };
116
+ }
117
+ await response.body?.cancel();
118
+ if (response.status === 404 || response.status === 410) {
119
+ if (entry) await drop(id);
120
+ return { status: 'missing' };
121
+ }
122
+ return { why: `HTTP ${response.status}` };
123
+ }
124
+
125
+ async function get(address, id, { offline = false } = {}) {
126
+ const entry = await load(id);
127
+ const kept = typeof entry?.body === 'string' && typeof entry.fetched === 'number';
128
+ if (kept && (fresh(entry) || offline)) return { status: 'ok', body: entry.body };
129
+ if (offline) return { status: 'error', message: `${name}: ${address}: offline` };
130
+ let found;
131
+ try {
132
+ found = await request(address, id, entry);
133
+ } catch (error) {
134
+ found = { why: reason(error) };
135
+ }
136
+ if (found.status) return found;
137
+ if (!kept)
138
+ return { status: 'error', message: `${name}: ${address}: ${found.why}`, offline: true };
139
+ return {
140
+ status: 'ok',
141
+ body: entry.body,
142
+ stale: true,
143
+ note: `${name}: ${address}: ${found.why}; showing the copy cached ${stamp(entry.fetched)}`,
144
+ };
145
+ }
146
+
147
+ const address = (topic) => `${root}/man/${topic}.md`;
148
+
149
+ async function page(topic, options) {
150
+ if (!valid(topic)) return { status: 'missing' };
151
+ return get(address(topic), key(topic), options);
152
+ }
153
+
154
+ async function build(list, offline) {
155
+ const entries = new Array(list.length);
156
+ let next = 0;
157
+ let down = offline;
158
+ async function worker() {
159
+ while (next < list.length) {
160
+ const at = next++;
161
+ const topic = list[at];
162
+ const found = await page(topic, { offline: down });
163
+ if (found.stale || found.offline) down = true;
164
+ entries[at] = { topic, description: found.status === 'ok' ? describe(found.body) : '' };
165
+ }
166
+ }
167
+ await Promise.all(Array.from({ length: Math.min(PARALLEL, list.length) }, worker));
168
+ return { entries, down };
169
+ }
170
+
171
+ async function index() {
172
+ const cached = await load('index');
173
+ const usable = Array.isArray(cached?.entries);
174
+ if (usable && fresh(cached)) return { entries: cached.entries };
175
+ const map = `${root}/sitemap.xml`;
176
+ const found = await get(map, 'sitemap');
177
+ if (found.status !== 'ok') {
178
+ const message = found.message ?? `${name}: ${map}: HTTP 404`;
179
+ if (usable && typeof cached.fetched === 'number') {
180
+ const why = message.slice(`${name}: ${map}: `.length);
181
+ return {
182
+ entries: cached.entries,
183
+ note: `${name}: ${map}: ${why}; showing the copy cached ${stamp(cached.fetched)}`,
184
+ };
185
+ }
186
+ return { error: message };
187
+ }
188
+ if (found.stale) {
189
+ if (usable) return { entries: cached.entries, note: found.note };
190
+ return { entries: (await build(topics(found.body), true)).entries, note: found.note };
191
+ }
192
+ const sitemap = await load('sitemap');
193
+ const built = await build(topics(found.body), false);
194
+ if (!built.down) {
195
+ await save('index', {
196
+ fetched: now(),
197
+ maxAge: sitemap?.maxAge ?? FRESH,
198
+ entries: built.entries,
199
+ });
200
+ }
201
+ return { entries: built.entries };
202
+ }
203
+
204
+ return { address, page, index };
205
+ }
package/package.json CHANGED
@@ -1,10 +1,56 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-man",
3
- "version": "0.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "man, apropos and whatis for SLICC, and scman for SLICC, macOS, Linux and Windows, reading pages from www.sliccy.com/man",
5
+ "type": "module",
5
6
  "license": "Apache-2.0",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/ai-ecoverse/slicc-man.git"
10
+ },
11
+ "engines": {
12
+ "node": ">=24"
13
+ },
14
+ "bin": {
15
+ "scman": "host/scman.mjs"
16
+ },
17
+ "fledgling": {
18
+ "provider": "github",
19
+ "workflow": "ci.yml",
20
+ "repo": "ai-ecoverse/slicc-man",
21
+ "publish": true
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "files": [
28
+ "host",
29
+ "lib"
30
+ ],
31
+ "slicc": {
32
+ "unitSources": [
33
+ "host",
34
+ "lib",
35
+ "scripts"
36
+ ]
37
+ },
38
+ "scripts": {
39
+ "prepare": "slicc-install-hooks",
40
+ "lint": "slicc-lint && shellcheck -s sh install.sh && dash -n install.sh",
41
+ "format": "biome check --write .",
42
+ "test": "node --test --test-timeout=120000 test/integration/*.test.mjs",
43
+ "test:unit": "node scripts/cover.mjs",
44
+ "build": "npm pack --dry-run",
45
+ "trust": "npx fledgling sync -y --skip-publish",
46
+ "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
47
+ "release": "semantic-release"
48
+ },
49
+ "devDependencies": {
50
+ "@ai-ecoverse/slicc-shared-web": "2.11.3",
51
+ "@biomejs/biome": "2.5.15",
52
+ "@semantic-release/exec": "7.1.0",
53
+ "lefthook": "2.2.2",
54
+ "semantic-release": "25.0.9"
9
55
  }
10
56
  }