mikser-io-layouts 2.7.0 → 2.8.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/index.js +1 -1
- package/lib/inspect.js +10 -2
- package/lib/mcp.js +203 -1
- package/package.json +1 -1
- package/test/check-entity.test.js +150 -0
package/index.js
CHANGED
|
@@ -102,7 +102,7 @@ export function layouts(userOptions = {}) {
|
|
|
102
102
|
// in the plugins array for this to fire; that constraint is
|
|
103
103
|
// documented in mikser-io's CLAUDE.md.
|
|
104
104
|
onLoaded(async () => {
|
|
105
|
-
registerMcpTools({ runtime, useLogger })
|
|
105
|
+
registerMcpTools({ runtime, useLogger, findEntity, findEntities, useDatabase, collection })
|
|
106
106
|
})
|
|
107
107
|
|
|
108
108
|
onSync(collection, async ({ action, context }) => {
|
package/lib/inspect.js
CHANGED
|
@@ -320,9 +320,17 @@ async function buildContract({ layout, templateSource, runtime, findEntities, co
|
|
|
320
320
|
// The subset an author actually writes. Everything else in `consumes` is
|
|
321
321
|
// render-time furniture — helpers, loop variables, plugin surfaces — and
|
|
322
322
|
// is kept, but it is not what a document's front matter is checked against.
|
|
323
|
+
// Entity meta, whichever root binding reached it.
|
|
324
|
+
//
|
|
325
|
+
// A template can arrive at the same keys by more than one name: `data` is
|
|
326
|
+
// whatever a layout sidecar returned, and the entity is ALSO bound under
|
|
327
|
+
// its own type, so `document.meta.title` and `data.meta.title` are the same
|
|
328
|
+
// document key. Matching only `data.` was lmed-shaped — a project with no
|
|
329
|
+
// sidecar writes `document.meta.` and would have got an empty contract,
|
|
330
|
+
// which reads exactly like a layout that needs nothing.
|
|
331
|
+
const META_ROOT = /^[A-Za-z_$][\w$]*\.meta\./
|
|
323
332
|
const meta = [...consumes]
|
|
324
|
-
.
|
|
325
|
-
.map(p => p.slice('data.meta.'.length))
|
|
333
|
+
.map(p => (META_ROOT.test(p) ? p.replace(META_ROOT, '') : (p.startsWith('meta.') ? p.slice(5) : null)))
|
|
326
334
|
.filter(Boolean)
|
|
327
335
|
|
|
328
336
|
return {
|
package/lib/mcp.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { z } from 'zod'
|
|
12
12
|
|
|
13
|
-
export function registerMcpTools({ runtime, useLogger }) {
|
|
13
|
+
export function registerMcpTools({ runtime, useLogger, findEntity, findEntities, useDatabase, collection }) {
|
|
14
14
|
const mcp = runtime.options.mcp
|
|
15
15
|
if (!mcp) return // mcp plugin not loaded — nothing to register
|
|
16
16
|
if (!runtime.options.layouts?.inspect) return // shouldn't happen if we run after our own inspect setup, but defensive
|
|
@@ -66,5 +66,207 @@ export function registerMcpTools({ runtime, useLogger }) {
|
|
|
66
66
|
},
|
|
67
67
|
)
|
|
68
68
|
|
|
69
|
+
registerCheckTool({ runtime, findEntity, findEntities, useDatabase, collection, logger })
|
|
70
|
+
|
|
69
71
|
logger.debug('MCP tool registered: mikser_layouts_inspect (layouts plugin)')
|
|
70
72
|
}
|
|
73
|
+
|
|
74
|
+
// Meta keys the ENGINE owns. They appear on documents, are consumed by mikser
|
|
75
|
+
// itself rather than by any layout, and would otherwise be reported as unused on
|
|
76
|
+
// every entity — noise that teaches a reader to ignore the list.
|
|
77
|
+
const ENGINE_KEYS = new Set([
|
|
78
|
+
'layout', 'layouts', 'task', 'destination', 'format', 'template', 'href',
|
|
79
|
+
'lang', 'url', 'pages', 'parent', 'plugins', 'presets', 'refs', 'date',
|
|
80
|
+
'draft', 'stamp', 'postprocessor',
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
// A document's meta in the same vocabulary a layout contract uses: dotted
|
|
84
|
+
// paths, with `[]` for "each element of" rather than an index. Both sides have
|
|
85
|
+
// to speak it or the comparison is meaningless.
|
|
86
|
+
function flattenMeta(value, prefix, out) {
|
|
87
|
+
if (value === null || typeof value !== 'object') return out
|
|
88
|
+
if (Array.isArray(value)) {
|
|
89
|
+
// One `[]` for the whole array: the contract says "each tag has a
|
|
90
|
+
// label", never "tag 7 has a label", so element shapes are merged.
|
|
91
|
+
if (value.length) out.add(`${prefix}[]`)
|
|
92
|
+
for (const item of value) flattenMeta(item, `${prefix}[]`, out)
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
for (const [k, v] of Object.entries(value)) {
|
|
96
|
+
const path = prefix ? `${prefix}.${k}` : k
|
|
97
|
+
out.add(path)
|
|
98
|
+
flattenMeta(v, path, out)
|
|
99
|
+
}
|
|
100
|
+
return out
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Observed reads carry the prefix of where they were seen — `meta.` for the
|
|
104
|
+
// document's own keys as a sidecar read them, `data.meta.` for what a template
|
|
105
|
+
// read. Both describe the same document, so both reduce to the bare key.
|
|
106
|
+
const bareKey = (p) => p.replace(/^[A-Za-z_$][\w$]*\.meta\./, '').replace(/^meta\./, '')
|
|
107
|
+
|
|
108
|
+
// Edit distance, capped: only used to ask "did they mean this one", so anything
|
|
109
|
+
// past a couple of edits is not a typo and the answer stops mattering.
|
|
110
|
+
function distance(a, b) {
|
|
111
|
+
if (Math.abs(a.length - b.length) > 3) return 99
|
|
112
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
113
|
+
for (let i = 1; i <= a.length; i++) {
|
|
114
|
+
let last = prev[0]
|
|
115
|
+
prev[0] = i
|
|
116
|
+
for (let j = 1; j <= b.length; j++) {
|
|
117
|
+
const tmp = prev[j]
|
|
118
|
+
prev[j] = Math.min(
|
|
119
|
+
prev[j] + 1,
|
|
120
|
+
prev[j - 1] + 1,
|
|
121
|
+
last + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
122
|
+
)
|
|
123
|
+
last = tmp
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return prev[b.length]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function registerCheckTool({ runtime, findEntity, findEntities, useDatabase, collection, logger }) {
|
|
130
|
+
const mcp = runtime.options.mcp
|
|
131
|
+
|
|
132
|
+
// Which layout this entity ACTUALLY rendered with, taken from its most
|
|
133
|
+
// recent snapshot rather than predicted.
|
|
134
|
+
//
|
|
135
|
+
// Layout selection has three mechanisms — declared meta.layout, a match
|
|
136
|
+
// pattern, and the auto-layout peel ladder — and re-deriving them here
|
|
137
|
+
// would be a second implementation to keep in step with the first. The
|
|
138
|
+
// manifest already recorded the answer.
|
|
139
|
+
const layoutFromSnapshot = (id) => {
|
|
140
|
+
const db = useDatabase?.()
|
|
141
|
+
if (!db?.handle) return null
|
|
142
|
+
const row = db.handle.prepare(
|
|
143
|
+
'SELECT refClosure FROM mikser_snapshots WHERE id = ? ORDER BY renderedAt DESC LIMIT 1',
|
|
144
|
+
).get(id)
|
|
145
|
+
if (!row?.refClosure) return null
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(row.refClosure).find(e => e.kind === 'layout')?.target ?? null
|
|
148
|
+
} catch { return null }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const readsFromSnapshot = (id) => {
|
|
152
|
+
const db = useDatabase?.()
|
|
153
|
+
if (!db?.handle) return []
|
|
154
|
+
const row = db.handle.prepare(
|
|
155
|
+
'SELECT metaReads FROM mikser_snapshots WHERE id = ? ORDER BY renderedAt DESC LIMIT 1',
|
|
156
|
+
).get(id)
|
|
157
|
+
if (!row?.metaReads) return []
|
|
158
|
+
try { return JSON.parse(row.metaReads) } catch { return [] }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
mcp.simpleTool(
|
|
162
|
+
'mikser_check_entity',
|
|
163
|
+
'Check a document against the contract of the layout that renders it, BEFORE the mistake ships.\n\n'
|
|
164
|
+
+ 'A mistyped key does not fail a build. The section it named simply does not render, the page goes out '
|
|
165
|
+
+ 'with a hole in it, and every signal reads clean. This is the check that catches it: what the layout '
|
|
166
|
+
+ 'tree needs, against what the document actually provides.\n\n'
|
|
167
|
+
+ 'Read `missing` first — keys the layout consumes that this document has no value for. Then '
|
|
168
|
+
+ '`likelyTypos`, which pairs a key you wrote against a key the layout wanted that is one or two edits '
|
|
169
|
+
+ 'away; a typo usually shows up as both at once, which is a far stronger signal than either alone.\n\n'
|
|
170
|
+
+ '`unused` is weaker evidence and says so: another layout may consume the key, and keys the engine '
|
|
171
|
+
+ 'itself owns are excluded. Treat it as a prompt to look, not a defect.\n\n'
|
|
172
|
+
+ 'Check `reliable` before trusting an absence. When false, some branch of the layout could not be read '
|
|
173
|
+
+ 'or the document has never rendered, and a key may be missing from the analysis rather than from the '
|
|
174
|
+
+ 'document.',
|
|
175
|
+
{
|
|
176
|
+
id: z.string().describe('Entity id, e.g. "/documents/en/index.md".'),
|
|
177
|
+
layout: z.string().optional()
|
|
178
|
+
.describe('Layout id to check against. Omit to use the layout this entity last rendered with, '
|
|
179
|
+
+ 'which is what the manifest recorded rather than a prediction.'),
|
|
180
|
+
},
|
|
181
|
+
async ({ id, layout }) => {
|
|
182
|
+
const entity = await findEntity({ id })
|
|
183
|
+
if (!entity) {
|
|
184
|
+
return { isError: true, content: [{ type: 'text', text:
|
|
185
|
+
`No entity ${JSON.stringify(id)}. Use mikser_query_entities to find its id.` }] }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Resolve the layout: explicit, then what it rendered with, then
|
|
189
|
+
// what it asked for. Each fallback is weaker, and which one was
|
|
190
|
+
// used is reported so the caller can judge.
|
|
191
|
+
let layoutId = layout ?? layoutFromSnapshot(id)
|
|
192
|
+
let source = layout ? 'given' : (layoutId ? 'last render' : null)
|
|
193
|
+
if (!layoutId && entity.meta?.layout) {
|
|
194
|
+
const named = (await findEntities({ name: entity.meta.layout })) ?? []
|
|
195
|
+
layoutId = named.find(e => e.collection === collection)?.id ?? null
|
|
196
|
+
source = layoutId ? 'meta.layout' : null
|
|
197
|
+
}
|
|
198
|
+
if (!layoutId) {
|
|
199
|
+
return { isError: true, content: [{ type: 'text', text:
|
|
200
|
+
`Cannot tell which layout renders ${id}: it has never rendered and declares no meta.layout. `
|
|
201
|
+
+ 'Pass `layout` explicitly, or build once so the manifest records it.' }] }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const inspected = await runtime.options.layouts.inspect(layoutId, { samples: 0 })
|
|
205
|
+
const contract = inspected.references?.contract ?? { meta: [], complete: false, incomplete: [] }
|
|
206
|
+
|
|
207
|
+
// Consumed is the UNION of both halves, and it has to be: the
|
|
208
|
+
// contract is blind to a sidecar (plain JavaScript, nothing to
|
|
209
|
+
// parse) and the observed reads are blind to a branch this document
|
|
210
|
+
// never took. Using either alone reports keys as unused that are
|
|
211
|
+
// demonstrably read.
|
|
212
|
+
const observed = readsFromSnapshot(id).map(bareKey)
|
|
213
|
+
const consumed = new Set([...(contract.meta ?? []), ...observed])
|
|
214
|
+
const provided = flattenMeta(entity.meta ?? {}, '', new Set())
|
|
215
|
+
|
|
216
|
+
const isUsed = (key) => consumed.has(key)
|
|
217
|
+
// A parent is used when anything under it is: a document
|
|
218
|
+
// providing `hero` for a layout that reads `hero.title` has not
|
|
219
|
+
// provided a spare key.
|
|
220
|
+
|| [...consumed].some(c => c.startsWith(`${key}.`) || c.startsWith(`${key}[`))
|
|
221
|
+
const isProvided = (key) => provided.has(key)
|
|
222
|
+
// And the reverse: a layout reading `hero.tags[]` is satisfied
|
|
223
|
+
// by a document that provided the array.
|
|
224
|
+
|| [...provided].some(p => p.startsWith(`${key}.`) || p.startsWith(`${key}[`))
|
|
225
|
+
|
|
226
|
+
const missing = [...consumed].filter(k => k && !isProvided(k)).sort()
|
|
227
|
+
const unused = [...provided]
|
|
228
|
+
.filter(k => !isUsed(k))
|
|
229
|
+
.filter(k => !ENGINE_KEYS.has(k.split('.')[0].replace(/\[\]$/, '')))
|
|
230
|
+
.sort()
|
|
231
|
+
|
|
232
|
+
// The pair. A mistyped key leaves a hole where the real key was
|
|
233
|
+
// wanted AND a stray key nothing reads, so matching one list
|
|
234
|
+
// against the other turns two weak signals into one strong one.
|
|
235
|
+
const likelyTypos = []
|
|
236
|
+
for (const wrote of unused) {
|
|
237
|
+
let best = null
|
|
238
|
+
for (const wanted of missing) {
|
|
239
|
+
const d = distance(wrote, wanted)
|
|
240
|
+
if (d > 0 && d <= 2 && (!best || d < best.distance)) best = { meant: wanted, distance: d }
|
|
241
|
+
}
|
|
242
|
+
if (best) likelyTypos.push({ wrote, ...best })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const reliable = contract.complete === true && observed.length > 0
|
|
246
|
+
return {
|
|
247
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
248
|
+
entity: id,
|
|
249
|
+
layout: layoutId,
|
|
250
|
+
layoutFrom: source,
|
|
251
|
+
reliable,
|
|
252
|
+
missing,
|
|
253
|
+
likelyTypos,
|
|
254
|
+
unused,
|
|
255
|
+
counts: { consumed: consumed.size, provided: provided.size,
|
|
256
|
+
missing: missing.length, unused: unused.length },
|
|
257
|
+
contract: { complete: contract.complete, incomplete: contract.incomplete ?? [] },
|
|
258
|
+
observedReads: observed.length,
|
|
259
|
+
notes: [
|
|
260
|
+
'missing is the strong signal: the layout consumes the key and this document has no value for it.',
|
|
261
|
+
'likelyTypos pairs an unused key against a missing one within two edits. A real typo usually produces both, which is why the pair is worth more than either list.',
|
|
262
|
+
'unused is weak evidence. A key may be consumed by a DIFFERENT layout — a card rendered on a listing page is the common case — and keys the engine owns (href, lang, layout, task, ...) are excluded outright.',
|
|
263
|
+
'reliable is false when the contract could not be fully read, or when this entity has never rendered so nothing was observed. An absence is only evidence when reliable is true.',
|
|
264
|
+
'Coverage is the union of what the layout templates read (the contract) and what renders actually read (observed, which includes sidecars). Neither sees everything alone.',
|
|
265
|
+
],
|
|
266
|
+
}, null, 2) }],
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
logger.debug('MCP tool registered: mikser_check_entity (layouts plugin)')
|
|
272
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-layouts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "SSG-flavor task-production policy for mikser-io. Multi-match layout assignment, per-layout destinations, postprocess chain attachment, layout inspection.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Checking a document against the contract of the layout that renders it.
|
|
2
|
+
//
|
|
3
|
+
// A mistyped key does not fail a build. The section it named simply does not
|
|
4
|
+
// render, the page ships with a hole, and every signal reads clean. This is the
|
|
5
|
+
// check that catches it before it ships, and it exists because the two halves it
|
|
6
|
+
// needs — the layout contract and what renders actually read — are useless
|
|
7
|
+
// separately: the contract is blind to a sidecar, and observed reads are blind
|
|
8
|
+
// to a branch this document never took.
|
|
9
|
+
|
|
10
|
+
import { describe, it, before, after } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { createInspect } from '../lib/inspect.js'
|
|
17
|
+
import { registerCheckTool } from '../lib/mcp.js'
|
|
18
|
+
import * as liquid from 'mikser-io-render-liquid'
|
|
19
|
+
|
|
20
|
+
const LAYOUTS = {
|
|
21
|
+
'page.liquid': '<html>{% include "sections/hero" %}<p>{{ document.meta.summary }}</p></html>',
|
|
22
|
+
'sections/hero.liquid':
|
|
23
|
+
'{%- assign hero = document.meta.hero -%}<h1>{{ hero.title }}</h1>'
|
|
24
|
+
+ '{% for t in hero.tags %}<i>{{ t.label }}</i>{% endfor %}',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DOCS = {
|
|
28
|
+
'/documents/good.md': { summary: 'S', hero: { title: 'T', tags: [{ label: 'A' }] } },
|
|
29
|
+
// `heroo` instead of `hero`: nothing under it is read, and everything the
|
|
30
|
+
// layout wanted is absent. One mistake, visible from both sides.
|
|
31
|
+
'/documents/typo.md': { summary: 'S', heroo: { title: 'T' } },
|
|
32
|
+
// Provides a key no layout here consumes. Weak evidence on purpose.
|
|
33
|
+
'/documents/extra.md': { summary: 'S', hero: { title: 'T', tags: [{ label: 'A' }] }, card: { order: 2 } },
|
|
34
|
+
// Engine-owned keys must never be reported as unused.
|
|
35
|
+
'/documents/routing.md': { summary: 'S', href: '/r', lang: 'bg', task: 'worker',
|
|
36
|
+
hero: { title: 'T', tags: [{ label: 'A' }] } },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let dir, check
|
|
40
|
+
|
|
41
|
+
before(async () => {
|
|
42
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-check-'))
|
|
43
|
+
const entities = []
|
|
44
|
+
for (const [rel, content] of Object.entries(LAYOUTS)) {
|
|
45
|
+
const uri = path.join(dir, rel)
|
|
46
|
+
await mkdir(path.dirname(uri), { recursive: true })
|
|
47
|
+
await writeFile(uri, content)
|
|
48
|
+
entities.push({ id: `/layouts/${rel}`, name: rel.replace(/\.liquid$/, ''), uri,
|
|
49
|
+
collection: 'layouts', template: 'liquid' })
|
|
50
|
+
}
|
|
51
|
+
for (const [id, meta] of Object.entries(DOCS)) {
|
|
52
|
+
entities.push({ id, name: id.slice(11), collection: 'documents', meta })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A stand-in manifest. Every document rendered with page.liquid, and read
|
|
56
|
+
// what a correct one would — so `unused` and `missing` are exercised
|
|
57
|
+
// without needing a real build in a unit test.
|
|
58
|
+
const observed = ['data.meta.summary', 'data.meta.hero', 'data.meta.hero.title',
|
|
59
|
+
'data.meta.hero.tags', 'data.meta.hero.tags[]', 'data.meta.hero.tags[].label']
|
|
60
|
+
const useDatabase = () => ({
|
|
61
|
+
handle: {
|
|
62
|
+
prepare: (sql) => ({
|
|
63
|
+
get: (id) => (DOCS[id]
|
|
64
|
+
? (sql.includes('refClosure')
|
|
65
|
+
? { refClosure: JSON.stringify([{ kind: 'layout', target: '/layouts/page.liquid' }]) }
|
|
66
|
+
: { metaReads: JSON.stringify(observed) })
|
|
67
|
+
: undefined),
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const findEntity = async ({ id }) => entities.find(e => e.id === id) ?? null
|
|
73
|
+
const findEntities = async (q) => entities.filter(e => q.name == null || e.name === q.name)
|
|
74
|
+
const runtime = {
|
|
75
|
+
options: {},
|
|
76
|
+
renderers: new Map([['liquid', { parseReferences: liquid.parseReferences }]]),
|
|
77
|
+
}
|
|
78
|
+
runtime.options.layouts = {
|
|
79
|
+
inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
|
|
80
|
+
}
|
|
81
|
+
const registry = new Map()
|
|
82
|
+
runtime.options.mcp = { simpleTool: (name, _d, _s, handler) => registry.set(name, handler) }
|
|
83
|
+
registerCheckTool({ runtime, findEntity, findEntities, useDatabase,
|
|
84
|
+
collection: 'layouts', logger: { debug() {} } })
|
|
85
|
+
|
|
86
|
+
check = async (id) => JSON.parse((await registry.get('mikser_check_entity')({ id })).content[0].text)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
after(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
|
90
|
+
|
|
91
|
+
describe('mikser_check_entity', () => {
|
|
92
|
+
it('says nothing is wrong with a document that satisfies the layout', async () => {
|
|
93
|
+
// The assertion that matters most: no false positives. A check that
|
|
94
|
+
// cries wolf on a correct document is one nobody reads.
|
|
95
|
+
const r = await check('/documents/good.md')
|
|
96
|
+
assert.deepEqual(r.missing, [])
|
|
97
|
+
assert.deepEqual(r.unused, [])
|
|
98
|
+
assert.deepEqual(r.likelyTypos, [])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('names the keys the layout wants and the document lacks', async () => {
|
|
102
|
+
const r = await check('/documents/typo.md')
|
|
103
|
+
assert.ok(r.missing.includes('hero.title'), `missing: ${r.missing.join(', ')}`)
|
|
104
|
+
assert.ok(r.missing.includes('hero.tags[].label'))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('pairs the key written against the key meant', async () => {
|
|
108
|
+
// The point of the tool. A typo shows up as a hole AND a stray key, and
|
|
109
|
+
// matching one list against the other turns two weak signals into one
|
|
110
|
+
// strong one.
|
|
111
|
+
const r = await check('/documents/typo.md')
|
|
112
|
+
const pair = r.likelyTypos.find(t => t.wrote === 'heroo')
|
|
113
|
+
assert.ok(pair, `no pairing found in ${JSON.stringify(r.likelyTypos)}`)
|
|
114
|
+
assert.equal(pair.meant, 'hero')
|
|
115
|
+
assert.equal(pair.distance, 1)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('reports a key no layout reads, without calling it an error', async () => {
|
|
119
|
+
const r = await check('/documents/extra.md')
|
|
120
|
+
assert.ok(r.unused.includes('card'), `unused: ${r.unused.join(', ')}`)
|
|
121
|
+
assert.deepEqual(r.missing, [], 'an unused key is not a missing one')
|
|
122
|
+
assert.ok(r.notes.some(n => /DIFFERENT layout/.test(n)),
|
|
123
|
+
'the weakness of this signal has to travel with it')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('never reports engine-owned keys as unused', async () => {
|
|
127
|
+
// href, lang and task are consumed by mikser, not by any layout. Listing
|
|
128
|
+
// them on every document would teach a reader to skip the list.
|
|
129
|
+
const r = await check('/documents/routing.md')
|
|
130
|
+
for (const key of ['href', 'lang', 'task']) {
|
|
131
|
+
assert.ok(!r.unused.includes(key), `${key} leaked into unused: ${r.unused.join(', ')}`)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('counts a parent as provided when the layout reads through it', async () => {
|
|
136
|
+
const r = await check('/documents/good.md')
|
|
137
|
+
assert.ok(!r.missing.includes('hero'), 'hero is provided by providing its contents')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('uses the layout the entity actually rendered with', async () => {
|
|
141
|
+
const r = await check('/documents/good.md')
|
|
142
|
+
assert.equal(r.layout, '/layouts/page.liquid')
|
|
143
|
+
assert.equal(r.layoutFrom, 'last render')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('refuses an entity it cannot find rather than guessing', async () => {
|
|
147
|
+
const r = await check('/documents/nope.md').catch(() => null)
|
|
148
|
+
assert.equal(r, null, 'a missing entity must not produce a report')
|
|
149
|
+
})
|
|
150
|
+
})
|