@viglet/create-shio-app 2026.3.2
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/LICENSE +201 -0
- package/README.md +98 -0
- package/package.json +59 -0
- package/src/emit-guard.mjs +98 -0
- package/src/emit.mjs +292 -0
- package/src/index.mjs +88 -0
- package/src/published.mjs +67 -0
- package/src/scaffold.mjs +185 -0
- package/template/.env.example +15 -0
- package/template/README.md +108 -0
- package/template/app/[...slug]/page.tsx +110 -0
- package/template/app/api/preview/route.ts +65 -0
- package/template/app/api/revalidate/route.ts +96 -0
- package/template/app/layout.tsx +25 -0
- package/template/app/page.tsx +72 -0
- package/template/gitignore +26 -0
- package/template/lib/shio.ts +70 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.mjs +12 -0
- package/template/package.json +25 -0
- package/template/tsconfig.json +21 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
A [Next.js](https://nextjs.org) (App Router) site powered by **Shio CMS** via the
|
|
4
|
+
Content Delivery API — headless, TypeScript-first, **zero Java**.
|
|
5
|
+
|
|
6
|
+
## Getting started
|
|
7
|
+
|
|
8
|
+
1. Set your connection details in `.env.local`:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
SHIO_URL=https://your-shio-instance
|
|
12
|
+
SHIO_SITE_ID=<your site id>
|
|
13
|
+
SHIO_CDA_TOKEN=<your CDA token>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
2. Run the dev server:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm run dev
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
3. Open [http://localhost:3000](http://localhost:3000).
|
|
23
|
+
|
|
24
|
+
## How it works
|
|
25
|
+
|
|
26
|
+
- [`lib/shio.ts`](lib/shio.ts) creates a shared [`@viglet/shio-client`](https://www.npmjs.com/package/@viglet/shio-client) instance from the environment.
|
|
27
|
+
- [`app/page.tsx`](app/page.tsx) lists the site's published posts (`shio.query`).
|
|
28
|
+
- [`app/[...slug]/page.tsx`](app/[...slug]/page.tsx) resolves any path to a post via `shio.getPostByUrl(siteId, "/the/path")` — friendly-URL routing straight from the CDA, with `generateStaticParams` (SSG) + `revalidate` (ISR).
|
|
29
|
+
|
|
30
|
+
All content comes from the Shio CDA (`/api/v2/cda/**`). You never touch the Java backend — just edit React/TypeScript.
|
|
31
|
+
|
|
32
|
+
## Rendering modes
|
|
33
|
+
|
|
34
|
+
This starter ships all three Next.js rendering modes against the CDA. Pick per
|
|
35
|
+
route — they compose freely.
|
|
36
|
+
|
|
37
|
+
### SSG — static at build time
|
|
38
|
+
|
|
39
|
+
`generateStaticParams` in [`app/[...slug]/page.tsx`](app/[...slug]/page.tsx)
|
|
40
|
+
pre-renders every published post's friendly URL at `next build`. Fastest
|
|
41
|
+
possible response; ideal for content that changes rarely.
|
|
42
|
+
|
|
43
|
+
### ISR — incremental static regeneration
|
|
44
|
+
|
|
45
|
+
`export const revalidate = 60` caches a rendered page and re-generates it at most
|
|
46
|
+
once per minute on the next request. Pages not covered by `generateStaticParams`
|
|
47
|
+
are rendered on-demand and then cached the same way. Tune the window per route.
|
|
48
|
+
|
|
49
|
+
### On-demand revalidation — instant updates
|
|
50
|
+
|
|
51
|
+
Instead of waiting out the ISR window, Shio can push the moment content is
|
|
52
|
+
published. [`app/api/revalidate/route.ts`](app/api/revalidate/route.ts) receives
|
|
53
|
+
a signed webhook and calls `revalidatePath` so the affected page re-renders on
|
|
54
|
+
its next hit — staleness drops from minutes to seconds.
|
|
55
|
+
|
|
56
|
+
1. Set `SHIO_WEBHOOK_SECRET` in `.env.local`.
|
|
57
|
+
2. In Shio, add a publish webhook for this site pointing at
|
|
58
|
+
`https://your-site/api/revalidate` with the same secret (HMAC-SHA256).
|
|
59
|
+
|
|
60
|
+
The webhook body is `{ event, siteId, postId, url, tags? }`, signed as
|
|
61
|
+
`x-shio-signature: sha256=<hex>`. See the full contract in
|
|
62
|
+
[docs/recipes/nextjs-rendering.md](https://github.com/openviglet/shio/blob/main/docs/recipes/nextjs-rendering.md).
|
|
63
|
+
|
|
64
|
+
### SSR draft preview
|
|
65
|
+
|
|
66
|
+
Editors can preview unpublished content before it goes live, using a
|
|
67
|
+
PREVIEW-env CDA token (which serves draft-preferred content):
|
|
68
|
+
|
|
69
|
+
1. Set `SHIO_PREVIEW_TOKEN` (a PREVIEW-env token) and `SHIO_PREVIEW_SECRET`.
|
|
70
|
+
2. Open `/api/preview?secret=<SHIO_PREVIEW_SECRET>&url=/the/path` — this turns on
|
|
71
|
+
Next.js Draft Mode and the page renders dynamically (SSR) through the preview
|
|
72
|
+
client. A banner with an "Exit preview" link appears.
|
|
73
|
+
|
|
74
|
+
When Draft Mode is off (the default for visitors), everything is plain SSG/ISR.
|
|
75
|
+
|
|
76
|
+
## Customizing
|
|
77
|
+
|
|
78
|
+
The post's fields arrive as `post.attrs` (a JSON object keyed by your post-type
|
|
79
|
+
attribute names). The catch-all page renders a `content`/`body`/`text`/`html`
|
|
80
|
+
field if present, and otherwise dumps `attrs` so you can see the shape. Adapt the
|
|
81
|
+
rendering to your own post types.
|
|
82
|
+
|
|
83
|
+
## Typed content (`post.attrs.*` autocomplete)
|
|
84
|
+
|
|
85
|
+
`post.attrs` is `Record<string, unknown>` by default. Generate TypeScript
|
|
86
|
+
declarations from your live post-types so you get autocomplete and type-checking:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm run shio:types # reads SHIO_URL / SHIO_CDA_TOKEN, writes lib/shio-types.d.ts
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
This emits one `Post` interface per post-type plus a `ShioPostTypes` name→type
|
|
93
|
+
map. Cast a fetched post to its generated type to unlock typed `attrs`:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import type { ArticlePost } from "@/lib/shio-types";
|
|
97
|
+
|
|
98
|
+
const post = (await client.getPostByUrl(SITE_ID, url)) as ArticlePost | null;
|
|
99
|
+
post?.attrs.title; // string — autocompleted
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Re-run after changing a post-type. The output is auto-generated — don't edit it
|
|
103
|
+
by hand.
|
|
104
|
+
|
|
105
|
+
## Learn more
|
|
106
|
+
|
|
107
|
+
- [Shio CMS](https://github.com/openviglet/shio)
|
|
108
|
+
- [Next.js Docs](https://nextjs.org/docs)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import { draftMode } from "next/headers";
|
|
3
|
+
import type { Metadata } from "next";
|
|
4
|
+
import { getShioClient, shio, SITE_ID, slugToUrl } from "@/lib/shio";
|
|
5
|
+
|
|
6
|
+
// ISR: pages not pre-rendered at build are rendered on-demand and cached for
|
|
7
|
+
// 60s. For instant updates, wire publish webhooks to `/api/revalidate` (SH9) —
|
|
8
|
+
// then this fixed window is just a fallback. In Draft Mode the route renders
|
|
9
|
+
// dynamically (SSR) and this value is ignored.
|
|
10
|
+
export const revalidate = 60;
|
|
11
|
+
|
|
12
|
+
type Params = { slug?: string[] };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pre-render every published post's friendly URL at build time (SSG). Pages not
|
|
16
|
+
* listed here are still rendered on-demand and cached (ISR via `revalidate`).
|
|
17
|
+
*/
|
|
18
|
+
export async function generateStaticParams(): Promise<Params[]> {
|
|
19
|
+
if (!SITE_ID) return [];
|
|
20
|
+
const listing = await shio.query({ siteId: SITE_ID, size: 500 });
|
|
21
|
+
return listing.posts
|
|
22
|
+
.filter((p) => p.furl)
|
|
23
|
+
.map((p) => ({ slug: p.furl!.replace(/^\//, "").split("/") }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function generateMetadata({
|
|
27
|
+
params,
|
|
28
|
+
}: {
|
|
29
|
+
params: Promise<Params>;
|
|
30
|
+
}): Promise<Metadata> {
|
|
31
|
+
const { slug } = await params;
|
|
32
|
+
if (!SITE_ID) return {};
|
|
33
|
+
const client = await getShioClient();
|
|
34
|
+
const post = await client.getPostByUrl(SITE_ID, slugToUrl(slug));
|
|
35
|
+
if (!post) return {};
|
|
36
|
+
return {
|
|
37
|
+
title: post.title ?? undefined,
|
|
38
|
+
description: post.summary ?? undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default async function CatchAllPage({
|
|
43
|
+
params,
|
|
44
|
+
}: {
|
|
45
|
+
params: Promise<Params>;
|
|
46
|
+
}) {
|
|
47
|
+
const { slug } = await params;
|
|
48
|
+
if (!SITE_ID) notFound();
|
|
49
|
+
|
|
50
|
+
// PREVIEW client when Draft Mode is on (SSR draft preview), PROD otherwise.
|
|
51
|
+
const { isEnabled: isDraft } = await draftMode();
|
|
52
|
+
const client = await getShioClient();
|
|
53
|
+
|
|
54
|
+
const post = await client.getPostByUrl(SITE_ID, slugToUrl(slug));
|
|
55
|
+
if (!post) notFound();
|
|
56
|
+
|
|
57
|
+
// `attrs` is the post-type field map (e.g. content, body, image...). This
|
|
58
|
+
// starter renders a `content`/`body`/`text` HTML field if present; adapt to
|
|
59
|
+
// your own post types.
|
|
60
|
+
const html = pickHtml(post.attrs);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<article>
|
|
64
|
+
{isDraft && <DraftBanner />}
|
|
65
|
+
<h1>{post.title}</h1>
|
|
66
|
+
{post.summary && <p style={{ color: "#666" }}>{post.summary}</p>}
|
|
67
|
+
{html ? (
|
|
68
|
+
<div dangerouslySetInnerHTML={{ __html: html }} />
|
|
69
|
+
) : (
|
|
70
|
+
<pre
|
|
71
|
+
style={{
|
|
72
|
+
background: "#f6f6f6",
|
|
73
|
+
padding: "1rem",
|
|
74
|
+
borderRadius: 8,
|
|
75
|
+
overflow: "auto",
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{JSON.stringify(post.attrs, null, 2)}
|
|
79
|
+
</pre>
|
|
80
|
+
)}
|
|
81
|
+
</article>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pickHtml(attrs: Record<string, unknown>): string | null {
|
|
86
|
+
for (const key of ["content", "body", "text", "html"]) {
|
|
87
|
+
const value = attrs[key];
|
|
88
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Shown while Draft Mode is active; links to disable it. */
|
|
94
|
+
function DraftBanner() {
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
style={{
|
|
98
|
+
background: "#fff7ed",
|
|
99
|
+
border: "1px solid #fdba74",
|
|
100
|
+
borderRadius: 8,
|
|
101
|
+
padding: "0.5rem 0.75rem",
|
|
102
|
+
marginBottom: "1rem",
|
|
103
|
+
fontSize: "0.85rem",
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
Draft preview is on.{" "}
|
|
107
|
+
<a href="/api/preview?disable=1">Exit preview</a>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { cookies, draftMode } from "next/headers";
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import { PREVIEW_SECRET, PREVIEW_TOKEN_COOKIE } from "@/lib/shio";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Draft-preview entry point (Shio roadmap SH9 + SH13, riding on SH5 PREVIEW-env keys).
|
|
7
|
+
*
|
|
8
|
+
* Two ways in:
|
|
9
|
+
* - Console-issued, short-lived token (SH13):
|
|
10
|
+
* `/api/preview?token=<minted>&url=/the/path`
|
|
11
|
+
* Shio's console mints an expiring PREVIEW-env CDA token and hands the editor
|
|
12
|
+
* this link. The token is stashed in an httpOnly cookie and used per-request
|
|
13
|
+
* by `getShioClient()`; it stops working on its own when it expires server-side.
|
|
14
|
+
* - Static shared secret (SH9):
|
|
15
|
+
* `/api/preview?secret=<SHIO_PREVIEW_SECRET>&url=/the/path`
|
|
16
|
+
* Falls back to the long-lived `SHIO_PREVIEW_TOKEN` server client.
|
|
17
|
+
*
|
|
18
|
+
* Disable: `/api/preview?disable=1`
|
|
19
|
+
*
|
|
20
|
+
* Enabling turns on Next.js Draft Mode (a cookie); subsequent requests render
|
|
21
|
+
* dynamically (SSR) through the PREVIEW CDA client, which prefers draft rows
|
|
22
|
+
* over published ones.
|
|
23
|
+
*/
|
|
24
|
+
export async function GET(request: Request): Promise<NextResponse> {
|
|
25
|
+
const { searchParams } = new URL(request.url);
|
|
26
|
+
const draft = await draftMode();
|
|
27
|
+
const cookieStore = await cookies();
|
|
28
|
+
|
|
29
|
+
if (searchParams.get("disable")) {
|
|
30
|
+
draft.disable();
|
|
31
|
+
cookieStore.delete(PREVIEW_TOKEN_COOKIE);
|
|
32
|
+
return NextResponse.redirect(new URL(searchParams.get("url") ?? "/", request.url));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Redirect target — relative paths only, to avoid open redirects.
|
|
36
|
+
const target = searchParams.get("url") ?? "/";
|
|
37
|
+
const safeTarget = target.startsWith("/") ? target : "/";
|
|
38
|
+
|
|
39
|
+
const token = searchParams.get("token");
|
|
40
|
+
if (token) {
|
|
41
|
+
// SH13: trust the console-minted token; the backend enforces its expiry and
|
|
42
|
+
// single-site scope. Stash it httpOnly so getShioClient() reads draft-preferred.
|
|
43
|
+
draft.enable();
|
|
44
|
+
cookieStore.set(PREVIEW_TOKEN_COOKIE, token, {
|
|
45
|
+
httpOnly: true,
|
|
46
|
+
sameSite: "lax",
|
|
47
|
+
path: "/",
|
|
48
|
+
maxAge: 60 * 60, // 1h ceiling; the token itself may expire sooner server-side
|
|
49
|
+
});
|
|
50
|
+
return NextResponse.redirect(new URL(safeTarget, request.url));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!PREVIEW_SECRET) {
|
|
54
|
+
return NextResponse.json(
|
|
55
|
+
{ message: "SHIO_PREVIEW_SECRET is not configured" },
|
|
56
|
+
{ status: 500 }
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (searchParams.get("secret") !== PREVIEW_SECRET) {
|
|
60
|
+
return NextResponse.json({ message: "Invalid preview secret" }, { status: 401 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
draft.enable();
|
|
64
|
+
return NextResponse.redirect(new URL(safeTarget, request.url));
|
|
65
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { revalidatePath, revalidateTag } from "next/cache";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { WEBHOOK_SECRET } from "@/lib/shio";
|
|
5
|
+
|
|
6
|
+
// Webhook receiver — runs on the Node runtime (needs `node:crypto`).
|
|
7
|
+
export const runtime = "nodejs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* On-demand revalidation endpoint (Shio roadmap SH9).
|
|
11
|
+
*
|
|
12
|
+
* Shio's publish pipeline (SH12) POSTs here on publish / unpublish / delete. The
|
|
13
|
+
* body is HMAC-SHA256-signed with `SHIO_WEBHOOK_SECRET`; we verify it, then ask
|
|
14
|
+
* Next.js to drop the affected page(s) from the ISR cache so the next request
|
|
15
|
+
* re-renders fresh content — turning minutes-of-staleness ISR into seconds.
|
|
16
|
+
*
|
|
17
|
+
* Expected payload (the contract SH12 emits — see docs/recipes/nextjs-rendering.md):
|
|
18
|
+
* {
|
|
19
|
+
* "event": "publish" | "unpublish" | "delete",
|
|
20
|
+
* "siteId": "<site id>",
|
|
21
|
+
* "postId": "<post id>",
|
|
22
|
+
* "url": "/the/friendly-url", // optional but preferred
|
|
23
|
+
* "tags": ["optional", "cache", "tags"]
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* Signature header: `x-shio-signature: sha256=<hex hmac of the raw body>`.
|
|
27
|
+
*/
|
|
28
|
+
export async function POST(request: Request): Promise<NextResponse> {
|
|
29
|
+
if (!WEBHOOK_SECRET) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ revalidated: false, message: "SHIO_WEBHOOK_SECRET is not configured" },
|
|
32
|
+
{ status: 500 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const raw = await request.text();
|
|
37
|
+
const signature = request.headers.get("x-shio-signature");
|
|
38
|
+
if (!verifySignature(raw, signature, WEBHOOK_SECRET)) {
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{ revalidated: false, message: "Invalid signature" },
|
|
41
|
+
{ status: 401 }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let payload: WebhookPayload;
|
|
46
|
+
try {
|
|
47
|
+
payload = JSON.parse(raw) as WebhookPayload;
|
|
48
|
+
} catch {
|
|
49
|
+
return NextResponse.json(
|
|
50
|
+
{ revalidated: false, message: "Invalid JSON body" },
|
|
51
|
+
{ status: 400 }
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const revalidated: string[] = [];
|
|
56
|
+
|
|
57
|
+
// Drop the affected page itself plus the home/listing page.
|
|
58
|
+
if (payload.url) {
|
|
59
|
+
revalidatePath(payload.url);
|
|
60
|
+
revalidated.push(`path:${payload.url}`);
|
|
61
|
+
}
|
|
62
|
+
revalidatePath("/");
|
|
63
|
+
revalidated.push("path:/");
|
|
64
|
+
|
|
65
|
+
// Honour any explicit cache tags (pair with `fetch(..., { next: { tags } })`).
|
|
66
|
+
for (const tag of payload.tags ?? []) {
|
|
67
|
+
revalidateTag(tag);
|
|
68
|
+
revalidated.push(`tag:${tag}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return NextResponse.json({ revalidated: true, event: payload.event, dropped: revalidated });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type WebhookPayload = {
|
|
75
|
+
event?: string;
|
|
76
|
+
siteId?: string;
|
|
77
|
+
postId?: string;
|
|
78
|
+
url?: string;
|
|
79
|
+
tags?: string[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Constant-time verification of `sha256=<hex>` against HMAC-SHA256(raw, secret). */
|
|
83
|
+
function verifySignature(
|
|
84
|
+
raw: string,
|
|
85
|
+
header: string | null,
|
|
86
|
+
secret: string
|
|
87
|
+
): boolean {
|
|
88
|
+
if (!header) return false;
|
|
89
|
+
const provided = header.startsWith("sha256=") ? header.slice(7) : header;
|
|
90
|
+
const expected = createHmac("sha256", secret).update(raw).digest("hex");
|
|
91
|
+
// timingSafeEqual throws if lengths differ — guard first.
|
|
92
|
+
const a = Buffer.from(provided, "hex");
|
|
93
|
+
const b = Buffer.from(expected, "hex");
|
|
94
|
+
if (a.length !== b.length || a.length === 0) return false;
|
|
95
|
+
return timingSafeEqual(a, b);
|
|
96
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export const metadata = {
|
|
4
|
+
title: "Shio site",
|
|
5
|
+
description: "Powered by Shio CMS — headless, JavaScript-first.",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
9
|
+
return (
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<body
|
|
12
|
+
style={{
|
|
13
|
+
fontFamily: "system-ui, sans-serif",
|
|
14
|
+
margin: 0,
|
|
15
|
+
lineHeight: 1.6,
|
|
16
|
+
color: "#1a1a1a",
|
|
17
|
+
}}
|
|
18
|
+
>
|
|
19
|
+
<main style={{ maxWidth: 720, margin: "0 auto", padding: "2rem 1rem" }}>
|
|
20
|
+
{children}
|
|
21
|
+
</main>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { getShioClient, SITE_ID } from "@/lib/shio";
|
|
3
|
+
|
|
4
|
+
// ISR: re-fetch from the CDA at most once per minute. Pair with publish
|
|
5
|
+
// webhooks + on-demand revalidation (`/api/revalidate`) for instant updates
|
|
6
|
+
// (see Shio roadmap SH9/SH12). In Draft Mode this renders dynamically (SSR).
|
|
7
|
+
export const revalidate = 60;
|
|
8
|
+
|
|
9
|
+
export default async function HomePage() {
|
|
10
|
+
if (!SITE_ID) {
|
|
11
|
+
return <SetupHint />;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const shio = await getShioClient();
|
|
15
|
+
const site = await shio.getSite(SITE_ID);
|
|
16
|
+
const listing = await shio.query({ siteId: SITE_ID, size: 50 });
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
<header style={{ marginBottom: "2rem" }}>
|
|
21
|
+
<h1 style={{ marginBottom: 4 }}>{site?.name ?? "Shio site"}</h1>
|
|
22
|
+
{site?.description && (
|
|
23
|
+
<p style={{ color: "#666", marginTop: 0 }}>{site.description}</p>
|
|
24
|
+
)}
|
|
25
|
+
</header>
|
|
26
|
+
|
|
27
|
+
<h2 style={{ fontSize: "1rem", textTransform: "uppercase", color: "#888" }}>
|
|
28
|
+
Published content
|
|
29
|
+
</h2>
|
|
30
|
+
|
|
31
|
+
{listing.posts.length === 0 ? (
|
|
32
|
+
<p style={{ color: "#666" }}>
|
|
33
|
+
No published posts yet. Publish content in Shio, then refresh.
|
|
34
|
+
</p>
|
|
35
|
+
) : (
|
|
36
|
+
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
37
|
+
{listing.posts.map((post) => (
|
|
38
|
+
<li key={post.id} style={{ padding: "0.75rem 0", borderBottom: "1px solid #eee" }}>
|
|
39
|
+
{post.furl ? (
|
|
40
|
+
<Link href={post.furl}>{post.title ?? post.furl}</Link>
|
|
41
|
+
) : (
|
|
42
|
+
<span>{post.title ?? post.id}</span>
|
|
43
|
+
)}
|
|
44
|
+
{post.summary && (
|
|
45
|
+
<p style={{ margin: "4px 0 0", color: "#666", fontSize: "0.9rem" }}>
|
|
46
|
+
{post.summary}
|
|
47
|
+
</p>
|
|
48
|
+
)}
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ul>
|
|
52
|
+
)}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function SetupHint() {
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<h1>Welcome to your Shio site 👋</h1>
|
|
61
|
+
<p>
|
|
62
|
+
Set <code>SHIO_SITE_ID</code> (and <code>SHIO_URL</code> /{" "}
|
|
63
|
+
<code>SHIO_CDA_TOKEN</code>) in <code>.env.local</code>, then restart{" "}
|
|
64
|
+
<code>npm run dev</code>.
|
|
65
|
+
</p>
|
|
66
|
+
<p style={{ color: "#666" }}>
|
|
67
|
+
This starter talks to the Shio Content Delivery API through{" "}
|
|
68
|
+
<code>@viglet/shio-client</code> — no Java, just TypeScript.
|
|
69
|
+
</p>
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# dependencies
|
|
2
|
+
/node_modules
|
|
3
|
+
|
|
4
|
+
# next.js
|
|
5
|
+
/.next/
|
|
6
|
+
/out/
|
|
7
|
+
|
|
8
|
+
# production
|
|
9
|
+
/build
|
|
10
|
+
|
|
11
|
+
# misc
|
|
12
|
+
.DS_Store
|
|
13
|
+
*.pem
|
|
14
|
+
|
|
15
|
+
# debug
|
|
16
|
+
npm-debug.log*
|
|
17
|
+
yarn-debug.log*
|
|
18
|
+
yarn-error.log*
|
|
19
|
+
|
|
20
|
+
# env files (keep .env.example, ignore real secrets)
|
|
21
|
+
.env*.local
|
|
22
|
+
.env
|
|
23
|
+
|
|
24
|
+
# typescript
|
|
25
|
+
*.tsbuildinfo
|
|
26
|
+
next-env.d.ts
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { cookies, draftMode } from "next/headers";
|
|
2
|
+
import { ShioClient } from "@viglet/shio-client";
|
|
3
|
+
|
|
4
|
+
const SHIO_BASE_URL = process.env.SHIO_URL ?? "http://localhost:2710";
|
|
5
|
+
|
|
6
|
+
/** httpOnly cookie holding a console-minted, short-lived preview token (SH13). */
|
|
7
|
+
export const PREVIEW_TOKEN_COOKIE = "shio-preview-token";
|
|
8
|
+
|
|
9
|
+
/** A draft-preferred client bound to a specific (short-lived) preview token. */
|
|
10
|
+
function previewClientFor(token: string): ShioClient {
|
|
11
|
+
return new ShioClient({ baseUrl: SHIO_BASE_URL, apiToken: token });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shared Shio Content Delivery API clients.
|
|
16
|
+
*
|
|
17
|
+
* Reads connection details from the environment (see `.env.local`). Safe to
|
|
18
|
+
* import from any Server Component, Route Handler, or `generateStaticParams`.
|
|
19
|
+
*
|
|
20
|
+
* Two clients are exposed:
|
|
21
|
+
* - `shio` — a PROD-env CDA key; serves PUBLISHED content (SSG/ISR).
|
|
22
|
+
* - `shioPreview` — a PREVIEW-env CDA key; serves draft-preferred content (SSR
|
|
23
|
+
* draft preview, see `app/api/preview`). `null` unless
|
|
24
|
+
* `SHIO_PREVIEW_TOKEN` is set.
|
|
25
|
+
*
|
|
26
|
+
* Use {@link getShioClient} to pick the right one for the current request — it
|
|
27
|
+
* honours Next.js Draft Mode automatically.
|
|
28
|
+
*/
|
|
29
|
+
export const shio = new ShioClient({
|
|
30
|
+
baseUrl: process.env.SHIO_URL ?? "http://localhost:2710",
|
|
31
|
+
apiToken: process.env.SHIO_CDA_TOKEN,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** The PREVIEW-env client (draft-preferred reads), or `null` if not configured. */
|
|
35
|
+
export const shioPreview: ShioClient | null = process.env.SHIO_PREVIEW_TOKEN
|
|
36
|
+
? new ShioClient({
|
|
37
|
+
baseUrl: process.env.SHIO_URL ?? "http://localhost:2710",
|
|
38
|
+
apiToken: process.env.SHIO_PREVIEW_TOKEN,
|
|
39
|
+
})
|
|
40
|
+
: null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pick the CDA client for the current request. Under Next.js Draft Mode, prefer
|
|
44
|
+
* a console-minted short-lived preview token (SH13) from the request cookie;
|
|
45
|
+
* otherwise fall back to the long-lived PREVIEW server client (SH9). With Draft
|
|
46
|
+
* Mode off, always the PROD client. Call from Server Components / Route Handlers.
|
|
47
|
+
*/
|
|
48
|
+
export async function getShioClient(): Promise<ShioClient> {
|
|
49
|
+
const { isEnabled } = await draftMode();
|
|
50
|
+
if (isEnabled) {
|
|
51
|
+
const token = (await cookies()).get(PREVIEW_TOKEN_COOKIE)?.value;
|
|
52
|
+
if (token) return previewClientFor(token);
|
|
53
|
+
if (shioPreview) return shioPreview;
|
|
54
|
+
}
|
|
55
|
+
return shio;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The site this front-end renders. */
|
|
59
|
+
export const SITE_ID = process.env.SHIO_SITE_ID ?? "";
|
|
60
|
+
|
|
61
|
+
/** Shared secret the publish webhook signs its body with (HMAC-SHA256, SH12). */
|
|
62
|
+
export const WEBHOOK_SECRET = process.env.SHIO_WEBHOOK_SECRET ?? "";
|
|
63
|
+
|
|
64
|
+
/** Secret that gates the draft-preview entry point (`app/api/preview`). */
|
|
65
|
+
export const PREVIEW_SECRET = process.env.SHIO_PREVIEW_SECRET ?? "";
|
|
66
|
+
|
|
67
|
+
/** Normalize Next's `slug[]` into a CDA friendly-URL path (always leading slash). */
|
|
68
|
+
export function slugToUrl(slug?: string[]): string {
|
|
69
|
+
return "/" + (slug ?? []).join("/");
|
|
70
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** @type {import('next').NextConfig} */
|
|
2
|
+
const nextConfig = {
|
|
3
|
+
// Allow <Image> to load media served from the Shio instance.
|
|
4
|
+
images: {
|
|
5
|
+
remotePatterns: [
|
|
6
|
+
{ protocol: "http", hostname: "localhost" },
|
|
7
|
+
// Add your Shio host here, e.g. { protocol: "https", hostname: "cms.example.com" }
|
|
8
|
+
],
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default nextConfig;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev",
|
|
8
|
+
"build": "next build",
|
|
9
|
+
"start": "next start",
|
|
10
|
+
"lint": "next lint",
|
|
11
|
+
"shio:types": "shio-types --url ${SHIO_URL:-http://localhost:2710} --token $SHIO_CDA_TOKEN --out lib/shio-types.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@viglet/shio-client": "^2026.2.1",
|
|
15
|
+
"next": "^15.0.0",
|
|
16
|
+
"react": "^19.0.0",
|
|
17
|
+
"react-dom": "^19.0.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"@types/react": "^19.0.0",
|
|
22
|
+
"@types/react-dom": "^19.0.0",
|
|
23
|
+
"typescript": "^5.6.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "ES2022"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": { "@/*": ["./*"] }
|
|
18
|
+
},
|
|
19
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
20
|
+
"exclude": ["node_modules"]
|
|
21
|
+
}
|