@pirateandfox/pagestack 0.1.0-beta.5 → 0.1.0-beta.6

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
@@ -293,7 +293,8 @@ editor response is `noindex` and `no-store`.
293
293
 
294
294
  | option | default | |
295
295
  |---|---|---|
296
- | `renderer` | required | Astro component rendering `{ page, editing }` |
296
+ | `renderer` | required, unless `pages: false` | Astro component rendering `{ page, editing }` |
297
+ | `pages` | `true` | `false` for a site with no block pages: only collections, globals and media |
297
298
  | `sections` | required | module exporting `{ schemas, presets }` |
298
299
  | `pagesDir` | `src/content/pages` | |
299
300
  | `imagesDir` / `uploadsDir` / `publicDir` | `public/images` / `public/images/uploads` / `public` | |
package/index.mjs CHANGED
@@ -18,7 +18,8 @@ const route = (file) => new URL(`./src/routes/${file}`, import.meta.url);
18
18
 
19
19
  /**
20
20
  * @typedef {object} SectionEditorOptions
21
- * @property {string} renderer Astro component rendering a page: props { page, editing }.
21
+ * @property {string} [renderer] Astro component rendering a page: props { page, editing }. Required unless `pages: false`.
22
+ * @property {boolean} [pages] false for a site with no block pages: only collections, globals and media.
22
23
  * @property {string} sections Module exporting { schemas, presets } (see runtime.ts types).
23
24
  * @property {string} [pagesDir='src/content/pages'] Directory of <slug>.json pages.
24
25
  * @property {string} [imagesDir='public/images'] Browsable image library root.
@@ -58,7 +59,8 @@ function staticRoutes(srcDir) {
58
59
 
59
60
  /** @param {SectionEditorOptions} userOptions */
60
61
  export default function sectionEditor(userOptions) {
61
- if (!userOptions?.renderer || !userOptions?.sections) throw new Error('pagestack: `renderer` and `sections` are required');
62
+ if (!userOptions?.sections) throw new Error('pagestack: `sections` is required');
63
+ if (!userOptions.renderer && userOptions.pages !== false) throw new Error('pagestack: `renderer` is required unless `pages: false`');
62
64
  // When the site has no adapter of its own we bring @astrojs/node along and forward every
63
65
  // integration hook to it (an integration added via updateConfig() doesn't get to call
64
66
  // setAdapter() in time, so we proxy its hooks instead).
@@ -99,7 +101,7 @@ export default function sectionEditor(userOptions) {
99
101
  const root = fileURLToPath(config.root);
100
102
  const abs = (p) => path.isAbsolute(p) ? p : path.resolve(root, p);
101
103
  const reservedSlugs = [...new Set([...(options.reservedSlugs ?? []), ...staticRoutes(fileURLToPath(config.srcDir))])];
102
- const serial = { ...options, reservedSlugs, root, renderer: abs(options.renderer), sections: abs(options.sections), command };
104
+ const serial = { ...options, reservedSlugs, root, renderer: options.renderer ? abs(options.renderer) : null, sections: abs(options.sections), command };
103
105
 
104
106
  updateConfig({
105
107
  vite: {
@@ -109,7 +111,7 @@ export default function sectionEditor(userOptions) {
109
111
  load(id) {
110
112
  // The renderer (and the site layout/CSS behind it) is only imported by the preview
111
113
  // route, so the editor shell doesn't drag the whole site's CSS into its page.
112
- if (id === '\0' + VIRTUAL_RENDERER) return `export { default as Renderer } from ${JSON.stringify(serial.renderer)};`;
114
+ if (id === '\0' + VIRTUAL_RENDERER) return serial.renderer ? `export { default as Renderer } from ${JSON.stringify(serial.renderer)};` : 'export const Renderer = null;';
113
115
  if (id !== '\0' + VIRTUAL) return;
114
116
  return [
115
117
  `export * as site from ${JSON.stringify(serial.sections)};`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pirateandfox/pagestack",
3
- "version": "0.1.0-beta.5",
3
+ "version": "0.1.0-beta.6",
4
4
  "description": "PageStack: visual section editor for Astro sites. Git-only, static public site, passkey login, saves are commits.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,7 +5,7 @@ import { confirmDialog, promptDialog, toast } from './ui';
5
5
  const boot = (window as any).__ASE_COLLECTION__ as { id: string; singular: string; slugs: string[]; reserved: string[] };
6
6
  const api = `/api/editor/collection/${boot.id}`;
7
7
 
8
- document.getElementById('ase-add-item')!.addEventListener('click', async () => {
8
+ document.getElementById('ase-add-item')?.addEventListener('click', async () => {
9
9
  const title = await promptDialog({ title: `Add ${boot.singular}`, label: 'Title', submit: `Add ${boot.singular}` });
10
10
  if (!title) return;
11
11
  const slug = uniqueSlug(slugify(title) || boot.singular, [...boot.slugs, ...boot.reserved]);
@@ -5,7 +5,7 @@ const { current } = Astro.props as { current: string };
5
5
  const user = editorOf(Astro.locals);
6
6
  const firstGlobal = Object.keys(globals)[0];
7
7
  const links = [
8
- { id: 'pages', label: 'Pages', href: '/edit' },
8
+ ...(opts.pages === false ? [] : [{ id: 'pages', label: 'Pages', href: '/edit' }]),
9
9
  ...Object.entries(collections).map(([id, c]) => ({ id: `collection-${id}`, label: c.label, href: `/edit/collection/${id}` })),
10
10
  { id: 'media', label: 'Media', href: '/edit/media' },
11
11
  ...(firstGlobal ? [{ id: 'settings', label: Object.keys(globals).length > 1 ? 'Site settings' : globals[firstGlobal].label, href: `/edit/global/${firstGlobal}` }] : []),
@@ -39,6 +39,7 @@ export const GET: APIRoute = async ({ params }) => {
39
39
  export const POST: APIRoute = async ({ params, request, locals }) => {
40
40
  const c = find(params.id);
41
41
  if (!c || params.slug) return bad('no such collection', 404);
42
+ if (c.create === false) return bad('Items here cannot be added from the editor.', 405);
42
43
  const body = await request.json().catch(() => null);
43
44
  const title = plainText(String(body?.title ?? '')).trim();
44
45
  const slug = String(body?.slug ?? '').trim();
@@ -47,7 +48,10 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
47
48
  const problem = SLUG.test(slug) ? slugProblem(slug, { existing, reserved: reservedSlugs() }) : 'Use letters, numbers and dashes in the URL.';
48
49
  if (problem) return bad(problem, 409);
49
50
  const data: Record<string, unknown> = { title };
50
- for (const [k, f] of Object.entries(c.fields)) if (f.type === 'date') data[k] = new Date().toISOString().slice(0, f.time === false ? 10 : 19);
51
+ for (const [k, f] of Object.entries(c.fields)) if (f.default !== undefined && k !== 'title') data[k] = f.default;
52
+ // Only the first date is "today": others (modified, reviewed on) mean something else happened.
53
+ const dated = Object.entries(c.fields).find(([, f]) => f.type === 'date');
54
+ if (dated && data[dated[0]] === undefined) data[dated[0]] = new Date().toISOString().slice(0, dated[1].time === false ? 10 : 19);
51
55
  if (c.fields.draft?.type === 'boolean') data.draft = true;
52
56
  try {
53
57
  const result = await storage().commit({ message: commitMessage(`Add ${name(c)} ${slug}`, locals), files: [{ path: file(c, slug), content: writeMarkdown(null, data, '\n') }] });
@@ -1,7 +1,7 @@
1
1
  // GET /api/editor/pages -> { pages }
2
2
  // POST /api/editor/pages { title, slug, copyOf?, template? } -> creates a draft page.
3
3
  import type { APIRoute } from 'astro';
4
- import { bad, clean, commitMessage, json, listPages, pagePath, readPage, reservedSlugs, serializePage, storage, templates, validate } from '../../server/context';
4
+ import { opts, bad, clean, commitMessage, json, listPages, pagePath, readPage, reservedSlugs, serializePage, storage, templates, validate } from '../../server/context';
5
5
  import { copyPage, newPage, slugProblem } from '../../core/pages';
6
6
  import type { PageDoc } from '../../runtime';
7
7
  export const prerender = false;
@@ -9,6 +9,7 @@ export const prerender = false;
9
9
  export const GET: APIRoute = async () => json({ pages: await listPages() });
10
10
 
11
11
  export const POST: APIRoute = async ({ request, locals }) => {
12
+ if (opts.pages === false) return bad('This site has no block pages.', 404);
12
13
  let body: any;
13
14
  try { body = await request.json(); } catch { return bad('invalid JSON'); }
14
15
  const title = String(body?.title ?? '').trim();
@@ -24,7 +24,7 @@ const boot = { id, singular, slugs: items.map((i) => i.slug), reserved: reserved
24
24
  <main class="ase-index__main">
25
25
  <header class="ase-index__head">
26
26
  <div><h1>{c.label} <span class="ase-muted">{items.length}</span></h1></div>
27
- <button type="button" id="ase-add-item" class="ase-btn ase-btn--primary">Add {singular}</button>
27
+ {c.create !== false && <button type="button" id="ase-add-item" class="ase-btn ase-btn--primary">Add {singular}</button>}
28
28
  </header>
29
29
  <div class="ase-index__tools"><input type="search" id="ase-page-search" placeholder={`Search ${c.label.toLowerCase()}`} aria-label="Search" /></div>
30
30
  <ul class="ase-pagelist" id="ase-pagelist">
@@ -1,9 +1,13 @@
1
1
  ---
2
2
  // /edit — the Pages screen: every page, with add, duplicate, delete and search.
3
- import { globals, listPages, opts, publicPath, reservedSlugs, templates } from '../server/context';
3
+ import { collections, globals, listPages, opts, publicPath, reservedSlugs, templates } from '../server/context';
4
4
  import Nav from './Nav.astro';
5
5
  import '../client/shell.css';
6
6
  export const prerender = false;
7
+ if (opts.pages === false) {
8
+ const first = Object.keys(collections)[0];
9
+ return Astro.redirect(first ? `/edit/collection/${first}` : '/edit/media');
10
+ }
7
11
  const pages = await listPages();
8
12
  const boot = {
9
13
  pages: pages.map((p) => ({ slug: p.slug, title: p.title })),
@@ -5,6 +5,7 @@
5
5
  // Preview button) renders the draft without edit mode, for a look at the finished page in a tab.
6
6
  import { Renderer } from 'virtual:pagestack/renderer';
7
7
  import { clean, readPage, validate } from '../server/context';
8
+ if (!Renderer) return new Response('Not found', { status: 404 });
8
9
  const locals = Astro.locals;
9
10
  export const prerender = false;
10
11
  const slug = Astro.params.slug || 'index';
package/src/runtime.ts CHANGED
@@ -101,6 +101,8 @@ export interface CollectionDoc {
101
101
  fields: Record<string, Field>;
102
102
  /** Site URL of an item, for "View" links. */
103
103
  url?: (slug: string) => string;
104
+ /** false hides Add, for a fixed set of items. Otherwise new items start from each field's `default`. */
105
+ create?: boolean;
104
106
  }
105
107
 
106
108
  /** A starting point offered by "Add page". */
@@ -17,7 +17,7 @@ import type { SavedPattern } from '../core/insert';
17
17
  export { options, site };
18
18
 
19
19
  export interface EditorOptions {
20
- root: string; renderer: string; sections: string; pagesDir: string; imagesDir: string; uploadsDir: string; publicDir: string; usersFile: string;
20
+ root: string; renderer: string | null; pages?: boolean; sections: string; pagesDir: string; imagesDir: string; uploadsDir: string; publicDir: string; usersFile: string;
21
21
  auth: 'none' | 'passkey' | 'none-behind-proxy'; storage: 'local' | 'github'; github: { repo: string; branch?: string; pullRequest?: boolean } | null;
22
22
  siteName: string; secretEnv: string; command: string; interactiveSelector: string;
23
23
  reservedSlugs: string[]; redirectsFile: string; patternsFile: string;
@@ -47,6 +47,7 @@ function harden(res: Response, url: URL, sandbox: boolean): Response {
47
47
  let warned = false;
48
48
 
49
49
  export const onRequest = defineMiddleware(async (ctx, next) => {
50
+ if (ctx.isPrerendered) return next(); // public pages, which never carry a session
50
51
  const { pathname } = ctx.url;
51
52
  const { request } = ctx;
52
53
  const isEditor = pathname === '/edit' || pathname.startsWith('/edit/') || pathname.startsWith('/api/editor/');