create-nextly-app 0.0.2-alpha.7 → 0.0.2-alpha.71

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.
Files changed (75) hide show
  1. package/README.md +36 -9
  2. package/dist/cli.cjs +3047 -32460
  3. package/dist/cli.cjs.map +1 -1
  4. package/dist/cli.d.cts +1 -1
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.mjs +3048 -3602
  7. package/dist/cli.mjs.map +1 -1
  8. package/dist/create-nextly-5wiGMVI6.mjs +52550 -0
  9. package/dist/create-nextly-5wiGMVI6.mjs.map +1 -0
  10. package/dist/create-nextly-BbKGDrEC.cjs +52628 -0
  11. package/dist/create-nextly-BbKGDrEC.cjs.map +1 -0
  12. package/dist/index.cjs +3 -24861
  13. package/dist/index.d.cts +40 -34
  14. package/dist/index.d.ts +40 -34
  15. package/dist/index.mjs +1 -6
  16. package/package.json +28 -19
  17. package/skills/nextly-authentication/SKILL.md +187 -0
  18. package/skills/nextly-collections/SKILL.md +215 -0
  19. package/skills/nextly-deploy/SKILL.md +248 -0
  20. package/skills/nextly-install/SKILL.md +121 -0
  21. package/templates/base/.env.example +21 -6
  22. package/templates/base/AGENTS.md.template +226 -0
  23. package/templates/base/CLAUDE.md.template +1 -0
  24. package/templates/base/gitignore +42 -0
  25. package/templates/base/next.config.ts +11 -17
  26. package/templates/base/src/app/admin/[[...params]]/page.tsx +3 -0
  27. package/templates/base/src/app/admin/api/media/[[...path]]/route.ts +26 -0
  28. package/templates/base/src/app/globals.css +15 -2
  29. package/templates/base/src/app/layout.tsx +32 -18
  30. package/templates/base/src/lib/site-url.ts +55 -0
  31. package/templates/base/src/types/generated/nextly-types.ts +6 -4
  32. package/templates/base/src/types/generated/plugin-admin-components.generated.ts +6 -0
  33. package/templates/base/src/types/generated/plugin-admin-imports.generated.ts +32 -0
  34. package/templates/base/tsconfig.json +8 -1
  35. package/templates/blank/src/{components → app/(frontend)/components}/ThemeToggle.tsx +55 -24
  36. package/templates/blank/src/app/(frontend)/page.tsx +2 -2
  37. package/templates/blank/src/app/globals.css +37 -1
  38. package/templates/blank/src/app/layout.tsx +28 -18
  39. package/templates/blank/src/instrumentation.ts +32 -0
  40. package/templates/plugin/AGENTS.md.template +125 -0
  41. package/templates/plugin/CLAUDE.md.template +1 -0
  42. package/templates/plugin/README.md +48 -0
  43. package/templates/plugin/dev/.env.example +11 -0
  44. package/templates/plugin/dev/next.config.ts +43 -0
  45. package/templates/plugin/dev/nextly.config.ts +17 -0
  46. package/templates/plugin/dev/src/app/admin/[[...params]]/page.tsx +24 -0
  47. package/templates/plugin/dev/src/app/admin/api/[[...params]]/route.ts +19 -0
  48. package/templates/plugin/dev/src/app/layout.tsx +13 -0
  49. package/templates/plugin/dev/src/instrumentation.ts +25 -0
  50. package/templates/plugin/dev/src/seed-dev-user.ts +51 -0
  51. package/templates/plugin/dev/src/types/generated/plugin-admin-components.generated.ts +8 -0
  52. package/templates/plugin/dev/src/types/generated/plugin-admin-imports.generated.ts +37 -0
  53. package/templates/plugin/dev/tsconfig.json +22 -0
  54. package/templates/plugin/eslint.config.mjs +19 -0
  55. package/templates/plugin/gitignore +13 -0
  56. package/templates/plugin/src/admin/SettingsPage.tsx +57 -0
  57. package/templates/plugin/src/admin/index.ts +24 -0
  58. package/templates/plugin/src/collections/example.ts +13 -0
  59. package/templates/plugin/src/index.ts +6 -0
  60. package/templates/plugin/src/plugin.test.ts +35 -0
  61. package/templates/plugin/src/plugin.ts +48 -0
  62. package/templates/plugin/src/types.ts +17 -0
  63. package/templates/plugin/template.json +14 -0
  64. package/templates/plugin/tsconfig.json +18 -0
  65. package/templates/plugin/tsdown.config.ts +30 -0
  66. package/templates/plugin/vitest.config.ts +27 -0
  67. package/dist/chunk-RPGFJSDP.mjs +0 -28835
  68. package/dist/chunk-RPGFJSDP.mjs.map +0 -1
  69. package/dist/index.cjs.map +0 -1
  70. package/dist/index.mjs.map +0 -1
  71. package/templates/blank/src/access/README.md +0 -22
  72. package/templates/blank/src/collections/README.md +0 -22
  73. package/templates/blank/src/lib/README.md +0 -15
  74. package/templates/blank/src/singles/README.md +0 -19
  75. /package/templates/blank/src/{components → app/(frontend)/components}/README.md +0 -0
@@ -1,21 +1,15 @@
1
1
  import type { NextConfig } from "next";
2
2
 
3
- const nextConfig: NextConfig = {
4
- serverExternalPackages: [
5
- "nextly",
6
- "@nextlyhq/adapter-drizzle",
7
- "@nextlyhq/adapter-postgres",
8
- "@nextlyhq/adapter-mysql",
9
- "@nextlyhq/adapter-sqlite",
10
- "drizzle-orm",
11
- "drizzle-kit",
12
- "pg",
13
- "mysql2",
14
- "better-sqlite3",
15
- "bcryptjs",
16
- "sharp",
17
- "esbuild",
18
- ],
19
- };
3
+ /**
4
+ * Minimal base `next.config.ts` for bundled templates.
5
+ *
6
+ * This file is intentionally left minimal (no adapter whitelist). The
7
+ * scaffolder will generate or patch a database-specific `next.config.ts`
8
+ * that includes only the selected adapter and driver in
9
+ * `serverExternalPackages`. Keeping the base file minimal avoids
10
+ * advertising adapters the user didn't choose and prevents misleading
11
+ * generated projects.
12
+ */
13
+ const nextConfig: NextConfig = {};
20
14
 
21
15
  export default nextConfig;
@@ -1,6 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import "@nextlyhq/admin/style.css";
4
+ // Registers every plugin admin component the app's config declares. Generated
5
+ // by `nextly generate:types`; run it again after adding or changing a plugin.
6
+ import "../../../types/generated/plugin-admin-imports.generated";
4
7
  import { RootLayout, QueryProvider, ErrorBoundary } from "@nextlyhq/admin";
5
8
 
6
9
  export default function AdminPage() {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Admin Media API — permission-gated management surface.
3
+ *
4
+ * The admin's session cookie is scoped to `/admin`, so it reaches here but not
5
+ * the public `/api/media`. Every operation is checked against a media
6
+ * permission (read/create/update/delete-media) and the acting identity is
7
+ * taken from the session/API key, never a request field. This is the only
8
+ * place media can be uploaded, edited, moved or deleted.
9
+ *
10
+ * Supported (all gated):
11
+ * - GET/POST/PATCH/DELETE /admin/api/media/* (files, folders, bulk delete)
12
+ */
13
+
14
+ import { createMediaHandlers } from "nextly/api/media-handlers";
15
+
16
+ import config from "@nextly-config";
17
+
18
+ const handlers = createMediaHandlers({
19
+ config,
20
+ requireAuth: true,
21
+ });
22
+
23
+ export const GET = handlers.GET;
24
+ export const POST = handlers.POST;
25
+ export const PATCH = handlers.PATCH;
26
+ export const DELETE = handlers.DELETE;
@@ -1,5 +1,15 @@
1
1
  @import "tailwindcss";
2
2
 
3
+ /* The families the imported font stylesheets declare, bound to the variables the rules
4
+ below read. Named here rather than by `next/font`, which used to inject them: the faces
5
+ now arrive as ordinary stylesheets, so the binding has to be written down somewhere, and
6
+ the name is load-bearing — the admin theme reads `--font-geist`. Quoted family names, so
7
+ the two-word values stay one identifier. */
8
+ :root {
9
+ --font-geist: "Geist Variable";
10
+ --font-geist-mono: "Geist Mono Variable";
11
+ }
12
+
3
13
  :root {
4
14
  --background: #ffffff;
5
15
  --foreground: #171717;
@@ -8,8 +18,11 @@
8
18
  @theme inline {
9
19
  --color-background: var(--background);
10
20
  --color-foreground: var(--foreground);
11
- --font-sans: var(--font-geist-sans);
12
- --font-mono: var(--font-geist-mono);
21
+ /* Fallback arguments, not a trailing family list: an unresolved var() with no
22
+ fallback invalidates the whole declaration rather than falling through to
23
+ what comes after the closing paren. */
24
+ --font-sans: var(--font-geist, ui-sans-serif), system-ui, sans-serif;
25
+ --font-mono: var(--font-geist-mono, ui-monospace), monospace;
13
26
  }
14
27
 
15
28
  @media (prefers-color-scheme: dark) {
@@ -1,16 +1,32 @@
1
1
  import type { Metadata } from "next";
2
- import { Geist, Geist_Mono } from "next/font/google";
2
+ import "@fontsource-variable/geist";
3
+ import "@fontsource-variable/geist-mono";
3
4
  import "./globals.css";
5
+ import { SITE_URL } from "@/lib/site-url";
4
6
 
5
- const geistSans = Geist({
6
- variable: "--font-geist-sans",
7
- subsets: ["latin"],
8
- });
9
-
10
- const geistMono = Geist_Mono({
11
- variable: "--font-geist-mono",
12
- subsets: ["latin"],
13
- });
7
+ /**
8
+ * The faces are imported as STYLESHEETS from packages in `node_modules`, rather
9
+ * than fetched from fonts.googleapis.com by `next/font/google` while
10
+ * `next build` runs — which made every build depend on reaching a third party
11
+ * and fail behind a proxy, on a locked-down runner, or offline.
12
+ *
13
+ * A bare package import rather than `next/font/local` pointing INTO
14
+ * `node_modules`: a literal path asserts where the package physically lives,
15
+ * and that assertion is false under Yarn's Plug'n'Play linker (no
16
+ * `node_modules` at all), under npm/Yarn workspace hoisting (the package moves
17
+ * to the workspace root), and under pnpm's own symlinked store. An import asks
18
+ * the resolver instead, which is correct under every layout.
19
+ *
20
+ * The trade is `next/font`'s metric-adjusted fallback, so text can shift
21
+ * slightly as the face arrives. The families are bound to this app's `--font-*`
22
+ * variables in `globals.css`, which is what every rule downstream reads. *
23
+ * Importing the package root ships every subset the face offers — Latin, Latin
24
+ * Extended, Cyrillic, Vietnamese — but each `@font-face` carries a
25
+ * `unicode-range`, so a browser downloads only the subsets the page actually
26
+ * uses. The deployed bundle is larger than a single hand-picked file; what a
27
+ * reader fetches is not, and a page in Cyrillic now gets its face instead of a
28
+ * fallback.
29
+ */
14
30
 
15
31
  /**
16
32
  * `metadataBase` tells Next.js how to resolve relative URLs in OpenGraph
@@ -19,9 +35,7 @@ const geistMono = Geist_Mono({
19
35
  * `https://yourblog.com`). The localhost fallback keeps dev working.
20
36
  */
21
37
  export const metadata: Metadata = {
22
- metadataBase: new URL(
23
- process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"
24
- ),
38
+ metadataBase: new URL(SITE_URL),
25
39
  title: { default: "Nextly", template: "%s — Nextly" },
26
40
  description: "A site built with Nextly.",
27
41
  };
@@ -32,12 +46,12 @@ export default function RootLayout({
32
46
  children: React.ReactNode;
33
47
  }>) {
34
48
  return (
49
+ // The font variables belong on <html>: the admin theme declares --font-sans
50
+ // in a non-inline @theme, which emits it into :root and resolves the
51
+ // reference THERE. A variable exposed lower down, on <body>, is invisible to
52
+ // that declaration however it is spelled.
35
53
  <html lang="en" suppressHydrationWarning>
36
- <body
37
- className={`${geistSans.variable} ${geistMono.variable} antialiased`}
38
- >
39
- {children}
40
- </body>
54
+ <body className="antialiased">{children}</body>
41
55
  </html>
42
56
  );
43
57
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The origin this site's absolute URLs are built from.
3
+ *
4
+ * Absolute URLs are needed in places that leave the browser's context —
5
+ * `metadataBase`, canonical links, Open Graph tags, the sitemap, RSS and
6
+ * JSON-LD. A relative path is meaningless in all of them, so something has to
7
+ * name the origin.
8
+ *
9
+ * ## Why two variables, in this order
10
+ *
11
+ * `NEXT_PUBLIC_SITE_URL` is where READERS find the site. `NEXT_PUBLIC_APP_URL`
12
+ * is where the Nextly app itself lives — the admin, the API, the origin its
13
+ * emails and preview links point at. They are usually the same host and are
14
+ * not the same question: a headless setup serves its public pages from one
15
+ * origin and runs the admin on another.
16
+ *
17
+ * So the public one wins when it is set, and the app's own origin is used when
18
+ * it is not. That second step matters more than it looks: the generated `.env`
19
+ * sets `NEXT_PUBLIC_APP_URL`, so without it a project that configured the only
20
+ * URL it was given would still publish `localhost` canonicals and OG tags to
21
+ * production — silently, because nothing about a wrong absolute URL fails at
22
+ * build time.
23
+ *
24
+ * ## The development fallback reads the port it is actually served on
25
+ *
26
+ * Not a hardcoded 3000. Next assigns `process.env.PORT` to the port it ends up
27
+ * listening on — after its own "port in use, using available port instead"
28
+ * fallback — so this follows `next dev -p 4000`, and follows Next onto a
29
+ * different port when 3000 is taken. A literal cannot do either, and the
30
+ * failure is quiet: pages render while every absolute URL points somewhere
31
+ * nothing is listening.
32
+ *
33
+ * `PORT` is a server-side value and is not inlined into client bundles, so a
34
+ * client component importing this reads the plain default. That is the same
35
+ * answer it would have had anyway, and client code should prefer
36
+ * `window.location.origin`, which is exact.
37
+ */
38
+ const DEV_PORT = process.env.PORT ?? "3000";
39
+
40
+ export const SITE_URL =
41
+ process.env.NEXT_PUBLIC_SITE_URL ??
42
+ process.env.NEXT_PUBLIC_APP_URL ??
43
+ `http://localhost:${DEV_PORT}`;
44
+
45
+ /**
46
+ * Join a path onto {@link SITE_URL} without duplicating slashes.
47
+ *
48
+ * @param path - A root-relative path; a leading slash is optional
49
+ * @returns The absolute URL
50
+ */
51
+ export function absoluteUrl(path = "/"): string {
52
+ const base = SITE_URL.replace(/\/$/, "");
53
+ const p = path.startsWith("/") ? path : `/${path}`;
54
+ return `${base}${p}`;
55
+ }
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Nextly Generated Types
3
3
  *
4
- * This file will be auto-generated when you run:
5
- * npx nextly dev
6
- * npx nextly generate:types
4
+ * This file is generated by `nextly generate:types`, which this project runs
5
+ * as its `types:generate` script. Run it after every change to the schema in
6
+ * nextly.config.ts. It reads only that file, so collections built in the
7
+ * Schema Builder get types once they are moved into code
8
+ * (`nextly db:sync --promote <slug>`).
7
9
  *
8
10
  * Do not edit this file manually - it will be overwritten.
9
11
  */
10
12
 
11
- // Types will be generated here after running nextly dev
13
+ // Types are generated here by `nextly generate:types`.
12
14
  export {};
@@ -0,0 +1,6 @@
1
+ /* AUTO-GENERATED by `nextly generate:types` — DO NOT EDIT.
2
+ * The component paths `plugin-admin-imports.generated.ts` registers, as
3
+ * plain strings, so server code can read what the admin's map holds
4
+ * without importing the components. Regenerated with it. */
5
+
6
+ export const registeredComponentPaths = [] as const;
@@ -0,0 +1,32 @@
1
+ /* AUTO-GENERATED by `nextly generate:types` — DO NOT EDIT.
2
+ * Statically registers the plugin admin components the admin resolves by
3
+ * string (pages, settings, views, slots, field-type editors) so they load
4
+ * without manual host imports. */
5
+
6
+ import { registeredComponentPaths as listed } from "./plugin-admin-components.generated";
7
+
8
+ const registered = {};
9
+
10
+ type Registered = keyof typeof registered;
11
+ type Listed = (typeof listed)[number];
12
+ /** `true` only while this map registers exactly the paths its list names. */
13
+ type Agrees = [Registered] extends [Listed]
14
+ ? [Listed] extends [Registered]
15
+ ? true
16
+ : "component map and its list disagree: run npx nextly generate:types"
17
+ : "component map and its list disagree: run npx nextly generate:types";
18
+ export const componentMapAgreesWithList: Agrees = true;
19
+
20
+ if (
21
+ JSON.stringify(Object.keys(registered).sort()) !==
22
+ JSON.stringify([...listed].sort())
23
+ ) {
24
+ throw Object.assign(
25
+ new Error(
26
+ "The admin's component map and its list disagree: run npx nextly generate:types."
27
+ ),
28
+ { code: "NEXTLY_ADMIN_COMPONENT_MAP_MISMATCH" }
29
+ );
30
+ }
31
+
32
+ export const registeredComponentPaths = listed;
@@ -20,7 +20,14 @@
20
20
  ],
21
21
  "paths": {
22
22
  "@/*": ["./src/*"],
23
- "@nextly-config": ["./nextly.config.ts"]
23
+ "@/components/*": ["./src/app/(frontend)/components/*"],
24
+ "@nextly-config": ["./nextly.config.ts"],
25
+ // Pagefind's browser bundle is generated into public/ at build time, so it
26
+ // is imported by URL and cannot be resolved on disk while type-checking.
27
+ // A `declare module` cannot describe it either: TypeScript treats a rooted
28
+ // specifier as relative and rejects the declaration (TS2436). Templates
29
+ // without a search page simply never import it, leaving this mapping inert.
30
+ "/pagefind/pagefind.js": ["./src/types/pagefind.d.ts"]
24
31
  }
25
32
  },
26
33
  "include": [
@@ -1,36 +1,67 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useEffect, useSyncExternalStore } from "react";
4
4
 
5
5
  type Theme = "light" | "dark" | "system";
6
6
 
7
- export function ThemeToggle() {
8
- const [theme, setTheme] = useState<Theme>("system");
9
-
10
- useEffect(() => {
11
- const savedTheme = localStorage.getItem("theme") as Theme | null;
12
- if (savedTheme) {
13
- setTheme(savedTheme);
14
- applyTheme(savedTheme);
15
- } else {
16
- applyTheme("system");
17
- }
18
- }, []);
7
+ const STORAGE_KEY = "theme";
8
+ const THEMES: readonly Theme[] = ["light", "dark", "system"];
9
+ const DARK_QUERY = "(prefers-color-scheme: dark)";
19
10
 
20
- const applyTheme = (newTheme: Theme) => {
21
- const isDark =
22
- newTheme === "dark" ||
23
- (newTheme === "system" &&
24
- window.matchMedia("(prefers-color-scheme: dark)").matches);
11
+ /**
12
+ * Everything showing the theme in this tab. The browser's `storage` event
13
+ * only reaches other tabs, so a change made here is announced here by hand.
14
+ */
15
+ const listeners = new Set<() => void>();
25
16
 
26
- document.documentElement.classList.toggle("dark", isDark);
17
+ function subscribe(listener: () => void) {
18
+ listeners.add(listener);
19
+ window.addEventListener("storage", listener);
20
+ return () => {
21
+ listeners.delete(listener);
22
+ window.removeEventListener("storage", listener);
27
23
  };
24
+ }
28
25
 
29
- const handleThemeChange = (newTheme: Theme) => {
30
- setTheme(newTheme);
31
- localStorage.setItem("theme", newTheme);
32
- applyTheme(newTheme);
33
- };
26
+ /** The saved choice, or "system" when there is none or it is not one of the three. */
27
+ function readTheme(): Theme {
28
+ const saved = localStorage.getItem(STORAGE_KEY);
29
+ return THEMES.find(theme => theme === saved) ?? "system";
30
+ }
31
+
32
+ function saveTheme(theme: Theme) {
33
+ localStorage.setItem(STORAGE_KEY, theme);
34
+ for (const listener of listeners) listener();
35
+ }
36
+
37
+ function applyTheme(theme: Theme) {
38
+ const isDark =
39
+ theme === "dark" ||
40
+ (theme === "system" && window.matchMedia(DARK_QUERY).matches);
41
+ document.documentElement.classList.toggle("dark", isDark);
42
+ }
43
+
44
+ export function ThemeToggle() {
45
+ // Read from storage rather than copied into state by an effect. The server
46
+ // renders "system"; after hydration React swaps in the saved choice.
47
+ const theme = useSyncExternalStore<Theme>(
48
+ subscribe,
49
+ readTheme,
50
+ () => "system"
51
+ );
52
+
53
+ // Keep the page's colours in step with the choice, and with the system's
54
+ // setting for as long as "system" is the choice.
55
+ useEffect(() => {
56
+ applyTheme(theme);
57
+ if (theme !== "system") return;
58
+ const query = window.matchMedia(DARK_QUERY);
59
+ const follow = () => applyTheme("system");
60
+ query.addEventListener("change", follow);
61
+ return () => query.removeEventListener("change", follow);
62
+ }, [theme]);
63
+
64
+ const handleThemeChange = (newTheme: Theme) => saveTheme(newTheme);
34
65
 
35
66
  return (
36
67
  <div className="fixed top-6 right-6 flex items-center border border-foreground/10 bg-background p-1 z-50">
@@ -1,6 +1,7 @@
1
1
  import Link from "next/link";
2
2
 
3
3
  import { ThemeToggle } from "@/components/ThemeToggle";
4
+ import { SITE_URL } from "@/lib/site-url";
4
5
 
5
6
  /**
6
7
  * Blank-template landing page (public route via the (frontend) route group).
@@ -26,8 +27,7 @@ interface SetupStatus {
26
27
 
27
28
  async function fetchSetupStatus(): Promise<SetupStatus | null> {
28
29
  try {
29
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
30
- const res = await fetch(`${baseUrl}/api/auth/setup-status`, {
30
+ const res = await fetch(`${SITE_URL}/api/auth/setup-status`, {
31
31
  cache: "no-store",
32
32
  });
33
33
  if (!res.ok) return null;
@@ -1,5 +1,25 @@
1
1
  @import "tailwindcss";
2
2
 
3
+ /* The families the imported font stylesheets declare, bound to the variables the rules
4
+ below read. Named here rather than by `next/font`, which used to inject them: the faces
5
+ now arrive as ordinary stylesheets, so the binding has to be written down somewhere, and
6
+ the name is load-bearing — the admin theme reads `--font-geist`. Quoted family names, so
7
+ the two-word values stay one identifier. */
8
+ :root {
9
+ /* `--font-geist` and `--font-geist-mono` are the names the ADMIN theme reads
10
+ (`--font-sans: var(--font-geist, Geist), ...` in a non-inline `@theme`, so
11
+ it resolves at `:root`). A template that loads a different face still has to
12
+ expose it under these names or the admin renders in a system fallback while
13
+ the app's own pages render correctly. */
14
+ --font-geist: "Inter Variable";
15
+ --font-geist-mono: "JetBrains Mono Variable";
16
+ /* This template's own display face, read by `.font-display` below. NOT named
17
+ `--font-mono` for its mono face: that is the theme's OWN token, declared at
18
+ `:root`, and redefining it here replaces the theme's whole fallback chain
19
+ rather than feeding it. */
20
+ --font-display: "Inter Variable";
21
+ }
22
+
3
23
  /**
4
24
  * Blank-template global styles.
5
25
  *
@@ -28,6 +48,18 @@
28
48
  --color-foreground: #f9fafb;
29
49
  }
30
50
 
51
+ /* `@theme inline` on purpose, and it is the whole point of this block. An inline theme
52
+ SUBSTITUTES the value where the utility is used; a plain `@theme` emits the variable into
53
+ `:root`, which is where `@nextlyhq/admin` declares its own `--font-mono` — redefining it there
54
+ would replace the admin's entire fallback chain with this one family.
55
+
56
+ Needed because the frontend routes do not load the admin stylesheet, so nothing else binds the
57
+ `font-mono` utility this template uses on its badge and file-path text; without it they fall
58
+ back to Tailwind's default mono rather than the face the template loads. */
59
+ @theme inline {
60
+ --font-mono: var(--font-geist-mono, ui-monospace), monospace;
61
+ }
62
+
31
63
  @theme {
32
64
  --color-background: var(--color-background);
33
65
  --color-foreground: var(--color-foreground);
@@ -67,7 +99,11 @@ body {
67
99
  }
68
100
 
69
101
  .font-display {
70
- font-family: var(--font-display), ui-sans-serif, system-ui, sans-serif;
102
+ /* The generic families are the var()'s fallback argument rather than a list
103
+ after it: an unresolved var() with no fallback invalidates the whole
104
+ declaration, so the element would inherit its parent's face instead of
105
+ falling through to these. */
106
+ font-family: var(--font-display, ui-sans-serif), system-ui, sans-serif;
71
107
  }
72
108
 
73
109
  .btn-primary {
@@ -1,19 +1,33 @@
1
1
  import type { Metadata } from "next";
2
- import { Inter, JetBrains_Mono } from "next/font/google";
2
+ import "@fontsource-variable/inter";
3
+ import "@fontsource-variable/jetbrains-mono";
3
4
 
4
5
  import "./globals.css";
6
+ import { SITE_URL } from "@/lib/site-url";
5
7
 
6
- const display = Inter({
7
- variable: "--font-display",
8
- subsets: ["latin"],
9
- display: "swap",
10
- });
11
-
12
- const mono = JetBrains_Mono({
13
- variable: "--font-mono",
14
- subsets: ["latin"],
15
- display: "swap",
16
- });
8
+ /**
9
+ * The faces are imported as STYLESHEETS from packages in `node_modules`, rather
10
+ * than fetched from fonts.googleapis.com by `next/font/google` while
11
+ * `next build` runs — which made every build depend on reaching a third party
12
+ * and fail behind a proxy, on a locked-down runner, or offline.
13
+ *
14
+ * A bare package import rather than `next/font/local` pointing INTO
15
+ * `node_modules`: a literal path asserts where the package physically lives,
16
+ * and that assertion is false under Yarn's Plug'n'Play linker (no
17
+ * `node_modules` at all), under npm/Yarn workspace hoisting (the package moves
18
+ * to the workspace root), and under pnpm's own symlinked store. An import asks
19
+ * the resolver instead, which is correct under every layout.
20
+ *
21
+ * The trade is `next/font`'s metric-adjusted fallback, so text can shift
22
+ * slightly as the face arrives. The families are bound to this app's `--font-*`
23
+ * variables in `globals.css`, which is what every rule downstream reads. *
24
+ * Importing the package root ships every subset the face offers — Latin, Latin
25
+ * Extended, Cyrillic, Vietnamese — but each `@font-face` carries a
26
+ * `unicode-range`, so a browser downloads only the subsets the page actually
27
+ * uses. The deployed bundle is larger than a single hand-picked file; what a
28
+ * reader fetches is not, and a page in Cyrillic now gets its face instead of a
29
+ * fallback.
30
+ */
17
31
 
18
32
  /**
19
33
  * Blank-template root layout.
@@ -24,9 +38,7 @@ const mono = JetBrains_Mono({
24
38
  * blank-template pages can use relative OG image URLs without breaking.
25
39
  */
26
40
  export const metadata: Metadata = {
27
- metadataBase: new URL(
28
- process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"
29
- ),
41
+ metadataBase: new URL(SITE_URL),
30
42
  title: { default: "Nextly", template: "%s — Nextly" },
31
43
  description: "A Nextly project.",
32
44
  };
@@ -38,9 +50,7 @@ export default function RootLayout({
38
50
  }>) {
39
51
  return (
40
52
  <html lang="en" suppressHydrationWarning>
41
- <body className={`${display.variable} ${mono.variable} antialiased`}>
42
- {children}
43
- </body>
53
+ <body className="antialiased">{children}</body>
44
54
  </html>
45
55
  );
46
56
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Starts Nextly when the server starts, before it serves a request.
3
+ *
4
+ * Next.js runs `register()` once per server instance and finishes it before
5
+ * the instance handles traffic, in every serverless function as well as the
6
+ * admin's. Without it, Nextly starts on the first request that happens to
7
+ * need it, so a route that cannot start it alone, such as `/api/health`,
8
+ * answers as if the server could not serve until some other request has.
9
+ *
10
+ * It also registers Nextly's Next.js cache adapter here, so a Direct API write
11
+ * from any function flushes the cache tags it touches.
12
+ *
13
+ * Guarded to the Node.js runtime and imported dynamically inside the guard:
14
+ * Nextly needs Node-only packages that the Edge runtime cannot bundle. Next.js
15
+ * skips `register()` during `next build`, so this never opens a database
16
+ * connection at build time.
17
+ */
18
+ export async function register(): Promise<void> {
19
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
20
+
21
+ const { registerNextCacheRevalidator } = await import("nextly/runtime");
22
+ registerNextCacheRevalidator();
23
+
24
+ const { createRegister } = await import("nextly");
25
+ const { default: config } = await import("../nextly.config");
26
+ // Next.js writes its Cache Components setting into this file as it builds
27
+ // it. Nextly, loaded from node_modules, cannot read that for itself, and
28
+ // warns when Cache Components is on, which it does not support yet.
29
+ await createRegister(config, {
30
+ cacheComponents: process.env.__NEXT_CACHE_COMPONENTS,
31
+ })();
32
+ }