@pcbjs/cli 0.0.0 → 0.0.1-next.e49dd98cbe255530f38a7eb0f82a8339af65dce7
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/dist/boards.d.ts +92 -0
- package/dist/boards.js +126 -0
- package/dist/bridge.d.ts +53 -0
- package/dist/bridge.js +147 -0
- package/dist/build.d.ts +21 -0
- package/dist/build.js +165 -0
- package/dist/check.d.ts +83 -0
- package/dist/check.js +261 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +278 -0
- package/dist/config.d.ts +70 -0
- package/dist/config.js +141 -0
- package/dist/create.d.ts +18 -0
- package/dist/create.js +73 -0
- package/dist/dev-reuse.d.ts +36 -0
- package/dist/dev-reuse.js +51 -0
- package/dist/docs.d.ts +21 -0
- package/dist/docs.js +101 -0
- package/dist/doctor.d.ts +31 -0
- package/dist/doctor.js +151 -0
- package/dist/drafts.d.ts +47 -0
- package/dist/drafts.js +113 -0
- package/dist/fetch-model-child.d.ts +1 -0
- package/dist/fetch-model-child.js +12 -0
- package/dist/fetch-model.d.ts +32 -0
- package/dist/fetch-model.js +177 -0
- package/dist/host-client.d.ts +60 -0
- package/dist/host-client.js +182 -0
- package/dist/ide.d.ts +38 -0
- package/dist/ide.js +119 -0
- package/dist/import.d.ts +9 -0
- package/dist/import.js +120 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/inspect-sch.d.ts +6 -0
- package/dist/inspect-sch.js +28 -0
- package/dist/inspect.d.ts +259 -0
- package/dist/inspect.js +451 -0
- package/dist/lsp.d.ts +25 -0
- package/dist/lsp.js +104 -0
- package/dist/model-child.d.ts +1 -0
- package/dist/model-child.js +26 -0
- package/dist/model.d.ts +101 -0
- package/dist/model.js +634 -0
- package/dist/models.d.ts +28 -0
- package/dist/models.js +236 -0
- package/dist/plugin.d.ts +59 -0
- package/dist/plugin.js +920 -0
- package/dist/prerender.d.ts +18 -0
- package/dist/prerender.js +79 -0
- package/dist/preview.d.ts +33 -0
- package/dist/preview.js +100 -0
- package/dist/project.d.ts +38 -0
- package/dist/project.js +154 -0
- package/dist/reference-child.d.ts +1 -0
- package/dist/reference-child.js +15 -0
- package/dist/references.d.ts +42 -0
- package/dist/references.js +328 -0
- package/dist/repo.d.ts +11 -0
- package/dist/repo.js +64 -0
- package/dist/scope-plugin.d.ts +2 -0
- package/dist/scope-plugin.js +22 -0
- package/dist/scope-state.d.ts +70 -0
- package/dist/scope-state.js +42 -0
- package/dist/scopes.d.ts +27 -0
- package/dist/scopes.js +96 -0
- package/dist/server.d.ts +55 -0
- package/dist/server.js +141 -0
- package/dist/shot.d.ts +74 -0
- package/dist/shot.js +300 -0
- package/dist/skills.d.ts +5 -0
- package/dist/skills.js +38 -0
- package/dist/source-ranges.d.ts +29 -0
- package/dist/source-ranges.js +261 -0
- package/dist/typecheck.d.ts +17 -0
- package/dist/typecheck.js +66 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +12 -0
- package/dist/vite.d.ts +10 -0
- package/dist/vite.js +71 -0
- package/dist/workspace.d.ts +11 -0
- package/dist/workspace.js +53 -0
- package/docs/3d.md +100 -0
- package/docs/autorouter.md +108 -0
- package/docs/board.md +142 -0
- package/docs/circuits.md +140 -0
- package/docs/cli.md +158 -0
- package/docs/design.md +69 -0
- package/docs/development.md +94 -0
- package/docs/editor.md +112 -0
- package/docs/fab-set.md +87 -0
- package/docs/fab.md +99 -0
- package/docs/findings.md +86 -0
- package/docs/fonts.md +93 -0
- package/docs/highlight.md +45 -0
- package/docs/ide-bridge.md +145 -0
- package/docs/import.md +94 -0
- package/docs/inspect.md +25 -0
- package/docs/kicad.md +93 -0
- package/docs/layout.md +137 -0
- package/docs/length.md +37 -0
- package/docs/models.md +40 -0
- package/docs/pairs.md +126 -0
- package/docs/pours.md +74 -0
- package/docs/quickstart.md +126 -0
- package/docs/references.md +88 -0
- package/docs/routing.md +146 -0
- package/docs/rules.md +118 -0
- package/docs/schematic-graphic.md +137 -0
- package/docs/schematic.md +172 -0
- package/docs/skills.md +42 -0
- package/docs/sourcing.md +68 -0
- package/docs/stackup-and-rules.md +144 -0
- package/docs/stdlib.md +176 -0
- package/docs/viewer.md +107 -0
- package/docs/workspace.md +53 -0
- package/package.json +67 -3
- package/templates/board/README.md +14 -0
- package/templates/board/_gitignore +2 -0
- package/templates/board/package.json +16 -0
- package/templates/board/pcb.config.ts +8 -0
- package/templates/board/pcb.tsx +35 -0
- package/templates/board/tsconfig.json +15 -0
- package/vscode/manifest.json +9 -0
- package/vscode/vscode-pcbjs-0.0.1000.vsix +0 -0
- package/vscode/vscode-pcbjs.vsix +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yuxuan Zhang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/boards.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { SiteData } from '@pcbjs/export/site-data';
|
|
2
|
+
import type { Entry } from './config.js';
|
|
3
|
+
import { type Compiled } from './project.js';
|
|
4
|
+
export interface BoardSlot {
|
|
5
|
+
entry: Entry;
|
|
6
|
+
/** the last good compile, one per fab target in list order, kept while a later one fails */
|
|
7
|
+
compiled: Compiled[] | null;
|
|
8
|
+
/** formatted for the viewer */
|
|
9
|
+
error: string | null;
|
|
10
|
+
/** set when an earlier entry already uses this board's name */
|
|
11
|
+
conflict: string | null;
|
|
12
|
+
/** dev: size and mtime of every file of its module graph at the last good compile */
|
|
13
|
+
stamps?: Map<string, string>;
|
|
14
|
+
/** dev: Date.now() when the last good compile finished */
|
|
15
|
+
doneAt?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare const slotsFor: (entries: Entry[]) => BoardSlot[];
|
|
18
|
+
/** The first entry to claim a board name keeps it; every later one gets a conflict naming both files. */
|
|
19
|
+
export declare function markConflicts(slots: BoardSlot[]): void;
|
|
20
|
+
/** In entry order; a failed compile keeps its last good board. */
|
|
21
|
+
export declare const liveSlots: (slots: BoardSlot[]) => BoardSlot[];
|
|
22
|
+
export declare const errorOf: (s: BoardSlot) => string | null;
|
|
23
|
+
export declare function errorsByEntry(slots: BoardSlot[]): Record<string, string | null>;
|
|
24
|
+
export declare const slotName: (s: BoardSlot) => string;
|
|
25
|
+
export interface BoardsIndex {
|
|
26
|
+
/** one per board, or per target of a board with variants */
|
|
27
|
+
boards: {
|
|
28
|
+
name: string;
|
|
29
|
+
variant?: string;
|
|
30
|
+
title: string;
|
|
31
|
+
revision: string;
|
|
32
|
+
file: string;
|
|
33
|
+
entry: string;
|
|
34
|
+
status: string;
|
|
35
|
+
}[];
|
|
36
|
+
/** one board, however many targets it has */
|
|
37
|
+
single: boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare function boardsIndex(compiled: Compiled[], single: boolean): BoardsIndex;
|
|
40
|
+
export interface BoardsPayload {
|
|
41
|
+
boards: SiteData[];
|
|
42
|
+
single: boolean;
|
|
43
|
+
/** by entry path, in entry order */
|
|
44
|
+
errors: Record<string, string | null>;
|
|
45
|
+
/** by board name */
|
|
46
|
+
entries: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
/** What the virtual module and pcbjs:boards carry; `transient`, dev's isolated preview, goes last with `transient: true`. */
|
|
49
|
+
export declare function boardsPayload(slots: BoardSlot[], transient?: Compiled | null): BoardsPayload;
|
|
50
|
+
interface GraphNode {
|
|
51
|
+
file: string | null;
|
|
52
|
+
importers: Set<GraphNode>;
|
|
53
|
+
}
|
|
54
|
+
/** Walks importers up the module graph; null when the file is not in it, so the caller recompiles everything. */
|
|
55
|
+
export declare function entriesReaching(modules: Iterable<GraphNode> | undefined, entryFiles: Iterable<string>): Set<string> | null;
|
|
56
|
+
/** `<name>/<file>` or `<name>/<slug>/<file>`; anything else is the legacy single-board form and goes to the first board. */
|
|
57
|
+
export declare function routeEndpoint(path: string, names: string[], slugs?: Record<string, string[]>): {
|
|
58
|
+
name: string;
|
|
59
|
+
variant?: string;
|
|
60
|
+
file: string;
|
|
61
|
+
} | null;
|
|
62
|
+
export declare function slugsOf(slots: BoardSlot[]): Record<string, string[]>;
|
|
63
|
+
/** Vite's `moduleGraph`, or a stand-in in the tests. */
|
|
64
|
+
export interface ModuleLookup {
|
|
65
|
+
getModulesByFile(file: string): Iterable<GraphNode> | undefined;
|
|
66
|
+
}
|
|
67
|
+
export interface FocusTarget {
|
|
68
|
+
/** the only live board holding the file, the viewer's own when it is one of several, else the first; null when none */
|
|
69
|
+
board: string | null;
|
|
70
|
+
/** or of the first entry that imports the file when none of them has a live board */
|
|
71
|
+
entry: string | null;
|
|
72
|
+
/** in entry order */
|
|
73
|
+
boards: string[];
|
|
74
|
+
}
|
|
75
|
+
/** A shared file never moves a viewer off `current`; an entry file always reaches its own board, also before the first compile. */
|
|
76
|
+
export declare function focusTarget(graph: ModuleLookup | undefined, slots: BoardSlot[], file: string, current?: string | null): FocusTarget;
|
|
77
|
+
export interface IdeBoard {
|
|
78
|
+
name: string;
|
|
79
|
+
title: string;
|
|
80
|
+
entry: string;
|
|
81
|
+
targets: string[];
|
|
82
|
+
status: 'ok' | 'error' | 'incomplete';
|
|
83
|
+
draft?: true;
|
|
84
|
+
skipped?: number;
|
|
85
|
+
}
|
|
86
|
+
/** One per entry with a board of its own; `error` marks a last good board, `incomplete` DRC errors or unconnected nets. */
|
|
87
|
+
export declare function ideBoards(slots: BoardSlot[]): {
|
|
88
|
+
boards: IdeBoard[];
|
|
89
|
+
single: boolean;
|
|
90
|
+
errors: Record<string, string | null>;
|
|
91
|
+
};
|
|
92
|
+
export {};
|
package/dist/boards.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { boardFile } from './project.js';
|
|
2
|
+
import { variantSlug } from '@pcbjs/core/variants';
|
|
3
|
+
export const slotsFor = (entries) => entries.map(entry => ({ entry, compiled: null, error: null, conflict: null }));
|
|
4
|
+
/** The first entry to claim a board name keeps it; every later one gets a conflict naming both files. */
|
|
5
|
+
export function markConflicts(slots) {
|
|
6
|
+
const owner = new Map();
|
|
7
|
+
for (const s of slots) {
|
|
8
|
+
s.conflict = null;
|
|
9
|
+
if (!s.compiled)
|
|
10
|
+
continue;
|
|
11
|
+
const name = s.compiled[0].board.name;
|
|
12
|
+
const first = owner.get(name);
|
|
13
|
+
if (first)
|
|
14
|
+
s.conflict = `board name "${name}" is used by both ${first.entry.rel} and ${s.entry.rel}`;
|
|
15
|
+
else
|
|
16
|
+
owner.set(name, s);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** In entry order; a failed compile keeps its last good board. */
|
|
20
|
+
export const liveSlots = (slots) => slots.filter(s => s.compiled && !s.conflict);
|
|
21
|
+
export const errorOf = (s) => s.error ?? s.conflict;
|
|
22
|
+
export function errorsByEntry(slots) {
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const s of slots)
|
|
25
|
+
out[s.entry.rel] = errorOf(s);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
export const slotName = (s) => s.compiled[0].board.name;
|
|
29
|
+
export function boardsIndex(compiled, single) {
|
|
30
|
+
return {
|
|
31
|
+
boards: compiled.map(c => ({
|
|
32
|
+
name: c.board.name, ...(c.board.variant ? { variant: c.board.variant.name } : {}), title: c.board.title, revision: c.board.revision,
|
|
33
|
+
file: boardFile(c.board), entry: c.entry.rel, status: c.site.fab.status,
|
|
34
|
+
})),
|
|
35
|
+
single,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** What the virtual module and pcbjs:boards carry; `transient`, dev's isolated preview, goes last with `transient: true`. */
|
|
39
|
+
export function boardsPayload(slots, transient) {
|
|
40
|
+
const live = liveSlots(slots);
|
|
41
|
+
return {
|
|
42
|
+
boards: [...live.flatMap(s => s.compiled.map(c => c.site)), ...(transient ? [{ ...transient.site, transient: true }] : [])], single: slots.length === 1, errors: errorsByEntry(slots),
|
|
43
|
+
entries: Object.fromEntries(live.map(s => [slotName(s), s.entry.rel])),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Walks importers up the module graph; null when the file is not in it, so the caller recompiles everything. */
|
|
47
|
+
export function entriesReaching(modules, entryFiles) {
|
|
48
|
+
const start = modules ? [...modules] : [];
|
|
49
|
+
if (!start.length)
|
|
50
|
+
return null;
|
|
51
|
+
const entries = new Set(entryFiles);
|
|
52
|
+
const hit = new Set();
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const stack = start;
|
|
55
|
+
while (stack.length) {
|
|
56
|
+
const m = stack.pop();
|
|
57
|
+
if (seen.has(m))
|
|
58
|
+
continue;
|
|
59
|
+
seen.add(m);
|
|
60
|
+
if (m.file && entries.has(m.file))
|
|
61
|
+
hit.add(m.file);
|
|
62
|
+
for (const i of m.importers)
|
|
63
|
+
stack.push(i);
|
|
64
|
+
}
|
|
65
|
+
return hit;
|
|
66
|
+
}
|
|
67
|
+
/** `<name>/<file>` or `<name>/<slug>/<file>`; anything else is the legacy single-board form and goes to the first board. */
|
|
68
|
+
export function routeEndpoint(path, names, slugs = {}) {
|
|
69
|
+
const i = path.indexOf('/');
|
|
70
|
+
let name, rest;
|
|
71
|
+
if (i > 0 && names.includes(decodeURIComponent(path.slice(0, i)))) {
|
|
72
|
+
name = decodeURIComponent(path.slice(0, i));
|
|
73
|
+
rest = path.slice(i + 1);
|
|
74
|
+
}
|
|
75
|
+
else if (names.length) {
|
|
76
|
+
name = names[0];
|
|
77
|
+
rest = path;
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
return null;
|
|
81
|
+
const targets = slugs[name];
|
|
82
|
+
if (!targets?.length)
|
|
83
|
+
return { name, file: decodeURIComponent(rest) };
|
|
84
|
+
const j = rest.indexOf('/');
|
|
85
|
+
const head = j > 0 ? decodeURIComponent(rest.slice(0, j)) : null;
|
|
86
|
+
if (head !== null && targets.includes(head))
|
|
87
|
+
return { name, variant: head, file: decodeURIComponent(rest.slice(j + 1)) };
|
|
88
|
+
return { name, variant: targets[0], file: decodeURIComponent(rest) };
|
|
89
|
+
}
|
|
90
|
+
export function slugsOf(slots) {
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const s of liveSlots(slots)) {
|
|
93
|
+
const v = s.compiled[0].board.variant;
|
|
94
|
+
if (v)
|
|
95
|
+
out[slotName(s)] = v.names.map(variantSlug);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** A shared file never moves a viewer off `current`; an entry file always reaches its own board, also before the first compile. */
|
|
100
|
+
export function focusTarget(graph, slots, file, current = null) {
|
|
101
|
+
const hit = entriesReaching(graph?.getModulesByFile(file), slots.map(s => s.entry.file)) ?? new Set();
|
|
102
|
+
if (slots.some(s => s.entry.file === file))
|
|
103
|
+
hit.add(file);
|
|
104
|
+
const reached = slots.filter(s => hit.has(s.entry.file));
|
|
105
|
+
const live = reached.filter(s => s.compiled && !s.conflict);
|
|
106
|
+
const names = live.map(slotName);
|
|
107
|
+
const board = current != null && names.includes(current) ? current : names[0] ?? null;
|
|
108
|
+
const at = board ? live[names.indexOf(board)] : reached[0];
|
|
109
|
+
return { board, entry: at ? at.entry.rel : null, boards: names };
|
|
110
|
+
}
|
|
111
|
+
/** One per entry with a board of its own; `error` marks a last good board, `incomplete` DRC errors or unconnected nets. */
|
|
112
|
+
export function ideBoards(slots) {
|
|
113
|
+
return {
|
|
114
|
+
boards: liveSlots(slots).map(s => {
|
|
115
|
+
const c = s.compiled;
|
|
116
|
+
return {
|
|
117
|
+
name: slotName(s), title: c[0].board.title || slotName(s), entry: s.entry.rel,
|
|
118
|
+
targets: c.flatMap(t => (t.board.variant ? [t.board.variant.name] : [])),
|
|
119
|
+
status: s.error ? 'error' : c.some(t => t.site.fab.status !== 'ok') ? 'incomplete' : 'ok',
|
|
120
|
+
...(c[0].site.draft ? { draft: true, skipped: c[0].site.draft.skipped } : {}),
|
|
121
|
+
};
|
|
122
|
+
}),
|
|
123
|
+
single: slots.length === 1,
|
|
124
|
+
errors: errorsByEntry(slots),
|
|
125
|
+
};
|
|
126
|
+
}
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Connect, ViteDevServer } from 'vite';
|
|
2
|
+
import { type HostClient } from './host-client.js';
|
|
3
|
+
export interface CursorEvent {
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
col: number;
|
|
7
|
+
target?: CursorTarget;
|
|
8
|
+
}
|
|
9
|
+
/** What the code under the cursor names, per board, from the language server; absent without one. */
|
|
10
|
+
export interface CursorTarget {
|
|
11
|
+
kind: string;
|
|
12
|
+
boards: {
|
|
13
|
+
board: string;
|
|
14
|
+
ids: number[];
|
|
15
|
+
refs: string[];
|
|
16
|
+
points?: [number, number][];
|
|
17
|
+
reference?: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}[];
|
|
20
|
+
}
|
|
21
|
+
export interface SelectEvent {
|
|
22
|
+
file: string;
|
|
23
|
+
line: number;
|
|
24
|
+
col: number;
|
|
25
|
+
endLine: number;
|
|
26
|
+
endCol: number;
|
|
27
|
+
}
|
|
28
|
+
/** A path as given by an editor or the viewer, absolute or relative to the root, both ways. */
|
|
29
|
+
export declare function normalizeFile(root: string, file: string): {
|
|
30
|
+
abs: string;
|
|
31
|
+
rel: string;
|
|
32
|
+
};
|
|
33
|
+
export interface IdeBridge {
|
|
34
|
+
connected(): number;
|
|
35
|
+
middleware: Connect.NextHandleFunction;
|
|
36
|
+
cursor(file: string, line: unknown, col: unknown, target?: CursorTarget): void;
|
|
37
|
+
lastCursor(): CursorEvent | null;
|
|
38
|
+
}
|
|
39
|
+
export interface BridgeOptions {
|
|
40
|
+
/** gets selections ahead of the bridges while connected */
|
|
41
|
+
host?: () => HostClient | null;
|
|
42
|
+
/** an absolute or root-relative file */
|
|
43
|
+
onFocus?: (file: string) => void;
|
|
44
|
+
onClose?: (file: string) => void;
|
|
45
|
+
/** keyed 'host' or by Vite socket; a session that takes `pcbjs/select` gets it instead of `pcbjs:ide:select` */
|
|
46
|
+
lsp?: {
|
|
47
|
+
select(key: unknown, sel: SelectEvent): boolean;
|
|
48
|
+
keys(): unknown[];
|
|
49
|
+
padSource?(board: string, ref: string, num: string): SelectEvent | undefined;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Register the bridge events on the server's WebSocket and return the /__pcbjs/{cursor,open,ide} middleware. */
|
|
53
|
+
export declare function ideBridge(server: ViteDevServer, rootOf: string | (() => string), opts?: BridgeOptions): IdeBridge;
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Editor bridges over Vite's HMR socket and the /__pcbjs HTTP endpoints. docs/ide-bridge.md has the protocol.
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import launch from 'launch-editor';
|
|
5
|
+
import { helloWarning, parseHello } from './host-client.js';
|
|
6
|
+
/** A path as given by an editor or the viewer, absolute or relative to the root, both ways. */
|
|
7
|
+
export function normalizeFile(root, file) {
|
|
8
|
+
const abs = isAbsolute(file) ? resolve(file) : resolve(root, file);
|
|
9
|
+
return { abs, rel: relative(root, abs).split(sep).join('/') };
|
|
10
|
+
}
|
|
11
|
+
const int = (v, d) => {
|
|
12
|
+
const n = Number(v);
|
|
13
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : d;
|
|
14
|
+
};
|
|
15
|
+
function readBody(req, limit = 64 * 1024) {
|
|
16
|
+
return new Promise((ok, fail) => {
|
|
17
|
+
let size = 0;
|
|
18
|
+
const chunks = [];
|
|
19
|
+
req.on('data', (c) => { size += c.length; if (size > limit) {
|
|
20
|
+
fail(new Error('body too large'));
|
|
21
|
+
req.destroy();
|
|
22
|
+
}
|
|
23
|
+
else
|
|
24
|
+
chunks.push(c); });
|
|
25
|
+
req.on('end', () => ok(Buffer.concat(chunks).toString('utf8')));
|
|
26
|
+
req.on('error', fail);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Register the bridge events on the server's WebSocket and return the /__pcbjs/{cursor,open,ide} middleware. */
|
|
30
|
+
export function ideBridge(server, rootOf, opts = {}) {
|
|
31
|
+
// a workspace dev server passes the root of the displayed scope, which changes with it
|
|
32
|
+
const fixedRoot = typeof rootOf === 'string' ? rootOf : null;
|
|
33
|
+
const root = () => fixedRoot ?? rootOf();
|
|
34
|
+
const bridges = new Map();
|
|
35
|
+
let last = null;
|
|
36
|
+
const broadcastCursor = (file, line, col, target) => {
|
|
37
|
+
const data = { file: normalizeFile(root(), file).rel, line: int(line, 1), col: int(col, 1), ...(target ? { target } : {}) };
|
|
38
|
+
last = data;
|
|
39
|
+
server.ws.send({ type: 'custom', event: 'pcbjs:cursor', data });
|
|
40
|
+
};
|
|
41
|
+
server.ws.on('pcbjs:ide:hello', (data, client) => {
|
|
42
|
+
const known = bridges.has(client.socket);
|
|
43
|
+
const hello = parseHello(data);
|
|
44
|
+
bridges.set(client.socket, { client, ...hello });
|
|
45
|
+
// a repeated hello is the bridge's heartbeat
|
|
46
|
+
if (!known) {
|
|
47
|
+
client.socket.once('close', () => bridges.delete(client.socket));
|
|
48
|
+
server.config.logger.info(`[pcbjs] ide bridge connected: ${hello.name} ${hello.version}`.trimEnd(), { timestamp: true });
|
|
49
|
+
const w = helloWarning(hello);
|
|
50
|
+
if (w)
|
|
51
|
+
server.config.logger.warn(w, { timestamp: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
server.ws.on('pcbjs:ide:bye', (_data, client) => { bridges.delete(client.socket); });
|
|
55
|
+
server.ws.on('pcbjs:ide:cursor', (data) => {
|
|
56
|
+
if (data && typeof data.file === 'string')
|
|
57
|
+
broadcastCursor(data.file, data.line, data.col);
|
|
58
|
+
});
|
|
59
|
+
server.ws.on('pcbjs:ide:focus', (data) => {
|
|
60
|
+
if (data && typeof data.file === 'string' && data.file)
|
|
61
|
+
opts.onFocus?.(data.file);
|
|
62
|
+
});
|
|
63
|
+
server.ws.on('pcbjs:ide:close', (data) => {
|
|
64
|
+
if (data && typeof data.file === 'string' && data.file)
|
|
65
|
+
opts.onClose?.(data.file);
|
|
66
|
+
});
|
|
67
|
+
// a file the editor may open: inside a directory Vite is allowed to serve, which holds the project root
|
|
68
|
+
const allowed = (abs) => [root(), ...server.config.server.fs.allow].some(d => abs === d || abs.startsWith(d.endsWith(sep) ? d : d + sep));
|
|
69
|
+
const middleware = (req, res, next) => {
|
|
70
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
71
|
+
const p = url.pathname;
|
|
72
|
+
if (p === '/__pcbjs/ide') {
|
|
73
|
+
res.setHeader('content-type', 'application/json');
|
|
74
|
+
const host = opts.host?.();
|
|
75
|
+
res.end(JSON.stringify({
|
|
76
|
+
connected: bridges.size, clients: [...bridges.values()].map(b => ({ name: b.name, version: b.version, protocol: b.protocol, major: b.major })),
|
|
77
|
+
host: host ? { connected: host.connected(), ...(host.hello() ?? {}) } : null,
|
|
78
|
+
}));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (p === '/__pcbjs/cursor') {
|
|
82
|
+
if (req.method !== 'POST') {
|
|
83
|
+
res.statusCode = 405;
|
|
84
|
+
res.setHeader('allow', 'POST');
|
|
85
|
+
res.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
readBody(req).then(text => {
|
|
89
|
+
const body = JSON.parse(text || '{}');
|
|
90
|
+
if (typeof body.file !== 'string' || !body.file) {
|
|
91
|
+
res.statusCode = 400;
|
|
92
|
+
res.end('file required');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
broadcastCursor(body.file, body.line, body.col);
|
|
96
|
+
res.statusCode = 204;
|
|
97
|
+
res.end();
|
|
98
|
+
}).catch(e => { res.statusCode = 400; res.end(String(e?.message ?? e)); });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (p === '/__pcbjs/open') {
|
|
102
|
+
const q = url.searchParams;
|
|
103
|
+
const f = q.get('file');
|
|
104
|
+
if (!f) {
|
|
105
|
+
res.statusCode = 400;
|
|
106
|
+
res.end('file required');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const { abs } = normalizeFile(root(), f);
|
|
110
|
+
if (!allowed(abs) || !existsSync(abs)) {
|
|
111
|
+
res.statusCode = 404;
|
|
112
|
+
res.end('not found');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const line = int(q.get('line'), 1), col = int(q.get('col'), 1);
|
|
116
|
+
// a pad goes to its key in the part's connect call when the language server finds one
|
|
117
|
+
const pad = /^(.+)\.([^.]+)$/.exec(q.get('pad') ?? '');
|
|
118
|
+
const sel = (pad && opts.lsp?.padSource?.(q.get('board') ?? '', pad[1], pad[2])) || { file: abs, line, col, endLine: int(q.get('endLine'), line), endCol: int(q.get('endCol'), col) };
|
|
119
|
+
// the editor host first, then clients on the Vite socket, then launch-editor
|
|
120
|
+
const host = opts.host?.();
|
|
121
|
+
const lspKeys = (opts.lsp?.keys() ?? []).filter(k => k !== 'host' && !bridges.has(k));
|
|
122
|
+
if (host?.connected() && (opts.lsp?.select('host', sel) || host.send('pcbjs:ide:select', sel))) {
|
|
123
|
+
res.setHeader('x-pcbjs-opened', 'host');
|
|
124
|
+
}
|
|
125
|
+
else if (bridges.size || lspKeys.length) {
|
|
126
|
+
for (const b of bridges.values())
|
|
127
|
+
if (!opts.lsp?.select(b.client.socket, sel))
|
|
128
|
+
b.client.send({ type: 'custom', event: 'pcbjs:ide:select', data: sel });
|
|
129
|
+
for (const k of lspKeys)
|
|
130
|
+
opts.lsp.select(k, sel);
|
|
131
|
+
res.setHeader('x-pcbjs-opened', 'ide');
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// as Vite's /__open-in-editor
|
|
135
|
+
launch(`${abs}:${line}:${col}`, (file, err) => {
|
|
136
|
+
server.config.logger.warn(`[pcbjs] could not open ${file} in an editor${err ? ': ' + err : ''}`, { timestamp: true });
|
|
137
|
+
});
|
|
138
|
+
res.setHeader('x-pcbjs-opened', 'editor');
|
|
139
|
+
}
|
|
140
|
+
res.statusCode = 204;
|
|
141
|
+
res.end();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
next();
|
|
145
|
+
};
|
|
146
|
+
return { connected: () => bridges.size, middleware, cursor: broadcastCursor, lastCursor: () => last };
|
|
147
|
+
}
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type PluginState } from './plugin.js';
|
|
2
|
+
import { boardsPayload } from './boards.js';
|
|
3
|
+
import { type OutputOptions } from './check.js';
|
|
4
|
+
export interface BuildOptions extends OutputOptions {
|
|
5
|
+
entries?: string[];
|
|
6
|
+
root?: string;
|
|
7
|
+
config?: string;
|
|
8
|
+
outDir?: string;
|
|
9
|
+
date?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function build(opts: BuildOptions): Promise<void>;
|
|
12
|
+
/** The boards a built site carries: every entry's, never the isolated preview of dev. */
|
|
13
|
+
export declare const buildPayload: (state: PluginState) => ReturnType<typeof boardsPayload>;
|
|
14
|
+
/** True for a base that names the site's URL path, such as `/` or `/repo/`, which prerendering needs. */
|
|
15
|
+
export declare const isAbsoluteBase: (base: string) => boolean;
|
|
16
|
+
/** Render every route with the SSR bundle to `<outDir>/<route>/index.html` and 404.html; returns the routes written. */
|
|
17
|
+
export declare function prerenderSite(bundle: string, outDir: string, base: string, data: ReturnType<typeof boardsPayload>): Promise<string[]>;
|
|
18
|
+
export declare function preview(opts: BuildOptions & {
|
|
19
|
+
port?: number;
|
|
20
|
+
host?: string | boolean;
|
|
21
|
+
}): Promise<void>;
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { build as viteBuild, preview as vitePreview } from 'vite';
|
|
5
|
+
import { resolveConfig } from './config.js';
|
|
6
|
+
import { CLIENT_DIR, viteConfig } from './vite.js';
|
|
7
|
+
import { pluginState } from './plugin.js';
|
|
8
|
+
import { boardDir, boardFile, writeFab } from './project.js';
|
|
9
|
+
import { boardsIndex, boardsPayload } from './boards.js';
|
|
10
|
+
import { CHECK_CAP, compileServer, compileSlots, errorText, logLevelFor, markStale, reportOf, slotLines, slotsFailed, totalLine, writeReport } from './check.js';
|
|
11
|
+
import { msText } from '@pcbjs/export/findings';
|
|
12
|
+
import { fabDate } from '@pcbjs/export/fab';
|
|
13
|
+
import { pageHtml, previewPrerendered, safeSegment, scriptJson, siteRoutes } from './prerender.js';
|
|
14
|
+
import { resolveRepo } from './repo.js';
|
|
15
|
+
export async function build(opts) {
|
|
16
|
+
// createServer would set NODE_ENV to development, giving the builds below Vue's development build
|
|
17
|
+
process.env.NODE_ENV ??= 'production';
|
|
18
|
+
const cfg = await resolveConfig(opts.entries ?? [], 'build', opts.config, opts.root);
|
|
19
|
+
if (opts.outDir)
|
|
20
|
+
cfg.outDir = resolve(cfg.root, opts.outDir);
|
|
21
|
+
// one date for every file of every set: --date, else SOURCE_DATE_EPOCH, else now, see fabDate
|
|
22
|
+
cfg.fabDate = fabDate(opts.date);
|
|
23
|
+
// every fab set written is read back and compared with its board, see fab-check.ts in @pcbjs/export
|
|
24
|
+
cfg.fabCheck = true;
|
|
25
|
+
const state = pluginState(cfg);
|
|
26
|
+
const say = (line) => { if (!opts.quiet)
|
|
27
|
+
console.log(line); };
|
|
28
|
+
const rel = (p) => relative(cfg.root, p) || '.';
|
|
29
|
+
// a failed entry does not stop the others
|
|
30
|
+
const errors = new Map();
|
|
31
|
+
const server = await compileServer(cfg, state, { verbose: opts.verbose });
|
|
32
|
+
try {
|
|
33
|
+
await compileSlots(server, cfg, state.slots, errors);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await server.close();
|
|
37
|
+
}
|
|
38
|
+
const broken = state.slots.filter(s => !s.compiled || s.conflict);
|
|
39
|
+
// every entry: one per board, one per target of a board with variants
|
|
40
|
+
const all = state.slots.filter(s => s.compiled && !s.conflict).flatMap(s => s.compiled);
|
|
41
|
+
// the repository link for cmd+click on a source range, built sites only
|
|
42
|
+
const repo = resolveRepo(cfg.repo, cfg.root);
|
|
43
|
+
if (repo)
|
|
44
|
+
for (const c of all)
|
|
45
|
+
c.site.repo = repo;
|
|
46
|
+
// a failed entry keeps its last fab set, marked stale
|
|
47
|
+
if (!broken.length)
|
|
48
|
+
rmSync(cfg.fabDir, { recursive: true, force: true });
|
|
49
|
+
const fabs = new Map();
|
|
50
|
+
for (const c of all) {
|
|
51
|
+
const dir = join(cfg.fabDir, ...boardDir(c.board));
|
|
52
|
+
if (broken.length)
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
const names = writeFab(c, dir);
|
|
55
|
+
fabs.set(c, { dir, zip: join(dir, c.fab.zipName), files: names.length });
|
|
56
|
+
}
|
|
57
|
+
const more = opts.json ? `all in ${opts.json}` : 'all with --json <file>';
|
|
58
|
+
for (const s of state.slots)
|
|
59
|
+
for (const l of slotLines(s, errors.get(s), { root: cfg.root, cap: CHECK_CAP, verbose: opts.verbose, more }))
|
|
60
|
+
console.log(l);
|
|
61
|
+
const files = [...fabs.values()].reduce((n, f) => n + f.files, 0);
|
|
62
|
+
if (all.length)
|
|
63
|
+
say(`fab ${rel(cfg.fabDir)} ${all.length} set${all.length === 1 ? '' : 's'}, ${files} files`);
|
|
64
|
+
const report = (pages) => {
|
|
65
|
+
if (!opts.json)
|
|
66
|
+
return;
|
|
67
|
+
const boards = state.slots.flatMap(s => reportOf(cfg, s, errors.get(s), { pages, fab: c => fabs.get(c) ?? null }));
|
|
68
|
+
console.log(`json ${writeReport(cfg, opts.json, { boards })}`);
|
|
69
|
+
};
|
|
70
|
+
if (broken.length) {
|
|
71
|
+
for (const s of broken) {
|
|
72
|
+
if (s.conflict)
|
|
73
|
+
continue;
|
|
74
|
+
const marked = markStale(cfg, s.entry.rel, errorText(errors.get(s), false, cfg.root).line);
|
|
75
|
+
if (marked.length)
|
|
76
|
+
say(`stale ${marked.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
say(`site not rebuilt: ${broken.length} entr${broken.length === 1 ? 'y' : 'ies'} failed, ${rel(cfg.outDir)} is from an earlier build`);
|
|
79
|
+
console.log(totalLine(state.slots));
|
|
80
|
+
report([]);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// a relative base cannot name the routes' URLs, so only an absolute one is prerendered
|
|
85
|
+
const t0 = performance.now();
|
|
86
|
+
const vc = viteConfig(cfg, state, 'build');
|
|
87
|
+
const logLevel = logLevelFor(cfg, opts.verbose);
|
|
88
|
+
const prerender = isAbsoluteBase(cfg.base);
|
|
89
|
+
if (!prerender)
|
|
90
|
+
say(`site base "${cfg.base}" is relative: client-rendered pages only, set an absolute base such as "/" to prerender every route`);
|
|
91
|
+
state.inline = !prerender;
|
|
92
|
+
await viteBuild({ ...vc, logLevel, mode: 'production', build: { ...vc.build, manifest: true } });
|
|
93
|
+
let pages = [];
|
|
94
|
+
if (prerender) {
|
|
95
|
+
const ssrDir = join(cfg.root, '.pcbjs', 'cache', 'ssr');
|
|
96
|
+
await viteBuild({
|
|
97
|
+
...vc, mode: 'production', logLevel: logLevel === 'info' ? 'warn' : logLevel,
|
|
98
|
+
// everything is bundled, Vue included, so the bundle runs from the cache without the project resolving the client's dependencies
|
|
99
|
+
ssr: { ...vc.ssr, noExternal: true },
|
|
100
|
+
build: { ...vc.build, ssr: join(CLIENT_DIR, 'src', 'entry-server.ts'), outDir: ssrDir, emptyOutDir: true, copyPublicDir: false, rollupOptions: { output: { entryFileNames: 'entry-server.mjs', format: 'es' } } },
|
|
101
|
+
});
|
|
102
|
+
const data = buildPayload(state);
|
|
103
|
+
pages = await prerenderSite(join(ssrDir, 'entry-server.mjs'), cfg.outDir, cfg.base, data);
|
|
104
|
+
if (opts.verbose)
|
|
105
|
+
say(`pages ${pages.length} prerendered: ${pages.map(p => '/' + p).join(' ')}`);
|
|
106
|
+
}
|
|
107
|
+
cpSync(cfg.fabDir, join(cfg.outDir, 'fab'), { recursive: true });
|
|
108
|
+
mkdirSync(join(cfg.outDir, 'boards'), { recursive: true });
|
|
109
|
+
for (const c of all) {
|
|
110
|
+
const file = join(cfg.outDir, ...boardFile(c.board).split('/').map(decodeURIComponent));
|
|
111
|
+
mkdirSync(join(file, '..'), { recursive: true });
|
|
112
|
+
writeFileSync(file, JSON.stringify(c.site));
|
|
113
|
+
}
|
|
114
|
+
writeFileSync(join(cfg.outDir, 'boards.json'), JSON.stringify(boardsIndex(all, state.slots.length === 1), null, 2));
|
|
115
|
+
const n = state.slots.length, targets = all.length > n ? `, ${all.length} entries` : '';
|
|
116
|
+
say(`site ${rel(cfg.outDir)} ${n} board${n === 1 ? '' : 's'}${targets}${prerender ? `, ${pages.length} pages` : ''} ${msText(performance.now() - t0)}`);
|
|
117
|
+
console.log(totalLine(state.slots));
|
|
118
|
+
report(pages);
|
|
119
|
+
if (slotsFailed(state.slots))
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
}
|
|
122
|
+
/** The boards a built site carries: every entry's, never the isolated preview of dev. */
|
|
123
|
+
export const buildPayload = (state) => boardsPayload(state.slots);
|
|
124
|
+
/** True for a base that names the site's URL path, such as `/` or `/repo/`, which prerendering needs. */
|
|
125
|
+
export const isAbsoluteBase = (base) => base.startsWith('/');
|
|
126
|
+
/** Render every route with the SSR bundle to `<outDir>/<route>/index.html` and 404.html; returns the routes written. */
|
|
127
|
+
export async function prerenderSite(bundle, outDir, base, data) {
|
|
128
|
+
const { render } = await import(pathToFileURL(bundle).href + `?t=${Date.now()}`);
|
|
129
|
+
const template = readFileSync(join(outDir, 'index.html'), 'utf8');
|
|
130
|
+
const json = scriptJson(data);
|
|
131
|
+
const b = base.endsWith('/') ? base : base + '/';
|
|
132
|
+
const written = [];
|
|
133
|
+
// the root last, since the template is the root's index.html
|
|
134
|
+
const routes = siteRoutes(data);
|
|
135
|
+
for (const segs of [...routes.slice(1), routes[0]]) {
|
|
136
|
+
if (!segs.every(safeSegment)) {
|
|
137
|
+
console.warn(`pcbjs: no page for ${segs.join('/')}, the name cannot be a directory; the site still shows it from another page`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const url = b + segs.map(encodeURIComponent).join('/');
|
|
141
|
+
const r = await render(url, data);
|
|
142
|
+
const dir = join(outDir, ...segs);
|
|
143
|
+
mkdirSync(dir, { recursive: true });
|
|
144
|
+
writeFileSync(join(dir, 'index.html'), pageHtml(template, json, r));
|
|
145
|
+
written.push(segs.join('/'));
|
|
146
|
+
}
|
|
147
|
+
// static hosts such as GitHub Pages answer an unknown path with 404.html; the app then shows its own "no such board" page
|
|
148
|
+
writeFileSync(join(outDir, '404.html'), pageHtml(template, json));
|
|
149
|
+
return written;
|
|
150
|
+
}
|
|
151
|
+
export async function preview(opts) {
|
|
152
|
+
const cfg = await resolveConfig(opts.entries ?? [], 'build', opts.config, opts.root);
|
|
153
|
+
if (opts.outDir)
|
|
154
|
+
cfg.outDir = resolve(cfg.root, opts.outDir);
|
|
155
|
+
if (!existsSync(cfg.outDir))
|
|
156
|
+
throw new Error(`${cfg.outDir} does not exist: run pcbjs build first`);
|
|
157
|
+
const vc = viteConfig(cfg, pluginState(cfg), 'build');
|
|
158
|
+
// a prerendered site is served as a static host serves it, a client-rendered one with Vite's index.html fallback
|
|
159
|
+
const prerendered = existsSync(join(cfg.outDir, '404.html')) && isAbsoluteBase(cfg.base);
|
|
160
|
+
const server = await vitePreview({
|
|
161
|
+
...vc, preview: { port: opts.port ?? 4173, host: opts.host },
|
|
162
|
+
...(prerendered ? { appType: 'mpa', plugins: [...(vc.plugins ?? []), previewPrerendered(cfg.outDir, cfg.base)] } : {}),
|
|
163
|
+
});
|
|
164
|
+
server.printUrls();
|
|
165
|
+
}
|