@cueloop/extension-api 0.1.0-alpha.6 → 0.1.0-alpha.61
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/package.json +20 -20
- package/src/index.ts +0 -1
- package/src/registry.test.ts +24 -81
- package/src/registry.ts +9 -78
- package/src/types.ts +10 -46
- package/src/loader.ts +0 -98
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/extension-api",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.61",
|
|
4
|
+
"description": "The typed contract for extending cueloop: renderers, commands, keybindings, exporters",
|
|
5
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
8
|
+
},
|
|
4
9
|
"license": "Apache-2.0",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
13
|
+
"directory": "packages/extension-api"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
5
19
|
"type": "module",
|
|
6
20
|
"exports": {
|
|
7
21
|
".": "./src/index.ts"
|
|
8
22
|
},
|
|
9
|
-
"dependencies": {
|
|
10
|
-
"@cueloop/schema": "0.1.0-alpha.6"
|
|
11
|
-
},
|
|
12
23
|
"publishConfig": {
|
|
13
24
|
"access": "public"
|
|
14
25
|
},
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@cueloop/schema": "0.1.0-alpha.61"
|
|
28
|
+
},
|
|
19
29
|
"engines": {
|
|
20
30
|
"bun": ">=1.3.0"
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
"type": "git",
|
|
24
|
-
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
25
|
-
"directory": "packages/extension-api"
|
|
26
|
-
},
|
|
27
|
-
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
28
|
-
"bugs": {
|
|
29
|
-
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
30
|
-
},
|
|
31
|
-
"description": "The typed contract for extending cueloop: renderers, commands, keybindings, exporters"
|
|
32
|
-
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
package/src/registry.test.ts
CHANGED
|
@@ -1,98 +1,41 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
2
|
import { Registry } from "./registry";
|
|
6
|
-
import { loadExtensions, readTrust } from "./loader";
|
|
7
3
|
import type { ReviewSession } from "@cueloop/schema";
|
|
8
4
|
|
|
9
5
|
const SESSION = { id: "ses_x", annotations: [] } as unknown as ReviewSession;
|
|
10
6
|
|
|
11
7
|
describe("Registry", () => {
|
|
12
|
-
test("captures
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
api.registerRenderer("plan", () => [[{ text: "builtin" }]]);
|
|
16
|
-
});
|
|
17
|
-
await reg.load("third-party", (api) => {
|
|
18
|
-
api.registerRenderer("plan", () => [[{ text: "override-attempt" }]]);
|
|
19
|
-
api.registerRenderer("svg", () => [[{ text: "svg" }]]);
|
|
20
|
-
});
|
|
21
|
-
expect(reg.rendererFor("plan")!({ content: "", meta: {} }, 80)[0]![0]!.text).toBe("builtin");
|
|
22
|
-
expect(reg.rendererFor("svg")).toBeDefined();
|
|
23
|
-
});
|
|
8
|
+
test("captures each extension's exporters, attributed by name", async () => {
|
|
9
|
+
// Arrange
|
|
10
|
+
const registry = new Registry();
|
|
24
11
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
throw new Error("boom");
|
|
12
|
+
// Act
|
|
13
|
+
await registry.load("obsidian", (api) => {
|
|
14
|
+
api.registerExporter("obsidian", async () => ({ success: true, path: "/vault/note.md" }));
|
|
29
15
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("reserved keybindings are rejected with attribution", async () => {
|
|
35
|
-
const reg = new Registry();
|
|
36
|
-
const record = await reg.load("greedy", (api) => {
|
|
37
|
-
api.registerKeybinding({ action: "steal", defaultKeys: ["j"], handler: () => {} });
|
|
38
|
-
api.registerKeybinding({ action: "fine", defaultKeys: ["F5"], handler: () => {} });
|
|
16
|
+
await registry.load("bear", (api) => {
|
|
17
|
+
api.registerExporter("bear", async () => ({ success: true }));
|
|
39
18
|
});
|
|
40
|
-
expect(record.keybindings.length).toBe(1);
|
|
41
|
-
expect(record.errors[0]).toContain('"j" is reserved');
|
|
42
|
-
});
|
|
43
19
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
throw new Error("observer bug");
|
|
50
|
-
});
|
|
51
|
-
api.on("session.resolved", (s) => seen.push(s.id));
|
|
52
|
-
});
|
|
53
|
-
reg.emit("session.resolved", SESSION);
|
|
54
|
-
expect(seen).toEqual(["ses_x"]);
|
|
20
|
+
// Assert
|
|
21
|
+
expect(registry.extensions.map((extension) => extension.name)).toEqual(["obsidian", "bear"]);
|
|
22
|
+
const obsidian = registry.extensions[0]!.exporters.get("obsidian")!;
|
|
23
|
+
|
|
24
|
+
expect(await obsidian(SESSION)).toEqual({ success: true, path: "/vault/note.md" });
|
|
55
25
|
});
|
|
56
|
-
});
|
|
57
26
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const userDir = join(dir, "user-ext");
|
|
62
|
-
const repoRoot = join(dir, "repo");
|
|
63
|
-
const home = join(dir, "home");
|
|
64
|
-
mkdirSync(userDir, { recursive: true });
|
|
65
|
-
mkdirSync(join(repoRoot, ".cueloop", "extensions"), { recursive: true });
|
|
66
|
-
writeFileSync(
|
|
67
|
-
join(userDir, "hello.ts"),
|
|
68
|
-
`export default (api) => { api.registerCommand("hello", { description: "hi", handler() {} }); };`,
|
|
69
|
-
);
|
|
70
|
-
writeFileSync(
|
|
71
|
-
join(repoRoot, ".cueloop", "extensions", "repo-ext.ts"),
|
|
72
|
-
`export default (api) => { api.registerCommand("repo", { description: "r", handler() {} }); };`,
|
|
73
|
-
);
|
|
74
|
-
try {
|
|
75
|
-
// untrusted repo: user loads, repo skipped
|
|
76
|
-
const reg1 = new Registry();
|
|
77
|
-
const r1 = await loadExtensions({ registry: reg1, userDir, repoRoot, home });
|
|
78
|
-
expect(r1.loaded.length).toBe(1);
|
|
79
|
-
expect(r1.skipped.length).toBe(1);
|
|
80
|
-
expect(reg1.commandFor("hello")).toBeDefined();
|
|
81
|
-
expect(reg1.commandFor("repo")).toBeUndefined();
|
|
27
|
+
test("a throwing factory is contained, not fatal", async () => {
|
|
28
|
+
// Arrange
|
|
29
|
+
const registry = new Registry();
|
|
82
30
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
expect(reg2.commandFor("repo")).toBeDefined();
|
|
88
|
-
expect(readTrust(home).trusted).toContain(repoRoot);
|
|
31
|
+
// Act
|
|
32
|
+
const record = await registry.load("broken", () => {
|
|
33
|
+
throw new Error("boom");
|
|
34
|
+
});
|
|
89
35
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
} finally {
|
|
95
|
-
rmSync(dir, { recursive: true, force: true });
|
|
96
|
-
}
|
|
36
|
+
// Assert
|
|
37
|
+
expect(record.errors).toEqual(["boom"]);
|
|
38
|
+
expect(record.exporters.size).toBe(0);
|
|
39
|
+
expect(registry.extensions.length).toBe(1);
|
|
97
40
|
});
|
|
98
41
|
});
|
package/src/registry.ts
CHANGED
|
@@ -1,28 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The contribution registry
|
|
3
|
-
*
|
|
4
|
-
* broken extension
|
|
2
|
+
* The contribution registry: an extension factory runs against a captured
|
|
3
|
+
* API, and its exporter registrations land as plain data attributed to that
|
|
4
|
+
* extension. One broken extension never takes the host down.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
8
|
-
ArtifactRenderer,
|
|
9
|
-
CommandRegistration,
|
|
10
|
-
Exporter,
|
|
11
|
-
ExtensionAPI,
|
|
12
|
-
ExtensionFactory,
|
|
13
|
-
KeybindingRegistration,
|
|
14
|
-
} from "./types";
|
|
15
|
-
import type { ReviewSession } from "@cueloop/schema";
|
|
16
|
-
|
|
17
|
-
const RESERVED_KEYS = new Set(["j", "k", "g", "G", "v", "c", "s", "x", "e", "n", "p", "q", "return", "escape", "backspace"]);
|
|
7
|
+
import type { Exporter, ExtensionAPI, ExtensionFactory } from "./types";
|
|
18
8
|
|
|
19
9
|
export interface ExtensionRecord {
|
|
20
10
|
name: string;
|
|
21
|
-
renderers: Map<string, ArtifactRenderer>;
|
|
22
|
-
commands: Map<string, CommandRegistration>;
|
|
23
|
-
keybindings: KeybindingRegistration[];
|
|
24
11
|
exporters: Map<string, Exporter>;
|
|
25
|
-
listeners: Map<string, ((session: ReviewSession) => void)[]>;
|
|
26
12
|
errors: string[];
|
|
27
13
|
}
|
|
28
14
|
|
|
@@ -31,75 +17,20 @@ export class Registry {
|
|
|
31
17
|
|
|
32
18
|
/** Run one extension factory, capturing its registrations. */
|
|
33
19
|
async load(name: string, factory: ExtensionFactory): Promise<ExtensionRecord> {
|
|
34
|
-
const record: ExtensionRecord = {
|
|
35
|
-
name,
|
|
36
|
-
renderers: new Map(),
|
|
37
|
-
commands: new Map(),
|
|
38
|
-
keybindings: [],
|
|
39
|
-
exporters: new Map(),
|
|
40
|
-
listeners: new Map(),
|
|
41
|
-
errors: [],
|
|
42
|
-
};
|
|
20
|
+
const record: ExtensionRecord = { name, exporters: new Map(), errors: [] };
|
|
43
21
|
const api: ExtensionAPI = {
|
|
44
|
-
|
|
45
|
-
record.
|
|
46
|
-
},
|
|
47
|
-
registerCommand(cmdName, registration) {
|
|
48
|
-
record.commands.set(cmdName, registration);
|
|
49
|
-
},
|
|
50
|
-
registerKeybinding(registration) {
|
|
51
|
-
const clash = registration.defaultKeys.find((k) => RESERVED_KEYS.has(k));
|
|
52
|
-
if (clash) {
|
|
53
|
-
record.errors.push(`keybinding "${registration.action}" rejected: "${clash}" is reserved`);
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
record.keybindings.push(registration);
|
|
57
|
-
},
|
|
58
|
-
registerExporter(expName, exporter) {
|
|
59
|
-
record.exporters.set(expName, exporter);
|
|
60
|
-
},
|
|
61
|
-
on(event, handler) {
|
|
62
|
-
const list = record.listeners.get(event) ?? [];
|
|
63
|
-
list.push(handler);
|
|
64
|
-
record.listeners.set(event, list);
|
|
22
|
+
registerExporter(exporterName, exporter) {
|
|
23
|
+
record.exporters.set(exporterName, exporter);
|
|
65
24
|
},
|
|
66
25
|
};
|
|
26
|
+
|
|
67
27
|
try {
|
|
68
28
|
await factory(api);
|
|
69
29
|
} catch (err) {
|
|
70
|
-
// one broken extension never takes the app down
|
|
71
30
|
record.errors.push(err instanceof Error ? err.message : String(err));
|
|
72
31
|
}
|
|
73
32
|
this.extensions.push(record);
|
|
74
|
-
return record;
|
|
75
|
-
}
|
|
76
33
|
|
|
77
|
-
|
|
78
|
-
rendererFor(artifactType: string): ArtifactRenderer | undefined {
|
|
79
|
-
for (const ext of this.extensions) {
|
|
80
|
-
const r = ext.renderers.get(artifactType);
|
|
81
|
-
if (r) return r;
|
|
82
|
-
}
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
commandFor(name: string): { extension: string; registration: CommandRegistration } | undefined {
|
|
87
|
-
for (const ext of this.extensions) {
|
|
88
|
-
const c = ext.commands.get(name);
|
|
89
|
-
if (c) return { extension: ext.name, registration: c };
|
|
90
|
-
}
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
emit(event: string, session: ReviewSession): void {
|
|
95
|
-
for (const ext of this.extensions) {
|
|
96
|
-
for (const handler of ext.listeners.get(event) ?? []) {
|
|
97
|
-
try {
|
|
98
|
-
handler(session);
|
|
99
|
-
} catch {
|
|
100
|
-
// observers never cancel and never crash the host
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
34
|
+
return record;
|
|
104
35
|
}
|
|
105
36
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,58 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
* so the contract stays self-contained.
|
|
2
|
+
* The typed extension contract. Import-free beyond the schema, so the
|
|
3
|
+
* contract stays self-contained.
|
|
5
4
|
*
|
|
6
|
-
* An extension is a factory:
|
|
7
|
-
* export default (cueloop: ExtensionAPI) => { cueloop.
|
|
8
|
-
* Registration is data written into a per-extension record;
|
|
9
|
-
*
|
|
5
|
+
* An extension is a factory that registers an exporter:
|
|
6
|
+
* export default (cueloop: ExtensionAPI) => { cueloop.registerExporter(...) }
|
|
7
|
+
* Registration is data written into a per-extension record; the exporter runs
|
|
8
|
+
* later, host-driven. No import-time side effects.
|
|
10
9
|
*/
|
|
11
10
|
|
|
12
|
-
import type {
|
|
13
|
-
|
|
14
|
-
/** A renderable line: styled runs the host paints into the terminal grid. */
|
|
15
|
-
export interface UiRun {
|
|
16
|
-
text: string;
|
|
17
|
-
fg?: string;
|
|
18
|
-
bg?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type UiLine = UiRun[];
|
|
22
|
-
|
|
23
|
-
/** Renderers project an artifact type into lines at a given width. */
|
|
24
|
-
export type ArtifactRenderer = (artifact: { content: string; meta: Record<string, unknown> }, width: number) => UiLine[];
|
|
25
|
-
|
|
26
|
-
export interface CommandContext {
|
|
27
|
-
session: ReviewSession | null;
|
|
28
|
-
annotate(annotation: Omit<Annotation, "createdAt">): Promise<void>;
|
|
29
|
-
resolve(verdict: VerdictKind, summary: string): Promise<void>;
|
|
30
|
-
notify(message: string): void;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface CommandRegistration {
|
|
34
|
-
description: string;
|
|
35
|
-
handler(ctx: CommandContext, args: string): void | Promise<void>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface KeybindingRegistration {
|
|
39
|
-
/** Action name; binds through the same [keys] config as built-ins. */
|
|
40
|
-
action: string;
|
|
41
|
-
defaultKeys: string[];
|
|
42
|
-
handler(ctx: CommandContext): void | Promise<void>;
|
|
43
|
-
}
|
|
11
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
44
12
|
|
|
45
13
|
/** Exporters ship resolved sessions somewhere (notes vaults, forges). */
|
|
46
|
-
export type Exporter = (
|
|
14
|
+
export type Exporter = (
|
|
15
|
+
session: ReviewSession,
|
|
16
|
+
) => Promise<{ success: boolean; path?: string; error?: string }>;
|
|
47
17
|
|
|
48
18
|
export interface ExtensionAPI {
|
|
49
|
-
/** Renderers for artifact types; built-ins register through this too. */
|
|
50
|
-
registerRenderer(artifactType: string, renderer: ArtifactRenderer): void;
|
|
51
|
-
registerCommand(name: string, registration: CommandRegistration): void;
|
|
52
|
-
registerKeybinding(registration: KeybindingRegistration): void;
|
|
53
19
|
registerExporter(name: string, exporter: Exporter): void;
|
|
54
|
-
/** Observe session lifecycle; fold semantics: observers never cancel. */
|
|
55
|
-
on(event: "session.created" | "session.resolved" | "session.updated", handler: (session: ReviewSession) => void): void;
|
|
56
20
|
}
|
|
57
21
|
|
|
58
22
|
export type ExtensionFactory = (cueloop: ExtensionAPI) => void | Promise<void>;
|
package/src/loader.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Extension discovery and loading (#10): zero-build TypeScript executed
|
|
3
|
-
* directly. Global user extensions always load; repo-local `.cueloop/
|
|
4
|
-
* extensions/` loads only when the repo is trusted (persisted decisions
|
|
5
|
-
* under the cueloop home).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { Registry } from "./registry";
|
|
12
|
-
import type { ExtensionFactory } from "./types";
|
|
13
|
-
|
|
14
|
-
export function userExtensionsDir(): string {
|
|
15
|
-
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "extensions");
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface LoadOptions {
|
|
19
|
-
registry: Registry;
|
|
20
|
-
userDir?: string;
|
|
21
|
-
repoRoot?: string;
|
|
22
|
-
/** Persisted trust decisions live under the cueloop home. */
|
|
23
|
-
home?: string;
|
|
24
|
-
/** Trust resolver for untrusted repos; default denies (never prompt-free). */
|
|
25
|
-
confirmTrust?: (repoRoot: string) => Promise<boolean>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface TrustStore {
|
|
29
|
-
trusted: string[];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function trustPath(home: string): string {
|
|
33
|
-
return join(home, "trusted-repos.json");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function readTrust(home: string): TrustStore {
|
|
37
|
-
try {
|
|
38
|
-
return JSON.parse(readFileSync(trustPath(home), "utf8")) as TrustStore;
|
|
39
|
-
} catch {
|
|
40
|
-
return { trusted: [] };
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function grantTrust(home: string, repoRoot: string): void {
|
|
45
|
-
const store = readTrust(home);
|
|
46
|
-
if (!store.trusted.includes(repoRoot)) store.trusted.push(repoRoot);
|
|
47
|
-
mkdirSync(home, { recursive: true });
|
|
48
|
-
writeFileSync(trustPath(home), JSON.stringify(store, null, 2));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function extensionFiles(dir: string): string[] {
|
|
52
|
-
if (!existsSync(dir)) return [];
|
|
53
|
-
return readdirSync(dir)
|
|
54
|
-
.filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
|
|
55
|
-
.map((f) => join(dir, f))
|
|
56
|
-
.sort();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export async function loadExtensions(opts: LoadOptions): Promise<{ loaded: string[]; skipped: string[] }> {
|
|
60
|
-
const loaded: string[] = [];
|
|
61
|
-
const skipped: string[] = [];
|
|
62
|
-
const files: { path: string; source: string }[] = [];
|
|
63
|
-
|
|
64
|
-
for (const path of extensionFiles(opts.userDir ?? userExtensionsDir())) {
|
|
65
|
-
files.push({ path, source: "user" });
|
|
66
|
-
}
|
|
67
|
-
if (opts.repoRoot) {
|
|
68
|
-
const repoDir = join(opts.repoRoot, ".cueloop", "extensions");
|
|
69
|
-
const repoFiles = extensionFiles(repoDir);
|
|
70
|
-
if (repoFiles.length) {
|
|
71
|
-
const home = opts.home ?? join(homedir(), ".cueloop");
|
|
72
|
-
const trusted =
|
|
73
|
-
readTrust(home).trusted.includes(opts.repoRoot) ||
|
|
74
|
-
(opts.confirmTrust ? await opts.confirmTrust(opts.repoRoot) : false);
|
|
75
|
-
if (trusted) {
|
|
76
|
-
grantTrust(home, opts.repoRoot);
|
|
77
|
-
for (const path of repoFiles) files.push({ path, source: "repo" });
|
|
78
|
-
} else {
|
|
79
|
-
skipped.push(...repoFiles);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
for (const file of files) {
|
|
85
|
-
try {
|
|
86
|
-
const mod = (await import(file.path)) as { default?: ExtensionFactory };
|
|
87
|
-
if (typeof mod.default !== "function") {
|
|
88
|
-
skipped.push(file.path);
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
await opts.registry.load(file.path, mod.default);
|
|
92
|
-
loaded.push(file.path);
|
|
93
|
-
} catch {
|
|
94
|
-
skipped.push(file.path);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return { loaded, skipped };
|
|
98
|
-
}
|