Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

# production
/build
/dist

# misc
.DS_Store
Expand All @@ -36,4 +37,7 @@ yarn-error.log*
next-env.d.ts

# Database
local.db
local.db
.dev.vars
.env
.staging.vars
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
{
"cSpell.words": [
"hyperdrive"
]
}
1 change: 1 addition & 0 deletions .wrangler/deploy/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"configPath":"../../dist/server/wrangler.json","auxiliaryWorkers":[]}
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ You will need to fill in the following information:

Variable | Comments
--- | ---
`BASE_URL` | **OPTIONAL**. If you're deploying to Vercel or working locally, you won't need that. If you're deploying elsewhere, you'll need to specify the base URL for the app (e.g. `https://mycustomdomain.com`).
`DATABASE_URL` | The database URL, including your credentials (e.g. `postgresql://user:password@example.com:6543`). If you're using [Supabase](https://supabase.com), use the "Transaction pooler" url.
`BASE_URL` | Recommended outside Vercel. Set it to the public URL for the app (e.g. `https://cms.example.com`). For Cloudflare Workers, set this explicitly.
`DATABASE_URL` | The direct PostgreSQL URL used for local development and Drizzle migrations (e.g. `postgresql://user:password@example.com:6543`). If you're using [Supabase](https://supabase.com), use the direct connection string or local pooler string for migrations. Cloudflare runtime traffic should go through Hyperdrive instead of using this directly.
`CRYPTO_KEY` | Used to encrypt/decrypt GitHub tokens in the database. On MacOS/Linux*, you can use `openssl rand -base64 32`.
`GITHUB_APP_ID` | GitHub App ID from your GitHub App details page.
`GITHUB_APP_NAME` | Machine name for your GitHub App (e.g. `pages-cms`), should be the slug the URL of your GitHub App details page.
Expand All @@ -104,9 +104,31 @@ Variable | Comments
We assume you've already created the GitHub App and have a running tunnel for the GitHub App Webhook (using [ngrok](https://ngrok.com/) for example):

1. **Install the dependencies**: `npm install`
2. **Update your environment variables**: copy `.env.example` to `.env` and fill in the values according to your setting (see section above).
3. **Create the database**: `npm run db:migrate`
4. **Run it**: `npm run dev`
2. **Start local PostgreSQL**: `npm run db:docker:up`
3. **Update your environment variables**: create a local `.env` file and set `DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/pages_cms` plus the rest of the required values.
4. **Create the database**: `npm run db:migrate`
5. **Run it**: `npm run dev`

The default local Hyperdrive target in `wrangler.jsonc` also points to `postgresql://postgres:postgres@127.0.0.1:5432/pages_cms`, so local Worker development and Drizzle migrations use the same Docker database.

### Deploy on Cloudflare Workers + Supabase Postgres

1. **Create a PostgreSQL database**: Supabase is a good fit for this project.
2. **Keep `DATABASE_URL` for local development and migrations**: `npm run db:migrate` still runs outside Cloudflare.
3. **Create Hyperdrive bindings**: create one per environment and attach them in `wrangler.jsonc` under `env.staging.hyperdrive` and `env.production.hyperdrive`.
4. **Set Cloudflare secrets**: at minimum `BASE_URL`, `CRYPTO_KEY`, `GITHUB_APP_ID`, `GITHUB_APP_NAME`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_WEBHOOK_SECRET`, `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `RESEND_API_KEY`, `RESEND_FROM_EMAIL`, and `CRON_SECRET`.
5. **Generate Worker types**: run `npm run cf:typegen` after updating `wrangler.jsonc`.
6. **Deploy**: use `npm run deploy:staging` or `npm run deploy`.
7. **Point your custom domain**: add a `routes` entry in `wrangler.jsonc` or configure the route in the Cloudflare dashboard, then set `BASE_URL` to that hostname.

Example Hyperdrive commands:

```bash
npx wrangler hyperdrive create pages-cms-staging --connection-string="postgresql://..."
npx wrangler hyperdrive create pages-cms-production --connection-string="postgresql://..."
```

For local Worker development, `env.staging.hyperdrive[0].localConnectionString` is set to `postgresql://postgres:postgres@127.0.0.1:5432/pages_cms`. Start Docker with `npm run db:docker:up`, then use `vinext dev` or `wrangler dev --env staging`.

### Deploy on Vercel

Expand All @@ -125,4 +147,4 @@ There are [plenty of other options](https://nextjs.org/docs/app/building-your-ap

## License

Everything in this repo is released under the [MIT License](LICENSE).
Everything in this repo is released under the [MIT License](LICENSE).
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";

import { useMemo } from "react";
import { useParams } from "next/navigation";
import { useConfig } from "@/contexts/config-context";
import { getSchemaByName } from "@/lib/schema";
import { EntryEditor } from "@/components/entry/entry-editor";

export default function Page() {
const { config } = useConfig();
if (!config) throw new Error(`Configuration not found.`);

const params = useParams<{ name?: string; path?: string | string[] }>();
const name = decodeURIComponent(params.name || "");
const path = Array.isArray(params.path)
? params.path.map(segment => decodeURIComponent(segment)).join("/")
: decodeURIComponent(params.path || "");

const schema = useMemo(() => getSchemaByName(config.object, name), [config, name]);
if (!schema) throw new Error(`Schema not found for ${name}.`);

return (
<EntryEditor name={name} path={path}/>
);
}

This file was deleted.

20 changes: 6 additions & 14 deletions app/(main)/[owner]/[repo]/[branch]/collection/[name]/new/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
"use client";

import { useSearchParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { EntryEditor } from "@/components/entry/entry-editor";

export default function Page({
params
}: {
params: {
owner: string;
repo: string;
branch: string;
name: string;
path: string;
}
}) {
export default function Page() {
const searchParams = useSearchParams();
const parent = searchParams.get("parent") || undefined;
const params = useParams<{ name?: string }>();
const name = decodeURIComponent(params.name || "");

return (
<EntryEditor name={decodeURIComponent(params.name)} title="Create a new entry" parent={parent}/>
<EntryEditor name={name} title="Create a new entry" parent={parent}/>
);
}
}
18 changes: 5 additions & 13 deletions app/(main)/[owner]/[repo]/[branch]/collection/[name]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
"use client";

import { useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useConfig } from "@/contexts/config-context";
import { getSchemaByName } from "@/lib/schema";
import { CollectionView } from "@/components/collection/collection-view";

export default function Page({
params
}: {
params: {
owner: string;
repo: string;
branch: string;
name: string
}
}) {
export default function Page() {
const { config } = useConfig();
if (!config) throw new Error(`Configuration not found.`);

const name = decodeURIComponent(params.name);
const params = useParams<{ name?: string }>();
const name = decodeURIComponent(params.name || "");
const schema = useMemo(() => getSchemaByName(config?.object, name), [config, name]);
if (!schema) throw new Error(`Schema not found for ${name}.`);

Expand All @@ -36,4 +28,4 @@ export default function Page({
</div>
</div>
);
}
}
23 changes: 9 additions & 14 deletions app/(main)/[owner]/[repo]/[branch]/file/[name]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,22 @@
"use client";

import { useMemo } from "react";
import { useParams } from "next/navigation";
import { useConfig } from "@/contexts/config-context";
import { EntryEditor } from "@/components/entry/entry-editor";
import { getSchemaByName } from "@/lib/schema";

export default function Page({
params
}: {
params: {
owner: string;
repo: string;
branch: string;
name: string;
}
}) {
export default function Page() {
const { config } = useConfig();
if (!config) throw new Error(`Configuration not found.`);

const params = useParams<{ name?: string }>();
const name = decodeURIComponent(params.name || "");

const schema = useMemo(() => getSchemaByName(config?.object, decodeURIComponent(params.name)), [config, params.name]);
if (!schema) throw new Error(`Schema not found for ${decodeURIComponent(params.name)}.`);
const schema = useMemo(() => getSchemaByName(config?.object, name), [config, name]);
if (!schema) throw new Error(`Schema not found for ${name}.`);

return (
<EntryEditor name={params.name} path={schema.path} title={schema.label || schema.name}/>
<EntryEditor name={name} path={schema.path} title={schema.label || schema.name}/>
);
}
}
15 changes: 5 additions & 10 deletions app/(main)/[owner]/[repo]/[branch]/media/[name]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
"use client";

import { useSearchParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useConfig } from "@/contexts/config-context";
import { MediaView} from "@/components/media/media-view";

export default function Page({
params
}: {
params: {
name: string;
}
}) {
export default function Page() {
const searchParams = useSearchParams();
const path = searchParams.get("path") || "";
const params = useParams<{ name?: string }>();

const { config } = useConfig();
if (!config) throw new Error(`Configuration not found.`);
Expand All @@ -23,8 +18,8 @@ export default function Page({
<h1 className="font-semibold text-lg md:text-2xl">Media</h1>
</header>
<div className="flex flex-col relative flex-1">
<MediaView initialPath={path} media={params.name} />
<MediaView initialPath={path} media={params.name || ""} />
</div>
</div>
);
}
}
4 changes: 2 additions & 2 deletions app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function GET(
const schema = getSchemaByName(config.object, params.name);
if (!schema) throw new Error(`Schema not found for ${params.name}.`);

const searchParams = request.nextUrl.searchParams;
const searchParams = new URL(request.url).searchParams;
const path = searchParams.get("path") || "";
const type = searchParams.get("type");
const query = searchParams.get("query") || "";
Expand Down Expand Up @@ -215,4 +215,4 @@ const parseContents = (
contents: parsedContents,
errors: parsedErrors
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import { getToken } from "@/lib/token";
/**
* Fetches the history of a file from GitHub repositories.
*
* GET /api/[owner]/[repo]/[branch]/entries/[path]/history
* GET /api/[owner]/[repo]/[branch]/entries/[...path]/history
*
* Requires authentication.
*/

export async function GET(
request: NextRequest,
{ params }: { params: { owner: string, repo: string, branch: string, path: string } }
{ params }: { params: { owner: string, repo: string, branch: string, path: string[] } }
) {
try {
const { user, session } = await getAuth();
Expand All @@ -25,10 +25,10 @@ export async function GET(
const token = await getToken(user, params.owner, params.repo);
if (!token) throw new Error("Token not found");

const searchParams = request.nextUrl.searchParams;
const searchParams = new URL(request.url).searchParams;
const name = searchParams.get("name") || "";

const normalizedPath = normalizePath(params.path);
const normalizedPath = normalizePath(params.path.join("/"));

if (name) {
const config = await getConfig(params.owner, params.repo, params.branch);
Expand All @@ -37,7 +37,7 @@ export async function GET(
const schema = getSchemaByName(config.object, name);
if (!schema) throw new Error(`Schema not found for ${name}.`);

if (!normalizedPath.startsWith(schema.path)) throw new Error(`Invalid path "${params.path}" for ${schema.type} "${name}".`);
if (!normalizedPath.startsWith(schema.path)) throw new Error(`Invalid path "${normalizedPath}" for ${schema.type} "${name}".`);

if (getFileExtension(normalizedPath) !== schema.extension) throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for ${schema.type} "${name}".`);
} else if (normalizedPath !== ".pages.yml") {
Expand All @@ -63,4 +63,4 @@ export async function GET(
message: error.message,
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import { getToken } from "@/lib/token";
* Fetches and parses individual file contents from GitHub repositories
* (usually for editing).
*
* GET /api/[owner]/[repo]/[branch]/entries/[path]?name=[schemaName]
* GET /api/[owner]/[repo]/[branch]/entries/[...path]?name=[schemaName]
*
* Requires authentication. If no schema name is provided, we return the raw
* contents.
*/

export async function GET(
request: NextRequest,
{ params }: { params: { owner: string, repo: string, branch: string, path: string } }
{ params }: { params: { owner: string, repo: string, branch: string, path: string[] } }
) {
try {
const { user, session } = await getAuth();
Expand All @@ -29,10 +29,10 @@ export async function GET(
const token = await getToken(user, params.owner, params.repo);
if (!token) throw new Error("Token not found");

const searchParams = request.nextUrl.searchParams;
const searchParams = new URL(request.url).searchParams;
const name = searchParams.get("name");

const normalizedPath = normalizePath(params.path);
const normalizedPath = normalizePath(params.path.join("/"));

if (!name && normalizedPath !== ".pages.yml") throw new Error("If no content entry name is provided, the path must be \".pages.yml\".");

Expand All @@ -46,7 +46,7 @@ export async function GET(
schema = getSchemaByName(config.object, name);
if (!schema) throw new Error(`Schema not found for ${name}.`);

if (!normalizedPath.startsWith(schema.path)) throw new Error(`Invalid path "${params.path}" for ${schema.type} "${name}".`);
if (!normalizedPath.startsWith(schema.path)) throw new Error(`Invalid path "${normalizedPath}" for ${schema.type} "${name}".`);

if (getFileExtension(normalizedPath) !== schema.extension) throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for ${schema.type} "${name}".`);
} else {
Expand Down Expand Up @@ -139,4 +139,4 @@ const parseContent = (
}

return contentObject;
};
};
Loading