kitfly 0.1.2 → 0.2.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/CHANGELOG.md +34 -0
- package/README.md +63 -16
- package/VERSION +1 -1
- package/dist/_raw/content/deployment/preflight.md +134 -0
- package/dist/_raw/content/deployment/recipes/aws-s3.md +128 -0
- package/dist/_raw/content/deployment/recipes/cloudflare-pages.md +73 -0
- package/dist/_raw/content/deployment/recipes/cloudflare-r2.md +156 -0
- package/dist/_raw/content/deployment/recipes/fly-io.md +57 -0
- package/dist/_raw/content/deployment/recipes/github-pages.md +112 -0
- package/dist/_raw/content/deployment/recipes/netlify.md +99 -0
- package/dist/_raw/content/deployment/recipes/vercel.md +88 -0
- package/dist/_raw/content/deployment/secrets-and-env-vars.md +75 -0
- package/dist/_raw/content/deployment.md +128 -0
- package/dist/_raw/content/guide/approaches.md +182 -0
- package/dist/_raw/content/guide/features.md +121 -0
- package/dist/_raw/content/guide/getting-started.md +112 -0
- package/dist/_raw/content/guide/kitfly-overview.md +209 -0
- package/dist/_raw/content/reference/configuration.md +259 -0
- package/dist/_raw/content/reference/design-catalog.md +167 -0
- package/dist/_raw/content/reference/environment-variables.md +66 -0
- package/dist/_raw/content/reference/glossary.md +92 -0
- package/dist/_raw/content/reference/key-concepts.md +118 -0
- package/dist/_raw/content/reference/plugins.md +220 -0
- package/dist/_raw/content/reference/structure.md +166 -0
- package/dist/_raw/content/reference.md +19 -0
- package/dist/_raw/content/templates/crucible.md +192 -0
- package/dist/_raw/content/templates/handbook.md +83 -0
- package/dist/_raw/content/templates/minimal.md +138 -0
- package/dist/_raw/content/templates/overview.md +187 -0
- package/dist/_raw/content/templates/pipeline.md +151 -0
- package/dist/_raw/content/templates/productbook.md +187 -0
- package/dist/_raw/content/templates/runbook.md +193 -0
- package/dist/_raw/content/templates/servicebook.md +163 -0
- package/dist/_raw/docs/decisions/ADR-0001-minimalist-site-code.md +118 -0
- package/dist/_raw/docs/decisions/ADR-0002-ai-accessibility.md +153 -0
- package/dist/_raw/docs/decisions/ADR-0003-single-file-bundle.md +93 -0
- package/dist/_raw/docs/decisions/ADR-0004-bun-runtime.md +98 -0
- package/dist/_raw/docs/decisions/ADR-0005-plugin-contract-and-distribution.md +110 -0
- package/dist/_raw/docs/decisions/DDR-0001-viewport-locked-layout.md +111 -0
- package/dist/_raw/docs/decisions/DDR-0002-theme-system.md +131 -0
- package/dist/_raw/docs/decisions/DDR-0003-bounded-logo-slot.md +106 -0
- package/dist/_raw/docs/decisions/DDR-0004-slides-rendering-model.md +113 -0
- package/dist/_raw/docs/decisions/DDR-0005-deterministic-layout-boundary.md +107 -0
- package/dist/_raw/docs/userguide/cli/build.md +85 -0
- package/dist/_raw/docs/userguide/cli/bundle.md +81 -0
- package/dist/_raw/docs/userguide/cli/dev.md +92 -0
- package/dist/_raw/docs/userguide/cli/init.md +116 -0
- package/dist/_raw/docs/userguide/cli/servers.md +69 -0
- package/dist/_raw/docs/userguide/cli/stop.md +76 -0
- package/dist/_raw/docs/userguide/cli/update.md +78 -0
- package/dist/_raw/docs/userguide/cli/version.md +65 -0
- package/dist/_raw/docs/userguide/cli.md +34 -0
- package/dist/_raw/docs/userguide/sharing.md +94 -0
- package/dist/_raw/schemas/plugin-schemas-notes.md +71 -0
- package/dist/_raw/schemas.md +42 -0
- package/dist/assets/brand/kitfly-favicon-32.png +0 -0
- package/dist/assets/brand/kitfly-icon-64.png +0 -0
- package/dist/assets/brand/kitfly-logo-128.png +0 -0
- package/dist/assets/brand/kitfly-logo-512.png +0 -0
- package/dist/assets/brand/kitfly-logo.svg +12132 -0
- package/dist/assets/brand/kitfly-neon-128.png +0 -0
- package/dist/assets/brand/kitfly-neon-192.png +0 -0
- package/dist/assets/brand/kitfly-neon-256.png +0 -0
- package/dist/assets/brand/kitfly-neon.png +0 -0
- package/dist/assets/brand/palette.md +75 -0
- package/dist/content/deployment/index.html +11 -0
- package/dist/content/deployment/preflight.html +418 -0
- package/dist/content/deployment/recipes/aws-s3.html +421 -0
- package/dist/content/deployment/recipes/cloudflare-pages.html +372 -0
- package/dist/content/deployment/recipes/cloudflare-r2.html +443 -0
- package/dist/content/deployment/recipes/fly-io.html +356 -0
- package/dist/content/deployment/recipes/github-pages.html +414 -0
- package/dist/content/deployment/recipes/index.html +11 -0
- package/dist/content/deployment/recipes/netlify.html +394 -0
- package/dist/content/deployment/recipes/vercel.html +382 -0
- package/dist/content/deployment/secrets-and-env-vars.html +380 -0
- package/dist/content/deployment.html +426 -0
- package/dist/content/guide/approaches.html +501 -0
- package/dist/content/guide/features.html +436 -0
- package/dist/content/guide/getting-started.html +403 -0
- package/dist/content/guide/index.html +11 -0
- package/dist/content/guide/kitfly-overview.html +544 -0
- package/dist/content/index.html +11 -0
- package/dist/content/reference/configuration.html +580 -0
- package/dist/content/reference/design-catalog.html +449 -0
- package/dist/content/reference/environment-variables.html +367 -0
- package/dist/content/reference/glossary.html +368 -0
- package/dist/content/reference/index.html +11 -0
- package/dist/content/reference/key-concepts.html +399 -0
- package/dist/content/reference/plugins.html +491 -0
- package/dist/content/reference/structure.html +463 -0
- package/dist/content/reference.html +334 -0
- package/dist/content/templates/crucible.html +546 -0
- package/dist/content/templates/handbook.html +405 -0
- package/dist/content/templates/index.html +11 -0
- package/dist/content/templates/minimal.html +447 -0
- package/dist/content/templates/overview.html +558 -0
- package/dist/content/templates/pipeline.html +494 -0
- package/dist/content/templates/productbook.html +540 -0
- package/dist/content/templates/runbook.html +543 -0
- package/dist/content/templates/servicebook.html +523 -0
- package/dist/content-index.json +540 -0
- package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +491 -0
- package/dist/docs/decisions/ADR-0002-ai-accessibility.html +434 -0
- package/dist/docs/decisions/ADR-0003-single-file-bundle.html +412 -0
- package/dist/docs/decisions/ADR-0004-bun-runtime.html +409 -0
- package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +402 -0
- package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +459 -0
- package/dist/docs/decisions/DDR-0002-theme-system.html +452 -0
- package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +423 -0
- package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +399 -0
- package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +422 -0
- package/dist/docs/decisions/index.html +11 -0
- package/dist/docs/userguide/cli/build.html +408 -0
- package/dist/docs/userguide/cli/bundle.html +419 -0
- package/dist/docs/userguide/cli/dev.html +428 -0
- package/dist/docs/userguide/cli/index.html +11 -0
- package/dist/docs/userguide/cli/init.html +436 -0
- package/dist/docs/userguide/cli/servers.html +393 -0
- package/dist/docs/userguide/cli/stop.html +408 -0
- package/dist/docs/userguide/cli/update.html +406 -0
- package/dist/docs/userguide/cli/version.html +406 -0
- package/dist/docs/userguide/cli.html +386 -0
- package/dist/docs/userguide/index.html +11 -0
- package/dist/docs/userguide/sharing.html +465 -0
- package/dist/index.html +387 -0
- package/dist/llms.txt +18 -0
- package/dist/provenance.json +7 -0
- package/dist/schemas/index.html +11 -0
- package/dist/schemas/plugin-registry.schema.html +327 -0
- package/dist/schemas/plugin-schemas-notes.html +364 -0
- package/dist/schemas/plugin.schema.html +327 -0
- package/dist/schemas/plugins.schema.html +327 -0
- package/dist/schemas/v0/common.schema.html +386 -0
- package/dist/schemas/v0/index.html +11 -0
- package/dist/schemas/v0/plugin-registry.schema.html +547 -0
- package/dist/schemas/v0/plugin.schema.html +497 -0
- package/dist/schemas/v0/plugins.schema.html +406 -0
- package/dist/schemas/v0/site.schema.html +541 -0
- package/dist/schemas/v0/theme.schema.html +615 -0
- package/dist/schemas.html +351 -0
- package/dist/styles.css +1262 -0
- package/package.json +4 -2
- package/plugins-dist/callouts.css +32 -0
- package/plugins-dist/callouts.js +46 -0
- package/plugins-dist/slides-visuals.css +224 -0
- package/plugins-dist/slides-visuals.js +598 -0
- package/registry/plugins.yaml +35 -0
- package/schemas/README.md +10 -0
- package/schemas/plugin-registry.schema.json +5 -0
- package/schemas/plugin-schemas-notes.md +71 -0
- package/schemas/plugin.schema.json +5 -0
- package/schemas/plugins.schema.json +5 -0
- package/schemas/v0/common.schema.json +64 -0
- package/schemas/v0/plugin-registry.schema.json +225 -0
- package/schemas/v0/plugin.schema.json +175 -0
- package/schemas/v0/plugins.schema.json +84 -0
- package/schemas/v0/site.schema.json +56 -9
- package/schemas/v0/theme.schema.json +105 -22
- package/scripts/build.ts +155 -3
- package/scripts/bundle.ts +258 -95
- package/scripts/dev.ts +203 -1
- package/src/__tests__/build.test.ts +158 -1
- package/src/__tests__/bundle.test.ts +31 -0
- package/src/__tests__/cli.test.ts +14 -3
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/bad-list-indent.md +5 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/blank-line.md +5 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/compare-object-items.md +9 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/indented-fence.md +4 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/stat-grid-missing-fields.md +5 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/unknown-type.md +3 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/compare.md +10 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/comparison-table.md +14 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/funnel.md +7 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/kpi.md +5 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/layer-cake.md +6 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/pyramid.md +6 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/quadrant-grid.md +8 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/scorecard.md +13 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/stat-grid.md +8 -0
- package/src/__tests__/init.test.ts +35 -0
- package/src/__tests__/plugin-loader.test.ts +221 -0
- package/src/__tests__/shared.test.ts +428 -0
- package/src/__tests__/slides-visuals-fence-contract.test.ts +28 -0
- package/src/__tests__/slides-visuals-runtime-regressions.bun.test.ts +114 -0
- package/src/__tests__/styles.test.ts +35 -0
- package/src/cli.ts +9 -4
- package/src/plugin-loader.ts +245 -0
- package/src/shared.ts +614 -7
- package/src/site/styles.css +331 -0
- package/src/site/template.html +66 -5
- package/src/templates/deck.ts +186 -0
- package/src/templates/driver.ts +11 -1
- package/src/templates/minimal.ts +1 -0
package/scripts/dev.ts
CHANGED
|
@@ -14,20 +14,23 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { watch } from "node:fs";
|
|
17
|
-
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
18
18
|
import { basename, extname, join, resolve } from "node:path";
|
|
19
19
|
import { marked, Renderer } from "marked";
|
|
20
20
|
import { ENGINE_ASSETS_DIR, ENGINE_SITE_DIR } from "../src/engine.ts";
|
|
21
|
+
import { loadPluginInjections } from "../src/plugin-loader.ts";
|
|
21
22
|
import {
|
|
22
23
|
buildBreadcrumbsSimple,
|
|
23
24
|
buildFooter,
|
|
24
25
|
buildNavSimple,
|
|
25
26
|
buildPageMeta,
|
|
27
|
+
buildSlideNav,
|
|
26
28
|
buildToc,
|
|
27
29
|
// Network utilities
|
|
28
30
|
checkPortOrExit,
|
|
29
31
|
// Navigation/template building
|
|
30
32
|
collectFiles,
|
|
33
|
+
collectSlides,
|
|
31
34
|
envBool,
|
|
32
35
|
envInt,
|
|
33
36
|
// Config helpers
|
|
@@ -41,13 +44,16 @@ import {
|
|
|
41
44
|
type Provenance,
|
|
42
45
|
// Markdown utilities
|
|
43
46
|
parseFrontmatter,
|
|
47
|
+
parseYaml,
|
|
44
48
|
resolveStylesPath,
|
|
45
49
|
resolveTemplatePath,
|
|
50
|
+
rewriteRelativeAssetUrls,
|
|
46
51
|
// Types
|
|
47
52
|
type SiteConfig,
|
|
48
53
|
slugify,
|
|
49
54
|
toUrlPath,
|
|
50
55
|
validatePath,
|
|
56
|
+
validateSlidesVisualsFences,
|
|
51
57
|
} from "../src/shared.ts";
|
|
52
58
|
import { generateThemeCSS, getPrismUrls, loadTheme, type Theme } from "../src/theme.ts";
|
|
53
59
|
|
|
@@ -187,6 +193,72 @@ marked.use({ renderer });
|
|
|
187
193
|
// Track connected clients for hot reload
|
|
188
194
|
const clients: Set<ReadableStreamDefaultController> = new Set();
|
|
189
195
|
|
|
196
|
+
let pluginCache: { key: string; head: string; bodyEnd: string } | null = null;
|
|
197
|
+
|
|
198
|
+
async function isSlidesVisualsEnabled(): Promise<boolean> {
|
|
199
|
+
const configPath = join(ROOT, "kitfly.plugins.yaml");
|
|
200
|
+
try {
|
|
201
|
+
const raw = await readFile(configPath, "utf-8");
|
|
202
|
+
const parsed = parseYaml(raw) as unknown as Record<string, unknown>;
|
|
203
|
+
const plugins = Array.isArray(parsed?.plugins) ? (parsed.plugins as unknown[]) : [];
|
|
204
|
+
return plugins.some((p) => typeof p === "string" && p.startsWith("slides-visuals@"));
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function getPluginInjectionsCached(
|
|
211
|
+
mode: "docs" | "slides",
|
|
212
|
+
): Promise<{ head: string; bodyEnd: string }> {
|
|
213
|
+
const configPath = join(ROOT, "kitfly.plugins.yaml");
|
|
214
|
+
let configMtime = "missing";
|
|
215
|
+
try {
|
|
216
|
+
configMtime = String((await stat(configPath)).mtimeMs);
|
|
217
|
+
} catch {
|
|
218
|
+
return { head: "", bodyEnd: "" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const siteRegistryPath = join(ROOT, "registry", "plugins.yaml");
|
|
222
|
+
let registryMtime = "none";
|
|
223
|
+
try {
|
|
224
|
+
registryMtime = String((await stat(siteRegistryPath)).mtimeMs);
|
|
225
|
+
} catch {
|
|
226
|
+
// Uses engine registry by default.
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let pluginAssetsMtime = "none";
|
|
230
|
+
try {
|
|
231
|
+
const mtimes: number[] = [];
|
|
232
|
+
const dirs = [join(ROOT, "plugins-dist"), join(ENGINE_SITE_DIR, "..", "plugins-dist")];
|
|
233
|
+
for (const dir of dirs) {
|
|
234
|
+
try {
|
|
235
|
+
const entries = await readdir(dir);
|
|
236
|
+
for (const name of entries) {
|
|
237
|
+
if (!/\.(js|css)$/i.test(name)) continue;
|
|
238
|
+
try {
|
|
239
|
+
mtimes.push((await stat(join(dir, name))).mtimeMs);
|
|
240
|
+
} catch {
|
|
241
|
+
// ignore
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// ignore
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
pluginAssetsMtime = mtimes.length ? String(Math.max(...mtimes)) : "none";
|
|
249
|
+
} catch {
|
|
250
|
+
// ignore
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const key = `${mode}:${configMtime}:${registryMtime}:${pluginAssetsMtime}`;
|
|
254
|
+
if (pluginCache && pluginCache.key === key) {
|
|
255
|
+
return { head: pluginCache.head, bodyEnd: pluginCache.bodyEnd };
|
|
256
|
+
}
|
|
257
|
+
const injected = await loadPluginInjections({ root: ROOT, mode });
|
|
258
|
+
pluginCache = { key, head: injected.head, bodyEnd: injected.bodyEnd };
|
|
259
|
+
return injected;
|
|
260
|
+
}
|
|
261
|
+
|
|
190
262
|
// Convert markdown to HTML with template
|
|
191
263
|
async function renderPage(
|
|
192
264
|
filePath: string,
|
|
@@ -234,6 +306,7 @@ async function renderPage(
|
|
|
234
306
|
const themeCSS = generateThemeCSS(theme);
|
|
235
307
|
const prismUrls = getPrismUrls(theme);
|
|
236
308
|
const pathPrefix = "/";
|
|
309
|
+
const plugins = await getPluginInjectionsCached(config.mode === "slides" ? "slides" : "docs");
|
|
237
310
|
|
|
238
311
|
const hotReloadScript = `
|
|
239
312
|
<script>
|
|
@@ -243,12 +316,15 @@ async function renderPage(
|
|
|
243
316
|
</script>`;
|
|
244
317
|
|
|
245
318
|
const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
|
|
319
|
+
const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
|
|
246
320
|
|
|
247
321
|
return template
|
|
322
|
+
.replace("{{BODY_CLASS}}", "mode-docs")
|
|
248
323
|
.replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
|
|
249
324
|
.replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
|
|
250
325
|
.replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
|
|
251
326
|
.replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
|
|
327
|
+
.replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
|
|
252
328
|
.replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
|
|
253
329
|
.replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
|
|
254
330
|
.replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
|
|
@@ -263,6 +339,118 @@ async function renderPage(
|
|
|
263
339
|
.replace("{{TOC}}", toc)
|
|
264
340
|
.replace("{{FOOTER}}", footer)
|
|
265
341
|
.replace("{{THEME_CSS}}", themeCSS)
|
|
342
|
+
.replace("{{PLUGIN_HEAD}}", plugins.head)
|
|
343
|
+
.replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
|
|
344
|
+
.replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
|
|
345
|
+
.replace("{{PRISM_DARK_URL}}", prismUrls.dark)
|
|
346
|
+
.replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function renderSlidesPage(
|
|
350
|
+
provenance: Provenance,
|
|
351
|
+
config: SiteConfig,
|
|
352
|
+
theme: Theme,
|
|
353
|
+
): Promise<string> {
|
|
354
|
+
const uiVersion = provenance.version ? `v${provenance.version}` : "unversioned";
|
|
355
|
+
const template = await readFile(await resolveTemplatePath(ROOT), "utf-8");
|
|
356
|
+
const files = await collectFiles(ROOT, config);
|
|
357
|
+
const slides = await collectSlides(files);
|
|
358
|
+
|
|
359
|
+
if (slides.length === 0) {
|
|
360
|
+
return renderGettingStarted(provenance, config, theme);
|
|
361
|
+
}
|
|
362
|
+
const pathPrefix = "/";
|
|
363
|
+
const validateFences = await isSlidesVisualsEnabled();
|
|
364
|
+
|
|
365
|
+
const sections = await Promise.all(
|
|
366
|
+
slides.map(async (slide, i) => {
|
|
367
|
+
let inner = "";
|
|
368
|
+
if (slide.kind === "markdown") {
|
|
369
|
+
if (validateFences) {
|
|
370
|
+
const diagnostics = validateSlidesVisualsFences(slide.body);
|
|
371
|
+
if (diagnostics.length) {
|
|
372
|
+
const msg = diagnostics
|
|
373
|
+
.slice(0, 12)
|
|
374
|
+
.map((d) => ` - ${slide.sourcePath}:${d.line} ${d.message}`)
|
|
375
|
+
.join("\n");
|
|
376
|
+
throw new Error(`slides-visuals fence contract violations:\n${msg}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
inner = marked.parse(slide.body) as string;
|
|
380
|
+
} else if (slide.kind === "yaml") {
|
|
381
|
+
inner = `<pre><code class="language-yaml">${escapeHtml(slide.body)}</code></pre>`;
|
|
382
|
+
} else {
|
|
383
|
+
let prettyJson = slide.body;
|
|
384
|
+
try {
|
|
385
|
+
prettyJson = JSON.stringify(JSON.parse(slide.body), null, 2);
|
|
386
|
+
} catch {
|
|
387
|
+
// Use original if not valid JSON
|
|
388
|
+
}
|
|
389
|
+
inner = `<pre><code class="language-json">${escapeHtml(prettyJson)}</code></pre>`;
|
|
390
|
+
}
|
|
391
|
+
inner = rewriteRelativeAssetUrls(inner, slide.sourceUrlPath, pathPrefix);
|
|
392
|
+
|
|
393
|
+
const classToken = slide.className ? ` ${slide.className}` : "";
|
|
394
|
+
const activeClass = i === 0 ? " active" : "";
|
|
395
|
+
return `<section id="${slide.id}" class="slide${classToken}${activeClass}" data-slide-index="${i}">${inner}</section>`;
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const htmlContent = `
|
|
400
|
+
<div class="slides-shell" style="--slide-aspect: ${config.aspect || "16/9"}">
|
|
401
|
+
<div class="slide-viewport">
|
|
402
|
+
<div class="slide-frame">
|
|
403
|
+
${sections.join("\n")}
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
<div class="slide-nav" aria-label="Slide navigation">
|
|
407
|
+
<button class="slide-prev" type="button" aria-label="Previous slide">Prev</button>
|
|
408
|
+
<span class="slide-counter">1 / ${slides.length}</span>
|
|
409
|
+
<button class="slide-next" type="button" aria-label="Next slide">Next</button>
|
|
410
|
+
<div class="slide-progress" role="presentation">
|
|
411
|
+
<span class="slide-progress-bar" style="width: ${(1 / slides.length) * 100}%"></span>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</div>`;
|
|
415
|
+
|
|
416
|
+
const nav = buildSlideNav(slides, config, "slide-1");
|
|
417
|
+
const footer = buildFooter(provenance, config);
|
|
418
|
+
const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
|
|
419
|
+
const themeCSS = generateThemeCSS(theme);
|
|
420
|
+
const prismUrls = getPrismUrls(theme);
|
|
421
|
+
const hotReloadScript = `
|
|
422
|
+
<script>
|
|
423
|
+
const es = new EventSource('/__reload');
|
|
424
|
+
es.onmessage = () => location.reload();
|
|
425
|
+
es.onerror = () => setTimeout(() => location.reload(), 1000);
|
|
426
|
+
</script>`;
|
|
427
|
+
const plugins = await getPluginInjectionsCached("slides");
|
|
428
|
+
const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
|
|
429
|
+
const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
|
|
430
|
+
|
|
431
|
+
return template
|
|
432
|
+
.replace("{{BODY_CLASS}}", "mode-slides")
|
|
433
|
+
.replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
|
|
434
|
+
.replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
|
|
435
|
+
.replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
|
|
436
|
+
.replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
|
|
437
|
+
.replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
|
|
438
|
+
.replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
|
|
439
|
+
.replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
|
|
440
|
+
.replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
|
|
441
|
+
.replace(/\{\{SITE_TITLE\}\}/g, config.title)
|
|
442
|
+
.replace("{{TITLE}}", config.title)
|
|
443
|
+
.replace("{{VERSION}}", uiVersion)
|
|
444
|
+
.replace("{{BRANCH}}", provenance.gitBranch)
|
|
445
|
+
.replace("{{BREADCRUMBS}}", "")
|
|
446
|
+
.replace("{{PAGE_META}}", "")
|
|
447
|
+
.replace("{{NAV}}", nav)
|
|
448
|
+
.replace("{{CONTENT}}", htmlContent)
|
|
449
|
+
.replace("{{TOC}}", "")
|
|
450
|
+
.replace("{{FOOTER}}", footer)
|
|
451
|
+
.replace("{{THEME_CSS}}", themeCSS)
|
|
452
|
+
.replace("{{PLUGIN_HEAD}}", plugins.head)
|
|
453
|
+
.replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
|
|
266
454
|
.replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
|
|
267
455
|
.replace("{{PRISM_DARK_URL}}", prismUrls.dark)
|
|
268
456
|
.replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
|
|
@@ -303,6 +491,7 @@ sections:
|
|
|
303
491
|
const themeCSS = generateThemeCSS(theme);
|
|
304
492
|
const prismUrls = getPrismUrls(theme);
|
|
305
493
|
const pathPrefix = "/";
|
|
494
|
+
const plugins = await getPluginInjectionsCached(config.mode === "slides" ? "slides" : "docs");
|
|
306
495
|
|
|
307
496
|
const hotReloadScript = `
|
|
308
497
|
<script>
|
|
@@ -312,12 +501,15 @@ sections:
|
|
|
312
501
|
</script>`;
|
|
313
502
|
|
|
314
503
|
const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
|
|
504
|
+
const brandInitial = escapeHtml(config.brand.name.trim().charAt(0).toUpperCase() || "K");
|
|
315
505
|
|
|
316
506
|
return template
|
|
507
|
+
.replace("{{BODY_CLASS}}", "mode-docs")
|
|
317
508
|
.replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
|
|
318
509
|
.replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
|
|
319
510
|
.replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
|
|
320
511
|
.replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
|
|
512
|
+
.replace(/\{\{BRAND_INITIAL\}\}/g, brandInitial)
|
|
321
513
|
.replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
|
|
322
514
|
.replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
|
|
323
515
|
.replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
|
|
@@ -332,6 +524,8 @@ sections:
|
|
|
332
524
|
.replace("{{TOC}}", "")
|
|
333
525
|
.replace("{{FOOTER}}", buildFooter(provenance, config))
|
|
334
526
|
.replace("{{THEME_CSS}}", themeCSS)
|
|
527
|
+
.replace("{{PLUGIN_HEAD}}", plugins.head)
|
|
528
|
+
.replace("{{PLUGIN_BODY_END}}", plugins.bodyEnd)
|
|
335
529
|
.replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
|
|
336
530
|
.replace("{{PRISM_DARK_URL}}", prismUrls.dark)
|
|
337
531
|
.replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
|
|
@@ -610,6 +804,14 @@ async function main() {
|
|
|
610
804
|
});
|
|
611
805
|
}
|
|
612
806
|
|
|
807
|
+
// Slides mode renders as a single-page deck with hash routing
|
|
808
|
+
if (config.mode === "slides") {
|
|
809
|
+
const html = await renderSlidesPage(provenance, config, theme);
|
|
810
|
+
return new Response(html, {
|
|
811
|
+
headers: { "Content-Type": "text/html" },
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
613
815
|
// Find and render markdown/yaml file
|
|
614
816
|
const filePath = await findFile(url.pathname, config);
|
|
615
817
|
if (filePath) {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* create a temp site directory -> run build() -> verify output files
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
8
9
|
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
10
|
import { tmpdir } from "node:os";
|
|
10
11
|
import { join } from "node:path";
|
|
@@ -31,8 +32,10 @@ async function writeSiteYaml(dir: string, extra: Record<string, unknown> = {}):
|
|
|
31
32
|
const sections = extra.sections ?? " - name: Docs\n path: docs";
|
|
32
33
|
const title = extra.title ?? "Test Site";
|
|
33
34
|
const version = extra.version ? `version: ${extra.version}\n` : "";
|
|
35
|
+
const mode = extra.mode ? `mode: ${extra.mode}\n` : "";
|
|
36
|
+
const aspect = extra.aspect ? `aspect: ${extra.aspect}\n` : "";
|
|
34
37
|
const home = extra.home ? `home: ${extra.home}\n` : "";
|
|
35
|
-
const yaml = `title: ${title}\n${version}brand:\n${brand}\n${home}sections:\n${sections}\n`;
|
|
38
|
+
const yaml = `title: ${title}\n${version}${mode}${aspect}brand:\n${brand}\n${home}sections:\n${sections}\n`;
|
|
36
39
|
await writeFile(join(dir, "site.yaml"), yaml);
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -43,6 +46,10 @@ async function writeMd(dir: string, relPath: string, content: string): Promise<v
|
|
|
43
46
|
await writeFile(fullPath, content);
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
function sha256Hex(text: string): string {
|
|
50
|
+
return createHash("sha256").update(new TextEncoder().encode(text)).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
// ---------------------------------------------------------------------------
|
|
47
54
|
// Cleanup
|
|
48
55
|
// ---------------------------------------------------------------------------
|
|
@@ -209,6 +216,41 @@ describe("build", () => {
|
|
|
209
216
|
expect(html).toContain("unversioned");
|
|
210
217
|
});
|
|
211
218
|
|
|
219
|
+
it("builds a single-page hash-routed deck when mode is slides", async () => {
|
|
220
|
+
const siteDir = await makeTempDir();
|
|
221
|
+
const outDir = "out";
|
|
222
|
+
await writeSiteYaml(siteDir, {
|
|
223
|
+
mode: "slides",
|
|
224
|
+
aspect: '"4/3"',
|
|
225
|
+
sections: " - name: Slides\n path: slides",
|
|
226
|
+
});
|
|
227
|
+
await writeMd(
|
|
228
|
+
siteDir,
|
|
229
|
+
"slides/deck.md",
|
|
230
|
+
`---
|
|
231
|
+
title: Intro
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
# Intro
|
|
235
|
+

|
|
236
|
+
[Report](../files/report.pdf)
|
|
237
|
+
--- slide ---
|
|
238
|
+
# Next`,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await build({ folder: siteDir, out: outDir });
|
|
242
|
+
|
|
243
|
+
const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
|
|
244
|
+
expect(html).toContain('class="mode-slides"');
|
|
245
|
+
expect(html).toContain('id="slide-1"');
|
|
246
|
+
expect(html).toContain('id="slide-2"');
|
|
247
|
+
expect(html).toContain('href="#slide-1"');
|
|
248
|
+
expect(html).toContain('href="#slide-2"');
|
|
249
|
+
expect(html).toContain('src="./slides/img/diagram.png"');
|
|
250
|
+
expect(html).toContain('href="./files/report.pdf"');
|
|
251
|
+
expect(await exists(join(siteDir, outDir, "slides", "deck.html"))).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
212
254
|
it("generates AI accessibility files (content-index.json, llms.txt, _raw/)", async () => {
|
|
213
255
|
const siteDir = await makeTempDir();
|
|
214
256
|
const outDir = "out";
|
|
@@ -237,4 +279,119 @@ describe("build", () => {
|
|
|
237
279
|
const rawEntries = await readdir(join(dist, "_raw"), { recursive: true });
|
|
238
280
|
expect(rawEntries.length).toBeGreaterThan(0);
|
|
239
281
|
});
|
|
282
|
+
|
|
283
|
+
it("injects enabled plugins into generated HTML", async () => {
|
|
284
|
+
const siteDir = await makeTempDir();
|
|
285
|
+
const outDir = "out";
|
|
286
|
+
await writeSiteYaml(siteDir);
|
|
287
|
+
await writeMd(siteDir, "docs/page.md", "> NOTE: Hello\n\nBody");
|
|
288
|
+
|
|
289
|
+
// Local plugin assets + registry
|
|
290
|
+
const js = "console.log('callouts');";
|
|
291
|
+
const css = ".kitfly-callout{border-left:6px solid red;}";
|
|
292
|
+
await mkdir(join(siteDir, "plugins-dist"), { recursive: true });
|
|
293
|
+
await writeFile(join(siteDir, "plugins-dist", "callouts.js"), js, "utf-8");
|
|
294
|
+
await writeFile(join(siteDir, "plugins-dist", "callouts.css"), css, "utf-8");
|
|
295
|
+
|
|
296
|
+
await mkdir(join(siteDir, "registry"), { recursive: true });
|
|
297
|
+
await writeFile(
|
|
298
|
+
join(siteDir, "registry", "plugins.yaml"),
|
|
299
|
+
`version: 1
|
|
300
|
+
updated: "2026-02-12"
|
|
301
|
+
baseUrl: ""
|
|
302
|
+
plugins:
|
|
303
|
+
callouts:
|
|
304
|
+
name: "Callout Boxes"
|
|
305
|
+
description: "Test callouts"
|
|
306
|
+
version: "0.2.0"
|
|
307
|
+
contract: "1"
|
|
308
|
+
kitfly: ">=0.2.0 <1.0.0"
|
|
309
|
+
license: MIT
|
|
310
|
+
verified: true
|
|
311
|
+
assets:
|
|
312
|
+
js: "plugins-dist/callouts.js"
|
|
313
|
+
css: "plugins-dist/callouts.css"
|
|
314
|
+
assetSha256:
|
|
315
|
+
js: "sha256:${sha256Hex(js)}"
|
|
316
|
+
css: "sha256:${sha256Hex(css)}"
|
|
317
|
+
`,
|
|
318
|
+
"utf-8",
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
await writeFile(
|
|
322
|
+
join(siteDir, "kitfly.plugins.yaml"),
|
|
323
|
+
"plugins:\n - callouts@0.2.0\n",
|
|
324
|
+
"utf-8",
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
await build({ folder: siteDir, out: outDir });
|
|
328
|
+
|
|
329
|
+
const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
|
|
330
|
+
expect(html).toContain('data-kitfly-plugin="callouts@0.2.0"');
|
|
331
|
+
expect(html).toContain(css);
|
|
332
|
+
expect(html).toContain(js);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("injects slides-only plugins when mode=slides", async () => {
|
|
336
|
+
const siteDir = await makeTempDir();
|
|
337
|
+
const outDir = "out";
|
|
338
|
+
await writeSiteYaml(siteDir, { mode: "slides" });
|
|
339
|
+
await writeMd(
|
|
340
|
+
siteDir,
|
|
341
|
+
"docs/deck.md",
|
|
342
|
+
`# Title
|
|
343
|
+
|
|
344
|
+
:::kpi
|
|
345
|
+
label: Uptime
|
|
346
|
+
value: 99.95%
|
|
347
|
+
trend: +0.3%
|
|
348
|
+
:::
|
|
349
|
+
`,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const js = "console.log('slides visuals');";
|
|
353
|
+
const css = ".kitfly-visual{border:1px solid red;}";
|
|
354
|
+
await mkdir(join(siteDir, "plugins-dist"), { recursive: true });
|
|
355
|
+
await writeFile(join(siteDir, "plugins-dist", "slides-visuals.js"), js, "utf-8");
|
|
356
|
+
await writeFile(join(siteDir, "plugins-dist", "slides-visuals.css"), css, "utf-8");
|
|
357
|
+
|
|
358
|
+
await mkdir(join(siteDir, "registry"), { recursive: true });
|
|
359
|
+
await writeFile(
|
|
360
|
+
join(siteDir, "registry", "plugins.yaml"),
|
|
361
|
+
`version: 1
|
|
362
|
+
updated: "2026-02-13"
|
|
363
|
+
baseUrl: ""
|
|
364
|
+
plugins:
|
|
365
|
+
slides-visuals:
|
|
366
|
+
name: "Slides Visuals"
|
|
367
|
+
description: "Test visuals"
|
|
368
|
+
version: "0.2.0"
|
|
369
|
+
contract: "1"
|
|
370
|
+
kitfly: ">=0.2.0 <1.0.0"
|
|
371
|
+
license: MIT
|
|
372
|
+
verified: true
|
|
373
|
+
modes: ["slides"]
|
|
374
|
+
assets:
|
|
375
|
+
js: "plugins-dist/slides-visuals.js"
|
|
376
|
+
css: "plugins-dist/slides-visuals.css"
|
|
377
|
+
assetSha256:
|
|
378
|
+
js: "sha256:${sha256Hex(js)}"
|
|
379
|
+
css: "sha256:${sha256Hex(css)}"
|
|
380
|
+
`,
|
|
381
|
+
"utf-8",
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
await writeFile(
|
|
385
|
+
join(siteDir, "kitfly.plugins.yaml"),
|
|
386
|
+
"plugins:\n - slides-visuals@0.2.0\n",
|
|
387
|
+
"utf-8",
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
await build({ folder: siteDir, out: outDir });
|
|
391
|
+
|
|
392
|
+
const html = await readFile(join(siteDir, outDir, "index.html"), "utf-8");
|
|
393
|
+
expect(html).toContain('data-kitfly-plugin="slides-visuals@0.2.0"');
|
|
394
|
+
expect(html).toContain(css);
|
|
395
|
+
expect(html).toContain(js);
|
|
396
|
+
});
|
|
240
397
|
});
|
|
@@ -648,6 +648,12 @@ describe("buildBundleSidebarHeader", () => {
|
|
|
648
648
|
expect(source).toContain(`\${themeCSS}`);
|
|
649
649
|
});
|
|
650
650
|
|
|
651
|
+
it("bundle script keeps docs-mode smooth anchor scrolling", async () => {
|
|
652
|
+
const source = await readFile(`${process.cwd()}/scripts/bundle.ts`, "utf-8");
|
|
653
|
+
expect(source).toContain("if (!shell) {");
|
|
654
|
+
expect(source).toContain("scrollIntoView({ behavior: 'smooth', block: 'start' });");
|
|
655
|
+
});
|
|
656
|
+
|
|
651
657
|
it("shows version label with v prefix when version is provided", () => {
|
|
652
658
|
const config: SiteConfig = {
|
|
653
659
|
docroot: ".",
|
|
@@ -684,6 +690,31 @@ describe("buildBundleSidebarHeader", () => {
|
|
|
684
690
|
expect(html).toContain('alt="My Company"');
|
|
685
691
|
});
|
|
686
692
|
|
|
693
|
+
it("includes initial fallback metadata and onerror handler", () => {
|
|
694
|
+
const config: SiteConfig = {
|
|
695
|
+
docroot: ".",
|
|
696
|
+
title: "Test",
|
|
697
|
+
brand: { name: "Acme", url: "/" },
|
|
698
|
+
sections: [],
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
|
|
702
|
+
expect(html).toContain('data-initial="A"');
|
|
703
|
+
expect(html).toContain("classList.add('logo-fallback')");
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("escapes initial fallback character in data attribute", () => {
|
|
707
|
+
const config: SiteConfig = {
|
|
708
|
+
docroot: ".",
|
|
709
|
+
title: "Test",
|
|
710
|
+
brand: { name: '"quoted', url: "/" },
|
|
711
|
+
sections: [],
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const html = buildBundleSidebarHeader(config, "1.0", "logo.png");
|
|
715
|
+
expect(html).toContain('data-initial="""');
|
|
716
|
+
});
|
|
717
|
+
|
|
687
718
|
it("links brand to brand URL", () => {
|
|
688
719
|
const config: SiteConfig = {
|
|
689
720
|
docroot: ".",
|
|
@@ -54,7 +54,7 @@ kitfly v${version} - Turn your writing into a website
|
|
|
54
54
|
Usage:
|
|
55
55
|
kitfly dev [folder] Start dev server with hot reload
|
|
56
56
|
kitfly build [folder] Build static site to dist/
|
|
57
|
-
kitfly bundle [folder] Build single-file HTML bundle
|
|
57
|
+
kitfly bundle [folder] Build single-file HTML bundle to bundles/
|
|
58
58
|
kitfly init [name] Create new project from template
|
|
59
59
|
kitfly servers List running dev servers
|
|
60
60
|
kitfly stop <port|all> Stop dev server(s)
|
|
@@ -68,11 +68,15 @@ Dev options:
|
|
|
68
68
|
--json Output JSON (implies --daemon)
|
|
69
69
|
--no-open Don't open browser
|
|
70
70
|
|
|
71
|
-
Build
|
|
71
|
+
Build options:
|
|
72
72
|
--out <dir> Output directory [env: KITFLY_BUILD_OUT] (default: dist)
|
|
73
|
-
--name <file> Bundle filename (default: bundle.html)
|
|
74
73
|
--no-raw Don't include raw markdown
|
|
75
74
|
|
|
75
|
+
Bundle options:
|
|
76
|
+
--out <dir> Output directory [env: KITFLY_BUNDLE_OUT] (default: bundles)
|
|
77
|
+
--name <file> Bundle filename (default: bundle.html)
|
|
78
|
+
--no-raw Don't include raw markdown [env: KITFLY_BUNDLE_RAW]
|
|
79
|
+
|
|
76
80
|
Stop options:
|
|
77
81
|
--force Skip graceful shutdown, kill immediately
|
|
78
82
|
|
|
@@ -84,6 +88,7 @@ Examples:
|
|
|
84
88
|
kitfly stop 4000
|
|
85
89
|
kitfly stop all
|
|
86
90
|
kitfly build ./docs --out ./public
|
|
91
|
+
kitfly bundle ./docs --out ./bundles --name docs.html
|
|
87
92
|
kitfly init my-handbook
|
|
88
93
|
|
|
89
94
|
Documentation: https://kitfly.app
|
|
@@ -647,6 +652,12 @@ describe("command argument defaults", () => {
|
|
|
647
652
|
const name = (flags.name as string) || "bundle.html";
|
|
648
653
|
expect(name).toBe("bundle.html");
|
|
649
654
|
});
|
|
655
|
+
|
|
656
|
+
it("bundle defaults out to bundles", () => {
|
|
657
|
+
const { flags } = routeCommand(["bundle"]);
|
|
658
|
+
const out = (flags.out as string) || "bundles";
|
|
659
|
+
expect(out).toBe("bundles");
|
|
660
|
+
});
|
|
650
661
|
});
|
|
651
662
|
|
|
652
663
|
describe("daemon mode detection", () => {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
:::comparison-table
|
|
2
|
+
headers:
|
|
3
|
+
- Feature
|
|
4
|
+
- Us
|
|
5
|
+
- Competitor A
|
|
6
|
+
- Competitor B
|
|
7
|
+
rows:
|
|
8
|
+
- ["Real-time sync", "Yes", "Yes", "No"]
|
|
9
|
+
- ["Offline mode", "Yes", "No", "Yes"]
|
|
10
|
+
- ["Plugin system", "Yes (v0.2.0)", "No", "Limited"]
|
|
11
|
+
- ["Self-hosted option", "Yes", "No", "No"]
|
|
12
|
+
- ["AI generation", "Yes", "Beta", "No"]
|
|
13
|
+
- ["Price (team/mo)", "$49", "$79", "$39"]
|
|
14
|
+
:::
|