@motujs/cli 0.0.0-stage → 0.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/LICENSE +21 -0
- package/bundled/github/agents/archipelago-build.agent.md +298 -0
- package/bundled/github/agents/island-create.agent.md +261 -0
- package/bundled/github/agents/island-extract.agent.md +360 -0
- package/bundled/github/agents/island-locate.agent.md +208 -0
- package/bundled/github/agents/motu-checks.agent.md +158 -0
- package/bundled/github/agents/motu-evidence.agent.md +267 -0
- package/bundled/github/agents/motu-regions.agent.md +304 -0
- package/bundled/github/host-rules.md +43 -0
- package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
- package/bundled/github/prompts/island-create.prompt.md +37 -0
- package/bundled/github/prompts/island-extract.prompt.md +59 -0
- package/bundled/github/prompts/island-locate.prompt.md +43 -0
- package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
- package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
- package/bundled/packages/adapters/next/vite.mjs +121 -0
- package/bundled/packages/adapters/vite/vite.mjs +179 -0
- package/package.json +64 -3
- package/src/alias-loader.mjs +63 -0
- package/src/alias-register.mjs +28 -0
- package/src/asset-stub.cjs +3 -0
- package/src/asset-stub.mjs +5 -0
- package/src/cli.mjs +78 -0
- package/src/commands/adopt-root.mjs +194 -0
- package/src/commands/archipelago-init.mjs +314 -0
- package/src/commands/archipelago.mjs +138 -0
- package/src/commands/assurance.mjs +40 -0
- package/src/commands/check.mjs +346 -0
- package/src/commands/codegen.mjs +29 -0
- package/src/commands/coverage.mjs +358 -0
- package/src/commands/create.mjs +277 -0
- package/src/commands/defaults.mjs +129 -0
- package/src/commands/diff.mjs +595 -0
- package/src/commands/fixtures.mjs +153 -0
- package/src/commands/init.mjs +473 -0
- package/src/commands/integrate.mjs +208 -0
- package/src/commands/integration.mjs +1349 -0
- package/src/commands/lagoon-try.mjs +272 -0
- package/src/commands/lagoon.mjs +1419 -0
- package/src/commands/link.mjs +110 -0
- package/src/commands/region-coverage.mjs +604 -0
- package/src/commands/removal-check.mjs +878 -0
- package/src/commands/skills.mjs +156 -0
- package/src/commands/snapshot.mjs +456 -0
- package/src/commands/sync.mjs +38 -0
- package/src/commands/verify/island-runtime.mjs +581 -0
- package/src/commands/verify/island-static.mjs +586 -0
- package/src/commands/verify/island.mjs +135 -0
- package/src/commands/verify/reach.mjs +426 -0
- package/src/commands/verify/region-composition.mjs +391 -0
- package/src/commands/verify/region-config.mjs +330 -0
- package/src/commands/verify/region-flows.mjs +760 -0
- package/src/commands/verify/region-sources.mjs +516 -0
- package/src/commands/verify/region.mjs +271 -0
- package/src/commands/verify/report.mjs +338 -0
- package/src/commands/verify/screens-runtime.mjs +123 -0
- package/src/commands/verify.mjs +18 -0
- package/src/lagoon-build.mjs +25 -0
- package/src/lagoon-dev.mjs +32 -0
- package/src/lib/alias-core.mjs +49 -0
- package/src/lib/archipelagos.mjs +111 -0
- package/src/lib/assurance.mjs +144 -0
- package/src/lib/baselines.mjs +107 -0
- package/src/lib/bundlability.mjs +123 -0
- package/src/lib/changed.mjs +157 -0
- package/src/lib/component-props.mjs +145 -0
- package/src/lib/config.mjs +451 -0
- package/src/lib/contracts.mjs +244 -0
- package/src/lib/data-flow.mjs +47 -0
- package/src/lib/declared-sources.mjs +101 -0
- package/src/lib/dock-inject.mjs +47 -0
- package/src/lib/effects.mjs +97 -0
- package/src/lib/eject.mjs +715 -0
- package/src/lib/evidence-json.mjs +74 -0
- package/src/lib/expect-calls.mjs +53 -0
- package/src/lib/finding-memory.mjs +0 -0
- package/src/lib/flow-calls.mjs +53 -0
- package/src/lib/framework.mjs +98 -0
- package/src/lib/host-rules.mjs +55 -0
- package/src/lib/host-sources.mjs +121 -0
- package/src/lib/island-placement.mjs +64 -0
- package/src/lib/island-render.mjs +105 -0
- package/src/lib/islands.mjs +112 -0
- package/src/lib/lagoon-bundle.mjs +138 -0
- package/src/lib/lagoon-declares.mjs +553 -0
- package/src/lib/lagoon-materialize.mjs +217 -0
- package/src/lib/lagoon-vite.mjs +788 -0
- package/src/lib/live-url.mjs +53 -0
- package/src/lib/node-aliases.mjs +73 -0
- package/src/lib/operations.mjs +145 -0
- package/src/lib/provenance-plugin.mjs +74 -0
- package/src/lib/region-refs.mjs +40 -0
- package/src/lib/remote.mjs +171 -0
- package/src/lib/scaffold.mjs +606 -0
- package/src/lib/screens.mjs +266 -0
- package/src/lib/snapshots.mjs +85 -0
- package/src/lib/stubs.mjs +114 -0
- package/src/lib/ts-project.mjs +42 -0
- package/src/lib/util.mjs +626 -0
- package/src/lib/worlds.mjs +236 -0
- package/src/lib/write-if-changed.mjs +35 -0
- package/src/lib/writes-coverage.mjs +32 -0
- package/src/playwright-lagoon.mjs +2305 -0
- package/src/run.mjs +385 -0
- package/src/runtime-harness.mjs +225 -0
- package/README.md +0 -3
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The scenario/flow crossing between node processes, and the two things JSON cannot carry.
|
|
2
|
+
//
|
|
3
|
+
// `readScenarios` spawns the harness under tsx, which prints the evidence as JSON, and parses it
|
|
4
|
+
// back. That hop is invisible and was lossy in two different ways:
|
|
5
|
+
//
|
|
6
|
+
// A DATE came back as an ISO STRING, so an island that formats one threw on mount and rendered
|
|
7
|
+
// nothing — which `data-flow` then reported as "scenarios rendered NOTHING" and `responsive` as
|
|
8
|
+
// "renders nothing", neither naming a date. The lagoon a human opens BUNDLES the same evidence,
|
|
9
|
+
// keeps the Date and renders perfectly, so the two disagreed and only the check lane was wrong.
|
|
10
|
+
// Found in a project whose whole domain is meetings: every scenario carrying a séance was blank.
|
|
11
|
+
//
|
|
12
|
+
// A SET, MAP OR FUNCTION came back as `{}` — genuinely untransportable, and `seed-transport`
|
|
13
|
+
// exists to say so. But by the time that check ran it was looking at the `{}`, not at the Set, so
|
|
14
|
+
// it could only ever catch one that survived a DIFFERENT loader. The value is marked here, where
|
|
15
|
+
// the real object still exists, and the check reads the marker.
|
|
16
|
+
//
|
|
17
|
+
// Marker objects, not bare strings, in both cases: `"2026-08-19"` is indistinguishable from a string
|
|
18
|
+
// somebody meant to be a string, and `{}` is indistinguishable from an empty object.
|
|
19
|
+
|
|
20
|
+
const DATE = '__motuDate';
|
|
21
|
+
const LOST = '__motuLost';
|
|
22
|
+
|
|
23
|
+
/** What a value is, when JSON cannot carry it. Null for everything JSON handles. */
|
|
24
|
+
function lostKind(v) {
|
|
25
|
+
if (v instanceof Set) return 'Set';
|
|
26
|
+
if (v instanceof Map) return 'Map';
|
|
27
|
+
if (typeof v === 'function') return 'function';
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A `JSON.stringify` replacer that marks what the crossing would otherwise destroy.
|
|
33
|
+
*
|
|
34
|
+
* MUST be a plain function: `JSON.stringify` calls `Date.prototype.toJSON()` BEFORE handing the value
|
|
35
|
+
* to a replacer, so for a Date `value` is already a string and only `this[key]` is still the Date.
|
|
36
|
+
* A Set/Map has no `toJSON`, and a function is dropped entirely unless the replacer returns something
|
|
37
|
+
* in its place — so those three are read from `value` as usual.
|
|
38
|
+
*/
|
|
39
|
+
export function markEvidence(key, value) {
|
|
40
|
+
const raw = this?.[key];
|
|
41
|
+
if (raw instanceof Date) return { [DATE]: raw.toISOString() };
|
|
42
|
+
const lost = lostKind(value);
|
|
43
|
+
return lost ? { [LOST]: lost } : value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The other half. Dates are rebuilt; a LOST marker is left standing on purpose — it is the evidence
|
|
48
|
+
* `seed-transport` reports, and handing the island a plausible `{}` instead is what made that
|
|
49
|
+
* failure arrive as three misleading findings somewhere else.
|
|
50
|
+
*/
|
|
51
|
+
export function reviveEvidence(_key, value) {
|
|
52
|
+
return value && typeof value === 'object' && typeof value[DATE] === 'string'
|
|
53
|
+
? new Date(value[DATE])
|
|
54
|
+
: value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The kind a marked-lost value used to be, or null. */
|
|
58
|
+
export function lostMarker(v) {
|
|
59
|
+
return v && typeof v === 'object' && typeof v[LOST] === 'string' ? v[LOST] : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Evidence out. */
|
|
63
|
+
export function stringifyEvidence(value) {
|
|
64
|
+
return JSON.stringify(value, markEvidence);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Evidence in. Returns undefined on malformed input, so callers keep their existing fallbacks. */
|
|
68
|
+
export function parseEvidence(text) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(text, reviveEvidence);
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `expectCalls` on a flow step: the backend calls that step is allowed to make — and only those.
|
|
2
|
+
//
|
|
3
|
+
// WHY: the call ledger already recorded every ask (`flow-calls` fails one nothing ANSWERED), but no
|
|
4
|
+
// flow could say what a state should ask for. So a change that made a state ask for MORE went unseen
|
|
5
|
+
// as long as the extra call was answered: bench/evidence's blind mistake B9 dropped "only fetch the
|
|
6
|
+
// remote servers when the store starts empty", every harness default-answered `/servers.json`, and the
|
|
7
|
+
// only trace was "1 answered" in the lagoon's calls strip where there had been nothing.
|
|
8
|
+
//
|
|
9
|
+
// { expectRender: { list: 'Main shlink' }, expectCalls: [] } // asks for nothing
|
|
10
|
+
// { emit: …, expectCalls: ['GET /servers.json'] } // exactly this
|
|
11
|
+
//
|
|
12
|
+
// An entry names a call as `METHOD /path` (query ignored), as `/path` (any method), or by its
|
|
13
|
+
// declaration's label (`shots.list`, `table:shots(select)`). Compared as SETS of distinct calls: how
|
|
14
|
+
// many times a call repeated is a render detail (StrictMode doubles effects) and not the claim. Every
|
|
15
|
+
// call the step made must be named, and every name must have been called.
|
|
16
|
+
|
|
17
|
+
/** A recorded call, as the lagoon's ledger hands it over. */
|
|
18
|
+
const pathOf = (url) => String(url ?? '').split('?')[0];
|
|
19
|
+
|
|
20
|
+
/** Does this declared entry name this call? */
|
|
21
|
+
export function namesCall(entry, call) {
|
|
22
|
+
const e = String(entry).trim();
|
|
23
|
+
const m = e.match(/^([A-Z]+)\s+(\/\S*)$/);
|
|
24
|
+
if (m) return call.method?.toUpperCase() === m[1] && pathOf(call.url) === m[2];
|
|
25
|
+
if (e.startsWith('/')) return pathOf(call.url) === e;
|
|
26
|
+
return call.label === e;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A call as a person reads it, for messages. */
|
|
30
|
+
export const describeCall = (c) => `${c.method} ${pathOf(c.url)}${c.label && c.label !== pathOf(c.url) ? ` (${c.label})` : ''}`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The mismatches between what a step declared and what it called, in the shape flow mismatches take
|
|
34
|
+
* (`{ key, expected, actual }`), or `[]` when it holds. `undefined` declares nothing — no check.
|
|
35
|
+
*/
|
|
36
|
+
export function expectCallsMismatches(expected, calls) {
|
|
37
|
+
if (expected === undefined) return [];
|
|
38
|
+
const list = Array.isArray(expected) ? expected : [expected];
|
|
39
|
+
const unnamed = [...new Map(calls.filter((c) => !list.some((e) => namesCall(e, c))).map((c) => [describeCall(c), c])).keys()];
|
|
40
|
+
const missing = list.filter((e) => !calls.some((c) => namesCall(e, c)));
|
|
41
|
+
const out = [];
|
|
42
|
+
if (unnamed.length) {
|
|
43
|
+
out.push({
|
|
44
|
+
key: 'calls',
|
|
45
|
+
expected: list.length ? `only ${list.map((e) => JSON.stringify(e)).join(', ')}` : 'no backend call',
|
|
46
|
+
actual: `also called ${unnamed.join(', ')}`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (missing.length) {
|
|
50
|
+
out.push({ key: 'calls', expected: `a call to ${missing.map((e) => JSON.stringify(e)).join(', ')}`, actual: 'never called' });
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `flow-calls`: a flow step (or a screen) that made a backend call NOTHING declared fails.
|
|
2
|
+
//
|
|
3
|
+
// WHY. On 2026-11-11 Peps' screens made four calls nothing answered — `member_feature_grants`,
|
|
4
|
+
// `notifications`, `ticket_replies`, `business_services`. The app swallowed each error and rendered a
|
|
5
|
+
// plausible page, every check passed, and only the dock's call signals showed them: a signal is
|
|
6
|
+
// something an agent never reads. So a call that no fixture, table or route answered is now a
|
|
7
|
+
// finding, attributed to the step that caused it.
|
|
8
|
+
//
|
|
9
|
+
// A DECLARED failure is not one: a fixture's `status: 500` is the state "a failing search" working,
|
|
10
|
+
// and the live feed marks it `declared` (core's `LiveCall`).
|
|
11
|
+
|
|
12
|
+
/** What to declare so this call is answered — in the declaration's own spelling. */
|
|
13
|
+
export function callFix(call) {
|
|
14
|
+
const label = String(call.label ?? '');
|
|
15
|
+
if (!call.status) return `the call never got an answer${call.error ? ` (${String(call.error).slice(0, 120)})` : ''}`;
|
|
16
|
+
let m;
|
|
17
|
+
if ((m = /^table:([^(]+)/.exec(label))) return `declare the table \`${m[1]}\` in the wire's tables, or a fixture for it`;
|
|
18
|
+
if ((m = /^rpc:(.+)$/.exec(label))) return `declare a fixture for the rpc \`${m[1]}\``;
|
|
19
|
+
if ((m = /^fn:(.+)$/.exec(label))) return `declare a fixture for the edge function \`${m[1]}\``;
|
|
20
|
+
if ((m = /^route:(\S+) (.+)$/.exec(label))) return `declare a fixture for ${m[1]} ${m[2]} (an appRoutes route)`;
|
|
21
|
+
if ((m = /^([\w$]+)\.([\w$]+)$/.exec(label))) return `declare a fixture for ${m[1]}/${m[2]}`;
|
|
22
|
+
return `declare an answer for ${label || call.url}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One line per undeclared call: what was asked, what came back, and what to declare. */
|
|
26
|
+
export function describeCall(call) {
|
|
27
|
+
const answer = call.status ? `${call.status}${call.error ? ` ${String(call.error).replace(/\s+/g, ' ').slice(0, 90)}` : ''}` : 'no answer';
|
|
28
|
+
return `${call.method} ${call.label || call.url} → ${answer} (by ${call.owner}) — ${callFix(call)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Report the calls a run's steps made that nothing declared.
|
|
33
|
+
*
|
|
34
|
+
* `steps` carry `{ where, undeclared, calls }`. Same call made twice in one step is said once with a
|
|
35
|
+
* count: a refetch loop is one finding, not a wall.
|
|
36
|
+
*/
|
|
37
|
+
export function reportFlowCalls(report, steps, what = 'flow step(s)') {
|
|
38
|
+
let total = 0;
|
|
39
|
+
let bad = 0;
|
|
40
|
+
for (const s of steps) {
|
|
41
|
+
total += s.calls ?? 0;
|
|
42
|
+
const seen = new Map();
|
|
43
|
+
for (const c of s.undeclared ?? []) {
|
|
44
|
+
const k = `${c.method} ${c.label || c.url} ${c.status}`;
|
|
45
|
+
seen.set(k, { call: c, n: (seen.get(k)?.n ?? 0) + 1 });
|
|
46
|
+
}
|
|
47
|
+
for (const { call, n } of seen.values()) {
|
|
48
|
+
bad++;
|
|
49
|
+
report.error('flow-calls', `${s.where}: ${describeCall(call)}${n > 1 ? ` (×${n})` : ''}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!bad) report.ok('flow-calls', `every call the ${what} made was answered by a declaration`, { n: total, of: 'call(s)' });
|
|
53
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// WHERE THE FRAMEWORK IS, and therefore HOW it is reached: a checkout, or an install.
|
|
2
|
+
//
|
|
3
|
+
// motu grew up run FROM ITS OWN CHECKOUT: the CLI found vite and the build plugins in the checkout's
|
|
4
|
+
// devDependencies, aliased `@motujs/*` straight to `packages/*/src/*.ts`, symlinked them into each
|
|
5
|
+
// project's node_modules on every run, and read its skills and adapters from `.github/` and
|
|
6
|
+
// `packages/adapters/` beside it. Every one of those is a path that does not exist once the CLI is
|
|
7
|
+
// `npm i -D @motujs/cli` in somebody's app — there is no `packages/`, no `.github/`, and the code
|
|
8
|
+
// that looked for them fell back to the PROJECT root, so the lagoon aliased `@motujs/react` to
|
|
9
|
+
// `<app>/packages/react/src/index.ts` and died on a file the adopter had never heard of.
|
|
10
|
+
//
|
|
11
|
+
// Two modes, chosen by what is on disk, never by a flag a person has to remember:
|
|
12
|
+
//
|
|
13
|
+
// checkout the CLI sits at `<checkout>/packages/cli`, beside `packages/core/src/index.ts`
|
|
14
|
+
// (or `$MOTU_ROOT` names a checkout). Everything stays exactly as it was: source
|
|
15
|
+
// aliases, no-install links, build tools from the checkout root.
|
|
16
|
+
// installed anything else — in practice `node_modules/@motujs/cli`. The `@motujs/*` packages
|
|
17
|
+
// resolve the ordinary way, from the app's node_modules to their compiled `dist/`;
|
|
18
|
+
// build tools resolve from THIS package's own dependencies; and the few files that
|
|
19
|
+
// live outside `packages/cli` in a checkout ship inside it, under `bundled/`.
|
|
20
|
+
//
|
|
21
|
+
// This module imports nothing of the CLI's, so `config.mjs` (which everything else imports) can.
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { dirname, resolve } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
/** `packages/cli` in a checkout, `node_modules/@motujs/cli` in an install. */
|
|
27
|
+
export const CLI_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
28
|
+
|
|
29
|
+
const OWN_CHECKOUT = resolve(CLI_PACKAGE_ROOT, '../..');
|
|
30
|
+
const isCheckout = (dir) => existsSync(resolve(dir, 'packages/core/src/index.ts'));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The motu checkout this CLI runs from, or null when it was installed.
|
|
34
|
+
*
|
|
35
|
+
* `$MOTU_ROOT` still wins, as it always has — it is how a CI image points an installed binary at a
|
|
36
|
+
* checkout on purpose, and an override that only worked from inside a checkout would not be one.
|
|
37
|
+
*/
|
|
38
|
+
export const CHECKOUT_ROOT = process.env.MOTU_ROOT
|
|
39
|
+
? resolve(process.env.MOTU_ROOT)
|
|
40
|
+
: isCheckout(OWN_CHECKOUT)
|
|
41
|
+
? OWN_CHECKOUT
|
|
42
|
+
: null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where vite, the build plugins and Tailwind are resolved from FIRST.
|
|
46
|
+
*
|
|
47
|
+
* The checkout root when this CLI is the checkout's own (they are the root's devDependencies there),
|
|
48
|
+
* otherwise this package — where an install puts them, as `@motujs/cli`'s own dependencies. Keyed on
|
|
49
|
+
* the CLI's real location rather than on `$MOTU_ROOT`: that variable says where the framework SOURCE
|
|
50
|
+
* is, and an installed CLI pointed at a checkout still has its build tools where npm put them.
|
|
51
|
+
*/
|
|
52
|
+
export const BUILD_DEPS_ROOT = isCheckout(OWN_CHECKOUT) ? OWN_CHECKOUT : CLI_PACKAGE_ROOT;
|
|
53
|
+
|
|
54
|
+
/** 'checkout' | 'installed' — see the top of this file. */
|
|
55
|
+
export const FRAMEWORK_MODE = CHECKOUT_ROOT ? 'checkout' : 'installed';
|
|
56
|
+
export const INSTALLED = FRAMEWORK_MODE === 'installed';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The CLI's own version — the range `motu init` writes for every `@motujs/*` it records.
|
|
60
|
+
*
|
|
61
|
+
* Lockstep (scripts/release.mjs sets one version on every package), so the CLI's version IS the
|
|
62
|
+
* version of the set. `^` of it rather than an exact pin: a patch release of one package must not
|
|
63
|
+
* need the adopter to edit five lines of package.json.
|
|
64
|
+
*/
|
|
65
|
+
export const CLI_VERSION = (() => {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(readFileSync(resolve(CLI_PACKAGE_ROOT, 'package.json'), 'utf8')).version ?? '0.0.0';
|
|
68
|
+
} catch {
|
|
69
|
+
return '0.0.0';
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A framework file that lives OUTSIDE `packages/cli` in a checkout, wherever it actually is.
|
|
75
|
+
*
|
|
76
|
+
* `checkoutRel` is its path in the checkout (`.github/agents`, `packages/adapters/next/vite.mjs`).
|
|
77
|
+
* Installed, the same file ships inside this package under `bundled/` — copied there by
|
|
78
|
+
* `scripts/build-packages.mjs`, with `.github/` spelled `github/` because a dot-directory in a
|
|
79
|
+
* tarball is one more thing a packer is entitled to drop.
|
|
80
|
+
*
|
|
81
|
+
* A checkout reads the ORIGINAL, never the bundled copy: `bundled/` is a build output, and reading it
|
|
82
|
+
* there would make every edit to a skill or an adapter invisible until somebody re-ran the build.
|
|
83
|
+
*/
|
|
84
|
+
export function frameworkFile(checkoutRel) {
|
|
85
|
+
if (CHECKOUT_ROOT) return resolve(CHECKOUT_ROOT, checkoutRel);
|
|
86
|
+
return resolve(CLI_PACKAGE_ROOT, 'bundled', checkoutRel.replace(/^\.github\//, 'github/'));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How to install the browser the runtime checks drive.
|
|
91
|
+
*
|
|
92
|
+
* "(in packages/cli)" is right in a checkout — that is where playwright is installed, and `npx` must
|
|
93
|
+
* find THAT copy so the browser revision matches — and a path that does not exist for an adopter who
|
|
94
|
+
* installed from npm, where the app's own `npx playwright` is the CLI's copy already.
|
|
95
|
+
*/
|
|
96
|
+
export const CHROMIUM_INSTALL = INSTALLED
|
|
97
|
+
? '`npx playwright install chromium`'
|
|
98
|
+
: '`npx playwright install chromium` (in packages/cli)';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The rules a HOST's coding agent has to follow for motu to keep its guarantees.
|
|
2
|
+
//
|
|
3
|
+
// They used to live nowhere: every adopting repo re-typed them into its own CLAUDE.md from memory, or
|
|
4
|
+
// (more often) did not, and the framework enforced a shape nobody had told the agent about. They ship
|
|
5
|
+
// with motu now, from one source, and land in the host's instruction files — the same reasoning as
|
|
6
|
+
// `motu skills install`, which already refuses to let the judgement half drift from the CLI half.
|
|
7
|
+
//
|
|
8
|
+
// Written between markers and rewritten in place, so a `motu init` on an existing repo updates the
|
|
9
|
+
// block instead of appending a second copy, and anything the project added around it survives.
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import { frameworkFile } from './framework.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The checkout's `.github/host-rules.md`, or the copy shipped inside @motujs/cli when installed —
|
|
16
|
+
* `<cli>/../../.github` from node_modules is nowhere, and `hostRulesText()` returning null makes
|
|
17
|
+
* `motu init` skip the rules SILENTLY (see lib/framework.mjs).
|
|
18
|
+
*/
|
|
19
|
+
const SOURCE = frameworkFile('.github/host-rules.md');
|
|
20
|
+
|
|
21
|
+
const BEGIN = '<!-- motu:rules -->';
|
|
22
|
+
const END = '<!-- /motu:rules -->';
|
|
23
|
+
|
|
24
|
+
/** The instruction files a coding agent reads, in the order motu writes them. */
|
|
25
|
+
const TARGETS = ['CLAUDE.md', 'AGENTS.md', '.github/copilot-instructions.md'];
|
|
26
|
+
|
|
27
|
+
/** The shipped rules text, or null when this checkout has none (an older motu). */
|
|
28
|
+
export function hostRulesText() {
|
|
29
|
+
return existsSync(SOURCE) ? readFileSync(SOURCE, 'utf8').trim() : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Write the rules into every instruction file the repo already has (and CLAUDE.md if it has none).
|
|
34
|
+
*
|
|
35
|
+
* Returns the paths written, relative to `dir`. Idempotent: a repo that already carries the block gets
|
|
36
|
+
* it replaced, so upgrading motu upgrades the rules.
|
|
37
|
+
*/
|
|
38
|
+
export function applyHostRules(dir) {
|
|
39
|
+
const text = hostRulesText();
|
|
40
|
+
if (!text) return [];
|
|
41
|
+
const block = `${BEGIN}\n${text}\n${END}`;
|
|
42
|
+
const existing = TARGETS.filter((t) => existsSync(resolve(dir, t)));
|
|
43
|
+
const written = [];
|
|
44
|
+
for (const target of existing.length ? existing : ['CLAUDE.md']) {
|
|
45
|
+
const path = resolve(dir, target);
|
|
46
|
+
const before = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
47
|
+
const next = before.includes(BEGIN)
|
|
48
|
+
? before.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}`), block)
|
|
49
|
+
: `${before.trimEnd()}${before.trim() ? '\n\n' : ''}${block}\n`;
|
|
50
|
+
if (next === before) continue;
|
|
51
|
+
writeFileSync(path, next);
|
|
52
|
+
written.push(target);
|
|
53
|
+
}
|
|
54
|
+
return written;
|
|
55
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// WHERE THE HOST KEEPS ITS CODE — asked once, for every check that needs the answer.
|
|
2
|
+
//
|
|
3
|
+
// Two checks walk the host application: `removal-check` (delete motu, does it still compile?) and
|
|
4
|
+
// `integrate check` (does the page compose, place and read the region?). Both used to hardcode
|
|
5
|
+
// `['app', 'components', 'lib', 'src', 'pages']` — Next's layout, and the only host motu had ever been
|
|
6
|
+
// integrated into. Pointed at Twenty, whose source is all under `src/`, that found nothing and printed
|
|
7
|
+
// "no motu references in the host application" over a fully integrated app. A green result from an
|
|
8
|
+
// empty search is the worst failure mode either tool has.
|
|
9
|
+
//
|
|
10
|
+
// THE APPLICATION ALREADY DECLARED THIS, and it is not a guess: `tsconfig.json` says which files the
|
|
11
|
+
// project compiles, via `include`/`exclude` and its `extends` chain. `removal-check` was already
|
|
12
|
+
// trusting it for the half that decides the verdict — it runs `tsc --noEmit -p tsconfig.json` — while
|
|
13
|
+
// its file walk used the five-directory guess, so ONE CHECK CARRIED TWO DEFINITIONS of "the
|
|
14
|
+
// application" and they were free to disagree. Measured on this repo's own Next host, the guess misses
|
|
15
|
+
// `middleware.ts` and everything under `test/`: files `tsc` compiles and the surgery never unwraps,
|
|
16
|
+
// which is how a dangling import lands on a line that looks fine.
|
|
17
|
+
//
|
|
18
|
+
// Precedence, widest trust last:
|
|
19
|
+
//
|
|
20
|
+
// 1. `hostSources` in motu.config.json — an explicit answer for a layout neither of the below gets
|
|
21
|
+
// right. Still supported; it just stopped being the only way.
|
|
22
|
+
// 2. the host's tsconfig/jsconfig — the application's own declaration.
|
|
23
|
+
// 3. the five-directory guess — a host with no tsconfig at all (a JS host, or the review
|
|
24
|
+
// console, which has none).
|
|
25
|
+
//
|
|
26
|
+
// It returns FILES, not roots, because that is what a tsconfig knows: reducing its file set back to
|
|
27
|
+
// top-level directories would throw away the `exclude` that made it precise. Callers apply their own
|
|
28
|
+
// skip rule (motu's own source, for one; the directories motu writes into, for the other) and the
|
|
29
|
+
// ORIGIN travels with the answer so a report can say what it trusted.
|
|
30
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
31
|
+
import { resolve, sep } from 'node:path';
|
|
32
|
+
import { ts } from 'ts-morph';
|
|
33
|
+
|
|
34
|
+
/** Source files, as both walks have always defined them: `.ts`/`.tsx`, no `node_modules`, no dotdirs. */
|
|
35
|
+
const isSource = (p) => /\.tsx?$/.test(p);
|
|
36
|
+
|
|
37
|
+
/** A path is out if any segment is `node_modules` or starts with a dot — `.next/types` is generated. */
|
|
38
|
+
function inHiddenOrVendored(rel) {
|
|
39
|
+
return rel.split(sep).some((seg) => seg === 'node_modules' || (seg.startsWith('.') && seg !== '.' && seg !== '..'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The recursive walk both checks used, kept for the config and guess paths. */
|
|
43
|
+
function walkRoots(hostRoot, roots) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const walk = (dir) => {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (const e of entries) {
|
|
53
|
+
const full = resolve(dir, e.name);
|
|
54
|
+
if (e.isDirectory()) {
|
|
55
|
+
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
56
|
+
walk(full);
|
|
57
|
+
} else if (isSource(e.name)) {
|
|
58
|
+
out.push(full);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
for (const top of roots) walk(resolve(hostRoot, top));
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The file list the host's own TypeScript config declares, or `null` when there is no config to read.
|
|
68
|
+
*
|
|
69
|
+
* `getParsedCommandLineOfConfigFile` is the API that follows `extends` — `parseJsonConfigFileContent`
|
|
70
|
+
* alone does not, and a host whose `include` lives in a base config would silently resolve to nothing.
|
|
71
|
+
* A config that parses but matches no file returns `null` too, so the caller falls through to the
|
|
72
|
+
* guess rather than inheriting an empty answer: that is the original bug, arriving by a new door.
|
|
73
|
+
*/
|
|
74
|
+
function fromTsconfig(hostRoot) {
|
|
75
|
+
const file = ['tsconfig.json', 'jsconfig.json'].map((n) => resolve(hostRoot, n)).find((p) => existsSync(p));
|
|
76
|
+
if (!file) return null;
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = ts.getParsedCommandLineOfConfigFile(file, {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} });
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (!parsed?.fileNames?.length) return null;
|
|
84
|
+
const files = parsed.fileNames
|
|
85
|
+
.map((p) => resolve(p))
|
|
86
|
+
.filter((p) => isSource(p) && p.startsWith(hostRoot + sep) && !inHiddenOrVendored(p.slice(hostRoot.length + 1)));
|
|
87
|
+
return files.length ? { files, origin: 'tsconfig', detail: file } : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every host source file, and where the answer came from.
|
|
92
|
+
*
|
|
93
|
+
* `{ files, origin, detail }` — `origin` is `'config' | 'tsconfig' | 'guess'`, and `detail` names the
|
|
94
|
+
* tsconfig or the roots, so a check that examined nothing can say what it trusted before it found
|
|
95
|
+
* nothing. Callers still have to treat an empty result as "could not run": nothing here decides that.
|
|
96
|
+
*/
|
|
97
|
+
export function hostSourceFiles(hostRoot, cfg) {
|
|
98
|
+
const configured = Array.isArray(cfg?.hostSources) ? cfg.hostSources : null;
|
|
99
|
+
if (configured?.length) {
|
|
100
|
+
return { files: walkRoots(hostRoot, configured), origin: 'config', detail: configured.join(', ') };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const declared = fromTsconfig(hostRoot);
|
|
104
|
+
if (declared) return declared;
|
|
105
|
+
|
|
106
|
+
const guessed = ['app', 'components', 'lib', 'src', 'pages'].filter((d) => existsSync(resolve(hostRoot, d)));
|
|
107
|
+
const roots = guessed.length ? guessed : ['.'];
|
|
108
|
+
return { files: walkRoots(hostRoot, roots), origin: 'guess', detail: roots.join(', ') };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How to say, in a report, where the scan looked — because "scanned 0 files" alone cannot be acted on.
|
|
113
|
+
*
|
|
114
|
+
* `rel` is the caller's path shortener (`paths.rel`), kept out of here so this module stays free of
|
|
115
|
+
* the CLI's own config object.
|
|
116
|
+
*/
|
|
117
|
+
export function describeSources({ origin, detail }, rel = (p) => p) {
|
|
118
|
+
if (origin === 'tsconfig') return `declared by ${rel(detail)}`;
|
|
119
|
+
if (origin === 'config') return `hostSources: ${detail}`;
|
|
120
|
+
return detail === '.' ? 'guessed: the whole host root' : `guessed: ${detail}`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// WHICH SLOTS A PIECE OF HOST SOURCE PLACES — one answer, for the two checks that ask.
|
|
2
|
+
//
|
|
3
|
+
// `integrate check` reads placements from the host's files with ts-morph; `island-composition` in
|
|
4
|
+
// `verify` asked the SAME question with `matchAll(/<[A-Z][A-Za-z0-9]*\.Island\s[^>]*?slot=…/)`. Two
|
|
5
|
+
// implementations of one question drift by construction: the regex could not see a multi-line
|
|
6
|
+
// element, a slot written with a template literal, or the difference between `slot="x"` and
|
|
7
|
+
// `slot={x}` — and it counted matches inside comments, which is how a commented-out example became a
|
|
8
|
+
// declared slot elsewhere in this file.
|
|
9
|
+
//
|
|
10
|
+
// The RULE is the interesting part and it belongs in one place: a STRING LITERAL names a slot
|
|
11
|
+
// statically, and anything else — `slot={expr}` — is a placement motu cannot attribute. Saying so
|
|
12
|
+
// beats guessing, and both callers now say the same thing.
|
|
13
|
+
import { Project, SyntaxKind } from 'ts-morph';
|
|
14
|
+
|
|
15
|
+
/** A fresh in-memory project per call: these are one-shot reads, and a shared cache would go stale. */
|
|
16
|
+
function parse(code, name = 'host.tsx') {
|
|
17
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
18
|
+
return project.createSourceFile(name, code);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The slot each `<X.Island slot="…">` in this source names.
|
|
23
|
+
*
|
|
24
|
+
* Returns `{ named, computed }` — `named` is the statically readable slots, `computed` counts the
|
|
25
|
+
* placements whose slot is an expression, which a caller may want to report rather than ignore.
|
|
26
|
+
* `tags` filters to the region's own island components when given; omit it to take every `*.Island`.
|
|
27
|
+
*/
|
|
28
|
+
export function placementsIn(code, tags = null) {
|
|
29
|
+
const named = new Set();
|
|
30
|
+
let computed = 0;
|
|
31
|
+
let sf;
|
|
32
|
+
try {
|
|
33
|
+
sf = parse(code);
|
|
34
|
+
} catch {
|
|
35
|
+
// A syntax this project's own build accepts and ts-morph does not: read nothing rather than
|
|
36
|
+
// guess. The caller reports "could not be read", which is the honest outcome.
|
|
37
|
+
return { named, computed, parsed: false };
|
|
38
|
+
}
|
|
39
|
+
const elements = [
|
|
40
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
41
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
42
|
+
];
|
|
43
|
+
for (const el of elements) {
|
|
44
|
+
const tag = el.getTagNameNode().getText();
|
|
45
|
+
if (tags ? !tags.includes(tag) : !/^[A-Z][A-Za-z0-9]*\.Island$/.test(tag)) continue;
|
|
46
|
+
const init = el.getAttribute?.('slot')?.getInitializer?.();
|
|
47
|
+
if (init?.getKind?.() === SyntaxKind.StringLiteral) named.add(init.getLiteralText());
|
|
48
|
+
else if (init) computed++;
|
|
49
|
+
}
|
|
50
|
+
return { named, computed, parsed: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The slot an element's `slot` attribute names: the string, `null` for a computed one, `undefined`
|
|
55
|
+
* where there is no attribute at all.
|
|
56
|
+
*
|
|
57
|
+
* Three answers rather than two, because the caller treats them differently: a computed slot is
|
|
58
|
+
* REPORTED (motu cannot attribute it), and a missing one is simply not a placement.
|
|
59
|
+
*/
|
|
60
|
+
export function slotNameOfElement(init, attr) {
|
|
61
|
+
if (init?.getKind?.() === SyntaxKind.StringLiteral) return init.getLiteralText();
|
|
62
|
+
if (init?.getKind?.() === SyntaxKind.JsxExpression) return null;
|
|
63
|
+
return attr ? null : undefined;
|
|
64
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT AN ISLAND RENDERED — its own subtree, plus what its own mount PORTALED out of it.
|
|
3
|
+
*
|
|
4
|
+
* Every check that compares or measures an island's render read the island root's innerHTML. A
|
|
5
|
+
* dialog, sheet, popover or drawer built on a React portal (Radix Dialog, vaul Drawer — every shadcn
|
|
6
|
+
* overlay) renders as a child of `document.body`, not of the island, so an island whose whole visible
|
|
7
|
+
* output is such a dialog was reported as "rendered NOTHING" by `data-flow` — while the region flow
|
|
8
|
+
* asserting that very dialog's text passed, because `expectRender` searches the whole document. Two
|
|
9
|
+
* checks of one framework disagreeing about what is on the screen.
|
|
10
|
+
*
|
|
11
|
+
* The fix is NOT a document-wide snapshot. That would count the lagoon's own chrome (dock, toolbar,
|
|
12
|
+
* call toasts, the debug overlay), anything that existed before the island mounted, and any other
|
|
13
|
+
* mount's output as this island's — and a genuinely empty island would then pass on the strength of
|
|
14
|
+
* the furniture around it. Output is counted only when it is ATTRIBUTABLE to this island:
|
|
15
|
+
*
|
|
16
|
+
* - a direct child of `document.body` (where portals land by default),
|
|
17
|
+
* - that is not the island, nor an ancestor of it, nor lagoon chrome (`data-motu-chrome`),
|
|
18
|
+
* - whose React fiber — or, for a library that creates its own portal container, the first
|
|
19
|
+
* descendant's fiber — walks UP (`fiber.return`, which crosses a portal back to the component
|
|
20
|
+
* that opened it) to a fiber that lives INSIDE the island: its wrapper element, a host element
|
|
21
|
+
* in its subtree, or the root container of a React root mounted inside it.
|
|
22
|
+
*
|
|
23
|
+
* That ancestry is what makes it attributable rather than temporal: a node that existed before the
|
|
24
|
+
* mount, a node the chrome drew in vanilla DOM, and a portal opened by a DIFFERENT island (or a
|
|
25
|
+
* previous mount of this one, whose wrapper element is not this one) all fail the walk. React's
|
|
26
|
+
* internal `__reactFiber$` key is the only coupling to React internals; it has been stable since 17.
|
|
27
|
+
*
|
|
28
|
+
* This function is INSTALLED, not imported: Playwright serialises it into the page with
|
|
29
|
+
* `addInitScript`, and the happy-dom harness and the tests call it on their own window. So it must
|
|
30
|
+
* not close over anything — every helper lives inside it.
|
|
31
|
+
*/
|
|
32
|
+
export function installIslandRender(win) {
|
|
33
|
+
const w = win || window;
|
|
34
|
+
const doc = w.document;
|
|
35
|
+
|
|
36
|
+
// A composed walk: through shadow roots to their hosts, so an island rendered inside a
|
|
37
|
+
// `<motu-archipelago>` shadow (or a React root inside a custom element's shadow) is still "inside".
|
|
38
|
+
const within = (outer, node) => {
|
|
39
|
+
for (let n = node; n; n = n.parentNode || n.host) if (n === outer) return true;
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const fiberKey = (node) => {
|
|
44
|
+
for (const k of Object.keys(node)) if (k.startsWith('__reactFiber$')) return k;
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const fiberOf = (node) => {
|
|
49
|
+
const k = fiberKey(node);
|
|
50
|
+
if (k) return node[k];
|
|
51
|
+
// A library that creates its own container (`document.createElement('div')` + `createPortal`
|
|
52
|
+
// into it) leaves the container fiber-less; its first rendered descendant carries the fiber.
|
|
53
|
+
const walker = doc.createTreeWalker(node, 1 /* SHOW_ELEMENT */);
|
|
54
|
+
for (let i = 0, n = walker.nextNode(); n && i < 200; i++, n = walker.nextNode()) {
|
|
55
|
+
const nk = fiberKey(n);
|
|
56
|
+
if (nk) return n[nk];
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const ownedBy = (node, island) => {
|
|
62
|
+
for (let f = fiberOf(node), hops = 0; f && hops < 5000; f = f.return, hops++) {
|
|
63
|
+
const s = f.stateNode;
|
|
64
|
+
if (!s) continue;
|
|
65
|
+
// A host element (div, span, the island's own `data-motu-island` wrapper) inside the island.
|
|
66
|
+
if (s.nodeType === 1 && within(island, s)) return true;
|
|
67
|
+
// A HostRoot (tag 3): a React root whose container is inside the island — the custom-element
|
|
68
|
+
// path mounts one root per island, in the island's own shadow/light DOM.
|
|
69
|
+
if (f.tag === 3 && s.containerInfo && within(island, s.containerInfo)) return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const SKIP = /^(SCRIPT|STYLE|LINK|TEMPLATE|NOSCRIPT|META)$/;
|
|
75
|
+
|
|
76
|
+
/** The body-level nodes this island's own mount portaled out of itself, in document order. */
|
|
77
|
+
const portals = (island) => {
|
|
78
|
+
if (!island || !doc.body) return [];
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const n of doc.body.children) {
|
|
81
|
+
if (SKIP.test(n.tagName)) continue;
|
|
82
|
+
if (n === island || within(n, island)) continue; // the island itself, or what contains it
|
|
83
|
+
// Lagoon chrome marks itself. Its nodes are vanilla DOM and would fail the fiber walk anyway;
|
|
84
|
+
// the mark makes the exclusion a declared rule rather than an accident of how chrome is drawn.
|
|
85
|
+
if (n.hasAttribute('data-motu-chrome') || n.querySelector('[data-motu-chrome]')) continue;
|
|
86
|
+
if (ownedBy(n, island)) out.push(n);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const own = (el) => (el ? (el.shadowRoot ? el.shadowRoot.innerHTML : el.innerHTML) : '');
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `{ own, portals }` — the island's own HTML and the outerHTML of each node it portaled. The HTML
|
|
95
|
+
* comparison string (`__motuRendered`) is the two joined, so a check that compares renders compares
|
|
96
|
+
* what a person sees, and an island with no own DOM and no portal still reads as ''.
|
|
97
|
+
*/
|
|
98
|
+
w.__motuRenderedParts = (el) => ({ own: own(el), portals: portals(el).map((n) => n.outerHTML) });
|
|
99
|
+
w.__motuRendered = (el) => {
|
|
100
|
+
const p = w.__motuRenderedParts(el);
|
|
101
|
+
return p.own + p.portals.map((h) => '\n<!--motu:portal-->' + h).join('');
|
|
102
|
+
};
|
|
103
|
+
/** The portaled ELEMENTS — for checks that measure (axe context, layout boxes) rather than compare. */
|
|
104
|
+
w.__motuPortals = portals;
|
|
105
|
+
}
|