Skip to content

Commit

Permalink
feat: add remix recipe
Browse files Browse the repository at this point in the history
  • Loading branch information
EarthlingDavey authored Nov 23, 2023
1 parent 3f8ebc0 commit f882878
Show file tree
Hide file tree
Showing 21 changed files with 2,709 additions and 71 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ npx create-puck-app my-app
Available recipes include:

- [**next**](https://github.com/measuredco/puck/tree/main/recipes/next): Next.js 13 app example, using App Router and static page generation
- [**remix**](https://github.com/measuredco/puck/tree/main/recipes/remix): Remix Run v2 app example, using dynamic routes at root-level

## Hire the Puck team

Expand Down
29 changes: 29 additions & 0 deletions packages/create-puck-app/templates/remix/package.json.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "{{appName}}",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "remix build",
"dev": "remix dev --manual",
"start": "remix-serve ./build/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@measured/puck": "{{puckVersion}}",
"@remix-run/css-bundle": "^2.2.0",
"@remix-run/node": "^2.2.0",
"@remix-run/react": "^2.2.0",
"@remix-run/serve": "^2.2.0",
"isbot": "^3.6.8",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^2.2.0",
"@remix-run/eslint-config": "^2.2.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"eslint": "^8.38.0",
"typescript": "^5.1.6"
}
}
4 changes: 4 additions & 0 deletions recipes/remix/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
};
6 changes: 6 additions & 0 deletions recipes/remix/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules

/.cache
/build
/public/build
.env
42 changes: 42 additions & 0 deletions recipes/remix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# `remix` recipe

The `remix` recipe showcases a Remix Run app with Puck, using it to provide an authoring tool for any root-level route in your Remix app.

## Demonstrates

- Remix Run V2 implementation
- JSON database implementation with HTTP API
- Dynamic routes to use puck for any root-level route on the platform
- Option to disable client-side JavaScript for Puck pages

## Usage

Run the generator and enter `next` when prompted

```
npx create-puck-app my-app
```

Start the server

```
yarn dev
```

Navigate to the homepage at https://localhost:3000. To edit the homepage, access the Puck editor at https://localhost:3000/edit.

You can do this for any **base** route on the application, **even if the page doesn't exist**. For example, visit https://localhost:3000/hello-world and you'll receive a 404. You can author and publish a page by visiting https://localhost:3000/hello-world/edit. After publishing, go back to the original URL to see your page.

## Using this recipe

To adopt this recipe you will need to:

- **IMPORTANT** Add authentication to `/edit` routes. This can be done by modifying the example routes `/app/routes/_index.tsx` and `/app/routes/edit.tsx` or the example model in `/app/models/page.server.ts`. **If you don't do this, Puck will be completely public.**
- Integrate your database into the API calls in `/app/models/page.server.ts`
- Implement a custom puck configuration in `puck.config.tsx`

By default, this recipe will have JavaScript enable on all routes - like a usual react app. If you know that your Puck content doesn't need react, then you can disable JS uncommenting the relevant code in `/app/root.tsx` and the example route `/app/routes/_index.tsx`. Check the network tab for no JS downloads, and verify that the page still works.

## License

MIT © [Measured Co.](https://github.com/measuredco)
18 changes: 18 additions & 0 deletions recipes/remix/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.client
*/

import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";

startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});
137 changes: 137 additions & 0 deletions recipes/remix/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.server
*/

import { PassThrough } from "node:stream";

import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";

const ABORT_DELAY = 5_000;

export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
loadContext: AppLoadContext
) {
return isbot(request.headers.get("user-agent"))
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}

function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);

responseHeaders.set("Content-Type", "text/html");

resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);

pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);

setTimeout(abort, ABORT_DELAY);
});
}

function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);

responseHeaders.set("Content-Type", "text/html");

resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);

pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);

setTimeout(abort, ABORT_DELAY);
});
}
27 changes: 27 additions & 0 deletions recipes/remix/app/models/page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Data } from "@measured/puck";
import fs from "fs";

// Replace with call to your database
export const getPage = (path: string) => {
const allData: Record<string, Data> | null = fs.existsSync("database.json")
? JSON.parse(fs.readFileSync("database.json", "utf-8"))
: null;

return allData ? allData[path] : null;
};

// Replace with call to your database
export const setPage = (path: string, data: Data) => {
const existingData = JSON.parse(
fs.existsSync("database.json")
? fs.readFileSync("database.json", "utf-8")
: "{}"
);

const updatedData = {
...existingData,
[path]: data,
};

fs.writeFileSync("database.json", JSON.stringify(updatedData));
};
43 changes: 43 additions & 0 deletions recipes/remix/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
// useMatches,
} from "@remix-run/react";

export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];

export default function App() {
/**
* Disable client-side JS - Optional
* @see https://remix.run/docs/en/main/guides/disabling-javascript
*/
// const matches = useMatches();
// const includeScripts = matches.some((match) => match.handle?.hydrate);

return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
{/* Conditionally render scripts - Optional */}
{/* {includeScripts ? <Scripts /> : null} */}
<Scripts />
<LiveReload />
</body>
</html>
);
}
2 changes: 2 additions & 0 deletions recipes/remix/app/routes/$puckPath.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from "./_index";
export * from "./_index";
5 changes: 5 additions & 0 deletions recipes/remix/app/routes/$puckPath_.edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { default } from "./edit";
// I think a bug in remix means loader needs to be explicitly exported here
export { action, loader } from "./edit";
// For meta and links etc.
export * from "./edit";
49 changes: 49 additions & 0 deletions recipes/remix/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Render, type Config } from "@measured/puck";
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import puckConfig from "../../puck.config";
import { getPage } from "~/models/page.server";

/**
* Disable client-side JS - Optional
* If you know that your Puck content doesn't need react.
* Then you can disable JS for this route.
* @see https://remix.run/docs/en/main/guides/disabling-javascript
*/

// export const handle = { hydrate: false };

export const loader = async ({ params }: LoaderFunctionArgs) => {
// Get path, and default to slash for root path.
const puckPath = params.puckPath || "/";
// Get puckData for this path, this could be a database call.
const puckData = getPage(puckPath);
if (!puckData) {
throw new Response(null, {
status: 404,
statusText: "Not Found",
});
}
// Return the data.
return json({ puckData });
};

export const meta: MetaFunction<typeof loader> = ({ data }) => {
const title = data?.puckData?.root?.title || "Page";

return [{ title }];
};

export default function Page() {
const { puckData } = useLoaderData<typeof loader>();

/**
* TypeStript error
* Type 'Config<Props>' is not assignable to type 'Config'. Use 'as Config' for now.
* @see https://github.com/measuredco/puck/issues/185
*/

return <Render config={puckConfig as Config} data={puckData} />;
}
Loading

1 comment on commit f882878

@vercel
Copy link

@vercel vercel bot commented on f882878 Nov 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.