@jtakeit/kit 0.2.0 → 0.4.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 +5 -0
- package/bin/jtkit.mjs +28 -0
- package/lib/build-codes.mjs +29 -0
- package/lib/catalogue.mjs +50 -10
- package/lib/content.mjs +101 -0
- package/lib/lint.mjs +369 -0
- package/lib/progress.mjs +317 -0
- package/package.json +15 -3
- package/types/content.d.ts +9 -0
- package/types/lint.d.ts +19 -0
- package/types/progress.d.ts +50 -0
package/README.md
CHANGED
|
@@ -165,6 +165,11 @@ scaffold.
|
|
|
165
165
|
|
|
166
166
|
## Also here
|
|
167
167
|
|
|
168
|
+
- `@jtakeit/kit/progress` — where the site is, read off the files: the brief's
|
|
169
|
+
fill, the pages written, the entries, the settings, what is missing. The
|
|
170
|
+
scaffold's dev server serves it as `/_jtk/progress.json` for the panel, and
|
|
171
|
+
`jtkit progress` prints the same list for the agent; a rule in one is a
|
|
172
|
+
rule in both, and nothing in it is declared.
|
|
168
173
|
- `@jtakeit/kit/money` — a price as the page says it: «2 300 ₴», no kopecks
|
|
169
174
|
on a whole price, no sign where the site has not said its currency.
|
|
170
175
|
- `@jtakeit/kit/booking/words` — the form's words per language, the salon's
|
package/bin/jtkit.mjs
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* kit's types, collections and module into jtk/catalogue.json
|
|
8
8
|
* jtkit catalogue [--declaration src/content/blocks.ts] [--emit-only] [--dist ./dist] [--judge]
|
|
9
9
|
* write jtk/catalogue.json from a declaration and check it against the built pages
|
|
10
|
+
* jtkit lint [--dist dist] [--root .] [--site-url https://…]
|
|
11
|
+
* the annotation lint the platform's build runs, on this working tree
|
|
12
|
+
* jtkit progress [--root .] [--json]
|
|
13
|
+
* where the site is, read off the files: the brief, the pages, the entries, what is missing
|
|
10
14
|
*/
|
|
11
15
|
import { pathToFileURL } from 'node:url';
|
|
12
16
|
import { join, dirname, resolve } from 'node:path';
|
|
@@ -44,6 +48,30 @@ switch (command) {
|
|
|
44
48
|
}
|
|
45
49
|
break;
|
|
46
50
|
}
|
|
51
|
+
case 'progress': {
|
|
52
|
+
// The same reading the scaffold's dev server serves the panel as
|
|
53
|
+
// /_jtk/progress.json — what the owner sees as missing is what this
|
|
54
|
+
// prints (jtakeit-core, wiki/73).
|
|
55
|
+
const { readProgress, sayProgress } = await import(pathToFileURL(join(HERE, '..', 'lib', 'progress.mjs')).href);
|
|
56
|
+
const progress = await readProgress(resolve(flag('root', '.')));
|
|
57
|
+
console.log(has('json') ? JSON.stringify(progress, null, 2) : sayProgress(progress));
|
|
58
|
+
process.exit(progress.missing.length === 0 ? 0 : 1);
|
|
59
|
+
}
|
|
60
|
+
case 'lint': {
|
|
61
|
+
// The annotation lint the platform's build runs, on this working tree:
|
|
62
|
+
// `dist/` against `jtk/` and the catalogue, every finding at once,
|
|
63
|
+
// before any push (core wiki/71 · §3.2).
|
|
64
|
+
const { lintCommand } = await import(pathToFileURL(join(HERE, '..', 'lib', 'lint.mjs')).href);
|
|
65
|
+
const root = resolve(flag('root', process.cwd()));
|
|
66
|
+
const dist = resolve(root, flag('dist', 'dist'));
|
|
67
|
+
try {
|
|
68
|
+
process.exit(await lintCommand({ root, dist, siteURL: flag('site-url', process.env.SITE_URL ?? '') }));
|
|
69
|
+
} catch (why) {
|
|
70
|
+
console.error(`jtkit lint: ${why.message}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
47
75
|
case 'catalogue':
|
|
48
76
|
case 'catalog': {
|
|
49
77
|
process.argv = [process.argv[0], process.argv[1], ...rest];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The codes the annotation lint may say.
|
|
3
|
+
*
|
|
4
|
+
* A copy of the platform's `backend/api/sitebuild/errcodes.mjs`, which is
|
|
5
|
+
* generated from the one declaration of the codes (`registry/errcode.go`), for
|
|
6
|
+
* the same reason `codes.mjs` is a copy: a local lint that needs the network to
|
|
7
|
+
* name a fault is a local lint nobody runs. What keeps it honest is the
|
|
8
|
+
* platform's own parity test, which reads the lint it stages from this package
|
|
9
|
+
* against the Go table on every builder change.
|
|
10
|
+
*/
|
|
11
|
+
export const CODES = Object.freeze({
|
|
12
|
+
UNANNOTATED_FIELD: 'JTK_E_UNANNOTATED_FIELD',
|
|
13
|
+
PAGE_NOT_BUILT: 'JTK_E_PAGE_NOT_BUILT',
|
|
14
|
+
ROBOTS_DISALLOW: 'JTK_E_ROBOTS_DISALLOW',
|
|
15
|
+
SITEMAP_EMPTY: 'JTK_E_SITEMAP_EMPTY',
|
|
16
|
+
CANONICAL_ELSEWHERE: 'JTK_E_CANONICAL_ELSEWHERE',
|
|
17
|
+
NOINDEX: 'JTK_E_NOINDEX',
|
|
18
|
+
SHARED_NOT_RENDERED: 'JTK_E_SHARED_NOT_RENDERED',
|
|
19
|
+
ANNOTATION_UNKNOWN: 'JTK_E_ANNOTATION_UNKNOWN',
|
|
20
|
+
CATALOGUE_MISSING: 'JTK_E_CATALOGUE_MISSING',
|
|
21
|
+
FORM_INCOMPLETE: 'JTK_E_FORM_INCOMPLETE',
|
|
22
|
+
FORM_FIELD_UNDECLARED: 'JTK_E_FORM_FIELD_UNDECLARED',
|
|
23
|
+
FORM_UNDECLARED: 'JTK_E_FORM_UNDECLARED',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** One line of the lint's output, with its code in front of it. */
|
|
27
|
+
export function said(code, message) {
|
|
28
|
+
return `${code} ${message}`;
|
|
29
|
+
}
|
package/lib/catalogue.mjs
CHANGED
|
@@ -735,7 +735,35 @@ for (const file of sourceFiles(join(root, 'src'))) {
|
|
|
735
735
|
*/
|
|
736
736
|
|
|
737
737
|
/** The credential, presented the way the admin API reads it. */
|
|
738
|
-
|
|
738
|
+
/**
|
|
739
|
+
* The working tree's other jtk/ files, sent beside the catalogue so the
|
|
740
|
+
* second judge — the settings, the entries, a price table's gaps, an add-on
|
|
741
|
+
* nobody can take — answers in the same call, before any push (core
|
|
742
|
+
* wiki/71 · §3.2). Everything under jtk/ but the catalogue itself, as
|
|
743
|
+
* `{path, text}`; a site with no jtk/ yet sends none.
|
|
744
|
+
* @param {string} root
|
|
745
|
+
* @returns {{ path: string, text: string }[]}
|
|
746
|
+
*/
|
|
747
|
+
export function filesBeside(root) {
|
|
748
|
+
const dir = join(root, 'jtk');
|
|
749
|
+
if (!existsSync(dir)) return [];
|
|
750
|
+
/** @type {{ path: string, text: string }[]} */
|
|
751
|
+
const out = [];
|
|
752
|
+
const walk = (/** @type {string} */ at) => {
|
|
753
|
+
for (const entry of readdirSync(at, { withFileTypes: true })) {
|
|
754
|
+
const full = join(at, entry.name);
|
|
755
|
+
if (entry.isDirectory()) { walk(full); continue; }
|
|
756
|
+
if (!entry.name.endsWith('.json')) continue;
|
|
757
|
+
const rel = relative(root, full).split(sep).join('/');
|
|
758
|
+
if (rel === 'jtk/catalogue.json') continue;
|
|
759
|
+
out.push({ path: rel, text: readFileSync(full, 'utf8') });
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
walk(dir);
|
|
763
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function judgeRequest(text, files) {
|
|
739
767
|
const api = (process.env.JTK_API ?? '').trim().replace(/\/+$/, '');
|
|
740
768
|
const token = (process.env.JTK_TOKEN ?? '').trim();
|
|
741
769
|
return {
|
|
@@ -753,7 +781,7 @@ function judgeRequest(text) {
|
|
|
753
781
|
accept: 'application/json',
|
|
754
782
|
'content-type': 'application/json',
|
|
755
783
|
},
|
|
756
|
-
body: JSON.stringify({ catalogue: text }),
|
|
784
|
+
body: JSON.stringify(files.length > 0 ? { catalogue: text, files } : { catalogue: text }),
|
|
757
785
|
},
|
|
758
786
|
};
|
|
759
787
|
}
|
|
@@ -782,13 +810,14 @@ as a bearer; anything else is taken for the \`sid\` cookie of a person signed in
|
|
|
782
810
|
to the admin. The MCP server itself reads neither variable — it is hosted, and
|
|
783
811
|
an agent connects to it through the browser.
|
|
784
812
|
|
|
785
|
-
An agent with the MCP server needs neither:
|
|
786
|
-
|
|
787
|
-
settings, the content —
|
|
813
|
+
An agent with the MCP server needs neither: \`validate_catalogue\` with the
|
|
814
|
+
catalogue and the jtk/ files beside it judges the whole working tree — this
|
|
815
|
+
catalogue, the settings, the content — before any push, every finding at once.`);
|
|
788
816
|
process.exit(1);
|
|
789
817
|
}
|
|
790
818
|
|
|
791
|
-
const
|
|
819
|
+
const files = filesBeside(root);
|
|
820
|
+
const { url, init } = judgeRequest(text, files);
|
|
792
821
|
|
|
793
822
|
let response;
|
|
794
823
|
try {
|
|
@@ -822,7 +851,18 @@ settings, the content — with the same judge and every finding at once.`);
|
|
|
822
851
|
return 0;
|
|
823
852
|
}
|
|
824
853
|
|
|
825
|
-
const
|
|
854
|
+
const all = verdict.findings ?? [];
|
|
855
|
+
// The files' findings — the settings, an entry, a price table — are the
|
|
856
|
+
// judge's alone, about files this checker never reads: printed as what
|
|
857
|
+
// they are, and not examined. The catalogue's own are the exam.
|
|
858
|
+
const ofFiles = all.filter((one) => /^jtk\//.test(one.path ?? ''));
|
|
859
|
+
const findings = all.filter((one) => !ofFiles.includes(one));
|
|
860
|
+
for (const one of ofFiles) {
|
|
861
|
+
console.error(` ✗ ${one.code} ${one.path}: ${one.says}`);
|
|
862
|
+
}
|
|
863
|
+
if (ofFiles.length > 0) {
|
|
864
|
+
console.error(` ${ofFiles.length} finding(s) in the files beside the catalogue (${files.length} sent) — refused at import until fixed.\n`);
|
|
865
|
+
}
|
|
826
866
|
// Advice is the judge's alone — rules about the bookings module it runs,
|
|
827
867
|
// which this checker has no copy of — so it is printed and not examined.
|
|
828
868
|
const advice = [...(verdict.advice ?? []), ...findings.filter((one) => isAdvice(one.code))];
|
|
@@ -845,10 +885,10 @@ settings, the content — with the same judge and every finding at once.`);
|
|
|
845
885
|
|
|
846
886
|
if (missed.length === 0 && invented.length === 0) {
|
|
847
887
|
console.log(
|
|
848
|
-
`judged by ${url} — contract ${verdict.contract}, ${findings.length} finding(s), ` +
|
|
849
|
-
|
|
888
|
+
`judged by ${url} — contract ${verdict.contract}, ${findings.length} finding(s) in the catalogue, ` +
|
|
889
|
+
`${ofFiles.length} in the ${files.length} file(s) beside it, and this checker said the same of the catalogue`,
|
|
850
890
|
);
|
|
851
|
-
return
|
|
891
|
+
return ofFiles.length;
|
|
852
892
|
}
|
|
853
893
|
|
|
854
894
|
console.error(`\njtk catalogue: the judge and this checker do not agree (contract ${verdict.contract})\n`);
|
package/lib/content.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The published content as one document, from the files in `jtk/`.
|
|
3
|
+
*
|
|
4
|
+
* The content lives in the repository as one file per page, because that is
|
|
5
|
+
* what makes a diff readable; everything that reads it — the build, the
|
|
6
|
+
* annotation lint — wants one document, because that is what a template
|
|
7
|
+
* renders from. This is the seam between the two, the same one the platform's
|
|
8
|
+
* build runs (`collect-content.mjs`), here so that `jtkit lint` can read a
|
|
9
|
+
* working tree the way the build will. Nothing over the network.
|
|
10
|
+
*/
|
|
11
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/** @param {string} path */
|
|
15
|
+
async function readJSON(path) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT') return null;
|
|
20
|
+
throw new Error(`${path}: ${error.message}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every content file under a directory, at any depth, sorted. @param {string} root */
|
|
25
|
+
async function contentFiles(root) {
|
|
26
|
+
/** @type {string[]} */
|
|
27
|
+
const found = [];
|
|
28
|
+
async function walk(/** @type {string} */ current) {
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT') return;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const path = join(current, entry.name);
|
|
38
|
+
if (entry.isDirectory()) await walk(path);
|
|
39
|
+
else if (entry.name.endsWith('.json')) found.push(path);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await walk(root);
|
|
43
|
+
return found.sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Which language a shared file is, out of its name: `shared.json` the site's
|
|
48
|
+
* own, `shared.de.json` German; null for a file that is not one.
|
|
49
|
+
* @param {string} name
|
|
50
|
+
*/
|
|
51
|
+
export function sharedLocaleOf(name) {
|
|
52
|
+
const match = /^shared(?:\.([a-z]{2}(?:-[a-z]{2})?))?\.json$/.exec(name);
|
|
53
|
+
return match === null ? null : (match[1] ?? '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The document the build renders from. `jtk/site.json` is what publish writes;
|
|
58
|
+
* a working tree before its first publish has none, and the lint reads it
|
|
59
|
+
* anyway with the site's facts left empty — the annotations do not depend on
|
|
60
|
+
* them.
|
|
61
|
+
* @param {string} root the site's root, holding `jtk/`
|
|
62
|
+
* @param {{ dir?: string }} [options] `dir`: where `jtk/` is, relative to the root
|
|
63
|
+
*/
|
|
64
|
+
export async function collectContent(root, options = {}) {
|
|
65
|
+
const dir = join(root, options.dir ?? 'jtk');
|
|
66
|
+
const site = (await readJSON(join(dir, 'site.json'))) ?? {};
|
|
67
|
+
const pages = [];
|
|
68
|
+
for (const file of await contentFiles(join(dir, 'content'))) {
|
|
69
|
+
const page = await readJSON(file);
|
|
70
|
+
if (page === null) continue;
|
|
71
|
+
if (typeof page.path !== 'string') throw new Error(`${file}: no "path" — cannot tell which page this is`);
|
|
72
|
+
const collected = { path: page.path, seo: page.seo ?? {}, blocks: page.blocks ?? [] };
|
|
73
|
+
if (typeof page.collection === 'string' && page.collection !== '') {
|
|
74
|
+
collected.collection = page.collection;
|
|
75
|
+
collected.visible = page.visible !== false;
|
|
76
|
+
}
|
|
77
|
+
pages.push(collected);
|
|
78
|
+
}
|
|
79
|
+
pages.sort((a, b) => (a.path === '/' ? -1 : b.path === '/' ? 1 : a.path.localeCompare(b.path)));
|
|
80
|
+
|
|
81
|
+
const shared = [];
|
|
82
|
+
for (const name of (await readdir(dir).catch(() => [])).sort()) {
|
|
83
|
+
const locale = sharedLocaleOf(name);
|
|
84
|
+
if (locale === null) continue;
|
|
85
|
+
const file = await readJSON(join(dir, name));
|
|
86
|
+
if (file?.blocks?.length) shared.push({ ...(locale === '' ? {} : { locale }), blocks: file.blocks });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
schema_version: site.schema_version ?? 1,
|
|
91
|
+
content_rev: site.content_rev ?? 0,
|
|
92
|
+
site: {
|
|
93
|
+
slug: site.slug ?? '',
|
|
94
|
+
locale: site.locale ?? 'uk',
|
|
95
|
+
media_base: site.media_base ?? '',
|
|
96
|
+
business: site.business ?? {},
|
|
97
|
+
},
|
|
98
|
+
pages,
|
|
99
|
+
...(shared.length > 0 ? { shared } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
package/lib/lint.mjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The annotation lint: the built pages against the content and the catalogue.
|
|
3
|
+
*
|
|
4
|
+
* «Every content field carries data-jtk-path» is not a convention — it is a
|
|
5
|
+
* gate. An element that loses its annotation breaks the visual editor
|
|
6
|
+
* SILENTLY: the sheet stops opening when the client taps that heading, and
|
|
7
|
+
* nobody finds out for a week. The lint compares two things it did not write
|
|
8
|
+
* — the content and the HTML the site produced — never the component source,
|
|
9
|
+
* so it works with any template, including one an agent wrote this afternoon.
|
|
10
|
+
*
|
|
11
|
+
* The platform's build runs this lint on every demo build. It is here so that
|
|
12
|
+
* a site can run it on its own `dist/` before any push (`jtkit lint`, core
|
|
13
|
+
* wiki/71 · §3.2): the same checks, the same codes, the same sentences, and
|
|
14
|
+
* every finding at once rather than one build round each.
|
|
15
|
+
*
|
|
16
|
+
* `lint()` returns findings rather than printing and exiting, so a script, a
|
|
17
|
+
* test and the build can each do what they do with them. The order and the
|
|
18
|
+
* early stops are the build's: a page that says noindex stops the lint, as it
|
|
19
|
+
* stops the build.
|
|
20
|
+
*/
|
|
21
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
22
|
+
import { join, relative } from 'node:path';
|
|
23
|
+
import { CODES, said } from './build-codes.mjs';
|
|
24
|
+
import { collectContent } from './content.mjs';
|
|
25
|
+
|
|
26
|
+
export { CODES, said };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The kinds a person edits by tapping the thing itself. A URL, a boolean or
|
|
30
|
+
* an alt text is edited in the panel beside it, so requiring an annotated
|
|
31
|
+
* element for those would fail every honest template.
|
|
32
|
+
*/
|
|
33
|
+
export const TAPPABLE = new Set(['text', 'textarea', 'richtext_lite', 'markdown', 'media']);
|
|
34
|
+
|
|
35
|
+
/** @param {any} field */
|
|
36
|
+
function tappable(field) {
|
|
37
|
+
if (!TAPPABLE.has(field.kind)) return false;
|
|
38
|
+
if (field.client_editable === false) return false;
|
|
39
|
+
if (field.no_tap_target === true) return false;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {string} url */
|
|
44
|
+
function hostOf(url) {
|
|
45
|
+
try {
|
|
46
|
+
return new URL(url).host.toLowerCase();
|
|
47
|
+
} catch {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A robots.txt whose `User-agent: *` group says `Disallow: /` and nothing narrower. @param {string} text */
|
|
53
|
+
function disallowsEverybody(text) {
|
|
54
|
+
let everybody = false;
|
|
55
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
56
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
57
|
+
if (line === '') continue;
|
|
58
|
+
const [key, ...rest] = line.split(':');
|
|
59
|
+
const value = rest.join(':').trim();
|
|
60
|
+
if (key.trim().toLowerCase() === 'user-agent') everybody = value === '*';
|
|
61
|
+
else if (everybody && key.trim().toLowerCase() === 'disallow' && value === '/') return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {string} path */
|
|
67
|
+
async function readOrEmpty(path) {
|
|
68
|
+
try {
|
|
69
|
+
return await readFile(path, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {string} dir */
|
|
76
|
+
async function htmlFiles(dir) {
|
|
77
|
+
/** @type {string[]} */
|
|
78
|
+
const out = [];
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const path = join(dir, entry.name);
|
|
87
|
+
if (entry.isDirectory()) out.push(...(await htmlFiles(path)));
|
|
88
|
+
else if (entry.name.endsWith('.html')) out.push(path);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Every path a built page is expected to carry, for one page's blocks. */
|
|
94
|
+
export function expectedPaths(blocks, typeOf) {
|
|
95
|
+
/** @type {string[]} */
|
|
96
|
+
const paths = [];
|
|
97
|
+
blocks.forEach((block, index) => {
|
|
98
|
+
const blockType = typeOf(block.type);
|
|
99
|
+
if (blockType === undefined) return;
|
|
100
|
+
for (const field of blockType.fields ?? []) {
|
|
101
|
+
const value = block[field.key];
|
|
102
|
+
if (value === undefined || value === null || value === '') continue;
|
|
103
|
+
if (field.kind === 'media' && field.multiple === true) {
|
|
104
|
+
if (!Array.isArray(value) || !tappable(field)) continue;
|
|
105
|
+
value.forEach((item, itemIndex) => {
|
|
106
|
+
if (item === null || typeof item !== 'object' || !item.src) return;
|
|
107
|
+
paths.push(`blocks[${index}].${field.key}[${itemIndex}].src`);
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (field.kind === 'list') {
|
|
112
|
+
if (!Array.isArray(value)) continue;
|
|
113
|
+
value.forEach((item, itemIndex) => {
|
|
114
|
+
for (const sub of field.of ?? []) {
|
|
115
|
+
const subValue = item[sub.key];
|
|
116
|
+
if (!tappable(sub)) continue;
|
|
117
|
+
if (subValue === undefined || subValue === null || subValue === '') continue;
|
|
118
|
+
paths.push(`blocks[${index}].${field.key}[${itemIndex}].${sub.key}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (tappable(field)) paths.push(`blocks[${index}].${field.key}`);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return paths;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Every form on the page that posts to the platform's enquiry endpoint. @param {string} html */
|
|
130
|
+
function leadForms(html) {
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const [form] of html.matchAll(/<form\b[^>]*>[\s\S]*?<\/form>/gi)) {
|
|
133
|
+
const action = attrOf(form.slice(0, form.indexOf('>') + 1), 'action') ?? '';
|
|
134
|
+
if (/(^|\/)api\/lead\/?(\?|#|$)/.test(action)) out.push(form);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The names of a form's controls, buttons left out. @param {string} form */
|
|
140
|
+
function controlsOf(form) {
|
|
141
|
+
const names = new Set();
|
|
142
|
+
for (const [tag] of form.matchAll(/<(?:input|select|textarea)\b[^>]*>/gi)) {
|
|
143
|
+
const type = (attrOf(tag, 'type') ?? '').toLowerCase();
|
|
144
|
+
if (type === 'submit' || type === 'button' || type === 'reset' || type === 'image') continue;
|
|
145
|
+
const name = attrOf(tag, 'name');
|
|
146
|
+
if (name !== undefined && name !== '') names.add(name);
|
|
147
|
+
}
|
|
148
|
+
return names;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {string} tag @param {string} name */
|
|
152
|
+
function attrOf(tag, name) {
|
|
153
|
+
const quoted = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag);
|
|
154
|
+
if (quoted === null) return undefined;
|
|
155
|
+
return quoted[1] ?? quoted[2] ?? quoted[3];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether a block collects enquiries — as the API judges it, not only as the
|
|
160
|
+
* file says: a catalogue written before `collects` existed says nothing, and
|
|
161
|
+
* a block with a `form` switch is one that can put a form on the page.
|
|
162
|
+
* @param {any} blockType
|
|
163
|
+
*/
|
|
164
|
+
function collectsEnquiry(blockType) {
|
|
165
|
+
if (blockType === undefined) return false;
|
|
166
|
+
if (blockType.collects === 'enquiry') return true;
|
|
167
|
+
if (blockType.collects !== undefined && blockType.collects !== '') return false;
|
|
168
|
+
return (blockType.fields ?? []).some((field) => field.key === 'form' && field.kind === 'bool');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const OWN_CONTROLS = new Set(['name', 'phone', 'email', 'contact', 'message', 'website']);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @typedef {{ code: string, says: string }} Finding
|
|
175
|
+
* @typedef {{ findings: Finding[], ok: boolean, pages: number, shared: boolean, line: string }} Verdict
|
|
176
|
+
*/
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Lint a built site.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} options
|
|
182
|
+
* @param {string} [options.root] the site's root, holding `jtk/` — the working directory by default
|
|
183
|
+
* @param {string} [options.dist] the built pages — `dist` under the root by default
|
|
184
|
+
* @param {string} [options.siteURL] the address the build is for (`SITE_URL`); empty skips the host checks
|
|
185
|
+
* @param {any} [options.catalogue] the catalogue, read from `jtk/catalogue.json` when left out
|
|
186
|
+
* @param {any} [options.content] the content document, collected from `jtk/` when left out
|
|
187
|
+
* @returns {Promise<Verdict>}
|
|
188
|
+
*/
|
|
189
|
+
export async function lint(options = {}) {
|
|
190
|
+
const root = options.root ?? process.cwd();
|
|
191
|
+
const distDir = options.dist ?? join(root, 'dist');
|
|
192
|
+
/** @type {Finding[]} */
|
|
193
|
+
const findings = [];
|
|
194
|
+
const fault = (code, says) => findings.push({ code, says });
|
|
195
|
+
const verdict = (line = '') => ({ findings, ok: findings.length === 0, pages: 0, shared: false, line });
|
|
196
|
+
|
|
197
|
+
// The catalogue: refused rather than skipped when missing. A lint that
|
|
198
|
+
// quietly passes when it cannot find its catalogue is worse than no lint.
|
|
199
|
+
let registry = options.catalogue;
|
|
200
|
+
if (registry === undefined) {
|
|
201
|
+
const file = join(root, 'jtk', 'catalogue.json');
|
|
202
|
+
try {
|
|
203
|
+
registry = JSON.parse(await readFile(file, 'utf8'));
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error.code !== 'ENOENT') throw new Error(`could not read ${file}: ${error.message}`);
|
|
206
|
+
fault(CODES.CATALOGUE_MISSING, `no jtk/catalogue.json — this site has no block catalogue, so nothing can check that its content is annotated`);
|
|
207
|
+
return verdict();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const typeOf = (name) => registry?.blocks?.find((block) => block.type === name);
|
|
211
|
+
const content = options.content ?? (await collectContent(root));
|
|
212
|
+
|
|
213
|
+
// What every build promises search engines, read off the files themselves.
|
|
214
|
+
{
|
|
215
|
+
const host = hostOf(options.siteURL ?? '');
|
|
216
|
+
const front = await readOrEmpty(join(distDir, 'index.html'));
|
|
217
|
+
if (/<meta[^>]+name=["']robots["'][^>]*noindex/i.test(front) || /<meta[^>]+content=["'][^"']*noindex[^"']*["'][^>]*name=["']robots["']/i.test(front)) {
|
|
218
|
+
fault(CODES.NOINDEX, "the front page says noindex — nothing in a page decides whether it is indexed; the edge keeps a preview out of the index, and a build is always made for the site's address");
|
|
219
|
+
return verdict();
|
|
220
|
+
}
|
|
221
|
+
if (disallowsEverybody(await readOrEmpty(join(distDir, 'robots.txt')))) {
|
|
222
|
+
fault(CODES.ROBOTS_DISALLOW, 'robots.txt says Disallow: / for every crawler');
|
|
223
|
+
return verdict();
|
|
224
|
+
}
|
|
225
|
+
const sitemap = await readOrEmpty(join(distDir, 'sitemap.xml'));
|
|
226
|
+
if (sitemap !== '') {
|
|
227
|
+
const locs = [...sitemap.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]);
|
|
228
|
+
const foreign = host === '' ? [] : locs.filter((loc) => hostOf(loc) !== host);
|
|
229
|
+
if (locs.length === 0 || foreign.length > 0) {
|
|
230
|
+
fault(CODES.SITEMAP_EMPTY, locs.length === 0 ? 'sitemap.xml lists no pages' : `sitemap.xml lists pages on ${hostOf(foreign[0])}, and this build is for ${host} (SITE_URL)`);
|
|
231
|
+
return verdict();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const canonical = /<link[^>]+rel=["']canonical["'][^>]*href=["']([^"']+)["']/i.exec(front)?.[1] ?? /<link[^>]+href=["']([^"']+)["'][^>]*rel=["']canonical["']/i.exec(front)?.[1];
|
|
235
|
+
if (host !== '' && canonical !== undefined && hostOf(canonical) !== '' && hostOf(canonical) !== host) {
|
|
236
|
+
fault(CODES.CANONICAL_ELSEWHERE, `the front page's canonical is on ${hostOf(canonical)}, and this build is for ${host} (SITE_URL)`);
|
|
237
|
+
return verdict();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const htmlByPath = new Map();
|
|
242
|
+
for (const file of await htmlFiles(distDir)) {
|
|
243
|
+
const rel = '/' + relative(distDir, file).replaceAll('\\', '/');
|
|
244
|
+
htmlByPath.set(rel.replace(/\/index\.html$/, '').replace(/\.html$/, '') || '/', await readFile(file, 'utf8'));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The form against the contract of an enquiry.
|
|
248
|
+
const sharedBlocks = (content.shared ?? []).flatMap((document) => document.blocks ?? []);
|
|
249
|
+
const siteCollects = (registry?.blocks ?? []).some(collectsEnquiry);
|
|
250
|
+
const pageByPath = new Map((content.pages ?? []).map((page) => [page.path, page]));
|
|
251
|
+
const asksOf = (blocks) => {
|
|
252
|
+
const keys = new Set();
|
|
253
|
+
for (const block of blocks) {
|
|
254
|
+
const blockType = typeOf(block.type);
|
|
255
|
+
if (!collectsEnquiry(blockType)) continue;
|
|
256
|
+
for (const ask of blockType.asks ?? []) keys.add(ask.key);
|
|
257
|
+
}
|
|
258
|
+
return keys;
|
|
259
|
+
};
|
|
260
|
+
let formFaults = 0;
|
|
261
|
+
for (const [where, html] of htmlByPath) {
|
|
262
|
+
for (const form of leadForms(html)) {
|
|
263
|
+
const controls = controlsOf(form);
|
|
264
|
+
const page = pageByPath.get(where);
|
|
265
|
+
const near = [...(page?.blocks ?? []), ...sharedBlocks];
|
|
266
|
+
const nearCollects = near.some((block) => collectsEnquiry(typeOf(block.type)));
|
|
267
|
+
if (!nearCollects && !siteCollects) {
|
|
268
|
+
fault(CODES.FORM_UNDECLARED, `${where}: a form posts to /api/lead and no block says collects: "enquiry"`);
|
|
269
|
+
formFaults++;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const asks = nearCollects ? asksOf(near) : asksOf((registry?.blocks ?? []).map((block) => ({ type: block.type })));
|
|
273
|
+
const lacking = [];
|
|
274
|
+
if (!controls.has('name')) lacking.push('a name control');
|
|
275
|
+
if (!controls.has('contact') && !controls.has('phone') && !controls.has('email')) lacking.push('a contact, phone or email control');
|
|
276
|
+
if (!controls.has('website')) lacking.push('the honeypot website');
|
|
277
|
+
if (lacking.length > 0) {
|
|
278
|
+
fault(CODES.FORM_INCOMPLETE, `${where}: the enquiry form has no ${lacking.join(', no ')}`);
|
|
279
|
+
formFaults++;
|
|
280
|
+
}
|
|
281
|
+
for (const control of controls) {
|
|
282
|
+
if (OWN_CONTROLS.has(control) || asks.has(control)) continue;
|
|
283
|
+
fault(CODES.FORM_FIELD_UNDECLARED, `${where}: the enquiry form asks for "${control}" and no block declares it in asks — the inbox would show a key, not a label`);
|
|
284
|
+
formFaults++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (formFaults > 0) return verdict(`${formFaults} enquiry form fault(s)`);
|
|
289
|
+
|
|
290
|
+
// The text that is on every page: a shared field the whole site annotates nowhere.
|
|
291
|
+
for (const document of content.shared ?? []) {
|
|
292
|
+
const language = document.locale === undefined ? '' : ` (${document.locale})`;
|
|
293
|
+
for (const path of expectedPaths(document.blocks ?? [], typeOf)) {
|
|
294
|
+
const shared = `shared:${path}`;
|
|
295
|
+
if (![...htmlByPath.values()].some((html) => html.includes(`data-jtk-path="${shared}"`))) {
|
|
296
|
+
fault(CODES.SHARED_NOT_RENDERED, `${shared}${language} is on no page — a shared field nothing renders is a control that edits nothing`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Entries of a collection without pages: on their listing, annotated there.
|
|
302
|
+
const pageless = new Set((registry?.collections ?? []).filter((one) => one.pages === false).map((one) => one.name));
|
|
303
|
+
const everywhere = [...htmlByPath.values()].join('\n');
|
|
304
|
+
const listable = (page) => page.visible !== false;
|
|
305
|
+
for (const one of registry?.collections ?? []) {
|
|
306
|
+
if (one.pages !== false) continue;
|
|
307
|
+
const hasEntries = (content.pages ?? []).some((page) => page.collection === one.name && listable(page));
|
|
308
|
+
if (hasEntries && !htmlByPath.has(one.prefix)) {
|
|
309
|
+
fault(CODES.PAGE_NOT_BUILT, `${one.prefix}: the collection "${one.name}" has no pages of its own and lists its entries here, and the build did not produce this page — make the prefix the page that lists them, or build one there`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const page of content.pages ?? []) {
|
|
313
|
+
if (page.collection !== undefined && pageless.has(page.collection)) {
|
|
314
|
+
if (!listable(page)) continue;
|
|
315
|
+
for (const path of expectedPaths(page.blocks ?? [], typeOf)) {
|
|
316
|
+
if (!everywhere.includes(`data-jtk-path="page:${page.path}:${path}"`)) {
|
|
317
|
+
fault(CODES.UNANNOTATED_FIELD, `${page.path}: ${path} is on no page — an entry without a page of its own has to be annotated on its listing`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const html = htmlByPath.get(page.path);
|
|
323
|
+
if (html === undefined) {
|
|
324
|
+
const listedOnly = (registry?.collections ?? []).find((one) => one.pages === false && page.path.startsWith(`${one.prefix}/`));
|
|
325
|
+
const hint = page.collection === undefined && listedOnly !== undefined
|
|
326
|
+
? ` — it sits under the "${listedOnly.name}" collection's prefix and names no collection; an entry says "collection": "${listedOnly.name}" and is annotated on the listing instead`
|
|
327
|
+
: '';
|
|
328
|
+
fault(CODES.PAGE_NOT_BUILT, `${page.path}: the content has this page and the build did not produce it${hint}`);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
for (const path of expectedPaths(page.blocks ?? [], typeOf)) {
|
|
332
|
+
if (!html.includes(`data-jtk-path="${path}"`)) fault(CODES.UNANNOTATED_FIELD, `${page.path}: ${path} is not annotated`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// A tile that names a page nobody has, or a field that page has not got.
|
|
337
|
+
const CROSS = /data-jtk-path="page:([^:"]*):([^"]+)"/g;
|
|
338
|
+
for (const [where, html] of htmlByPath) {
|
|
339
|
+
for (const [, address, inner] of html.matchAll(CROSS)) {
|
|
340
|
+
const named = pageByPath.get(address);
|
|
341
|
+
if (named === undefined) {
|
|
342
|
+
fault(CODES.ANNOTATION_UNKNOWN, `${where}: annotates page:${address}: and this site has no such page`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (!expectedPaths(named.blocks ?? [], typeOf).includes(inner)) {
|
|
346
|
+
fault(CODES.ANNOTATION_UNKNOWN, `${where}: annotates ${inner} on ${address}, which that page's document has no field for`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const withShared = content.shared === undefined ? '' : ' and the shared text';
|
|
352
|
+
const out = verdict(findings.length > 0
|
|
353
|
+
? `${findings.length} unannotated field(s) — the visual editor would silently stop working on them`
|
|
354
|
+
: `every content field on ${content.pages?.length ?? 0} page(s)${withShared} is annotated`);
|
|
355
|
+
out.pages = content.pages?.length ?? 0;
|
|
356
|
+
out.shared = content.shared !== undefined;
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The command: print the findings the way the build does, and exit 1 on any.
|
|
362
|
+
* @param {{ root?: string, dist?: string, siteURL?: string }} options
|
|
363
|
+
*/
|
|
364
|
+
export async function lintCommand(options) {
|
|
365
|
+
const out = await lint(options);
|
|
366
|
+
for (const one of out.findings) console.error(' ' + said(one.code, one.says));
|
|
367
|
+
if (out.line !== '') (out.ok ? console.log : console.error)(' ' + out.line);
|
|
368
|
+
return out.ok ? 0 : 1;
|
|
369
|
+
}
|
package/lib/progress.mjs
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress is a function of the files (jtakeit-core, wiki/73).
|
|
3
|
+
*
|
|
4
|
+
* Between «connect an agent» and the first preview a person sees nothing for
|
|
5
|
+
* an hour while the site is written. What they should see is not what the
|
|
6
|
+
* agent says it is doing but what the site *is*: this module reads the
|
|
7
|
+
* working tree and says, for the person and the agent alike, what is written
|
|
8
|
+
* and what is still missing. It declares nothing and believes nothing — the
|
|
9
|
+
* brief's own checklist is read out as the agent's words, apart from the
|
|
10
|
+
* files', and the rest is counted.
|
|
11
|
+
*
|
|
12
|
+
* One implementation, two readers: the scaffold's dev server serves this as
|
|
13
|
+
* `/_jtk/progress.json` (jtakeit-meta.mjs) for the panel, and `jtkit
|
|
14
|
+
* progress` prints it for the agent. A rule here is a rule in both.
|
|
15
|
+
*
|
|
16
|
+
* ── what «written» means ───────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* The scaffold's content leaves every text empty (`jtk/content/index.json`),
|
|
19
|
+
* so a page is being written when any of its text fields is filled, and is
|
|
20
|
+
* written when every field the catalogue marks `required` is — the exact
|
|
21
|
+
* rule where there is a catalogue, and the weaker one («has text») where a
|
|
22
|
+
* site on another generator has none yet, said as such in `rule`.
|
|
23
|
+
*/
|
|
24
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { collectContent } from './content.mjs';
|
|
27
|
+
|
|
28
|
+
/** Under the weaker rule, a key named like one of these is not words. */
|
|
29
|
+
const NOT_WORDS = /(^|_)(image|picture|photo|media|video|url|href|link|icon|src)(_|$)|_href$|_url$/;
|
|
30
|
+
/** The field kinds a person writes words into. */
|
|
31
|
+
const TEXT = new Set(['text', 'textarea', 'richtext_lite', 'markdown']);
|
|
32
|
+
/** The brief's headings, in the order the guide writes them. */
|
|
33
|
+
export const BRIEF_SECTIONS = ['In one sentence', 'The one action', 'Facts', 'Bookings', 'Decisions', 'Open — NEEDS CLIENT', 'Done when'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read the working tree and say where the site is.
|
|
37
|
+
* @param {string} root the site's root, holding `BRIEF.md`, `jtk/` and `src/`
|
|
38
|
+
* @param {{ dir?: string }} [options] `dir`: where `jtk/` is, relative to the root
|
|
39
|
+
*/
|
|
40
|
+
export async function readProgress(root, options = {}) {
|
|
41
|
+
const dir = options.dir ?? 'jtk';
|
|
42
|
+
const seen = [];
|
|
43
|
+
const note = (file) => stat(file).then((s) => seen.push(s.mtime), () => {});
|
|
44
|
+
|
|
45
|
+
const brief = await readBrief(join(root, 'BRIEF.md'));
|
|
46
|
+
await note(join(root, 'BRIEF.md'));
|
|
47
|
+
const catalogue = await readJSON(join(root, dir, 'catalogue.json'));
|
|
48
|
+
await note(join(root, dir, 'catalogue.json'));
|
|
49
|
+
const settings = await readJSON(join(root, dir, 'bookings.json'));
|
|
50
|
+
await note(join(root, dir, 'bookings.json'));
|
|
51
|
+
const facts = await readFacts(join(root, 'src', 'data', 'site.ts'));
|
|
52
|
+
await note(join(root, 'src', 'data', 'site.ts'));
|
|
53
|
+
let content = { pages: [] };
|
|
54
|
+
try {
|
|
55
|
+
content = await collectContent(root, { dir });
|
|
56
|
+
} catch {
|
|
57
|
+
// No jtk/ yet, or a document that is not a page: nothing is written.
|
|
58
|
+
}
|
|
59
|
+
for (const file of await contentFiles(join(root, dir, 'content'))) await note(file);
|
|
60
|
+
|
|
61
|
+
const fields = fieldsOf(catalogue);
|
|
62
|
+
const rule = catalogue ? 'exact' : 'has-text';
|
|
63
|
+
const pages = [];
|
|
64
|
+
const entries = {};
|
|
65
|
+
let media = 0;
|
|
66
|
+
for (const page of content.pages) {
|
|
67
|
+
const counted = countFields(page, fields, rule);
|
|
68
|
+
media += counted.media;
|
|
69
|
+
if (page.collection !== undefined) {
|
|
70
|
+
entries[page.collection] = (entries[page.collection] ?? 0) + 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
pages.push({
|
|
74
|
+
path: page.path,
|
|
75
|
+
written: counted.filled > 0 && counted.requiredMissing === 0,
|
|
76
|
+
filled: counted.filled,
|
|
77
|
+
of: counted.of,
|
|
78
|
+
...(counted.requiredMissing > 0 ? { missing: counted.requiredMissing } : {}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
pages.sort((a, b) => (a.path === '/' ? -1 : b.path === '/' ? 1 : a.path.localeCompare(b.path)));
|
|
82
|
+
|
|
83
|
+
const collections = {};
|
|
84
|
+
const bookings = catalogue?.modules?.bookings ?? null;
|
|
85
|
+
for (const one of catalogue?.collections ?? []) {
|
|
86
|
+
const expected = bookings !== null && (bookings.services === one.name || bookings.resources === one.name) ? 1 : 0;
|
|
87
|
+
collections[one.name] = { entries: entries[one.name] ?? 0, expected };
|
|
88
|
+
}
|
|
89
|
+
for (const [name, count] of Object.entries(entries)) {
|
|
90
|
+
if (collections[name] === undefined) collections[name] = { entries: count, expected: 0 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const block = settings?.blocks?.[0] ?? null;
|
|
94
|
+
const settingsRead =
|
|
95
|
+
block === null
|
|
96
|
+
? null
|
|
97
|
+
: {
|
|
98
|
+
kit: typeof block.kit === 'string' ? block.kit : '',
|
|
99
|
+
kit_version: typeof block.kit_version === 'string' ? block.kit_version : '',
|
|
100
|
+
hours: Array.isArray(block.hours) && block.hours.length > 0,
|
|
101
|
+
zone: typeof block.zone === 'string' && block.zone !== '',
|
|
102
|
+
payment: typeof block.payment === 'string' ? block.payment : '',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const missing = [];
|
|
106
|
+
if (brief === null) missing.push('BRIEF.md is not written yet');
|
|
107
|
+
else {
|
|
108
|
+
if (brief.open > 0) missing.push(`${brief.open} question${brief.open === 1 ? '' : 's'} for the client (Open — NEEDS CLIENT)`);
|
|
109
|
+
if (!brief.sections['Decisions']) missing.push('the brief has no Decisions yet');
|
|
110
|
+
}
|
|
111
|
+
if (!catalogue) missing.push('jtk/catalogue.json is not written yet (npx @jtakeit/astro catalogue)');
|
|
112
|
+
if (pages.length === 0) missing.push('no page has content yet');
|
|
113
|
+
for (const page of pages) {
|
|
114
|
+
if (page.written) continue;
|
|
115
|
+
missing.push(page.filled === 0 ? `${page.path} is not written yet` : `${page.path}: ${page.filled} of ${page.of} fields, ${page.missing ?? 0} required missing`);
|
|
116
|
+
}
|
|
117
|
+
for (const [name, one] of Object.entries(collections)) {
|
|
118
|
+
if (one.entries < one.expected) missing.push(`${name}: no entry yet`);
|
|
119
|
+
}
|
|
120
|
+
if (bookings !== null) {
|
|
121
|
+
if (settingsRead === null) missing.push('jtk/bookings.json is not written yet');
|
|
122
|
+
else {
|
|
123
|
+
if (!settingsRead.hours) missing.push('the opening hours are not set (jtk/bookings.json)');
|
|
124
|
+
if (!settingsRead.zone) missing.push('the time zone is not set (jtk/bookings.json)');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (facts !== null && facts.placeholders > 0) missing.push(`src/data/site.ts still carries ${facts.placeholders} placeholder${facts.placeholders === 1 ? '' : 's'}`);
|
|
128
|
+
if (pages.length > 0 && media === 0) missing.push('no picture yet');
|
|
129
|
+
|
|
130
|
+
const changedAt = seen.length > 0 ? new Date(Math.max(...seen.map((d) => d.getTime()))).toISOString() : null;
|
|
131
|
+
return {
|
|
132
|
+
version: 1,
|
|
133
|
+
rule,
|
|
134
|
+
kit: settingsRead === null ? null : { kind: settingsRead.kit, version: settingsRead.kit_version },
|
|
135
|
+
brief,
|
|
136
|
+
catalogue: catalogue !== null,
|
|
137
|
+
pages,
|
|
138
|
+
collections,
|
|
139
|
+
settings: settingsRead === null ? null : { hours: settingsRead.hours, zone: settingsRead.zone, payment: settingsRead.payment },
|
|
140
|
+
facts,
|
|
141
|
+
media,
|
|
142
|
+
missing,
|
|
143
|
+
changedAt,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The brief as a document: which sections have words, how many facts carry a
|
|
149
|
+
* source, how many questions are open, and the agent's own checklist under
|
|
150
|
+
* «Done when» — read out, never believed.
|
|
151
|
+
*/
|
|
152
|
+
export async function readBrief(file) {
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
text = await readFile(file, 'utf8');
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const sections = {};
|
|
160
|
+
const bodies = {};
|
|
161
|
+
let current = null;
|
|
162
|
+
for (const line of text.split('\n')) {
|
|
163
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line);
|
|
164
|
+
if (heading) {
|
|
165
|
+
current = heading[1];
|
|
166
|
+
bodies[current] = [];
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (current !== null) bodies[current].push(line);
|
|
170
|
+
}
|
|
171
|
+
// A section has words when a line of it is neither empty, nor a table's
|
|
172
|
+
// header or separator, nor the skeleton's own sentence (the kit's line
|
|
173
|
+
// under Bookings is written by `jtk create`, not by the agent).
|
|
174
|
+
for (const name of BRIEF_SECTIONS) {
|
|
175
|
+
sections[name] = rowsOf(bodies[name] ?? []).some((l) => !/^\|/.test(l) ? !/^The kit: .*, whole/.test(l) : true);
|
|
176
|
+
}
|
|
177
|
+
const factRows = rowsOf(bodies['Facts'] ?? []).filter((l) => /^\|/.test(l));
|
|
178
|
+
const open = (bodies['Open — NEEDS CLIENT'] ?? []).filter((l) => /^\s*[-*]\s+\S/.test(l)).length;
|
|
179
|
+
const items = (bodies['Done when'] ?? []).filter((l) => /^\s*[-*]\s+\[[ xX]\]/.test(l));
|
|
180
|
+
const done = items.filter((l) => /\[[xX]\]/.test(l)).length;
|
|
181
|
+
// Gaps are marked in the tables: a fact or a default whose value is still
|
|
182
|
+
// the client's to give. The heading and the checklist's own words are not
|
|
183
|
+
// gaps.
|
|
184
|
+
const needsClient = [...rowsOf(bodies['Facts'] ?? []), ...rowsOf(bodies['Bookings'] ?? [])]
|
|
185
|
+
.filter((l) => /^\|/.test(l) && /NEEDS CLIENT/.test(l)).length;
|
|
186
|
+
return {
|
|
187
|
+
sections,
|
|
188
|
+
facts: factRows.length,
|
|
189
|
+
open,
|
|
190
|
+
needsClient: Math.max(needsClient, 0),
|
|
191
|
+
doneWhen: { done, of: items.length, items: items.map((l) => ({ done: /\[[xX]\]/.test(l), says: l.replace(/^\s*[-*]\s+\[[ xX]\]\s*/, '').trim() })) },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The lines of a section that carry words: not blank, not a table's header or its separator. */
|
|
196
|
+
function rowsOf(body) {
|
|
197
|
+
const out = [];
|
|
198
|
+
for (let i = 0; i < body.length; i++) {
|
|
199
|
+
const line = body[i];
|
|
200
|
+
if (line.trim() === '') continue;
|
|
201
|
+
if (/^\|?\s*:?-+:?\s*\|/.test(line)) continue;
|
|
202
|
+
if (/^\|/.test(line) && /^\|?\s*:?-+:?\s*\|/.test(body[i + 1] ?? '')) continue;
|
|
203
|
+
out.push(line);
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function readFacts(file) {
|
|
209
|
+
let text;
|
|
210
|
+
try {
|
|
211
|
+
text = await readFile(file, 'utf8');
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return { placeholders: (text.match(/\{\{[A-Z_]+\}\}/g) ?? []).length };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function fieldsOf(catalogue) {
|
|
219
|
+
const byType = new Map();
|
|
220
|
+
for (const block of catalogue?.blocks ?? []) {
|
|
221
|
+
byType.set(block.type, block.fields ?? []);
|
|
222
|
+
}
|
|
223
|
+
return byType;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** How much of a document is written: the text fields filled, the required ones missing, the pictures in it. */
|
|
227
|
+
function countFields(page, fields, rule) {
|
|
228
|
+
let filled = 0;
|
|
229
|
+
let of = 0;
|
|
230
|
+
let requiredMissing = 0;
|
|
231
|
+
let media = 0;
|
|
232
|
+
for (const block of page.blocks ?? []) {
|
|
233
|
+
const defs = fields.get(block.type);
|
|
234
|
+
if (rule === 'exact' && defs !== undefined) {
|
|
235
|
+
for (const def of defs) {
|
|
236
|
+
if (def.kind === 'media') {
|
|
237
|
+
if (hasText(block[def.key]) || (Array.isArray(block[def.key]) && block[def.key].length > 0)) media++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!TEXT.has(def.kind)) continue;
|
|
241
|
+
of++;
|
|
242
|
+
if (hasText(block[def.key])) filled++;
|
|
243
|
+
else if (def.required) requiredMissing++;
|
|
244
|
+
}
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
// The weaker rule, with no catalogue to say what a field is: every
|
|
248
|
+
// string is words unless its name or its value says it is an address
|
|
249
|
+
// or a picture.
|
|
250
|
+
for (const [key, value] of Object.entries(block)) {
|
|
251
|
+
if (key === '_key' || key === 'type' || key === 'v') continue;
|
|
252
|
+
if (typeof value !== 'string') continue;
|
|
253
|
+
if (NOT_WORDS.test(key)) {
|
|
254
|
+
if (hasText(value) && /image|picture|photo|media|video/.test(key)) media++;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (/^(https?:|#|\/)/.test(value)) continue;
|
|
258
|
+
of++;
|
|
259
|
+
if (hasText(value)) filled++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { filled, of, requiredMissing, media };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function hasText(value) {
|
|
266
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function readJSON(file) {
|
|
270
|
+
try {
|
|
271
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function contentFiles(dir) {
|
|
278
|
+
const out = [];
|
|
279
|
+
let names;
|
|
280
|
+
try {
|
|
281
|
+
names = await readdir(dir, { withFileTypes: true });
|
|
282
|
+
} catch {
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
for (const one of names) {
|
|
286
|
+
const path = join(dir, one.name);
|
|
287
|
+
if (one.isDirectory()) out.push(...(await contentFiles(path)));
|
|
288
|
+
else if (one.name.endsWith('.json')) out.push(path);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The list as a person reads it in a terminal. */
|
|
294
|
+
export function sayProgress(progress) {
|
|
295
|
+
const lines = [];
|
|
296
|
+
if (progress.brief === null) lines.push('brief: not written');
|
|
297
|
+
else {
|
|
298
|
+
const b = progress.brief;
|
|
299
|
+
lines.push(`brief: ${b.facts} fact(s), ${b.open} open for the client, done when ${b.doneWhen.done}/${b.doneWhen.of}`);
|
|
300
|
+
}
|
|
301
|
+
lines.push(`catalogue: ${progress.catalogue ? 'written' : 'not yet'}`);
|
|
302
|
+
for (const page of progress.pages) {
|
|
303
|
+
lines.push(`${page.written ? 'ok ' : 'todo '}${page.path} ${page.filled}/${page.of} fields${page.missing ? `, ${page.missing} required missing` : ''}`);
|
|
304
|
+
}
|
|
305
|
+
for (const [name, one] of Object.entries(progress.collections)) {
|
|
306
|
+
lines.push(`${one.entries >= one.expected ? 'ok ' : 'todo '}${name}: ${one.entries} entr${one.entries === 1 ? 'y' : 'ies'}${one.expected ? ` (≥ ${one.expected})` : ''}`);
|
|
307
|
+
}
|
|
308
|
+
if (progress.settings) lines.push(`settings: hours ${progress.settings.hours ? 'set' : 'not set'}, zone ${progress.settings.zone ? 'set' : 'not set'}`);
|
|
309
|
+
lines.push(`pictures: ${progress.media}`);
|
|
310
|
+
lines.push('');
|
|
311
|
+
if (progress.missing.length === 0) lines.push('nothing missing — judge it, push, attach, build');
|
|
312
|
+
else {
|
|
313
|
+
lines.push('missing:');
|
|
314
|
+
for (const one of progress.missing) lines.push(` - ${one}`);
|
|
315
|
+
}
|
|
316
|
+
return lines.join('\n');
|
|
317
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jtakeit/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The framework-free half of a site on jtakeit: the booking form as an element and as markup, for Astro through @jtakeit/astro and for any other generator directly",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,7 +51,19 @@
|
|
|
51
51
|
"default": "./elements/alive.js"
|
|
52
52
|
},
|
|
53
53
|
"./booking.css": "./booking.css",
|
|
54
|
-
"./lead.css": "./lead.css"
|
|
54
|
+
"./lead.css": "./lead.css",
|
|
55
|
+
"./lint": {
|
|
56
|
+
"types": "./types/lint.d.ts",
|
|
57
|
+
"default": "./lib/lint.mjs"
|
|
58
|
+
},
|
|
59
|
+
"./progress": {
|
|
60
|
+
"types": "./types/progress.d.ts",
|
|
61
|
+
"default": "./lib/progress.mjs"
|
|
62
|
+
},
|
|
63
|
+
"./content": {
|
|
64
|
+
"types": "./types/content.d.ts",
|
|
65
|
+
"default": "./lib/content.mjs"
|
|
66
|
+
}
|
|
55
67
|
},
|
|
56
68
|
"files": [
|
|
57
69
|
"index.mjs",
|
|
@@ -68,7 +80,7 @@
|
|
|
68
80
|
},
|
|
69
81
|
"repository": {
|
|
70
82
|
"type": "git",
|
|
71
|
-
"url": "https://github.com/jtakeit/jtakeit-kit.git",
|
|
83
|
+
"url": "git+https://github.com/jtakeit/jtakeit-kit.git",
|
|
72
84
|
"directory": "kit"
|
|
73
85
|
},
|
|
74
86
|
"keywords": [
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The published content as one document, from the files in `jtk/` — what the build renders from. */
|
|
2
|
+
export function collectContent(root: string, options?: { dir?: string }): Promise<{
|
|
3
|
+
schema_version: number;
|
|
4
|
+
content_rev: number;
|
|
5
|
+
site: { slug: string; locale: string; media_base: string; business: Record<string, unknown> };
|
|
6
|
+
pages: { path: string; seo: Record<string, unknown>; blocks: Record<string, unknown>[]; collection?: string; visible?: boolean }[];
|
|
7
|
+
shared?: { locale?: string; blocks: Record<string, unknown>[] }[];
|
|
8
|
+
}>;
|
|
9
|
+
export function sharedLocaleOf(name: string): string | null;
|
package/types/lint.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Finding { code: string; says: string }
|
|
2
|
+
export interface Verdict { findings: Finding[]; ok: boolean; pages: number; shared: boolean; line: string }
|
|
3
|
+
export interface LintOptions {
|
|
4
|
+
/** The site's root, holding `jtk/`; the working directory by default. */
|
|
5
|
+
root?: string;
|
|
6
|
+
/** The built pages; `dist` under the root by default. */
|
|
7
|
+
dist?: string;
|
|
8
|
+
/** The address the build is for (`SITE_URL`); empty skips the host checks. */
|
|
9
|
+
siteURL?: string;
|
|
10
|
+
catalogue?: unknown;
|
|
11
|
+
content?: unknown;
|
|
12
|
+
}
|
|
13
|
+
/** The annotation lint the platform's build runs: the built pages against the content and the catalogue. */
|
|
14
|
+
export function lint(options?: LintOptions): Promise<Verdict>;
|
|
15
|
+
/** Print the findings the way the build does; 0 when clean, 1 otherwise. */
|
|
16
|
+
export function lintCommand(options?: LintOptions): Promise<number>;
|
|
17
|
+
export const TAPPABLE: Set<string>;
|
|
18
|
+
export const CODES: Readonly<Record<string, string>>;
|
|
19
|
+
export function said(code: string, message: string): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The brief's headings, in the order the guide writes them. */
|
|
2
|
+
export const BRIEF_SECTIONS: readonly string[];
|
|
3
|
+
|
|
4
|
+
export interface BriefRead {
|
|
5
|
+
/** Which sections have words in them. */
|
|
6
|
+
sections: Record<string, boolean>;
|
|
7
|
+
/** Rows in the Facts table. */
|
|
8
|
+
facts: number;
|
|
9
|
+
/** Items under «Open — NEEDS CLIENT». */
|
|
10
|
+
open: number;
|
|
11
|
+
/** How often `NEEDS CLIENT` appears outside its heading. */
|
|
12
|
+
needsClient: number;
|
|
13
|
+
/** The agent's own checklist under «Done when» — read out, never believed. */
|
|
14
|
+
doneWhen: { done: number; of: number; items: { done: boolean; says: string }[] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PageProgress {
|
|
18
|
+
path: string;
|
|
19
|
+
/** Written: some text, and every required text field filled (the exact rule); or any text (has-text). */
|
|
20
|
+
written: boolean;
|
|
21
|
+
filled: number;
|
|
22
|
+
of: number;
|
|
23
|
+
/** Required text fields still empty, under the exact rule. */
|
|
24
|
+
missing?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Progress {
|
|
28
|
+
version: 1;
|
|
29
|
+
/** `exact` with a catalogue, `has-text` without one — a weaker rule, said as such. */
|
|
30
|
+
rule: 'exact' | 'has-text';
|
|
31
|
+
kit: { kind: string; version: string } | null;
|
|
32
|
+
brief: BriefRead | null;
|
|
33
|
+
catalogue: boolean;
|
|
34
|
+
pages: PageProgress[];
|
|
35
|
+
collections: Record<string, { entries: number; expected: number }>;
|
|
36
|
+
settings: { hours: boolean; zone: boolean; payment: string } | null;
|
|
37
|
+
facts: { placeholders: number } | null;
|
|
38
|
+
media: number;
|
|
39
|
+
/** What is still to do, as sentences — the same list the panel shows. */
|
|
40
|
+
missing: string[];
|
|
41
|
+
/** The newest change among the files read, ISO 8601; null when none exist. */
|
|
42
|
+
changedAt: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read the working tree and say where the site is. */
|
|
46
|
+
export function readProgress(root: string, options?: { dir?: string }): Promise<Progress>;
|
|
47
|
+
/** The brief as a document, or null where there is none. */
|
|
48
|
+
export function readBrief(file: string): Promise<BriefRead | null>;
|
|
49
|
+
/** The list as a person reads it in a terminal. */
|
|
50
|
+
export function sayProgress(progress: Progress): string;
|