kishare 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # KiShare
2
+
3
+ A lightweight tool to view and share your KiCad projects online.
4
+
5
+ Built on [KiCanvas](https://kicanvas.org/), KiShare scans for KiCad project
6
+ files and generates a static site.
7
+
8
+ See the demo site [here](https://hmcty.github.io/kishare/).
9
+
10
+ ## Features
11
+
12
+ - Out-of-the-box support for hosting on GitHub Pages
13
+ - Can create and share location markers, e.g. to reference in GitHub issues / PR reviews
14
+ - Generates links for easy download of project-specific ZIP archives
15
+ - Provides basic support for inline documentation
16
+
17
+ ## Usage
18
+
19
+ ### Configuration
20
+
21
+ Create `kishare-config.json` in the root directory:
22
+
23
+ ```json
24
+ {
25
+ "title": "My KiCad Projects",
26
+ "projectDirs": ["projects"],
27
+ }
28
+ ```
29
+
30
+ | Field | Description | Default |
31
+ |-------|-------------|---------|
32
+ | `title` | Displayed as site title | Repository name |
33
+ | `projectDirs` | Array of directories containing KiCad project files | `["projects"]` |
34
+
35
+ ### Deployment
36
+
37
+ #### GitHub Pages
38
+
39
+ After enabling [GitHub Pages in your repository settings](https://docs.github.com/en/pages/quickstart), add the following GitHub workflow:
40
+
41
+ ```yaml
42
+ name: Build and Deploy KiShare
43
+
44
+ on:
45
+ push:
46
+ branches: [main]
47
+ workflow_dispatch:
48
+
49
+ permissions:
50
+ contents: read
51
+ pages: write
52
+ id-token: write
53
+
54
+ concurrency:
55
+ group: "pages"
56
+ cancel-in-progress: false
57
+
58
+ jobs:
59
+ build:
60
+ runs-on: ubuntu-latest
61
+ steps:
62
+ - name: Checkout repository
63
+ uses: actions/checkout@v4
64
+ with:
65
+ submodules: recursive
66
+
67
+ - name: Build KiShare
68
+ uses: hmcty/kishare@main
69
+
70
+ - name: Setup Pages
71
+ uses: actions/configure-pages@v4
72
+
73
+ - name: Upload artifact
74
+ uses: actions/upload-pages-artifact@v3
75
+ with:
76
+ path: './dist'
77
+
78
+ deploy:
79
+ environment:
80
+ name: github-pages
81
+ url: ${{ steps.deployment.outputs.page_url }}
82
+ runs-on: ubuntu-latest
83
+ needs: build
84
+ steps:
85
+ - name: Deploy to GitHub Pages
86
+ id: deployment
87
+ uses: actions/deploy-pages@v4
88
+ ```
89
+
90
+ See the [demo workflow](./.github/workflows/deploy.yml) for a complete example.
91
+
package/bin/kishare.js ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const require = createRequire(import.meta.url);
10
+
11
+ // Resolve tsx binary from our package's node_modules
12
+ const tsxBin = require.resolve('tsx/cli');
13
+ const cliPath = join(__dirname, '..', 'src', 'node', 'cli.ts');
14
+
15
+ // Spawn tsx to run the TypeScript CLI, passing through all arguments
16
+ const child = spawn(
17
+ process.execPath,
18
+ [tsxBin, cliPath, ...process.argv.slice(2)],
19
+ {
20
+ stdio: 'inherit',
21
+ cwd: process.cwd(),
22
+ }
23
+ );
24
+
25
+ child.on('exit', (code) => {
26
+ process.exit(code ?? 0);
27
+ });
package/index.html ADDED
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' https://fonts.gstatic.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self';">
7
+ <title>KiShare Workspace</title>
8
+ </head>
9
+ <body>
10
+ <workspace-app></workspace-app>
11
+
12
+ <script type="module" src="/kicanvas/kicanvas.js"></script>
13
+ <script type="module" src="/src/main.ts"></script>
14
+ </body>
15
+ </html>
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "kishare",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "CLI tool for generating static KiCad project viewer sites",
6
+ "homepage": "https://github.com/hmcty/kishare",
7
+ "license": "GPL-3.0-only",
8
+ "bin": {
9
+ "kishare": "bin/kishare.js"
10
+ },
11
+ "keywords": [
12
+ "kicad",
13
+ "vite"
14
+ ],
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "vendor/kicanvas/build",
19
+ "index.html",
20
+ "vite.config.ts",
21
+ "tsconfig.json"
22
+ ],
23
+ "scripts": {
24
+ "setup": "cd ./vendor/kicanvas && npm install && npm run build",
25
+ "predev": "npm run index && npm run copy-files",
26
+ "prebuild": "npm run index && npm run copy-files",
27
+ "index": "KISHARE_ROOT=. tsx src/indexer/scan-projects.ts",
28
+ "copy-files": "npm run copy-public && npm run copy-kicanvas",
29
+ "copy-public": "KISHARE_ROOT=. tsx src/indexer/copy-public-files.ts",
30
+ "copy-kicanvas": "mkdir -p public/kicanvas && cp ./vendor/kicanvas/build/kicanvas.js public/kicanvas/",
31
+ "dev": "vite",
32
+ "build": "vite build",
33
+ "preview": "vite preview",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest"
36
+ },
37
+ "devDependencies": {
38
+ "@types/archiver": "^7.0.0",
39
+ "@types/dompurify": "^3.0.5",
40
+ "@types/marked": "^5.0.2",
41
+ "@types/node": "^20.12.7",
42
+ "typescript": "^5.4.5",
43
+ "vitest": "^3.1.1"
44
+ },
45
+ "dependencies": {
46
+ "archiver": "^7.0.1",
47
+ "dompurify": "^3.3.3",
48
+ "marked": "^17.0.6",
49
+ "marked-base-url": "^1.1.8",
50
+ "tsx": "^4.7.2",
51
+ "vite": "^5.2.8"
52
+ }
53
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Project list component - displays navigation sidebar
3
+ */
4
+
5
+ import { marked } from 'marked';
6
+ import { baseUrl } from 'marked-base-url';
7
+ import DOMPurify from 'dompurify';
8
+ import type { ProjectMetadata, GitInfo } from '../lib/project-index.js';
9
+ import { router } from '../lib/router.js';
10
+ import { isSafeUrl, githubIcon } from '../lib/html-utils.js';
11
+
12
+ export class ProjectList extends HTMLElement {
13
+ private projects: ProjectMetadata[] = [];
14
+ private selectedProjectId: string | null = null;
15
+ private viewMode: 'list' | 'detail' = 'list';
16
+ private gitInfo: GitInfo | null = null;
17
+
18
+ constructor() {
19
+ super();
20
+ }
21
+
22
+ connectedCallback() {
23
+ this.render();
24
+ }
25
+
26
+ /**
27
+ * Set git info for building GitHub links
28
+ */
29
+ setGitInfo(git: GitInfo) {
30
+ this.gitInfo = git;
31
+ }
32
+
33
+ /**
34
+ * Set the list of projects to display
35
+ */
36
+ setProjects(projects: ProjectMetadata[]) {
37
+ this.projects = projects;
38
+ this.render();
39
+ }
40
+
41
+ /**
42
+ * Set the currently selected project
43
+ */
44
+ setSelectedProject(projectId: string | null) {
45
+ this.selectedProjectId = projectId;
46
+ // Switch to detail view when a project is selected
47
+ this.viewMode = projectId ? 'detail' : 'list';
48
+ this.render();
49
+ }
50
+
51
+ /**
52
+ * Return to project list view
53
+ */
54
+ private showProjectList() {
55
+ this.viewMode = 'list';
56
+ this.selectedProjectId = null;
57
+ router.navigate('/');
58
+ }
59
+
60
+ /**
61
+ * Handle project click
62
+ */
63
+ private handleProjectClick(projectId: string) {
64
+ router.navigate('/project', projectId);
65
+ }
66
+
67
+ /**
68
+ * Render the project list
69
+ */
70
+ private render() {
71
+ if (this.projects.length === 0) {
72
+ this.innerHTML = `
73
+ <div class="empty">
74
+ <p>No projects found</p>
75
+ </div>
76
+ `;
77
+ return;
78
+ }
79
+
80
+ // Render based on view mode
81
+ if (this.viewMode === 'detail' && this.selectedProjectId) {
82
+ this.renderDetailView();
83
+ } else {
84
+ this.renderListView();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Render the project list view
90
+ */
91
+ private renderListView() {
92
+ // Group projects by base name
93
+ const grouped = this.groupProjects();
94
+
95
+ let html = '<ul class="project-list">';
96
+
97
+ for (const [groupName, groupProjects] of grouped) {
98
+ if (groupProjects.length === 1) {
99
+ // Single project, no grouping
100
+ const project = groupProjects[0];
101
+ html += this.renderProjectItem(project);
102
+ } else {
103
+ // Multiple projects, show as group
104
+ html += `
105
+ <li class="project-group">
106
+ <div class="group-name">${groupName}</div>
107
+ <ul class="group-projects">
108
+ ${groupProjects.map(p => this.renderProjectItem(p)).join('')}
109
+ </ul>
110
+ </li>
111
+ `;
112
+ }
113
+ }
114
+
115
+ html += '</ul>';
116
+ this.innerHTML = DOMPurify.sanitize(html);
117
+
118
+ // Add click event listeners
119
+ this.querySelectorAll('.project-item').forEach(item => {
120
+ item.addEventListener('click', (e) => {
121
+ // Don't navigate if clicking the download button
122
+ if ((e.target as HTMLElement).closest('.download-btn')) return;
123
+
124
+ const projectId = item.getAttribute('data-project-id');
125
+ if (projectId) {
126
+ this.handleProjectClick(projectId);
127
+ }
128
+ });
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Render the project detail view
134
+ */
135
+ private renderDetailView() {
136
+ const project = this.projects.find(p => p.id === this.selectedProjectId);
137
+ if (!project) {
138
+ this.renderListView();
139
+ return;
140
+ }
141
+
142
+ // Format dates
143
+ const formatDate = (isoDate?: string) => {
144
+ if (!isoDate) return 'Unknown';
145
+ const date = new Date(isoDate);
146
+ return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
147
+ };
148
+
149
+ const badges = [];
150
+ if (project.schematics.length > 0) {
151
+ badges.push(`<span class="badge badge-sch">Sch</span>`);
152
+ }
153
+ if (project.pcb) {
154
+ badges.push(`<span class="badge badge-pcb">PCB</span>`);
155
+ }
156
+
157
+ const downloadBtn = project.zip ? `
158
+ <a href="${project.zip}" download class="download-btn" title="Download project">
159
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
160
+ <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
161
+ </svg>
162
+ </a>
163
+ ` : '';
164
+
165
+ const html = `
166
+ <div class="project-detail">
167
+ <button class="back-button">
168
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
169
+ <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
170
+ </svg>
171
+ View all projects
172
+ </button>
173
+
174
+ <div class="project-item selected">
175
+ <div class="project-name">${project.name}</div>
176
+ <div class="project-actions">
177
+ <div class="project-badges">${badges.join(' ')}</div>
178
+ ${downloadBtn}
179
+ </div>
180
+ </div>
181
+
182
+ <div class="detail-meta">
183
+ <div class="detail-meta-item">
184
+ <span class="meta-label">Created:</span>
185
+ <span class="meta-value">${formatDate(project.createdAt)}</span>
186
+ </div>
187
+ <div class="detail-meta-item">
188
+ <span class="meta-label">Last Updated:</span>
189
+ <span class="meta-value">${formatDate(project.updatedAt)}</span>
190
+ </div>
191
+ </div>
192
+
193
+ ${project.readme ? (() => {
194
+ const readmeUrl = this.getProjectUrl(project);
195
+ return `
196
+ <div class="detail-section">
197
+ <div class="detail-section-header">
198
+ <h3>Documentation</h3>
199
+ ${readmeUrl ? `
200
+ <a href="${readmeUrl}"
201
+ target="_blank"
202
+ rel="noopener"
203
+ class="github-link"
204
+ title="View on GitHub/GitLab">
205
+ ${githubIcon(14)}
206
+ </a>
207
+ ` : ''}
208
+ </div>
209
+ <div class="detail-readme">${this.renderMarkdown(project.readme, project.path)}</div>
210
+ </div>
211
+ `;
212
+ })() : ''}
213
+ </div>
214
+ `;
215
+ this.innerHTML = DOMPurify.sanitize(html);
216
+
217
+ // Add back button listener
218
+ const backButton = this.querySelector('.back-button');
219
+ if (backButton) {
220
+ backButton.addEventListener('click', () => {
221
+ this.showProjectList();
222
+ });
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Get remote URL for a project (handles submodules)
228
+ */
229
+ private getProjectUrl(project: ProjectMetadata): string | null {
230
+ const git = project.git || this.gitInfo;
231
+ if (!git?.repoUrl || !git?.commitHash) {
232
+ return null;
233
+ }
234
+
235
+ if (!isSafeUrl(git.repoUrl)) {
236
+ console.warn('Invalid URL protocol in git.repoUrl:', git.repoUrl);
237
+ return null;
238
+ }
239
+
240
+ // Build the URL - works for both GitHub and GitLab
241
+ // Note: These values are escaped when inserted into HTML
242
+ return `${git.repoUrl}/-/tree/${git.commitHash}/`;
243
+ }
244
+
245
+ /**
246
+ * Render markdown content (used for live doc display)
247
+ */
248
+ private renderMarkdown(markdown: string, projectPath: string): string {
249
+ marked.use(baseUrl(`/${projectPath}/`));
250
+
251
+ const html = marked.parse(markdown) as string;
252
+ return DOMPurify.sanitize(html, {
253
+ ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'strong', 'em', 'code', 'pre', 'a', 'img', 'ul', 'ol', 'li', 'blockquote'],
254
+ ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'target', 'rel'],
255
+ ALLOW_DATA_ATTR: false,
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Render a single project item
261
+ */
262
+ private renderProjectItem(project: ProjectMetadata): string {
263
+ const isSelected = project.id === this.selectedProjectId;
264
+ const selectedClass = isSelected ? ' selected' : '';
265
+
266
+ const badges = [];
267
+ if (project.schematics.length > 0) {
268
+ badges.push(`<span class="badge badge-sch">Sch</span>`);
269
+ }
270
+ if (project.pcb) {
271
+ badges.push(`<span class="badge badge-pcb">PCB</span>`);
272
+ }
273
+
274
+ const downloadBtn = project.zip ? `
275
+ <a href="${project.zip}" download class="download-btn" title="Download project">
276
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
277
+ <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
278
+ </svg>
279
+ </a>
280
+ ` : '';
281
+
282
+ return `
283
+ <li class="project-item${selectedClass}" data-project-id="${project.id}">
284
+ <div class="project-name">${project.name}</div>
285
+ <div class="project-actions">
286
+ <div class="project-badges">${badges.join(' ')}</div>
287
+ ${downloadBtn}
288
+ </div>
289
+ </li>
290
+ `;
291
+ }
292
+
293
+ /**
294
+ * Group projects by base name
295
+ */
296
+ private groupProjects(): Map<string, ProjectMetadata[]> {
297
+ const grouped = new Map<string, ProjectMetadata[]>();
298
+
299
+ for (const project of this.projects) {
300
+ // Extract base name (e.g., "deloop" from "deloop_mk0" or "deloop_mk1")
301
+ const baseName = this.extractBaseName(project.id);
302
+
303
+ if (!grouped.has(baseName)) {
304
+ grouped.set(baseName, []);
305
+ }
306
+ grouped.get(baseName)!.push(project);
307
+ }
308
+
309
+ return grouped;
310
+ }
311
+
312
+ /**
313
+ * Extract base name from project ID
314
+ */
315
+ private extractBaseName(projectId: string): string {
316
+ // Try to find common prefixes like "deloop", "pedal", etc.
317
+ const match = projectId.match(/^([a-z]+)_/);
318
+ if (match) {
319
+ return match[1];
320
+ }
321
+ return projectId;
322
+ }
323
+ }
324
+
325
+ // Register custom element
326
+ customElements.define('project-list', ProjectList);