@obelism/content 0.1.1 → 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/README.md CHANGED
@@ -77,6 +77,17 @@ const { summary } = await bridge.prompt('Make the heading shorter')
77
77
  bridge.undo()
78
78
  ```
79
79
 
80
+ On a site signed in to edit (`@obelism/content/site`), the box also has **New
81
+ page**: a path (`/pricing`, or under an existing page) and what the page is
82
+ for. The CMS writes the page like the one the editor is on and stores it as a
83
+ draft, and the bridge opens it at `/` + its path. When your routes put CMS
84
+ pages elsewhere, say where:
85
+
86
+ ```ts
87
+ getContentBridge({ pagePath: (path) => `/en/${path}` })
88
+ await bridge.createPage('/pricing', 'Our plans, for agencies') // or your own UI
89
+ ```
90
+
80
91
  The CMS runs prompts on Workers AI. To use your own model, set a prompt
81
92
  endpoint under Space settings → Prompt editing and answer it with
82
93
  `createPromptHandler` (it verifies the signed request; you call the model):
package/dist/bridge.d.mts CHANGED
@@ -24,6 +24,12 @@ type BridgeState = {
24
24
  type PromptResult = {
25
25
  summary: string;
26
26
  };
27
+ type CreatePageResult = {
28
+ summary: string;
29
+ entryId: string;
30
+ path: string;
31
+ url: string;
32
+ };
27
33
  type ContentBridge = {
28
34
  readonly isEditing: boolean;
29
35
  readonly mode: 'editor' | 'site' | null;
@@ -34,6 +40,7 @@ type ContentBridge = {
34
40
  prompt(prompt: string, options?: {
35
41
  uid?: string | null;
36
42
  }): Promise<PromptResult>;
43
+ createPage(path: string, prompt: string): Promise<CreatePageResult>;
37
44
  undo(): void;
38
45
  save(): Promise<void>;
39
46
  publish(): Promise<void>;
@@ -64,6 +71,13 @@ type PromptLabels = {
64
71
  signOut: string;
65
72
  viewingDrafts: string;
66
73
  selectHint: string;
74
+ newPage: string;
75
+ newPageCancel: string;
76
+ newPagePlaceholder: string;
77
+ path: string;
78
+ pathPlaceholder: string;
79
+ creating: string;
80
+ created: string;
67
81
  };
68
82
  declare const DEFAULT_PROMPT_LABELS: PromptLabels;
69
83
  type PromptBoxOptions = {
@@ -73,10 +87,11 @@ type PromptBoxOptions = {
73
87
  type BridgeOptions = {
74
88
  url?: string | undefined;
75
89
  prompt?: boolean | PromptBoxOptions | undefined;
90
+ pagePath?: ((path: string) => string) | undefined;
76
91
  };
77
92
  declare const peekContentBridge: () => ContentBridge | null;
78
- declare const getContentBridge: ({ url, prompt, }?: BridgeOptions) => ContentBridge;
93
+ declare const getContentBridge: ({ url, prompt, pagePath, }?: BridgeOptions) => ContentBridge;
79
94
 
80
95
  export { DEFAULT_PROMPT_LABELS, INITIAL_STATE, getContentBridge, peekContentBridge };
81
- export type { BridgeEvent, BridgeEvents, BridgeListener, BridgeOptions, BridgeState, ContentBridge, PromptBoxOptions, PromptLabels, PromptResult };
96
+ export type { BridgeEvent, BridgeEvents, BridgeListener, BridgeOptions, BridgeState, ContentBridge, CreatePageResult, PromptBoxOptions, PromptLabels, PromptResult };
82
97
  //# sourceMappingURL=bridge.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"bridge.d.mts","sources":["../src/bridge/createBridge.ts","../src/bridge/promptBox.ts","../src/bridge.ts"],"mappings":";;AAIA,KAAK,YAAY;AACjB;AACA,eAAe,KAAK;AACpB;AACA;AACA,WAAW,MAAM;AACjB,eAAe,MAAM;AACrB,YAAY,WAAW;AACvB;AACA,KAAK,WAAW,SAAS,YAAY;AACrC,KAAK,cAAc,WAAW,WAAW,cAAc,YAAY;AACnE,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,YAAY;AACjB;AACA;AACA,KAAK,aAAa;AAClB;AACA;AACA;AACA;AACA,iBAAiB,WAAW,sBAAsB,cAAc;AAChE,gBAAgB,WAAW;AAC3B;AACA;AACA,QAAQ,OAAO,CAAC,YAAY;AAC5B;AACA,YAAY,OAAO;AACnB,eAAe,OAAO;AACtB;AACA;AACA;AACA;AACA;AAEA,cAAc,aAAa,EAAE,WAAW;;AC9CxC,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,cAAc,qBAAqB,EAAE,YAAY;AACjD,KAAK,gBAAgB;AACrB,aAAa,OAAO,CAAC,YAAY;AACjC;;ACnBA,KAAK,aAAa;AAClB;AACA,uBAAuB,gBAAgB;AACvC;AACA,cAAc,iBAAiB,QAAQ,aAAa;AACpD,cAAc,gBAAgB,sBAAsB,aAAa,KAAK,aAAa","names":[]}
1
+ {"version":3,"file":"bridge.d.mts","sources":["../src/bridge/createBridge.ts","../src/bridge/promptBox.ts","../src/bridge.ts"],"mappings":";;AAIA,KAAK,YAAY;AACjB;AACA,eAAe,KAAK;AACpB;AACA;AACA,WAAW,MAAM;AACjB,eAAe,MAAM;AACrB,YAAY,WAAW;AACvB;AACA,KAAK,WAAW,SAAS,YAAY;AACrC,KAAK,cAAc,WAAW,WAAW,cAAc,YAAY;AACnE,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,YAAY;AACjB;AACA;AACA,KAAK,gBAAgB;AACrB;AACA;AACA;AACA;AACA;AACA,KAAK,aAAa;AAClB;AACA;AACA;AACA;AACA,iBAAiB,WAAW,sBAAsB,cAAc;AAChE,gBAAgB,WAAW;AAC3B;AACA;AACA,QAAQ,OAAO,CAAC,YAAY;AAC5B,8CAA8C,OAAO,CAAC,gBAAgB;AACtE;AACA,YAAY,OAAO;AACnB,eAAe,OAAO;AACtB;AACA;AACA;AACA;AACA;AAEA,cAAc,aAAa,EAAE,WAAW;;ACrDxC,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,cAAc,qBAAqB,EAAE,YAAY;AACjD,KAAK,gBAAgB;AACrB,aAAa,OAAO,CAAC,YAAY;AACjC;;AC1BA,KAAK,aAAa;AAClB;AACA,uBAAuB,gBAAgB;AACvC;AACA;AACA,cAAc,iBAAiB,QAAQ,aAAa;AACpD,cAAc,gBAAgB,gCAAgC,aAAa,KAAK,aAAa","names":[]}
package/dist/bridge.d.ts CHANGED
@@ -24,6 +24,12 @@ type BridgeState = {
24
24
  type PromptResult = {
25
25
  summary: string;
26
26
  };
27
+ type CreatePageResult = {
28
+ summary: string;
29
+ entryId: string;
30
+ path: string;
31
+ url: string;
32
+ };
27
33
  type ContentBridge = {
28
34
  readonly isEditing: boolean;
29
35
  readonly mode: 'editor' | 'site' | null;
@@ -34,6 +40,7 @@ type ContentBridge = {
34
40
  prompt(prompt: string, options?: {
35
41
  uid?: string | null;
36
42
  }): Promise<PromptResult>;
43
+ createPage(path: string, prompt: string): Promise<CreatePageResult>;
37
44
  undo(): void;
38
45
  save(): Promise<void>;
39
46
  publish(): Promise<void>;
@@ -64,6 +71,13 @@ type PromptLabels = {
64
71
  signOut: string;
65
72
  viewingDrafts: string;
66
73
  selectHint: string;
74
+ newPage: string;
75
+ newPageCancel: string;
76
+ newPagePlaceholder: string;
77
+ path: string;
78
+ pathPlaceholder: string;
79
+ creating: string;
80
+ created: string;
67
81
  };
68
82
  declare const DEFAULT_PROMPT_LABELS: PromptLabels;
69
83
  type PromptBoxOptions = {
@@ -73,10 +87,11 @@ type PromptBoxOptions = {
73
87
  type BridgeOptions = {
74
88
  url?: string | undefined;
75
89
  prompt?: boolean | PromptBoxOptions | undefined;
90
+ pagePath?: ((path: string) => string) | undefined;
76
91
  };
77
92
  declare const peekContentBridge: () => ContentBridge | null;
78
- declare const getContentBridge: ({ url, prompt, }?: BridgeOptions) => ContentBridge;
93
+ declare const getContentBridge: ({ url, prompt, pagePath, }?: BridgeOptions) => ContentBridge;
79
94
 
80
95
  export { DEFAULT_PROMPT_LABELS, INITIAL_STATE, getContentBridge, peekContentBridge };
81
- export type { BridgeEvent, BridgeEvents, BridgeListener, BridgeOptions, BridgeState, ContentBridge, PromptBoxOptions, PromptLabels, PromptResult };
96
+ export type { BridgeEvent, BridgeEvents, BridgeListener, BridgeOptions, BridgeState, ContentBridge, CreatePageResult, PromptBoxOptions, PromptLabels, PromptResult };
82
97
  //# sourceMappingURL=bridge.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"bridge.d.ts","sources":["../src/bridge/createBridge.ts","../src/bridge/promptBox.ts","../src/bridge.ts"],"mappings":";;AAIA,KAAK,YAAY;AACjB;AACA,eAAe,KAAK;AACpB;AACA;AACA,WAAW,MAAM;AACjB,eAAe,MAAM;AACrB,YAAY,WAAW;AACvB;AACA,KAAK,WAAW,SAAS,YAAY;AACrC,KAAK,cAAc,WAAW,WAAW,cAAc,YAAY;AACnE,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,YAAY;AACjB;AACA;AACA,KAAK,aAAa;AAClB;AACA;AACA;AACA;AACA,iBAAiB,WAAW,sBAAsB,cAAc;AAChE,gBAAgB,WAAW;AAC3B;AACA;AACA,QAAQ,OAAO,CAAC,YAAY;AAC5B;AACA,YAAY,OAAO;AACnB,eAAe,OAAO;AACtB;AACA;AACA;AACA;AACA;AAEA,cAAc,aAAa,EAAE,WAAW;;AC9CxC,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,cAAc,qBAAqB,EAAE,YAAY;AACjD,KAAK,gBAAgB;AACrB,aAAa,OAAO,CAAC,YAAY;AACjC;;ACnBA,KAAK,aAAa;AAClB;AACA,uBAAuB,gBAAgB;AACvC;AACA,cAAc,iBAAiB,QAAQ,aAAa;AACpD,cAAc,gBAAgB,sBAAsB,aAAa,KAAK,aAAa","names":[]}
1
+ {"version":3,"file":"bridge.d.ts","sources":["../src/bridge/createBridge.ts","../src/bridge/promptBox.ts","../src/bridge.ts"],"mappings":";;AAIA,KAAK,YAAY;AACjB;AACA,eAAe,KAAK;AACpB;AACA;AACA,WAAW,MAAM;AACjB,eAAe,MAAM;AACrB,YAAY,WAAW;AACvB;AACA,KAAK,WAAW,SAAS,YAAY;AACrC,KAAK,cAAc,WAAW,WAAW,cAAc,YAAY;AACnE,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,YAAY;AACjB;AACA;AACA,KAAK,gBAAgB;AACrB;AACA;AACA;AACA;AACA;AACA,KAAK,aAAa;AAClB;AACA;AACA;AACA;AACA,iBAAiB,WAAW,sBAAsB,cAAc;AAChE,gBAAgB,WAAW;AAC3B;AACA;AACA,QAAQ,OAAO,CAAC,YAAY;AAC5B,8CAA8C,OAAO,CAAC,gBAAgB;AACtE;AACA,YAAY,OAAO;AACnB,eAAe,OAAO;AACtB;AACA;AACA;AACA;AACA;AAEA,cAAc,aAAa,EAAE,WAAW;;ACrDxC,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,cAAc,qBAAqB,EAAE,YAAY;AACjD,KAAK,gBAAgB;AACrB,aAAa,OAAO,CAAC,YAAY;AACjC;;AC1BA,KAAK,aAAa;AAClB;AACA,uBAAuB,gBAAgB;AACvC;AACA;AACA,cAAc,iBAAiB,QAAQ,aAAa;AACpD,cAAc,gBAAgB,gCAAgC,aAAa,KAAK,aAAa","names":[]}
package/dist/bridge.js CHANGED
@@ -64,6 +64,7 @@ const onlyOnSite = ()=>Promise.reject(new Error('Save and publish in the visual
64
64
  // What every bridge without a feature does for it.
65
65
  const INERT_METHODS = {
66
66
  prompt: inertPrompt,
67
+ createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
67
68
  undo: ()=>{},
68
69
  save: onlyOnSite,
69
70
  publish: onlyOnSite,
@@ -306,11 +307,19 @@ const DEFAULT_PROMPT_LABELS = {
306
307
  unsaved: 'Unsaved changes',
307
308
  signOut: 'Exit editing',
308
309
  viewingDrafts: 'Viewing drafts',
309
- selectHint: 'Click a block to change only that block'
310
+ selectHint: 'Click a block to change only that block',
311
+ newPage: 'New page',
312
+ newPageCancel: 'Edit this page instead',
313
+ newPagePlaceholder: 'Describe the new page, e.g. “a pricing page for agencies”',
314
+ path: 'Path',
315
+ pathPlaceholder: '/pricing',
316
+ creating: 'Writing the page…',
317
+ created: 'Opening the new page…'
310
318
  };
311
319
  // Icons (Lucide), drawn in the text colour.
312
320
  const ICONS = {
313
321
  page: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
322
+ newPage: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M9 15h6"/><path d="M12 18v-6"/>',
314
323
  block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
315
324
  undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
316
325
  close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
@@ -415,6 +424,20 @@ textarea {
415
424
  font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
416
425
  }
417
426
  textarea::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
427
+ /* New page: where it goes, above what it is for. */
428
+ .path {
429
+ display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
430
+ color: rgb(var(--theme-foreground-muted-rgb));
431
+ }
432
+ .path input {
433
+ flex: 1; min-width: 0; height: 34px; padding: 0 14px; outline: 0;
434
+ border: 1px solid rgb(var(--theme-border-rgb) / .25); border-radius: var(--border-radius-pill);
435
+ background: rgb(var(--theme-background-actions-rgb) / .45); color: rgb(var(--theme-foreground-rgb));
436
+ transition: border-color .25s var(--ease-roll);
437
+ }
438
+ .path input:focus-visible { border-color: rgb(var(--theme-accent-rgb)); }
439
+ .path input::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
440
+ .path input:disabled { opacity: .5; }
418
441
  textarea:disabled { opacity: .5; }
419
442
  .toolbar { display: flex; align-items: center; gap: 6px; padding: 8px 12px 12px; }
420
443
  .spacer { flex: 1; }
@@ -492,12 +515,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
492
515
  mode: 'open'
493
516
  });
494
517
  const site = bridge.mode === 'site';
518
+ const pillText = element(doc, 'span', {
519
+ textContent: labels.open
520
+ });
495
521
  const pill = element(doc, 'button', {
496
522
  className: 'pill',
497
523
  type: 'button'
498
- }, element(doc, 'span', {
499
- textContent: labels.open
500
- }), // The Obelism mark.
524
+ }, pillText, // The Obelism mark.
501
525
  element(doc, 'span', {
502
526
  className: 'mark',
503
527
  textContent: '⁜'
@@ -540,6 +564,34 @@ const mountPromptBox = (doc, bridge, options = {})=>{
540
564
  className: 'action chip',
541
565
  type: 'button'
542
566
  }, icon(doc, 'undo'), labels.undo);
567
+ // New page (on the site, for people who may add pages): the scope becomes
568
+ // the new page, with a way back, and a path field shows above the prompt.
569
+ const newPage = element(doc, 'button', {
570
+ className: 'action chip',
571
+ type: 'button'
572
+ }, icon(doc, 'newPage'), labels.newPage);
573
+ const cancelNew = element(doc, 'button', {
574
+ type: 'button',
575
+ title: labels.newPageCancel
576
+ });
577
+ cancelNew.setAttribute('aria-label', labels.newPageCancel);
578
+ cancelNew.appendChild(icon(doc, 'close'));
579
+ const newScope = element(doc, 'span', {
580
+ className: 'chip scope'
581
+ }, icon(doc, 'newPage'), element(doc, 'span', {
582
+ textContent: labels.newPage
583
+ }), cancelNew);
584
+ const pathInput = element(doc, 'input', {
585
+ type: 'text',
586
+ placeholder: labels.pathPlaceholder,
587
+ autocomplete: 'off',
588
+ spellcheck: false
589
+ });
590
+ const pathRow = element(doc, 'label', {
591
+ className: 'path'
592
+ }, element(doc, 'span', {
593
+ textContent: labels.path
594
+ }), pathInput);
543
595
  const close = element(doc, 'button', {
544
596
  className: 'close icon-button',
545
597
  type: 'button',
@@ -583,9 +635,9 @@ const mountPromptBox = (doc, bridge, options = {})=>{
583
635
  }), signOut, save, publish);
584
636
  const panel = element(doc, 'form', {
585
637
  className: 'panel'
586
- }, status, input, element(doc, 'div', {
638
+ }, status, pathRow, input, element(doc, 'div', {
587
639
  className: 'toolbar'
588
- }, scope, undo, element(doc, 'span', {
640
+ }, scope, newScope, undo, newPage, element(doc, 'span', {
589
641
  className: 'spacer'
590
642
  }), close, element(doc, 'span', {
591
643
  className: 'divider'
@@ -599,6 +651,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
599
651
  textContent: CSS
600
652
  }), root);
601
653
  let open = false;
654
+ let creating = false;
602
655
  let message = null;
603
656
  const render = (state)=>{
604
657
  const available = state.features.includes('prompt');
@@ -608,24 +661,40 @@ const mountPromptBox = (doc, bridge, options = {})=>{
608
661
  pill.hidden = open || !available;
609
662
  viewing.hidden = available || !site;
610
663
  panel.hidden = !open || !available;
664
+ const canCreate = site && state.features.includes('create');
665
+ // A page of the site that isn't an entry (no `editable(entry)`, like a
666
+ // hand-written /about) has nothing to edit: only New page.
667
+ const pageless = site && bridge.entryId === null;
668
+ const onlyNew = pageless && canCreate;
669
+ if (!canCreate) creating = false;
670
+ if (onlyNew) creating = true;
671
+ pillText.textContent = onlyNew ? labels.newPage : labels.open;
672
+ cancelNew.hidden = onlyNew;
673
+ scope.hidden = creating;
674
+ newScope.hidden = !creating;
675
+ newPage.hidden = creating || !canCreate;
676
+ newPage.disabled = busy;
677
+ pathRow.hidden = !creating;
678
+ pathInput.disabled = busy;
679
+ input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
611
680
  scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
612
681
  clearBlock.hidden = !state.selectedUid;
613
682
  pageIcon.hidden = !!state.selectedUid;
614
683
  blockIcon.hidden = !state.selectedUid;
615
- undo.hidden = !state.canUndo;
684
+ undo.hidden = creating || !state.canUndo;
616
685
  undo.disabled = busy;
617
- send.disabled = busy || input.value.trim() === '';
686
+ send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
618
687
  input.disabled = busy;
619
- save.hidden = !state.features.includes('save');
688
+ save.hidden = !state.features.includes('save') || pageless;
620
689
  save.disabled = busy || !state.unsaved;
621
690
  save.textContent = state.saving ? labels.saving : labels.save;
622
- publish.hidden = !state.features.includes('publish');
691
+ publish.hidden = !state.features.includes('publish') || pageless;
623
692
  publish.disabled = busy;
624
693
  publish.textContent = state.publishing ? labels.publishing : labels.publish;
625
694
  signOut.hidden = !site;
626
695
  footer.hidden = !site;
627
696
  unsaved.hidden = !state.unsaved;
628
- const text = state.prompting ? labels.working : message?.text ?? (site && !state.selectedUid ? labels.selectHint : '');
697
+ const text = state.prompting ? creating ? labels.creating : labels.working : message?.text ?? (site && !creating && !state.selectedUid ? labels.selectHint : '');
629
698
  status.textContent = text;
630
699
  status.hidden = !text;
631
700
  status.className = `status${state.prompting ? ' working' : ''}${!busy && message?.error ? ' error' : ''}`;
@@ -640,11 +709,22 @@ const mountPromptBox = (doc, bridge, options = {})=>{
640
709
  if (!prompt || bridge.getState().prompting) return;
641
710
  message = null;
642
711
  try {
643
- const { summary } = await bridge.prompt(prompt);
644
- message = {
645
- text: summary,
646
- error: false
647
- };
712
+ if (creating) {
713
+ const path = pathInput.value.trim();
714
+ if (!path) return pathInput.focus();
715
+ // The bridge opens the new page; this shows until it does.
716
+ await bridge.createPage(path, prompt);
717
+ message = {
718
+ text: labels.created,
719
+ error: false
720
+ };
721
+ } else {
722
+ const { summary } = await bridge.prompt(prompt);
723
+ message = {
724
+ text: summary,
725
+ error: false
726
+ };
727
+ }
648
728
  input.value = '';
649
729
  grow();
650
730
  } catch (error) {
@@ -658,8 +738,16 @@ const mountPromptBox = (doc, bridge, options = {})=>{
658
738
  };
659
739
  const setOpen = (next)=>{
660
740
  open = next;
661
- if (site) bridge.setSelecting(open);
662
741
  refresh();
742
+ if (site) bridge.setSelecting(open && !creating);
743
+ };
744
+ const setCreating = (next)=>{
745
+ creating = next;
746
+ message = null;
747
+ bridge.setSelecting(open && !creating);
748
+ if (creating) bridge.select(null);
749
+ refresh();
750
+ (creating ? pathInput : input).focus();
663
751
  };
664
752
  const store = async (action)=>{
665
753
  message = null;
@@ -679,7 +767,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
679
767
  };
680
768
  pill.addEventListener('click', ()=>{
681
769
  setOpen(true);
682
- input.focus();
770
+ (creating ? pathInput : input).focus();
683
771
  });
684
772
  close.addEventListener('click', ()=>setOpen(false));
685
773
  save.addEventListener('click', ()=>void store('save'));
@@ -687,6 +775,17 @@ const mountPromptBox = (doc, bridge, options = {})=>{
687
775
  signOut.addEventListener('click', ()=>bridge.signOut());
688
776
  pillSignOut.addEventListener('click', ()=>bridge.signOut());
689
777
  clearBlock.addEventListener('click', ()=>bridge.select(null));
778
+ newPage.addEventListener('click', ()=>setCreating(true));
779
+ cancelNew.addEventListener('click', ()=>setCreating(false));
780
+ pathInput.addEventListener('input', refresh);
781
+ pathInput.addEventListener('keydown', (event)=>{
782
+ if (event.key === 'Enter' && !event.isComposing) {
783
+ event.preventDefault();
784
+ input.focus();
785
+ } else if (event.key === 'Escape') {
786
+ setOpen(false);
787
+ }
788
+ });
690
789
  undo.addEventListener('click', ()=>{
691
790
  message = null;
692
791
  bridge.undo();
@@ -740,7 +839,7 @@ const readSiteEditing = (doc)=>{
740
839
  return null;
741
840
  };
742
841
  const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
743
- const createSiteBridge = (win, basePath)=>{
842
+ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
744
843
  const doc = win.document;
745
844
  const emitter = createEmitter();
746
845
  const cleanup = [];
@@ -903,6 +1002,39 @@ const createSiteBridge = (win, basePath)=>{
903
1002
  });
904
1003
  }
905
1004
  },
1005
+ async createPage (path, prompt) {
1006
+ const text = prompt.trim();
1007
+ if (!text) throw new Error('Say what the page is for');
1008
+ if (!path.trim()) throw new Error('Say where the page goes, like /pricing');
1009
+ if (!has('create')) throw new Error('You may not add pages here');
1010
+ // Opening the new page would lose them.
1011
+ if (state.unsaved) {
1012
+ throw new Error('Save or undo your changes before adding a page');
1013
+ }
1014
+ setState({
1015
+ prompting: true
1016
+ });
1017
+ try {
1018
+ const created = await api('entries', {
1019
+ path,
1020
+ prompt: text,
1021
+ locale: locale(),
1022
+ from: pageEntryId()
1023
+ });
1024
+ const url = pagePath(created.path);
1025
+ win.location.assign(url);
1026
+ return {
1027
+ summary: created.summary,
1028
+ entryId: created.entryId,
1029
+ path: created.path,
1030
+ url
1031
+ };
1032
+ } finally{
1033
+ setState({
1034
+ prompting: false
1035
+ });
1036
+ }
1037
+ },
906
1038
  undo () {
907
1039
  const last = history.pop();
908
1040
  const entry = last && working.get(last.entryId);
@@ -1029,7 +1161,7 @@ const peekContentBridge = ()=>typeof window === 'undefined' ? null : window[KEY]
1029
1161
  // if (bridge.isEditing) bridge.on('update', ({ entry }) => render(entry))
1030
1162
  //
1031
1163
  // The first call's options win: there is one bridge per page.
1032
- const getContentBridge = ({ url, prompt = true } = {})=>{
1164
+ const getContentBridge = ({ url, prompt = true, pagePath } = {})=>{
1033
1165
  if (typeof window === 'undefined') return SERVER_BRIDGE;
1034
1166
  const win = window;
1035
1167
  if (win[KEY]) return win[KEY];
@@ -1041,7 +1173,9 @@ const getContentBridge = ({ url, prompt = true } = {})=>{
1041
1173
  // Not in the visual editor, but signed in to edit through the site's
1042
1174
  // own `/cms/login` (@obelism/content/site).
1043
1175
  const sitePath = bridge.isEditing || win.parent !== win ? null : readSiteEditing(win.document);
1044
- if (sitePath) bridge = createSiteBridge(win, sitePath);
1176
+ if (sitePath) bridge = createSiteBridge(win, sitePath, {
1177
+ pagePath
1178
+ });
1045
1179
  if (bridge.isEditing && prompt) {
1046
1180
  const unmount = mountPromptBox(win.document, bridge, prompt === true ? {} : prompt);
1047
1181
  const destroy = bridge.destroy;
package/dist/bridge.mjs CHANGED
@@ -62,6 +62,7 @@ const onlyOnSite = ()=>Promise.reject(new Error('Save and publish in the visual
62
62
  // What every bridge without a feature does for it.
63
63
  const INERT_METHODS = {
64
64
  prompt: inertPrompt,
65
+ createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
65
66
  undo: ()=>{},
66
67
  save: onlyOnSite,
67
68
  publish: onlyOnSite,
@@ -304,11 +305,19 @@ const DEFAULT_PROMPT_LABELS = {
304
305
  unsaved: 'Unsaved changes',
305
306
  signOut: 'Exit editing',
306
307
  viewingDrafts: 'Viewing drafts',
307
- selectHint: 'Click a block to change only that block'
308
+ selectHint: 'Click a block to change only that block',
309
+ newPage: 'New page',
310
+ newPageCancel: 'Edit this page instead',
311
+ newPagePlaceholder: 'Describe the new page, e.g. “a pricing page for agencies”',
312
+ path: 'Path',
313
+ pathPlaceholder: '/pricing',
314
+ creating: 'Writing the page…',
315
+ created: 'Opening the new page…'
308
316
  };
309
317
  // Icons (Lucide), drawn in the text colour.
310
318
  const ICONS = {
311
319
  page: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
320
+ newPage: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M9 15h6"/><path d="M12 18v-6"/>',
312
321
  block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
313
322
  undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
314
323
  close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
@@ -413,6 +422,20 @@ textarea {
413
422
  font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
414
423
  }
415
424
  textarea::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
425
+ /* New page: where it goes, above what it is for. */
426
+ .path {
427
+ display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
428
+ color: rgb(var(--theme-foreground-muted-rgb));
429
+ }
430
+ .path input {
431
+ flex: 1; min-width: 0; height: 34px; padding: 0 14px; outline: 0;
432
+ border: 1px solid rgb(var(--theme-border-rgb) / .25); border-radius: var(--border-radius-pill);
433
+ background: rgb(var(--theme-background-actions-rgb) / .45); color: rgb(var(--theme-foreground-rgb));
434
+ transition: border-color .25s var(--ease-roll);
435
+ }
436
+ .path input:focus-visible { border-color: rgb(var(--theme-accent-rgb)); }
437
+ .path input::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
438
+ .path input:disabled { opacity: .5; }
416
439
  textarea:disabled { opacity: .5; }
417
440
  .toolbar { display: flex; align-items: center; gap: 6px; padding: 8px 12px 12px; }
418
441
  .spacer { flex: 1; }
@@ -490,12 +513,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
490
513
  mode: 'open'
491
514
  });
492
515
  const site = bridge.mode === 'site';
516
+ const pillText = element(doc, 'span', {
517
+ textContent: labels.open
518
+ });
493
519
  const pill = element(doc, 'button', {
494
520
  className: 'pill',
495
521
  type: 'button'
496
- }, element(doc, 'span', {
497
- textContent: labels.open
498
- }), // The Obelism mark.
522
+ }, pillText, // The Obelism mark.
499
523
  element(doc, 'span', {
500
524
  className: 'mark',
501
525
  textContent: '⁜'
@@ -538,6 +562,34 @@ const mountPromptBox = (doc, bridge, options = {})=>{
538
562
  className: 'action chip',
539
563
  type: 'button'
540
564
  }, icon(doc, 'undo'), labels.undo);
565
+ // New page (on the site, for people who may add pages): the scope becomes
566
+ // the new page, with a way back, and a path field shows above the prompt.
567
+ const newPage = element(doc, 'button', {
568
+ className: 'action chip',
569
+ type: 'button'
570
+ }, icon(doc, 'newPage'), labels.newPage);
571
+ const cancelNew = element(doc, 'button', {
572
+ type: 'button',
573
+ title: labels.newPageCancel
574
+ });
575
+ cancelNew.setAttribute('aria-label', labels.newPageCancel);
576
+ cancelNew.appendChild(icon(doc, 'close'));
577
+ const newScope = element(doc, 'span', {
578
+ className: 'chip scope'
579
+ }, icon(doc, 'newPage'), element(doc, 'span', {
580
+ textContent: labels.newPage
581
+ }), cancelNew);
582
+ const pathInput = element(doc, 'input', {
583
+ type: 'text',
584
+ placeholder: labels.pathPlaceholder,
585
+ autocomplete: 'off',
586
+ spellcheck: false
587
+ });
588
+ const pathRow = element(doc, 'label', {
589
+ className: 'path'
590
+ }, element(doc, 'span', {
591
+ textContent: labels.path
592
+ }), pathInput);
541
593
  const close = element(doc, 'button', {
542
594
  className: 'close icon-button',
543
595
  type: 'button',
@@ -581,9 +633,9 @@ const mountPromptBox = (doc, bridge, options = {})=>{
581
633
  }), signOut, save, publish);
582
634
  const panel = element(doc, 'form', {
583
635
  className: 'panel'
584
- }, status, input, element(doc, 'div', {
636
+ }, status, pathRow, input, element(doc, 'div', {
585
637
  className: 'toolbar'
586
- }, scope, undo, element(doc, 'span', {
638
+ }, scope, newScope, undo, newPage, element(doc, 'span', {
587
639
  className: 'spacer'
588
640
  }), close, element(doc, 'span', {
589
641
  className: 'divider'
@@ -597,6 +649,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
597
649
  textContent: CSS
598
650
  }), root);
599
651
  let open = false;
652
+ let creating = false;
600
653
  let message = null;
601
654
  const render = (state)=>{
602
655
  const available = state.features.includes('prompt');
@@ -606,24 +659,40 @@ const mountPromptBox = (doc, bridge, options = {})=>{
606
659
  pill.hidden = open || !available;
607
660
  viewing.hidden = available || !site;
608
661
  panel.hidden = !open || !available;
662
+ const canCreate = site && state.features.includes('create');
663
+ // A page of the site that isn't an entry (no `editable(entry)`, like a
664
+ // hand-written /about) has nothing to edit: only New page.
665
+ const pageless = site && bridge.entryId === null;
666
+ const onlyNew = pageless && canCreate;
667
+ if (!canCreate) creating = false;
668
+ if (onlyNew) creating = true;
669
+ pillText.textContent = onlyNew ? labels.newPage : labels.open;
670
+ cancelNew.hidden = onlyNew;
671
+ scope.hidden = creating;
672
+ newScope.hidden = !creating;
673
+ newPage.hidden = creating || !canCreate;
674
+ newPage.disabled = busy;
675
+ pathRow.hidden = !creating;
676
+ pathInput.disabled = busy;
677
+ input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
609
678
  scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
610
679
  clearBlock.hidden = !state.selectedUid;
611
680
  pageIcon.hidden = !!state.selectedUid;
612
681
  blockIcon.hidden = !state.selectedUid;
613
- undo.hidden = !state.canUndo;
682
+ undo.hidden = creating || !state.canUndo;
614
683
  undo.disabled = busy;
615
- send.disabled = busy || input.value.trim() === '';
684
+ send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
616
685
  input.disabled = busy;
617
- save.hidden = !state.features.includes('save');
686
+ save.hidden = !state.features.includes('save') || pageless;
618
687
  save.disabled = busy || !state.unsaved;
619
688
  save.textContent = state.saving ? labels.saving : labels.save;
620
- publish.hidden = !state.features.includes('publish');
689
+ publish.hidden = !state.features.includes('publish') || pageless;
621
690
  publish.disabled = busy;
622
691
  publish.textContent = state.publishing ? labels.publishing : labels.publish;
623
692
  signOut.hidden = !site;
624
693
  footer.hidden = !site;
625
694
  unsaved.hidden = !state.unsaved;
626
- const text = state.prompting ? labels.working : message?.text ?? (site && !state.selectedUid ? labels.selectHint : '');
695
+ const text = state.prompting ? creating ? labels.creating : labels.working : message?.text ?? (site && !creating && !state.selectedUid ? labels.selectHint : '');
627
696
  status.textContent = text;
628
697
  status.hidden = !text;
629
698
  status.className = `status${state.prompting ? ' working' : ''}${!busy && message?.error ? ' error' : ''}`;
@@ -638,11 +707,22 @@ const mountPromptBox = (doc, bridge, options = {})=>{
638
707
  if (!prompt || bridge.getState().prompting) return;
639
708
  message = null;
640
709
  try {
641
- const { summary } = await bridge.prompt(prompt);
642
- message = {
643
- text: summary,
644
- error: false
645
- };
710
+ if (creating) {
711
+ const path = pathInput.value.trim();
712
+ if (!path) return pathInput.focus();
713
+ // The bridge opens the new page; this shows until it does.
714
+ await bridge.createPage(path, prompt);
715
+ message = {
716
+ text: labels.created,
717
+ error: false
718
+ };
719
+ } else {
720
+ const { summary } = await bridge.prompt(prompt);
721
+ message = {
722
+ text: summary,
723
+ error: false
724
+ };
725
+ }
646
726
  input.value = '';
647
727
  grow();
648
728
  } catch (error) {
@@ -656,8 +736,16 @@ const mountPromptBox = (doc, bridge, options = {})=>{
656
736
  };
657
737
  const setOpen = (next)=>{
658
738
  open = next;
659
- if (site) bridge.setSelecting(open);
660
739
  refresh();
740
+ if (site) bridge.setSelecting(open && !creating);
741
+ };
742
+ const setCreating = (next)=>{
743
+ creating = next;
744
+ message = null;
745
+ bridge.setSelecting(open && !creating);
746
+ if (creating) bridge.select(null);
747
+ refresh();
748
+ (creating ? pathInput : input).focus();
661
749
  };
662
750
  const store = async (action)=>{
663
751
  message = null;
@@ -677,7 +765,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
677
765
  };
678
766
  pill.addEventListener('click', ()=>{
679
767
  setOpen(true);
680
- input.focus();
768
+ (creating ? pathInput : input).focus();
681
769
  });
682
770
  close.addEventListener('click', ()=>setOpen(false));
683
771
  save.addEventListener('click', ()=>void store('save'));
@@ -685,6 +773,17 @@ const mountPromptBox = (doc, bridge, options = {})=>{
685
773
  signOut.addEventListener('click', ()=>bridge.signOut());
686
774
  pillSignOut.addEventListener('click', ()=>bridge.signOut());
687
775
  clearBlock.addEventListener('click', ()=>bridge.select(null));
776
+ newPage.addEventListener('click', ()=>setCreating(true));
777
+ cancelNew.addEventListener('click', ()=>setCreating(false));
778
+ pathInput.addEventListener('input', refresh);
779
+ pathInput.addEventListener('keydown', (event)=>{
780
+ if (event.key === 'Enter' && !event.isComposing) {
781
+ event.preventDefault();
782
+ input.focus();
783
+ } else if (event.key === 'Escape') {
784
+ setOpen(false);
785
+ }
786
+ });
688
787
  undo.addEventListener('click', ()=>{
689
788
  message = null;
690
789
  bridge.undo();
@@ -738,7 +837,7 @@ const readSiteEditing = (doc)=>{
738
837
  return null;
739
838
  };
740
839
  const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
741
- const createSiteBridge = (win, basePath)=>{
840
+ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
742
841
  const doc = win.document;
743
842
  const emitter = createEmitter();
744
843
  const cleanup = [];
@@ -901,6 +1000,39 @@ const createSiteBridge = (win, basePath)=>{
901
1000
  });
902
1001
  }
903
1002
  },
1003
+ async createPage (path, prompt) {
1004
+ const text = prompt.trim();
1005
+ if (!text) throw new Error('Say what the page is for');
1006
+ if (!path.trim()) throw new Error('Say where the page goes, like /pricing');
1007
+ if (!has('create')) throw new Error('You may not add pages here');
1008
+ // Opening the new page would lose them.
1009
+ if (state.unsaved) {
1010
+ throw new Error('Save or undo your changes before adding a page');
1011
+ }
1012
+ setState({
1013
+ prompting: true
1014
+ });
1015
+ try {
1016
+ const created = await api('entries', {
1017
+ path,
1018
+ prompt: text,
1019
+ locale: locale(),
1020
+ from: pageEntryId()
1021
+ });
1022
+ const url = pagePath(created.path);
1023
+ win.location.assign(url);
1024
+ return {
1025
+ summary: created.summary,
1026
+ entryId: created.entryId,
1027
+ path: created.path,
1028
+ url
1029
+ };
1030
+ } finally{
1031
+ setState({
1032
+ prompting: false
1033
+ });
1034
+ }
1035
+ },
904
1036
  undo () {
905
1037
  const last = history.pop();
906
1038
  const entry = last && working.get(last.entryId);
@@ -1027,7 +1159,7 @@ const peekContentBridge = ()=>typeof window === 'undefined' ? null : window[KEY]
1027
1159
  // if (bridge.isEditing) bridge.on('update', ({ entry }) => render(entry))
1028
1160
  //
1029
1161
  // The first call's options win: there is one bridge per page.
1030
- const getContentBridge = ({ url, prompt = true } = {})=>{
1162
+ const getContentBridge = ({ url, prompt = true, pagePath } = {})=>{
1031
1163
  if (typeof window === 'undefined') return SERVER_BRIDGE;
1032
1164
  const win = window;
1033
1165
  if (win[KEY]) return win[KEY];
@@ -1039,7 +1171,9 @@ const getContentBridge = ({ url, prompt = true } = {})=>{
1039
1171
  // Not in the visual editor, but signed in to edit through the site's
1040
1172
  // own `/cms/login` (@obelism/content/site).
1041
1173
  const sitePath = bridge.isEditing || win.parent !== win ? null : readSiteEditing(win.document);
1042
- if (sitePath) bridge = createSiteBridge(win, sitePath);
1174
+ if (sitePath) bridge = createSiteBridge(win, sitePath, {
1175
+ pagePath
1176
+ });
1043
1177
  if (bridge.isEditing && prompt) {
1044
1178
  const unmount = mountPromptBox(win.document, bridge, prompt === true ? {} : prompt);
1045
1179
  const destroy = bridge.destroy;
package/dist/site.js CHANGED
@@ -86,6 +86,10 @@ const PROXY_ROUTES = [
86
86
  method: 'GET',
87
87
  pattern: /^entries\/[\w-]+$/
88
88
  },
89
+ {
90
+ method: 'POST',
91
+ pattern: /^entries$/
92
+ },
89
93
  {
90
94
  method: 'POST',
91
95
  pattern: /^entries\/[\w-]+\/(preview|prompt|save|publish)$/
package/dist/site.mjs CHANGED
@@ -84,6 +84,10 @@ const PROXY_ROUTES = [
84
84
  method: 'GET',
85
85
  pattern: /^entries\/[\w-]+$/
86
86
  },
87
+ {
88
+ method: 'POST',
89
+ pattern: /^entries$/
90
+ },
87
91
  {
88
92
  method: 'POST',
89
93
  pattern: /^entries\/[\w-]+\/(preview|prompt|save|publish)$/
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@obelism/content",
3
3
  "description": "Obelism Content SDK: the delivery API client, the visual editor bridge with prompt editing, editing on your own site, and the content model CLI",
4
- "version": "0.1.1",
4
+ "version": "0.2.0",
5
5
  "keywords": [
6
6
  "cms",
7
7
  "headless-cms",