Chapter 1: Okay, chapter here we go!?

Chapter 1: Okay, chapter here we go!?



# Ghost Navigation Dropdowns & Auto-Add Plan


## Summary


Three interconnected features: (1) a JS snippet users inject into Ghost to turn `[subitem]` labels into dropdown menus, (2) automatic addition of new books/series to Ghost navigation as subitems under their schema group, and (3) a setting per schema controlling whether items auto-add to nav.


---


## Part 1: Dropdown JS Snippet + Setup Step 5


Ghost's navigation is a flat list of `{ label, url }` items. It has no native dropdown support. The workaround: use a `[subitem]` prefix convention in nav labels, and inject a JS snippet into Ghost's Code Injection that restructures the DOM into real dropdown menus.


### The JS snippet (to be copy-pasted into Ghost → Settings → Code Injection → Site Header)


```javascript

<script>

document.addEventListener('DOMContentLoaded', function() {

document.querySelectorAll('.gh-head-menu .nav').forEach(function(nav) {

var items = Array.from(nav.children);

var i = 0;

while (i < items.length) {

var item = items[i];

var link = item.querySelector('a');

if (!link) { i++; continue; }

// Collect subsequent [subitem] entries

var children = [];

var j = i + 1;

while (j < items.length) {

var childLink = items[j].querySelector('a');

if (!childLink || !childLink.textContent.trim().startsWith('[subitem]')) break;

childLink.textContent = childLink.textContent.replace(/^\[subitem\]\s*/, '');

children.push(items[j]);

j++;

}

if (children.length > 0) {

var wrapper = document.createElement('li');

wrapper.className = 'nav-dropdown';

wrapper.style.position = 'relative';

var trigger = item.cloneNode(true);

trigger.querySelector('a').style.cursor = 'pointer';

wrapper.appendChild(trigger);

var submenu = document.createElement('ul');

submenu.className = 'nav-dropdown-menu';

submenu.style.cssText = 'display:none;position:absolute;top:100%;left:0;background:var(--ghost-accent-color,#fff);border:1px solid rgba(0,0,0,0.1);border-radius:4px;min-width:180px;padding:4px 0;z-index:999;box-shadow:0 4px 12px rgba(0,0,0,0.15);';

children.forEach(function(c) {

c.querySelector('a').style.cssText = 'display:block;padding:6px 16px;white-space:nowrap;';

submenu.appendChild(c);

});

wrapper.appendChild(submenu);

wrapper.addEventListener('mouseenter', function() { submenu.style.display = 'block'; });

wrapper.addEventListener('mouseleave', function() { submenu.style.display = 'none'; });

item.replaceWith(wrapper);

i = j;

} else {

i++;

}

}

});

});

</script>

```


### Step 5 in Setup Guide (`TabSettingsDialog.tsx`)


Add a new collapsible step after Step 4, labeled **"Navigation Dropdowns (Optional)"**:

- Explanation: Ghost doesn't support dropdown menus natively. This snippet reads `[subitem]` prefixed labels and converts them into hover-dropdown menus.

- Instructions:

1. Open Ghost Admin → **Settings** → **Code Injection**

2. Paste the code into **Site Header**

3. Save

- "Copy Dropdown Code" button

- Note: "Items labeled `[subitem] My Book` will appear as children of the preceding nav item."


---


## Part 2: Auto-Add to Ghost Navigation on Creation


When a book or series (or any schema with nav enabled) is created via `UniversalCreationModal`, automatically:


1. Fetch current Ghost navigation via `fetchGhostNavigation()`

2. Find the parent group label (e.g., "Books" or "Series") in the nav list

3. Insert a new `[subitem]` entry immediately after the group label (and any existing subitems)

4. Call `updateGhostNavigation()` to push the change


### Logic in `ghostIntegration.ts` — new helper:


```typescript

export async function addNavSubitem(

groupLabel: string, // e.g. "Books"

itemLabel: string, // e.g. "The Beginning"

itemUrl: string, // e.g. "/library/the-beginning/"

supabaseUrl: string,

serviceRoleKey: string,

): Promise<void> {

const { navigation, secondary_navigation } = await fetchGhostNavigation(supabaseUrl, serviceRoleKey);

// Find group in primary nav

const groupIdx = navigation.findIndex(n => n.label.toLowerCase() === groupLabel.toLowerCase());

if (groupIdx === -1) {

// Group doesn't exist — create it with # url, then add subitem

navigation.push({ label: groupLabel, url: "#" });

navigation.push({ label: `[subitem] ${itemLabel}`, url: itemUrl });

} else {

// Find insertion point: after group + existing subitems

let insertIdx = groupIdx + 1;

while (insertIdx < navigation.length && navigation[insertIdx].label.startsWith("[subitem]")) {

insertIdx++;

}

navigation.splice(insertIdx, 0, { label: `[subitem] ${itemLabel}`, url: itemUrl });

}

await updateGhostNavigation(navigation, secondary_navigation, supabaseUrl, serviceRoleKey);

}

```


### Wire into `UniversalCreationModal.executeCreationFormula()`


After the YAML route is created (step 6 of the formula), if the schema has `addToNav: true`, call `addNavSubitem()`. The group label comes from the schema type name (e.g., "Books", "Series").


If creating both a series AND a book simultaneously (recursive creation), batch the nav updates: fetch once, insert both subitems, save once.


---


## Part 3: Schema Nav Settings


### New setting per schema type


Add to `GhostSpecialType` interface in `ghostRoutesStorage.ts`:


```typescript

addToNav?: boolean; // whether items of this type auto-add to Ghost navigation

navGroupLabel?: string; // custom group label (defaults to type name plural)

```


### Default values

- **Series**: `addToNav: true`, `navGroupLabel: "Series"`

- **Book**: `addToNav: true`, `navGroupLabel: "Books"`

- **Custom types**: `addToNav: false` by default (user can enable)


### UI in Settings Cog (`TabSettingsDialog.tsx`)


Add a new section inside the setup guide or as a separate collapsible panel: **"Navigation Auto-Add Settings"**


For each schema type (built-in + custom):

- Toggle: "Auto-add to navigation"

- Text input: "Nav group label" (e.g., "Books")

- Description: "When enabled, new items of this type will be added as dropdown subitems under the group label in Ghost's primary navigation."


### Storage


These settings are part of the `GhostSpecialType` object, already stored in `freeday-ghost-special-types`. Built-in types (Book, Series) need their defaults set in the `BUILTIN_BOOK_TYPE` and `BUILTIN_SERIES_TYPE` constants.


---


## Files Changed


| File | Changes |

|------|---------|

| `src/components/dialogs/TabSettingsDialog.tsx` | Add Step 5 collapsible with dropdown JS snippet, copy button, and Ghost Code Injection instructions. Add "Navigation Auto-Add Settings" section with per-schema toggles. |

| `src/lib/ghostIntegration.ts` | Add `addNavSubitem()` and `addNavSubitems()` (batch) helpers. |

| `src/lib/ghostRoutesStorage.ts` | Add `addToNav` and `navGroupLabel` fields to `GhostSpecialType`. Set defaults on built-in types. |

| `src/components/edits/UniversalCreationModal.tsx` | Call `addNavSubitem()` after creation when schema has `addToNav: true`. |

| `src/components/tabs/Edits.tsx` | Update `GhostNavigationManager` to visually distinguish `[subitem]` entries (indented, with a badge). |


## Implementation Order


1. Add `addToNav`/`navGroupLabel` to `GhostSpecialType` interface and built-in defaults

2. Add `addNavSubitem()`/`addNavSubitems()` to `ghostIntegration.ts`

3. Wire auto-nav into `UniversalCreationModal`'s creation formula

4. Add Step 5 (dropdown JS snippet) to setup guide

5. Add per-schema nav toggle to settings

6. Update `GhostNavigationManager` to show `[subitem]` entries with indent styling




Also, the current error for updating the navigation to website is: Save failed: Ghost API error (403): {"errors":[{"message":"API tokens do not have permission to access this endpoint","context":null,"type":"NoPermissionError","details":null,"property":null,"help":null,"code":null,"id":"c84fbc20-33b2-11f1-9a16-d9aceaa8f61a","ghostErrorCode":null}]}

This is a permissions issue with the type of API key you're using. Ghost has two types of API keys and they have very different access levels:


The Problem

Navigation is a write operation (it's part of Ghost's settings), so it requires an Admin API key. You're almost certainly hitting this endpoint with a Content API key.


How to fix it

1. Get an Admin API key

In your Ghost Admin panel:

  • Go to Settings → Integrations

  • Either click an existing custom integration, or create a new one

  • Copy the Admin API Key (it looks like id:secret — two parts separated by a colon)

2. Admin API auth is different

This is the tricky part — the Admin API doesn't just take the key as a header directly. It requires a JWT token generated from the key. The format is:

js

// Split the Admin API key

const [id, secret] = adminApiKey.split(':');


// Create a JWT

const token = jwt.sign({}, Buffer.from(secret, 'hex'), {

keyid: id,

algorithm: 'HS256',

expiresIn: '5m',

audience: '/admin/'

});


// Use it in your request

headers: {

'Authorization': `Ghost ${token}`

}

3. The navigation endpoint

Make sure you're hitting the right endpoint too:

PUT https://your-site.com/ghost/api/admin/settings/

With a body like:

json

{

"settings": [

{ "key": "navigation", "value": "[{\"label\":\"Home\",\"url\":\"/\"}]" }

]

}

Note that value is a JSON string (stringified), not a raw array.


Quick checklist

  • Are you using an Admin API key (not Content API)?

  • Is your proxy signing a JWT from it, not passing the key raw?

  • Is the value field stringified JSON?

If you share how your proxy function is currently building the auth header, I can pinpoint exactly what needs to change.



ALSO, please check the old way of attempting to upload routes. See if we tried uploading a full document, which I don’ think we did before? Try this way…

Got it! Yes, Ghost's routes.yaml requires a multipart file upload — you can't just POST JSON. Here's exactly how to do it:


How Ghost expects it

The endpoint is:

POST https://your-site.com/ghost/api/admin/routes/upload/

And it expects multipart/form-data with the YAML as an actual file in a field named routes.


Step by Step

1. In your PWA — build the request

js

async function saveRoutes(yamlString) {

// Convert the YAML string into a Blob (fake file)

const blob = new Blob([yamlString], { type: 'application/x-yaml' });

// Put it in a FormData object with the field name 'routes'

const formData = new FormData();

formData.append('routes', blob, 'routes.yaml');


// Send to YOUR proxy function

const response = await fetch('https://your-project.supabase.co/functions/v1/ghost-proxy', {

method: 'POST',

headers: {

'x-action': 'upload-routes' // so your proxy knows what to do

},

body: formData

// Do NOT set Content-Type manually — browser sets it with boundary automatically

});


const result = await response.json();

if (!result.ok) throw new Error(result.error);

return result;

}


2. In your Supabase Edge Function — forward it to Ghost

js

import { create } from "https://deno.land/x/djwt/mod.ts";


// Generate Ghost Admin JWT

async function getGhostToken(adminApiKey: string) {

const [id, secret] = adminApiKey.split(':');

const secretBytes = hexToBytes(secret);

const token = await create(

{ alg: "HS256", kid: id },

{ exp: Math.floor(Date.now() / 1000) + 300, aud: '/admin/' },

secretBytes

);

return token;

}


Deno.serve(async (req) => {

const action = req.headers.get('x-action');


if (action === 'upload-routes') {

// Get the formData from the PWA request

const formData = await req.formData();

const routesFile = formData.get('routes'); // this is a File object


// Build a new FormData to send to Ghost

const ghostFormData = new FormData();

ghostFormData.append('routes', routesFile, 'routes.yaml');


// Get your JWT

const token = await getGhostToken(Deno.env.get('GHOST_ADMIN_API_KEY'));


// POST to Ghost

const ghostRes = await fetch(`${Deno.env.get('GHOST_URL')}/ghost/api/admin/routes/upload/`, {

method: 'POST',

headers: {

'Authorization': `Ghost ${token}`

// Again, do NOT set Content-Type manually

},

body: ghostFormData

});


const data = await ghostRes.json();

return new Response(JSON.stringify(data), { status: ghostRes.status });

}

});


The key gotchas

  • Never manually set Content-Type: multipart/form-data — both the browser and Deno's fetch need to set it automatically so the boundary string gets included. If you set it manually you'll get a malformed request.

  • The FormData field name must be exactly routes — Ghost will reject it otherwise.

  • You need the Admin API key + JWT (same as the navigation issue) — not the Content key.

  • Ghost returns an empty 200 on success, not a JSON body, so don't expect data back.


Quick checklist

  • Field name is routes

  • Content-Type header is NOT manually set anywhere

  • Using Admin API key with JWT auth

  • Proxy is forwarding the file as FormData, not converting to JSON

Want me to look at your current proxy code and adapt it directly?



In the recently done step of modal creation, the series created and published TOC, but not cover.


Christopher X Sullivan © . All rights reserved.