@kdrgny/justjson 1.7.0 → 1.8.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 CHANGED
@@ -43,6 +43,7 @@ Design your schema, enter content, upload images. Everything is written to `cont
43
43
  | **Validate in CI** | `justjson validate` checks content against the schema — broken relations, duplicate slugs, missing media, type errors. Exits non-zero on failure. |
44
44
  | **Draft / published** | Toggle an entry's status; the generated loader returns published entries by default. |
45
45
  | **One-click site** | No site yet? `init --astro` (or a button in the editor) generates a working Astro site around your content. |
46
+ | **Design & Preview** | Theme your generated site (palette, accent, font, corners) with a live sample, and run it inside the editor to watch changes land. |
46
47
  | **Ship it** | Setup snippet for your framework, one-click content commit, push to GitHub, then deploy links for Vercel and Netlify — using the `git`/`gh` already on your machine. No tokens stored. |
47
48
  | **Astro collections** | [`@kdrgny/justjson-astro`](https://www.npmjs.com/package/@kdrgny/justjson-astro) turns your content into typed Astro content collections in one line. |
48
49
  | **English or Turkish** | The editor ships in English; switch to Turkish from the project menu any time. |
package/dist/cli.js CHANGED
@@ -4707,6 +4707,86 @@ function inferProject(data) {
4707
4707
  }
4708
4708
  return { schema: { version: 1, collections, singletons }, entries, singletons: singletonData };
4709
4709
  }
4710
+ var PALETTES = [
4711
+ {
4712
+ id: "paper",
4713
+ label: "Paper",
4714
+ bg: "#faf9f5",
4715
+ text: "#1c1a17",
4716
+ muted: "#6b6558",
4717
+ border: "#e4e0d5"
4718
+ },
4719
+ {
4720
+ id: "plain",
4721
+ label: "Plain",
4722
+ bg: "#ffffff",
4723
+ text: "#18181b",
4724
+ muted: "#71717a",
4725
+ border: "#e4e4e7"
4726
+ },
4727
+ { id: "ink", label: "Ink", bg: "#141414", text: "#f4f4f5", muted: "#a1a1aa", border: "#2a2a2a" },
4728
+ {
4729
+ id: "sand",
4730
+ label: "Sand",
4731
+ bg: "#f5f1e8",
4732
+ text: "#2b2620",
4733
+ muted: "#7a7060",
4734
+ border: "#ddd5c4"
4735
+ },
4736
+ { id: "sky", label: "Sky", bg: "#f7fafc", text: "#111b26", muted: "#5d7183", border: "#dbe6ef" }
4737
+ ];
4738
+ var THEME_FONTS = [
4739
+ {
4740
+ id: "sans",
4741
+ label: "Sans",
4742
+ stack: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
4743
+ },
4744
+ { id: "serif", label: "Serif", stack: 'ui-serif, Georgia, "Times New Roman", serif' },
4745
+ { id: "mono", label: "Mono", stack: "ui-monospace, SFMono-Regular, Menlo, monospace" },
4746
+ {
4747
+ id: "humanist",
4748
+ label: "Humanist",
4749
+ stack: '"Optima", "Gill Sans", "Trebuchet MS", ui-sans-serif, sans-serif'
4750
+ }
4751
+ ];
4752
+ var HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
4753
+ var MAX_RADIUS = 24;
4754
+ function defaultTheme() {
4755
+ return { palette: "paper", accent: "#b8860b", font: "sans", radius: 8, density: "normal" };
4756
+ }
4757
+ function pick(list, id, fallback) {
4758
+ return typeof id === "string" && list.some((x) => x.id === id) ? id : fallback;
4759
+ }
4760
+ function parseTheme(input) {
4761
+ const base = defaultTheme();
4762
+ if (typeof input !== "object" || input === null) return base;
4763
+ const raw = input;
4764
+ const radius = typeof raw.radius === "number" ? raw.radius : base.radius;
4765
+ const density = raw.density;
4766
+ return {
4767
+ palette: pick(PALETTES, raw.palette, base.palette),
4768
+ accent: typeof raw.accent === "string" && HEX.test(raw.accent) ? raw.accent : base.accent,
4769
+ font: pick(THEME_FONTS, raw.font, base.font),
4770
+ radius: Math.min(MAX_RADIUS, Math.max(0, Math.round(radius))),
4771
+ density: density === "tight" || density === "normal" || density === "roomy" ? density : base.density
4772
+ };
4773
+ }
4774
+ function themePath(contentDir) {
4775
+ return `${contentDir}/_theme.json`;
4776
+ }
4777
+ async function loadTheme(adapter, contentDir = "content") {
4778
+ const raw = await adapter.read(themePath(contentDir));
4779
+ if (raw === null) return parseTheme(null);
4780
+ try {
4781
+ return parseTheme(JSON.parse(raw));
4782
+ } catch {
4783
+ return parseTheme(null);
4784
+ }
4785
+ }
4786
+ async function saveTheme(adapter, theme, contentDir = "content") {
4787
+ await adapter.write(themePath(contentDir), `${JSON.stringify(parseTheme(theme), null, 2)}
4788
+ `);
4789
+ }
4710
4790
 
4711
4791
  // src/cli.ts
4712
4792
  import { Command } from "commander";
@@ -5602,24 +5682,56 @@ function formatJson(issues) {
5602
5682
  import { access, mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
5603
5683
  import { dirname as dirname2, join as join4 } from "path";
5604
5684
  var ASTRO_VERSION = "^5.18.0";
5605
- var LOADER_VERSION = "^1.7.0";
5606
- var STYLE = ` <style is:global>
5607
- :root { color-scheme: light dark; }
5608
- body {
5609
- margin: 0 auto;
5610
- max-width: 44rem;
5611
- padding: 3rem 1.25rem 6rem;
5612
- font: 16px/1.65 ui-sans-serif, system-ui, sans-serif;
5613
- }
5614
- h1 { font-size: 1.9rem; line-height: 1.2; margin: 0 0 0.35rem; }
5615
- h2 { font-size: 1.05rem; text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.6; margin: 2.75rem 0 0.75rem; }
5616
- ul { list-style: none; margin: 0; padding: 0; }
5617
- li + li { margin-top: 0.6rem; }
5618
- a { color: inherit; }
5619
- time { opacity: 0.6; font-size: 0.9rem; }
5620
- img { max-width: 100%; height: auto; border-radius: 8px; }
5621
- .body { white-space: pre-wrap; }
5622
- </style>`;
5685
+ var LOADER_VERSION = "^1.8.0";
5686
+ var LAYOUT = `---
5687
+ import { parseTheme, themeCss } from '@kdrgny/justjson-astro/theme'
5688
+ import theme from '../../content/_theme.json'
5689
+
5690
+ // G\xF6r\xFCn\xFCm content/_theme.json'dan gelir \u2014 JustJSON'da de\u011Fi\u015Ftirince buras\u0131 da de\u011Fi\u015Fir.
5691
+ const css = themeCss(parseTheme(theme))
5692
+ const { title } = Astro.props
5693
+ ---
5694
+ <html lang="en">
5695
+ <head>
5696
+ <meta charset="utf-8" />
5697
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
5698
+ <title>{title}</title>
5699
+ <style is:global set:html={css} />
5700
+ <style is:global>
5701
+ body {
5702
+ margin: 0 auto;
5703
+ max-width: 44rem;
5704
+ padding: var(--jj-page) 1.25rem calc(var(--jj-page) * 2);
5705
+ background: var(--jj-bg);
5706
+ color: var(--jj-text);
5707
+ font-family: var(--jj-font);
5708
+ font-size: 16px;
5709
+ line-height: var(--jj-lead);
5710
+ }
5711
+ h1 { font-size: 1.9rem; line-height: 1.2; margin: 0 0 0.35rem; }
5712
+ h2 {
5713
+ font-size: 0.8rem;
5714
+ text-transform: uppercase;
5715
+ letter-spacing: 0.12em;
5716
+ color: var(--jj-muted);
5717
+ margin: calc(var(--jj-page) * 0.8) 0 var(--jj-gap);
5718
+ }
5719
+ ul { list-style: none; margin: 0; padding: 0; }
5720
+ li + li { margin-top: var(--jj-gap); }
5721
+ a { color: var(--jj-accent); text-decoration: none; }
5722
+ a:hover { text-decoration: underline; }
5723
+ time { color: var(--jj-muted); font-size: 0.9rem; }
5724
+ img { max-width: 100%; height: auto; border-radius: var(--jj-radius); }
5725
+ code { background: color-mix(in oklab, var(--jj-text) 8%, transparent); padding: 0.1em 0.35em; border-radius: calc(var(--jj-radius) / 2); }
5726
+ .body { white-space: pre-wrap; }
5727
+ .back { color: var(--jj-muted); font-size: 0.9rem; }
5728
+ </style>
5729
+ </head>
5730
+ <body>
5731
+ <slot />
5732
+ </body>
5733
+ </html>
5734
+ `;
5623
5735
  function titleFieldOf(fields) {
5624
5736
  const preferred = fields.find(
5625
5737
  (f) => ["title", "name", "label", "role", "version"].includes(f.key)
@@ -5642,6 +5754,7 @@ function detailPage(collection) {
5642
5754
  ].filter(Boolean);
5643
5755
  return `---
5644
5756
  import { getCollection } from 'astro:content'
5757
+ import Layout from '../../layouts/Base.astro'
5645
5758
 
5646
5759
  export async function getStaticPaths() {
5647
5760
  const entries = await getCollection('${collection.name}')
@@ -5650,18 +5763,10 @@ export async function getStaticPaths() {
5650
5763
 
5651
5764
  const { entry } = Astro.props
5652
5765
  ---
5653
- <html lang="en">
5654
- <head>
5655
- <meta charset="utf-8" />
5656
- <meta name="viewport" content="width=device-width, initial-scale=1" />
5657
- <title>{entry.data.${titleKey} ?? entry.id}</title>
5658
- ${STYLE}
5659
- </head>
5660
- <body>
5661
- <p><a href="/">&larr; Back</a></p>
5662
- ${parts.map((p) => ` ${p}`).join("\n")}
5663
- </body>
5664
- </html>
5766
+ <Layout title={entry.data.${titleKey} ?? entry.id}>
5767
+ <p class="back"><a href="/">&larr; Back</a></p>
5768
+ ${parts.map((p) => ` ${p}`).join("\n")}
5769
+ </Layout>
5665
5770
  `;
5666
5771
  }
5667
5772
  function indexPage(schema, projectName) {
@@ -5686,23 +5791,17 @@ function indexPage(schema, projectName) {
5686
5791
  </ul>`;
5687
5792
  });
5688
5793
  const empty = ` <p>No content yet \u2014 run <code>npx @kdrgny/justjson</code> and add some.</p>`;
5794
+ const pageTitle = singleton ? `{site?.data.${titleFieldOf(singleton.fields)} ?? '${projectName}'}` : `"${projectName}"`;
5689
5795
  return `---
5690
5796
  import { ${imports} } from 'astro:content'
5797
+ import Layout from '../layouts/Base.astro'
5691
5798
 
5692
5799
  ${loads.join("\n")}
5693
5800
  ---
5694
- <html lang="en">
5695
- <head>
5696
- <meta charset="utf-8" />
5697
- <meta name="viewport" content="width=device-width, initial-scale=1" />
5698
- <title>${singleton ? `{site?.data.${titleFieldOf(singleton.fields)} ?? '${projectName}'}` : projectName}</title>
5699
- ${STYLE}
5700
- </head>
5701
- <body>
5702
- <h1>${heading}</h1>
5703
- ${sections.length ? sections.join("\n") : empty}
5704
- </body>
5705
- </html>
5801
+ <Layout title={${pageTitle.startsWith("{") ? pageTitle.slice(1, -1) : pageTitle}}>
5802
+ <h1>${heading}</h1>
5803
+ ${(sections.length ? sections.join("\n") : empty).replace(/^ {4}/gm, " ")}
5804
+ </Layout>
5706
5805
  `;
5707
5806
  }
5708
5807
  function varFor(name) {
@@ -5742,6 +5841,9 @@ dist
5742
5841
  // Every collection and singleton in content/_schema.json, typed from your fields.
5743
5842
  export const collections = await justjsonCollections()
5744
5843
  `,
5844
+ "content/_theme.json": `${JSON.stringify(defaultTheme(), null, 2)}
5845
+ `,
5846
+ "src/layouts/Base.astro": LAYOUT,
5745
5847
  "src/pages/index.astro": indexPage(schema, projectName)
5746
5848
  };
5747
5849
  for (const collection of schema.collections) {
@@ -5775,8 +5877,8 @@ async function writeAstroSite(root2, schema, projectName) {
5775
5877
 
5776
5878
  // src/server.ts
5777
5879
  import { exec } from "child_process";
5778
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile4 } from "fs/promises";
5779
- import { basename as basename2, dirname as dirname3, extname, join as join6, normalize } from "path";
5880
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
5881
+ import { basename as basename2, dirname as dirname3, extname, join as join7, normalize } from "path";
5780
5882
  import { fileURLToPath } from "url";
5781
5883
  import { serve } from "@hono/node-server";
5782
5884
  import { Hono } from "hono";
@@ -5905,6 +6007,90 @@ async function createGitHubRepo(root2, options) {
5905
6007
  return { name: options.name };
5906
6008
  }
5907
6009
 
6010
+ // src/preview.ts
6011
+ import { spawn } from "child_process";
6012
+ import { readFile as readFile3 } from "fs/promises";
6013
+ import { join as join6 } from "path";
6014
+ function detectDevScript(pkg) {
6015
+ if (typeof pkg !== "object" || pkg === null) return null;
6016
+ const scripts = pkg.scripts;
6017
+ if (!scripts?.dev) return null;
6018
+ return { command: "npm", args: ["run", "dev"] };
6019
+ }
6020
+ var ANSI = /\u001b\[[0-9;]*m/g;
6021
+ var URL_RE2 = /(https?:\/\/localhost:\d+[^\s\u001b]*)/i;
6022
+ function cleanUrl(line) {
6023
+ const match = line.replace(ANSI, "").match(URL_RE2);
6024
+ if (!match?.[1]) return null;
6025
+ return match[1].replace(/\/$/, "");
6026
+ }
6027
+ var PreviewProcess = class {
6028
+ constructor(root2) {
6029
+ this.root = root2;
6030
+ }
6031
+ root;
6032
+ child = null;
6033
+ state = { status: "idle" };
6034
+ log = [];
6035
+ getState() {
6036
+ return this.state;
6037
+ }
6038
+ recentLog() {
6039
+ return this.log.slice(-40).join("\n");
6040
+ }
6041
+ async start() {
6042
+ if (this.state.status === "running" || this.state.status === "starting") return this.state;
6043
+ let pkg;
6044
+ try {
6045
+ pkg = JSON.parse(await readFile3(join6(this.root, "package.json"), "utf8"));
6046
+ } catch {
6047
+ this.state = {
6048
+ status: "error",
6049
+ message: "No package.json \u2014 this folder has no site to preview."
6050
+ };
6051
+ return this.state;
6052
+ }
6053
+ const dev = detectDevScript(pkg);
6054
+ if (!dev) {
6055
+ this.state = {
6056
+ status: "error",
6057
+ message: 'No "dev" script \u2014 generate a site first (Ship it).'
6058
+ };
6059
+ return this.state;
6060
+ }
6061
+ this.log = [];
6062
+ this.state = { status: "starting" };
6063
+ const child = spawn(dev.command, dev.args, {
6064
+ cwd: this.root,
6065
+ env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none" },
6066
+ shell: process.platform === "win32"
6067
+ });
6068
+ this.child = child;
6069
+ const capture = (buf) => {
6070
+ const text = buf.toString();
6071
+ this.log.push(text);
6072
+ const url = cleanUrl(text);
6073
+ if (url && this.state.status !== "running") {
6074
+ this.state = { status: "running", url };
6075
+ }
6076
+ };
6077
+ child.stdout?.on("data", capture);
6078
+ child.stderr?.on("data", capture);
6079
+ child.on("exit", (code) => {
6080
+ this.child = null;
6081
+ if (this.state.status !== "error") {
6082
+ this.state = code === 0 || code === null ? { status: "idle" } : { status: "error", message: `Dev server stopped (exit ${code}).` };
6083
+ }
6084
+ });
6085
+ return this.state;
6086
+ }
6087
+ stop() {
6088
+ this.child?.kill();
6089
+ this.child = null;
6090
+ this.state = { status: "idle" };
6091
+ }
6092
+ };
6093
+
5908
6094
  // src/server.ts
5909
6095
  var editorDir = fileURLToPath(new URL("./editor", import.meta.url));
5910
6096
  var PROVIDER_BASE_URLS = {
@@ -5984,6 +6170,7 @@ async function createServer(root2) {
5984
6170
  const empty = { version: 1, collections: [], singletons: [] };
5985
6171
  let schema = await loadSchema(adapter, contentDir) ?? empty;
5986
6172
  let store = new ContentStore(adapter, schema, contentDir);
6173
+ const preview = new PreviewProcess(root2);
5987
6174
  const app = new Hono();
5988
6175
  app.onError((err, c) => {
5989
6176
  if (err instanceof NotFoundError) return c.json({ error: err.message }, 404);
@@ -6008,6 +6195,12 @@ async function createServer(root2) {
6008
6195
  }
6009
6196
  return c.json(await writeAstroSite(root2, schema, basename2(root2) || "my-site"));
6010
6197
  });
6198
+ app.get("/api/_preview", (c) => c.json(preview.getState()));
6199
+ app.post("/api/_preview/start", async (c) => c.json(await preview.start()));
6200
+ app.post("/api/_preview/stop", (c) => {
6201
+ preview.stop();
6202
+ return c.json(preview.getState());
6203
+ });
6011
6204
  app.get(
6012
6205
  "/api/_ship",
6013
6206
  async (c) => c.json({
@@ -6051,6 +6244,11 @@ async function createServer(root2) {
6051
6244
  return c.json({ error: e.message }, 400);
6052
6245
  }
6053
6246
  });
6247
+ app.get("/api/_theme", async (c) => c.json(await loadTheme(adapter, contentDir)));
6248
+ app.put("/api/_theme", async (c) => {
6249
+ await saveTheme(adapter, parseTheme(await c.req.json()), contentDir);
6250
+ return c.json(await loadTheme(adapter, contentDir));
6251
+ });
6054
6252
  app.get("/api/_templates", (c) => c.json({ items: templateList() }));
6055
6253
  app.post("/api/_init", async (c) => {
6056
6254
  const { template: id } = await c.req.json();
@@ -6134,7 +6332,7 @@ async function createServer(root2) {
6134
6332
  const base = slugify((body.filename ?? "gorsel").replace(/\.[^.]+$/, "")) || "gorsel";
6135
6333
  const name = `${base}-${Date.now().toString(36)}.webp`;
6136
6334
  const rel = `${contentDir}/media/${name}`;
6137
- const abs = join6(root2, rel);
6335
+ const abs = join7(root2, rel);
6138
6336
  await mkdir3(dirname3(abs), { recursive: true });
6139
6337
  await writeFile4(abs, Buffer.from(body.dataBase64, "base64"));
6140
6338
  return c.json({ path: rel });
@@ -6178,7 +6376,7 @@ async function createServer(root2) {
6178
6376
  return c.text("forbidden", 400);
6179
6377
  }
6180
6378
  try {
6181
- const bytes = await readFile3(join6(root2, contentDir, "media", file));
6379
+ const bytes = await readFile4(join7(root2, contentDir, "media", file));
6182
6380
  return new Response(bytes, { headers: { "content-type": "image/webp" } });
6183
6381
  } catch {
6184
6382
  return c.text("not found", 404);
@@ -6205,14 +6403,14 @@ async function createServer(root2) {
6205
6403
  app.get("/*", async (c) => {
6206
6404
  const urlPath = c.req.path === "/" ? "/index.html" : c.req.path;
6207
6405
  const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
6208
- const file = join6(editorDir, rel);
6406
+ const file = join7(editorDir, rel);
6209
6407
  if (!file.startsWith(editorDir)) return c.text("forbidden", 403);
6210
6408
  const type = MIME[extname(file)] ?? "application/octet-stream";
6211
6409
  try {
6212
- return new Response(await readFile3(file), { headers: { "content-type": type } });
6410
+ return new Response(await readFile4(file), { headers: { "content-type": type } });
6213
6411
  } catch {
6214
6412
  try {
6215
- const html = await readFile3(join6(editorDir, "index.html"));
6413
+ const html = await readFile4(join7(editorDir, "index.html"));
6216
6414
  return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
6217
6415
  } catch {
6218
6416
  return c.text("Editor UI not found (run the justjson build first).", 404);