create-shapio 0.3.0 → 0.4.0

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 (62) hide show
  1. package/dist/index.js +4 -0
  2. package/dist/index.js.map +1 -1
  3. package/package.json +1 -1
  4. package/templates/astro/.env.example +2 -0
  5. package/templates/astro/package.json +2 -2
  6. package/templates/astro/scripts/lib/content.ts +46 -5
  7. package/templates/astro/scripts/seed.ts +26 -4
  8. package/templates/astro/scripts/smoke.ts +13 -0
  9. package/templates/astro/scripts/smokeHttp.ts +11 -1
  10. package/templates/astro/shapio/components/seo.json +114 -0
  11. package/templates/astro/shapio/models/article.json +21 -0
  12. package/templates/astro/shapio/models/page.json +21 -0
  13. package/templates/astro/src/layouts/Base.astro +49 -7
  14. package/templates/astro/src/lib/preview.ts +1 -0
  15. package/templates/astro/src/lib/seo.ts +69 -0
  16. package/templates/astro/src/lib/shapio.ts +23 -4
  17. package/templates/astro/src/lib/types.ts +7 -0
  18. package/templates/astro/src/pages/[locale]/[...slug].astro +1 -1
  19. package/templates/astro/src/pages/[locale]/articles/[slug].astro +1 -1
  20. package/templates/next/.env.example +2 -0
  21. package/templates/next/package.json +2 -2
  22. package/templates/next/scripts/lib/content.ts +46 -5
  23. package/templates/next/scripts/seed.ts +26 -4
  24. package/templates/next/scripts/smokeHttp.ts +11 -1
  25. package/templates/next/shapio/components/seo.json +114 -0
  26. package/templates/next/shapio/models/article.json +21 -0
  27. package/templates/next/shapio/models/page.json +21 -0
  28. package/templates/next/src/app/[locale]/[[...slug]]/page.tsx +10 -2
  29. package/templates/next/src/app/[locale]/articles/[slug]/page.tsx +11 -2
  30. package/templates/next/src/app/[locale]/articles/page.tsx +6 -4
  31. package/templates/next/src/app/[locale]/colophon/page.tsx +6 -4
  32. package/templates/next/src/app/[locale]/layout.tsx +5 -1
  33. package/templates/next/src/app/api/revalidate/route.ts +6 -3
  34. package/templates/next/src/lib/config.ts +4 -0
  35. package/templates/next/src/lib/metadata.ts +68 -0
  36. package/templates/next/src/lib/preview.ts +1 -0
  37. package/templates/next/src/lib/revalidation.ts +20 -1
  38. package/templates/next/src/lib/revalidationTargets.test.ts +11 -1
  39. package/templates/next/src/lib/revalidationTargets.ts +5 -0
  40. package/templates/next/src/lib/seo.ts +69 -0
  41. package/templates/next/src/lib/shapio.ts +24 -3
  42. package/templates/next/src/lib/types.ts +7 -0
  43. package/templates/sveltekit/.env.example +2 -0
  44. package/templates/sveltekit/package.json +2 -2
  45. package/templates/sveltekit/scripts/lib/content.ts +46 -5
  46. package/templates/sveltekit/scripts/seed.ts +26 -4
  47. package/templates/sveltekit/scripts/smokeHttp.ts +11 -1
  48. package/templates/sveltekit/shapio/components/seo.json +114 -0
  49. package/templates/sveltekit/shapio/models/article.json +21 -0
  50. package/templates/sveltekit/shapio/models/page.json +21 -0
  51. package/templates/sveltekit/src/env.ts +1 -0
  52. package/templates/sveltekit/src/lib/components/Seo.svelte +73 -0
  53. package/templates/sveltekit/src/lib/preview.ts +1 -0
  54. package/templates/sveltekit/src/lib/seo.ts +69 -0
  55. package/templates/sveltekit/src/lib/server/config.ts +5 -0
  56. package/templates/sveltekit/src/lib/server/shapio.ts +24 -3
  57. package/templates/sveltekit/src/lib/types.ts +7 -0
  58. package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +8 -2
  59. package/templates/sveltekit/src/routes/[locale]/[...slug]/+page.svelte +2 -6
  60. package/templates/sveltekit/src/routes/[locale]/articles/+page.svelte +2 -3
  61. package/templates/sveltekit/src/routes/[locale]/articles/[slug]/+page.svelte +2 -6
  62. package/templates/sveltekit/src/routes/[locale]/colophon/+page.svelte +2 -3
package/dist/index.js CHANGED
@@ -75,6 +75,10 @@ export const config = defineConfig({
75
75
  // ['star-rating.js']. The admin loads them at runtime; installing one is a file copy plus a restart.
76
76
  editors: [],
77
77
 
78
+ // Admin colour themes, listed after the built-in ones. Each variant sets every UI token; see
79
+ // documentation/extensions.md (Admin themes) and examples/extension/extensions/sepiaTheme.ts.
80
+ // themes: [{ key: 'acme', name: 'Acme', dark: { background: '#101014', foreground: '#f4f4f5', ... } }],
81
+
78
82
  // Custom Fastify routes, mounted at /api/ext/<prefix>. Mutating routes must declare config.audit.
79
83
  // routes: [{ prefix: 'acme', plugin: async (app) => { app.get('/ping', async () => ({ ok: true })); } }],
80
84
 
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/templates.ts","../src/scaffold.ts","../src/starters.ts","../src/site.ts","../src/main.ts","../src/index.ts"],"sourcesContent":["export type TemplateInput = {\n projectName: string;\n shapioSpec: string;\n databaseUrl: string;\n sessionSecret: string;\n};\n\nconst packageJson = ({ projectName, shapioSpec }: TemplateInput) =>\n `${JSON.stringify(\n {\n name: projectName,\n private: true,\n type: 'module',\n engines: { node: '>=24' },\n scripts: {\n start: 'shapio start',\n migrate: 'shapio migrate',\n worker: 'shapio worker',\n status: 'shapio status',\n },\n dependencies: { '@shapio/cms': shapioSpec },\n },\n null,\n 2,\n )}\\n`;\n\nconst env = ({ databaseUrl, sessionSecret }: TemplateInput) =>\n [\n '# Shapio configuration. Every setting is an environment variable; the `shapio` command loads this file.',\n '# Real environment variables override values here.',\n 'NODE_ENV=production',\n 'HOST=127.0.0.1',\n 'PORT=4300',\n 'LOG_LEVEL=info',\n '',\n '# The origin people reach Shapio at; every absolute URL is built from it. Change it when you deploy.',\n 'PUBLIC_URL=http://localhost:4300',\n '# BASE_PATH=/cms',\n '',\n '# HTTPS without a reverse proxy: set PUBLIC_URL=https://your.domain and PORT=443, and point these at your',\n '# certificate and key (PEM). Renewed files are picked up without a restart. HTTP_PORT=80 redirects to HTTPS.',\n '# TLS_CERT_FILE=/etc/shapio/tls/fullchain.pem',\n '# TLS_KEY_FILE=/etc/shapio/tls/privkey.pem',\n '# HTTP_PORT=80',\n '',\n '# PostgreSQL >= 16, or sqlite:<path> for a single-process install',\n `DATABASE_URL=${databaseUrl}`,\n 'MIGRATE_ON_START=true',\n '',\n '# Signs preview links, private-media URLs and app-user tokens, and encrypts stored secrets.',\n '# Generated for this project; keep it secret, and back it up with the database.',\n `SESSION_SECRET=${sessionSecret}`,\n '',\n '# SETUP_REQUIRE_TOKEN=true # require the logged one-time token for first-run setup (for installs exposed before setup)',\n '',\n '# inline: jobs run inside the server process. dedicated: run `npm run worker` as a second process.',\n 'WORKER_MODE=inline',\n '',\n 'MEDIA_PATH=./media',\n '',\n ].join('\\n');\n\nconst shapioConfig =\n () => `// Project configuration for Shapio: code extensions (hooks, custom routes, services, jobs) and custom\n// field editors. Content models are NOT configured here; they are created live in the admin UI and synced\n// with \\`shapio schema pull\\` / \\`shapio schema apply\\`. This file is code: changing it, or anything in\n// ./extensions, needs a restart (never a rebuild). Check it with \\`npx shapio extensions check\\`.\n// TypeScript works as is (erasable syntax only: no enums or namespaces). Guide: documentation/extensions.md in Shapio's repository.\nimport { defineConfig } from '@shapio/cms/config';\n\nexport const config = defineConfig({\n // Lifecycle hooks by model API ID ('*' = every model). before* hooks run inside the write's transaction\n // and may reject it (422 HOOK_REJECTED); after* hooks run after commit, as retried jobs.\n hooks: {\n // article: {\n // beforePublish: ({ data, reject }) => {\n // if (!data?.cover) reject('An article needs a cover image');\n // },\n // },\n },\n\n // Custom React field editors: file names of ES modules built into ./extensions/editors/, e.g.\n // ['star-rating.js']. The admin loads them at runtime; installing one is a file copy plus a restart.\n editors: [],\n\n // Custom Fastify routes, mounted at /api/ext/<prefix>. Mutating routes must declare config.audit.\n // routes: [{ prefix: 'acme', plugin: async (app) => { app.get('/ping', async () => ({ ok: true })); } }],\n\n // Services constructed once at startup and shared by hooks, routes and jobs.\n // services: { stats: ({ services }) => ({ articles: () => services.content.count('article') }) },\n\n // Background jobs, registered as ext.<name>; enqueue with services.jobs.enqueue('<name>', payload).\n // jobs: { nightlyReport: async ({ services, logger }) => { logger.info('report'); } },\n});\n`;\n\nconst gitignore = () => `node_modules/\n.env\nmedia/*\n!media/.gitkeep\n`;\n\nconst pm2Ecosystem = ({ projectName }: TemplateInput) => `// PM2 process file: pm2 start ecosystem.config.cjs\n// One instance in fork mode, the simplest setup. Several instances on one database are supported too: use the\n// cluster variant below.\nmodule.exports = {\n apps: [\n {\n name: ${JSON.stringify(projectName)},\n script: 'node_modules/@shapio/cms/dist/cli.js',\n args: 'start',\n exec_mode: 'fork',\n instances: 1,\n kill_timeout: 20000,\n env: { NODE_ENV: 'production' },\n },\n // Cluster variant (replace the app above): two processes sharing the port, each with its inline worker.\n // { name: ${JSON.stringify(projectName)}, script: 'node_modules/@shapio/cms/dist/cli.js', args: 'start', exec_mode: 'cluster', instances: 2, kill_timeout: 20000, env: { NODE_ENV: 'production' } },\n ],\n};\n`;\n\nconst readme = ({ projectName }: TemplateInput) =>\n `# ${projectName}\n\nA [Shapio](https://github.com/mybrokengnome/shapio) project.\n\n1. Set \\`DATABASE_URL\\` in \\`.env\\` to a PostgreSQL (>= 16) database, or to \\`sqlite:<path>\\` for a\n single-process install.\n2. \\`npm run start\\`. Migrations run on start. The first boot logs where to create the owner account: open\n \\`/admin/\\`; the first person to complete Setup becomes the owner.\n3. Under PM2: \\`pm2 start ecosystem.config.cjs\\`.\n\nContent models are edited live in the admin; no restart is needed for modelling. Changing \\`shapio.config.ts\\`\nor \\`extensions/\\` (code) needs a restart; \\`npx shapio extensions check\\` validates them.\n`;\n\n/** Files to write, relative to the project directory. */\nexport const renderTemplates = (input: TemplateInput): ReadonlyArray<[path: string, content: string]> => [\n ['package.json', packageJson(input)],\n ['.env', env(input)],\n [\n '.env.example',\n env({ ...input, databaseUrl: 'postgres://user:password@localhost:5432/shapio', sessionSecret: '' }),\n ],\n ['shapio.config.ts', shapioConfig()],\n ['.gitignore', gitignore()],\n ['ecosystem.config.cjs', pm2Ecosystem(input)],\n ['README.md', readme(input)],\n ['extensions/.gitkeep', ''],\n ['extensions/editors/.gitkeep', ''],\n ['media/.gitkeep', ''],\n];\n","import { randomBytes } from 'node:crypto';\nimport { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';\nimport { basename, dirname, join, resolve } from 'node:path';\nimport { renderTemplates } from './templates.js';\n\nexport type ScaffoldOptions = {\n directory: string;\n shapioSpec: string;\n databaseUrl?: string;\n};\n\nexport class ScaffoldError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ScaffoldError';\n }\n}\n\n/** npm package names: lowercase, URL-safe. Derived from the directory name. */\nexport const toPackageName = (directory: string): string =>\n basename(resolve(directory))\n .toLowerCase()\n .replace(/[^a-z0-9._-]+/g, '-')\n .replace(/^[._-]+|[-.]+$/g, '') || 'shapio-project';\n\n/** Writes the project files. Refuses to touch a non-empty directory. Returns the absolute project path. */\nexport const scaffoldProject = ({ directory, shapioSpec, databaseUrl }: ScaffoldOptions): string => {\n const root = resolve(directory);\n if (existsSync(root) && readdirSync(root).length > 0) {\n throw new ScaffoldError(`${root} exists and is not empty`);\n }\n const projectName = toPackageName(root);\n const files = renderTemplates({\n projectName,\n shapioSpec,\n databaseUrl:\n databaseUrl ?? `postgres://postgres:postgres@localhost:5432/${projectName.replace(/[^a-z0-9_]/g, '_')}`,\n sessionSecret: randomBytes(32).toString('base64url'),\n });\n for (const [path, content] of files) {\n const target = join(root, path);\n mkdirSync(dirname(target), { recursive: true });\n writeFileSync(target, content, { flag: 'wx' });\n }\n return root;\n};\n","/** The site starters `create-shapio --site` scaffolds (examples/<name> in the repository). */\nexport const SITE_STARTERS = ['astro', 'next', 'sveltekit'] as const;\n\nexport type SiteStarter = (typeof SITE_STARTERS)[number];\n\nexport const isSiteStarter = (value: string): value is SiteStarter =>\n (SITE_STARTERS as readonly string[]).includes(value);\n\n/** npm leaves `.gitignore` out of published packages, so templates carry it under this name. */\nexport const PACKED_GITIGNORE = 'gitignore';\n","import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport { ScaffoldError, toPackageName } from './scaffold.js';\nimport { PACKED_GITIGNORE, type SiteStarter } from './starters.js';\n\nexport type ScaffoldSiteOptions = {\n directory: string;\n site: SiteStarter;\n /** The packed templates (templates/ next to dist/ in the published package). */\n templatesDir: string;\n};\n\n/**\n * Writes a standalone site project from a starter template: the framework app, the blog's model files in\n * shapio/, and the seed and smoke scripts. Refuses to touch a non-empty directory. Returns the project path.\n */\nexport const scaffoldSite = ({ directory, site, templatesDir }: ScaffoldSiteOptions): string => {\n const root = resolve(directory);\n if (existsSync(root) && readdirSync(root).length > 0) {\n throw new ScaffoldError(`${root} exists and is not empty`);\n }\n const template = join(templatesDir, site);\n if (!existsSync(join(template, 'package.json'))) {\n throw new ScaffoldError(\n `The ${site} template is missing from ${templatesDir}; build create-shapio first`,\n );\n }\n cpSync(template, root, { recursive: true });\n if (existsSync(join(root, PACKED_GITIGNORE))) {\n renameSync(join(root, PACKED_GITIGNORE), join(root, '.gitignore'));\n }\n const packagePath = join(root, 'package.json');\n const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) as Record<string, unknown>;\n writeFileSync(\n packagePath,\n `${JSON.stringify({ ...packageJson, name: toPackageName(root), version: '0.1.0' }, null, 2)}\\n`,\n );\n return root;\n};\n","import { spawnSync } from 'node:child_process';\nimport { readFileSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport { parseArgs } from 'node:util';\nimport { scaffoldProject, ScaffoldError } from './scaffold.js';\nimport { scaffoldSite } from './site.js';\nimport { isSiteStarter, SITE_STARTERS, type SiteStarter } from './starters.js';\n\nconst USAGE = `Usage: npx create-shapio <directory> [options]\n\nWithout --site: a Shapio project (the CMS server). With --site: a website that reads content from Shapio.\n\nOptions:\n --site <${SITE_STARTERS.join('|')}> Create a site starter instead (a blog: pages, articles, authors)\n --database-url <url> Database URL to put in .env (PostgreSQL, or sqlite:<path> for a single-process\n install; CMS project only)\n --shapio-spec <spec> Dependency spec for shapio (default: ^<this version>; a tarball path works too)\n --no-install Skip npm install\n -h, --help Show this help\n`;\n\n/** Packed from the repository's examples/ when create-shapio is built (templates/ beside dist/). */\nconst TEMPLATES_DIR = fileURLToPath(new URL('../templates/', import.meta.url));\n\nconst ownVersion = (): string => {\n const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {\n version: string;\n };\n return packageJson.version;\n};\n\ntype Output = { out: (text: string) => void; err: (text: string) => void };\n\n/** Returns the exit code. */\nexport const main = (argv: readonly string[], { out, err }: Output): number => {\n const { values, positionals } = parseArgs({\n args: [...argv],\n allowPositionals: true,\n allowNegative: true,\n options: {\n 'database-url': { type: 'string' },\n 'shapio-spec': { type: 'string' },\n site: { type: 'string' },\n install: { type: 'boolean', default: true },\n help: { type: 'boolean', short: 'h' },\n },\n });\n const [directory] = positionals;\n if (values.help || !directory) {\n (directory ? out : err)(USAGE);\n return values.help ? 0 : 1;\n }\n const site = values.site;\n if (site !== undefined && !isSiteStarter(site)) {\n err(`create-shapio: --site must be one of ${SITE_STARTERS.join(', ')}\\n`);\n return 1;\n }\n if (site !== undefined && (values['database-url'] || values['shapio-spec'])) {\n err('create-shapio: --database-url and --shapio-spec are for a CMS project, not --site\\n');\n return 1;\n }\n let root: string;\n try {\n root = site\n ? scaffoldSite({ directory, site, templatesDir: TEMPLATES_DIR })\n : scaffoldProject({\n directory,\n shapioSpec: values['shapio-spec'] ?? `^${ownVersion()}`,\n ...(values['database-url'] ? { databaseUrl: values['database-url'] } : {}),\n });\n } catch (error) {\n if (error instanceof ScaffoldError) {\n err(`create-shapio: ${error.message}\\n`);\n return 1;\n }\n throw error;\n }\n out(`Created a ${site ? `${site} site` : 'Shapio project'} in ${root}\\n`);\n if (values.install) {\n out('Installing dependencies with npm...\\n');\n const result = spawnSync('npm', ['install'], { cwd: root, stdio: 'inherit' });\n if (result.status !== 0) {\n err('create-shapio: npm install failed; run it yourself in the project directory\\n');\n return 1;\n }\n }\n out(\n site\n ? siteNextSteps(directory, site)\n : `\\nNext:\\n cd ${directory}\\n edit .env (DATABASE_URL)\\n npm run start\\n`,\n );\n return 0;\n};\n\nconst SERVE_COMMAND: Record<SiteStarter, string> = {\n astro: 'npm run preview',\n next: 'npm run start',\n sveltekit: 'npm run preview',\n};\n\nconst siteNextSteps = (directory: string, site: SiteStarter) =>\n [\n '',\n 'Next (with a running Shapio; README.md has the details):',\n ` cd ${directory}`,\n ' SHAPIO_URL=http://localhost:4300 SHAPIO_ADMIN_EMAIL=you@example.com SHAPIO_ADMIN_PASSWORD=... npm run seed',\n ' npm run build',\n ` ${SERVE_COMMAND[site]}`,\n '',\n ].join('\\n');\n","#!/usr/bin/env node\nimport { main } from './main.js';\n\nprocess.exitCode = main(process.argv.slice(2), {\n out: (text) => process.stdout.write(text),\n err: (text) => process.stderr.write(text),\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/templates.ts","../src/scaffold.ts","../src/starters.ts","../src/site.ts","../src/main.ts","../src/index.ts"],"sourcesContent":["export type TemplateInput = {\n projectName: string;\n shapioSpec: string;\n databaseUrl: string;\n sessionSecret: string;\n};\n\nconst packageJson = ({ projectName, shapioSpec }: TemplateInput) =>\n `${JSON.stringify(\n {\n name: projectName,\n private: true,\n type: 'module',\n engines: { node: '>=24' },\n scripts: {\n start: 'shapio start',\n migrate: 'shapio migrate',\n worker: 'shapio worker',\n status: 'shapio status',\n },\n dependencies: { '@shapio/cms': shapioSpec },\n },\n null,\n 2,\n )}\\n`;\n\nconst env = ({ databaseUrl, sessionSecret }: TemplateInput) =>\n [\n '# Shapio configuration. Every setting is an environment variable; the `shapio` command loads this file.',\n '# Real environment variables override values here.',\n 'NODE_ENV=production',\n 'HOST=127.0.0.1',\n 'PORT=4300',\n 'LOG_LEVEL=info',\n '',\n '# The origin people reach Shapio at; every absolute URL is built from it. Change it when you deploy.',\n 'PUBLIC_URL=http://localhost:4300',\n '# BASE_PATH=/cms',\n '',\n '# HTTPS without a reverse proxy: set PUBLIC_URL=https://your.domain and PORT=443, and point these at your',\n '# certificate and key (PEM). Renewed files are picked up without a restart. HTTP_PORT=80 redirects to HTTPS.',\n '# TLS_CERT_FILE=/etc/shapio/tls/fullchain.pem',\n '# TLS_KEY_FILE=/etc/shapio/tls/privkey.pem',\n '# HTTP_PORT=80',\n '',\n '# PostgreSQL >= 16, or sqlite:<path> for a single-process install',\n `DATABASE_URL=${databaseUrl}`,\n 'MIGRATE_ON_START=true',\n '',\n '# Signs preview links, private-media URLs and app-user tokens, and encrypts stored secrets.',\n '# Generated for this project; keep it secret, and back it up with the database.',\n `SESSION_SECRET=${sessionSecret}`,\n '',\n '# SETUP_REQUIRE_TOKEN=true # require the logged one-time token for first-run setup (for installs exposed before setup)',\n '',\n '# inline: jobs run inside the server process. dedicated: run `npm run worker` as a second process.',\n 'WORKER_MODE=inline',\n '',\n 'MEDIA_PATH=./media',\n '',\n ].join('\\n');\n\nconst shapioConfig =\n () => `// Project configuration for Shapio: code extensions (hooks, custom routes, services, jobs) and custom\n// field editors. Content models are NOT configured here; they are created live in the admin UI and synced\n// with \\`shapio schema pull\\` / \\`shapio schema apply\\`. This file is code: changing it, or anything in\n// ./extensions, needs a restart (never a rebuild). Check it with \\`npx shapio extensions check\\`.\n// TypeScript works as is (erasable syntax only: no enums or namespaces). Guide: documentation/extensions.md in Shapio's repository.\nimport { defineConfig } from '@shapio/cms/config';\n\nexport const config = defineConfig({\n // Lifecycle hooks by model API ID ('*' = every model). before* hooks run inside the write's transaction\n // and may reject it (422 HOOK_REJECTED); after* hooks run after commit, as retried jobs.\n hooks: {\n // article: {\n // beforePublish: ({ data, reject }) => {\n // if (!data?.cover) reject('An article needs a cover image');\n // },\n // },\n },\n\n // Custom React field editors: file names of ES modules built into ./extensions/editors/, e.g.\n // ['star-rating.js']. The admin loads them at runtime; installing one is a file copy plus a restart.\n editors: [],\n\n // Admin colour themes, listed after the built-in ones. Each variant sets every UI token; see\n // documentation/extensions.md (Admin themes) and examples/extension/extensions/sepiaTheme.ts.\n // themes: [{ key: 'acme', name: 'Acme', dark: { background: '#101014', foreground: '#f4f4f5', ... } }],\n\n // Custom Fastify routes, mounted at /api/ext/<prefix>. Mutating routes must declare config.audit.\n // routes: [{ prefix: 'acme', plugin: async (app) => { app.get('/ping', async () => ({ ok: true })); } }],\n\n // Services constructed once at startup and shared by hooks, routes and jobs.\n // services: { stats: ({ services }) => ({ articles: () => services.content.count('article') }) },\n\n // Background jobs, registered as ext.<name>; enqueue with services.jobs.enqueue('<name>', payload).\n // jobs: { nightlyReport: async ({ services, logger }) => { logger.info('report'); } },\n});\n`;\n\nconst gitignore = () => `node_modules/\n.env\nmedia/*\n!media/.gitkeep\n`;\n\nconst pm2Ecosystem = ({ projectName }: TemplateInput) => `// PM2 process file: pm2 start ecosystem.config.cjs\n// One instance in fork mode, the simplest setup. Several instances on one database are supported too: use the\n// cluster variant below.\nmodule.exports = {\n apps: [\n {\n name: ${JSON.stringify(projectName)},\n script: 'node_modules/@shapio/cms/dist/cli.js',\n args: 'start',\n exec_mode: 'fork',\n instances: 1,\n kill_timeout: 20000,\n env: { NODE_ENV: 'production' },\n },\n // Cluster variant (replace the app above): two processes sharing the port, each with its inline worker.\n // { name: ${JSON.stringify(projectName)}, script: 'node_modules/@shapio/cms/dist/cli.js', args: 'start', exec_mode: 'cluster', instances: 2, kill_timeout: 20000, env: { NODE_ENV: 'production' } },\n ],\n};\n`;\n\nconst readme = ({ projectName }: TemplateInput) =>\n `# ${projectName}\n\nA [Shapio](https://github.com/mybrokengnome/shapio) project.\n\n1. Set \\`DATABASE_URL\\` in \\`.env\\` to a PostgreSQL (>= 16) database, or to \\`sqlite:<path>\\` for a\n single-process install.\n2. \\`npm run start\\`. Migrations run on start. The first boot logs where to create the owner account: open\n \\`/admin/\\`; the first person to complete Setup becomes the owner.\n3. Under PM2: \\`pm2 start ecosystem.config.cjs\\`.\n\nContent models are edited live in the admin; no restart is needed for modelling. Changing \\`shapio.config.ts\\`\nor \\`extensions/\\` (code) needs a restart; \\`npx shapio extensions check\\` validates them.\n`;\n\n/** Files to write, relative to the project directory. */\nexport const renderTemplates = (input: TemplateInput): ReadonlyArray<[path: string, content: string]> => [\n ['package.json', packageJson(input)],\n ['.env', env(input)],\n [\n '.env.example',\n env({ ...input, databaseUrl: 'postgres://user:password@localhost:5432/shapio', sessionSecret: '' }),\n ],\n ['shapio.config.ts', shapioConfig()],\n ['.gitignore', gitignore()],\n ['ecosystem.config.cjs', pm2Ecosystem(input)],\n ['README.md', readme(input)],\n ['extensions/.gitkeep', ''],\n ['extensions/editors/.gitkeep', ''],\n ['media/.gitkeep', ''],\n];\n","import { randomBytes } from 'node:crypto';\nimport { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';\nimport { basename, dirname, join, resolve } from 'node:path';\nimport { renderTemplates } from './templates.js';\n\nexport type ScaffoldOptions = {\n directory: string;\n shapioSpec: string;\n databaseUrl?: string;\n};\n\nexport class ScaffoldError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ScaffoldError';\n }\n}\n\n/** npm package names: lowercase, URL-safe. Derived from the directory name. */\nexport const toPackageName = (directory: string): string =>\n basename(resolve(directory))\n .toLowerCase()\n .replace(/[^a-z0-9._-]+/g, '-')\n .replace(/^[._-]+|[-.]+$/g, '') || 'shapio-project';\n\n/** Writes the project files. Refuses to touch a non-empty directory. Returns the absolute project path. */\nexport const scaffoldProject = ({ directory, shapioSpec, databaseUrl }: ScaffoldOptions): string => {\n const root = resolve(directory);\n if (existsSync(root) && readdirSync(root).length > 0) {\n throw new ScaffoldError(`${root} exists and is not empty`);\n }\n const projectName = toPackageName(root);\n const files = renderTemplates({\n projectName,\n shapioSpec,\n databaseUrl:\n databaseUrl ?? `postgres://postgres:postgres@localhost:5432/${projectName.replace(/[^a-z0-9_]/g, '_')}`,\n sessionSecret: randomBytes(32).toString('base64url'),\n });\n for (const [path, content] of files) {\n const target = join(root, path);\n mkdirSync(dirname(target), { recursive: true });\n writeFileSync(target, content, { flag: 'wx' });\n }\n return root;\n};\n","/** The site starters `create-shapio --site` scaffolds (examples/<name> in the repository). */\nexport const SITE_STARTERS = ['astro', 'next', 'sveltekit'] as const;\n\nexport type SiteStarter = (typeof SITE_STARTERS)[number];\n\nexport const isSiteStarter = (value: string): value is SiteStarter =>\n (SITE_STARTERS as readonly string[]).includes(value);\n\n/** npm leaves `.gitignore` out of published packages, so templates carry it under this name. */\nexport const PACKED_GITIGNORE = 'gitignore';\n","import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport { ScaffoldError, toPackageName } from './scaffold.js';\nimport { PACKED_GITIGNORE, type SiteStarter } from './starters.js';\n\nexport type ScaffoldSiteOptions = {\n directory: string;\n site: SiteStarter;\n /** The packed templates (templates/ next to dist/ in the published package). */\n templatesDir: string;\n};\n\n/**\n * Writes a standalone site project from a starter template: the framework app, the blog's model files in\n * shapio/, and the seed and smoke scripts. Refuses to touch a non-empty directory. Returns the project path.\n */\nexport const scaffoldSite = ({ directory, site, templatesDir }: ScaffoldSiteOptions): string => {\n const root = resolve(directory);\n if (existsSync(root) && readdirSync(root).length > 0) {\n throw new ScaffoldError(`${root} exists and is not empty`);\n }\n const template = join(templatesDir, site);\n if (!existsSync(join(template, 'package.json'))) {\n throw new ScaffoldError(\n `The ${site} template is missing from ${templatesDir}; build create-shapio first`,\n );\n }\n cpSync(template, root, { recursive: true });\n if (existsSync(join(root, PACKED_GITIGNORE))) {\n renameSync(join(root, PACKED_GITIGNORE), join(root, '.gitignore'));\n }\n const packagePath = join(root, 'package.json');\n const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) as Record<string, unknown>;\n writeFileSync(\n packagePath,\n `${JSON.stringify({ ...packageJson, name: toPackageName(root), version: '0.1.0' }, null, 2)}\\n`,\n );\n return root;\n};\n","import { spawnSync } from 'node:child_process';\nimport { readFileSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport { parseArgs } from 'node:util';\nimport { scaffoldProject, ScaffoldError } from './scaffold.js';\nimport { scaffoldSite } from './site.js';\nimport { isSiteStarter, SITE_STARTERS, type SiteStarter } from './starters.js';\n\nconst USAGE = `Usage: npx create-shapio <directory> [options]\n\nWithout --site: a Shapio project (the CMS server). With --site: a website that reads content from Shapio.\n\nOptions:\n --site <${SITE_STARTERS.join('|')}> Create a site starter instead (a blog: pages, articles, authors)\n --database-url <url> Database URL to put in .env (PostgreSQL, or sqlite:<path> for a single-process\n install; CMS project only)\n --shapio-spec <spec> Dependency spec for shapio (default: ^<this version>; a tarball path works too)\n --no-install Skip npm install\n -h, --help Show this help\n`;\n\n/** Packed from the repository's examples/ when create-shapio is built (templates/ beside dist/). */\nconst TEMPLATES_DIR = fileURLToPath(new URL('../templates/', import.meta.url));\n\nconst ownVersion = (): string => {\n const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {\n version: string;\n };\n return packageJson.version;\n};\n\ntype Output = { out: (text: string) => void; err: (text: string) => void };\n\n/** Returns the exit code. */\nexport const main = (argv: readonly string[], { out, err }: Output): number => {\n const { values, positionals } = parseArgs({\n args: [...argv],\n allowPositionals: true,\n allowNegative: true,\n options: {\n 'database-url': { type: 'string' },\n 'shapio-spec': { type: 'string' },\n site: { type: 'string' },\n install: { type: 'boolean', default: true },\n help: { type: 'boolean', short: 'h' },\n },\n });\n const [directory] = positionals;\n if (values.help || !directory) {\n (directory ? out : err)(USAGE);\n return values.help ? 0 : 1;\n }\n const site = values.site;\n if (site !== undefined && !isSiteStarter(site)) {\n err(`create-shapio: --site must be one of ${SITE_STARTERS.join(', ')}\\n`);\n return 1;\n }\n if (site !== undefined && (values['database-url'] || values['shapio-spec'])) {\n err('create-shapio: --database-url and --shapio-spec are for a CMS project, not --site\\n');\n return 1;\n }\n let root: string;\n try {\n root = site\n ? scaffoldSite({ directory, site, templatesDir: TEMPLATES_DIR })\n : scaffoldProject({\n directory,\n shapioSpec: values['shapio-spec'] ?? `^${ownVersion()}`,\n ...(values['database-url'] ? { databaseUrl: values['database-url'] } : {}),\n });\n } catch (error) {\n if (error instanceof ScaffoldError) {\n err(`create-shapio: ${error.message}\\n`);\n return 1;\n }\n throw error;\n }\n out(`Created a ${site ? `${site} site` : 'Shapio project'} in ${root}\\n`);\n if (values.install) {\n out('Installing dependencies with npm...\\n');\n const result = spawnSync('npm', ['install'], { cwd: root, stdio: 'inherit' });\n if (result.status !== 0) {\n err('create-shapio: npm install failed; run it yourself in the project directory\\n');\n return 1;\n }\n }\n out(\n site\n ? siteNextSteps(directory, site)\n : `\\nNext:\\n cd ${directory}\\n edit .env (DATABASE_URL)\\n npm run start\\n`,\n );\n return 0;\n};\n\nconst SERVE_COMMAND: Record<SiteStarter, string> = {\n astro: 'npm run preview',\n next: 'npm run start',\n sveltekit: 'npm run preview',\n};\n\nconst siteNextSteps = (directory: string, site: SiteStarter) =>\n [\n '',\n 'Next (with a running Shapio; README.md has the details):',\n ` cd ${directory}`,\n ' SHAPIO_URL=http://localhost:4300 SHAPIO_ADMIN_EMAIL=you@example.com SHAPIO_ADMIN_PASSWORD=... npm run seed',\n ' npm run build',\n ` ${SERVE_COMMAND[site]}`,\n '',\n ].join('\\n');\n","#!/usr/bin/env node\nimport { main } from './main.js';\n\nprocess.exitCode = main(process.argv.slice(2), {\n out: (text) => process.stdout.write(text),\n err: (text) => process.stderr.write(text),\n});\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-shapio",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Scaffold a new Shapio project (npx create-shapio my-cms) or a site starter (--site astro|next|sveltekit)",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://shapio.dev",
@@ -8,3 +8,5 @@ SHAPIO_DELIVERY_TOKEN=
8
8
  # SHAPIO_SITE=marketing
9
9
  # Optional: the origin the browser calls for draft previews (defaults to SHAPIO_URL).
10
10
  # PUBLIC_SHAPIO_URL=https://cms.example.com
11
+ # Optional: this site's public origin; pages then carry a canonical URL and og:url.
12
+ # SITE_URL=https://www.example.com
@@ -19,8 +19,8 @@
19
19
  "typecheck": "tsc -p tsconfig.json"
20
20
  },
21
21
  "dependencies": {
22
- "@shapio/client": "^0.3.0",
23
- "@shapio/visual": "^0.3.0",
22
+ "@shapio/client": "^0.4.0",
23
+ "@shapio/visual": "^0.4.0",
24
24
  "astro": "7.3.5"
25
25
  },
26
26
  "devDependencies": {
@@ -448,15 +448,56 @@ export type EntrySpec = {
448
448
  publish: boolean;
449
449
  };
450
450
 
451
+ /**
452
+ * The SEO fields of one locale (the built-in `seo` component, per locale): a page describes itself with its
453
+ * description, an article with its excerpt and its cover (a shared field, so it comes from the English data).
454
+ * The title stays empty: `?seo=resolved` falls back to the entry's own title, through the site's template.
455
+ */
456
+ const seoOf = (data: Record<string, unknown>, shared: Record<string, unknown>) => ({
457
+ description: data.excerpt ?? data.description ?? null,
458
+ image: shared.cover ?? null,
459
+ });
460
+
461
+ const withSeo = (content: Localized<Record<string, unknown>>): Localized<Record<string, unknown>> => ({
462
+ en: { ...content.en, seo: seoOf(content.en, content.en) },
463
+ fr: { ...content.fr, seo: seoOf(content.fr, content.en) },
464
+ });
465
+
451
466
  export const entries = (media: MediaIds, authorId: string): EntrySpec[] => [
452
- { model: 'page', slug: 'home', content: home(media), publish: true },
453
- { model: 'page', slug: 'about', content: about(), publish: true },
467
+ { model: 'page', slug: 'home', content: withSeo(home(media)), publish: true },
468
+ { model: 'page', slug: 'about', content: withSeo(about()), publish: true },
454
469
  {
455
470
  model: 'article',
456
471
  slug: 'modelling-without-a-deploy',
457
- content: modelling(media, authorId),
472
+ content: withSeo(modelling(media, authorId)),
473
+ publish: true,
474
+ },
475
+ {
476
+ model: 'article',
477
+ slug: 'why-builds-pin-a-snapshot',
478
+ content: withSeo(snapshots(media, authorId)),
458
479
  publish: true,
459
480
  },
460
- { model: 'article', slug: 'why-builds-pin-a-snapshot', content: snapshots(media, authorId), publish: true },
461
- { model: 'article', slug: 'winter-projects', content: draft(media, authorId), publish: false },
481
+ { model: 'article', slug: 'winter-projects', content: withSeo(draft(media, authorId)), publish: false },
462
482
  ];
483
+
484
+ /**
485
+ * The site's SEO defaults (Settings → SEO; `PUT /api/admin/site/seo`): the name and template match the
486
+ * siteSettings singleton's site name, the description its tagline; the hero image is the default social image.
487
+ */
488
+ export const seoDefaults = (media: MediaIds) => ({
489
+ locales: {
490
+ en: {
491
+ siteName: String(SITE_SETTINGS.en.siteName),
492
+ titleTemplate: `%s · ${String(SITE_SETTINGS.en.siteName)}`,
493
+ description: String(SITE_SETTINGS.en.tagline),
494
+ },
495
+ fr: {
496
+ siteName: String(SITE_SETTINGS.fr.siteName),
497
+ titleTemplate: `%s · ${String(SITE_SETTINGS.fr.siteName)}`,
498
+ description: String(SITE_SETTINGS.fr.tagline),
499
+ },
500
+ },
501
+ imageId: media.hero,
502
+ twitterHandle: '@northwindstudio',
503
+ });
@@ -4,6 +4,7 @@ import { setTimeout as delay } from 'node:timers/promises';
4
4
  import { parseArgs } from 'node:util';
5
5
  import {
6
6
  buildUploadForm,
7
+ SEO_COMPONENT_ID,
7
8
  ShapioApiError,
8
9
  type MediaAsset,
9
10
  type SchemaApplyInput,
@@ -15,6 +16,7 @@ import {
15
16
  AUTHOR,
16
17
  entries,
17
18
  IMAGES,
19
+ seoDefaults,
18
20
  SITE_SETTINGS,
19
21
  type EntrySpec,
20
22
  type ImageSpec,
@@ -29,8 +31,9 @@ import { createPng } from './lib/png.js';
29
31
  * 2. the models and components in shapio/ through the schema apply API (live, no restart; the same planner
30
32
  * as `shapio schema apply` and the admin), as the site's own (SHAPIO_SITE, else the token's site, else the
31
33
  * primary): a starter's schema belongs to the first site it is seeded on;
34
+ * The built-in SEO component (components/seo.json) is the exception: it is always shared with every site;
32
35
  * 3. placeholder images, an author, the site settings, pages and articles in English and French, published
33
- * per locale;
36
+ * per locale, and the site's SEO defaults (Settings → SEO);
34
37
  * 4. a deployment connection named "Preview" whose preview URL opens this starter's /preview/ page (the
35
38
  * entry document's Preview pane and visual editing; it triggers no builds). The site's URL is SITE_URL, else
36
39
  * the starter's `--site-url` (its local dev server);
@@ -50,6 +53,8 @@ const PREVIEW_PATH = '/preview/?model={modelKey}&id={entryId}&locale={locale}#to
50
53
  /** A placeholder build hook: the connection has no triggers, so nothing is ever sent to it. */
51
54
  const PREVIEW_BUILD_HOOK = 'https://build.invalid/shapio-starter';
52
55
  const REVALIDATE_WEBHOOK_NAME = 'Site revalidation';
56
+ /** The request's site's SEO defaults (`site.settings`). */
57
+ const SEO_DEFAULTS_PATH = '/api/admin/site/seo';
53
58
  /** The events that change what a published site shows (the starter's route ignores any others). */
54
59
  const REVALIDATE_EVENTS = [
55
60
  'entry.published',
@@ -58,6 +63,8 @@ const REVALIDATE_EVENTS = [
58
63
  'change_set.shipped',
59
64
  'schema.activated',
60
65
  'schema.deleted',
66
+ // The site's SEO defaults changed: every page's head shows them.
67
+ 'site.updated',
61
68
  ];
62
69
  /** A local site needs Shapio's OUTBOUND_PRIVATE_NETWORK_ALLOWLIST too; the webhook opts in for loopback only. */
63
70
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
@@ -129,8 +136,11 @@ const applySchema = async (client: ShapioClient) => {
129
136
  try {
130
137
  response = await client.admin.schema.apply({
131
138
  definitions,
132
- // Every definition is the site's own (sites/<key>/ in a pulled tree), never shared by default.
133
- scopes: definitions.map(() => 'site' as const),
139
+ // Every definition is the site's own (sites/<key>/ in a pulled tree), never shared by default, except
140
+ // the built-in SEO component: Shapio knows it by its fixed ID, so one shared copy serves every site.
141
+ scopes: definitions.map((definition) =>
142
+ (definition as { id?: unknown }).id === SEO_COMPONENT_ID ? ('network' as const) : ('site' as const),
143
+ ),
134
144
  base: FIRST_APPLY_BASE,
135
145
  prune: false,
136
146
  dryRun: false,
@@ -143,7 +153,8 @@ const applySchema = async (client: ShapioClient) => {
143
153
  "This starter's schema already belongs to another site of this instance: a starter's schema belongs " +
144
154
  'to the first site it is seeded on. To reuse it on this site, share it with all sites ' +
145
155
  'with a network admin token (`shapio schema scope <apiKey> --shared --site <that site>` for each ' +
146
- 'component, then author, page, article and siteSettings) and seed again, or seed a separate instance.',
156
+ 'component, then author, page, article and siteSettings) and seed again, or seed a separate instance. ' +
157
+ 'The SEO component is always shared; a site that keeps its own copy of it blocks the others.',
147
158
  { cause: error },
148
159
  );
149
160
  }
@@ -259,6 +270,16 @@ const ensureSiteSettings = async (client: ShapioClient) => {
259
270
  log('Published siteSettings (en, fr)');
260
271
  };
261
272
 
273
+ /** The site's SEO defaults: the texts per locale, the default social image and the Twitter handle. */
274
+ const ensureSeoDefaults = async (client: ShapioClient, media: MediaIds) => {
275
+ const current = await client.request<{ version: number }>(SEO_DEFAULTS_PATH);
276
+ await client.request(SEO_DEFAULTS_PATH, {
277
+ method: 'PUT',
278
+ body: { expectedVersion: current.version, seo: seoDefaults(media) },
279
+ });
280
+ log('Set the SEO defaults (en, fr)');
281
+ };
282
+
262
283
  const upsertEntry = async (client: ShapioClient, spec: EntrySpec) => {
263
284
  const found = await client.admin.content.list(spec.model, {
264
285
  filters: { slug: { $eq: spec.slug } },
@@ -413,6 +434,7 @@ const main = async () => {
413
434
  await applySchema(admin.client);
414
435
  const media = await ensureMedia(admin.client);
415
436
  await ensureSiteSettings(admin.client);
437
+ await ensureSeoDefaults(admin.client, media);
416
438
  const authorId = await ensureAuthor(admin.client, media.avatar);
417
439
  for (const spec of entries(media, authorId)) {
418
440
  await upsertEntry(admin.client, spec);
@@ -62,6 +62,19 @@ const checkBuild = async () => {
62
62
  check(articleEn.includes('<table>') && articleEn.includes('With Shapio'), 'EN article: rich-text table');
63
63
  check(articleEn.includes('Ada Moreau'), 'EN article: populated author');
64
64
  check(articleFr.includes('Modéliser du contenu sans déploiement'), 'FR article: title');
65
+ check(
66
+ articleEn.includes('<title>Modelling content without a deploy · Northwind Studio</title>'),
67
+ 'EN article: SEO title through the site template',
68
+ );
69
+ check(
70
+ articleEn.includes('property="og:image"') &&
71
+ articleEn.includes('name="twitter:site" content="@northwindstudio"'),
72
+ 'EN article: Open Graph image and Twitter handle from the SEO fields and defaults',
73
+ );
74
+ check(
75
+ articleFr.includes('<title>Modéliser du contenu sans déploiement · Studio Northwind</title>'),
76
+ 'FR article: SEO title through the French template',
77
+ );
65
78
  check((await html('fr/about/index.html')).includes('À propos du studio'), 'FR about page');
66
79
  check(
67
80
  (await html('en/colophon/index.html')).includes('pinned to one publication snapshot per build'),
@@ -5,7 +5,8 @@ import { parseArgs } from 'node:util';
5
5
  * `npm run smoke`, after `npm run seed`, `npm run build` and starting the built site (`npm run start` or
6
6
  * `npm run preview`): fetches the site's pages over HTTP and checks the seeded content in English and French,
7
7
  * the site settings singleton, rich text, media variants and the author, that the draft article is not
8
- * served, that content carries the visual-editing attributes (@shapio/visual's `shapioAttr`), and that the
8
+ * served, that content carries the visual-editing attributes (@shapio/visual's `shapioAttr`), that pages carry
9
+ * their SEO tags (the entry's SEO fields with the site's defaults: title template, Open Graph, Twitter), and that the
9
10
  * preview page is served with a `frame-ancestors` policy. The same checks run against every starter, so all
10
11
  * three render the same blog.
11
12
  *
@@ -28,6 +29,9 @@ const PAGES: readonly PageCheck[] = [
28
29
  ['.webp', 'responsive image variants'],
29
30
  ['Northwind Studio', 'site name from the siteSettings singleton'],
30
31
  ['Built with Shapio', 'footer from the siteSettings singleton'],
32
+ ['<title>Home · Northwind Studio</title>', 'title through the SEO title template'],
33
+ ['property="og:image"', 'og:image (the default social image)'],
34
+ ['name="twitter:site" content="@northwindstudio"', 'twitter:site from the SEO defaults'],
31
35
  ],
32
36
  },
33
37
  {
@@ -36,6 +40,7 @@ const PAGES: readonly PageCheck[] = [
36
40
  ['Un contenu qui évolue aussi vite que votre produit', 'hero heading'],
37
41
  ['lang="fr"', 'lang="fr"'],
38
42
  ['Studio Northwind', 'site name from the siteSettings singleton'],
43
+ ['<title>Accueil · Studio Northwind</title>', 'title through the French SEO title template'],
39
44
  ],
40
45
  },
41
46
  {
@@ -43,6 +48,7 @@ const PAGES: readonly PageCheck[] = [
43
48
  expect: [
44
49
  ['Modelling content without a deploy', 'article list'],
45
50
  ['Why our builds pin a snapshot', 'second article'],
51
+ ['<title>Journal · Northwind Studio</title>', 'a page without an entry: title through the template'],
46
52
  ],
47
53
  },
48
54
  {
@@ -55,6 +61,10 @@ const PAGES: readonly PageCheck[] = [
55
61
  ['data-shapio-entry="', 'visual-editing entry attribute'],
56
62
  ['data-shapio-path="title"', 'visual-editing attribute on the title'],
57
63
  ['data-shapio-path="body"', 'visual-editing attribute on the body'],
64
+ ['<title>Modelling content without a deploy · Northwind Studio</title>', 'SEO title (resolved)'],
65
+ ['property="og:type" content="article"', 'og:type article'],
66
+ ['Nothing restarted.', 'SEO description from the entry'],
67
+ ['name="twitter:card" content="summary_large_image"', 'twitter:card with the cover image'],
58
68
  ],
59
69
  },
60
70
  { path: `/fr/articles/${ARTICLE}/`, expect: [['Modéliser du contenu sans déploiement', 'title']] },
@@ -0,0 +1,114 @@
1
+ {
2
+ "apiKey": "seo",
3
+ "category": "Built-in",
4
+ "description": "Search and social metadata: title, description, image, canonical URL and indexing.",
5
+ "display": {
6
+ "titleFieldId": "3fe294a9-6af9-4019-8b32-43010822e45a"
7
+ },
8
+ "fields": [
9
+ {
10
+ "apiKey": "title",
11
+ "deprecated": false,
12
+ "editor": {
13
+ "id": "textInput",
14
+ "options": {}
15
+ },
16
+ "filterable": false,
17
+ "id": "3fe294a9-6af9-4019-8b32-43010822e45a",
18
+ "label": "Title",
19
+ "localized": false,
20
+ "public": true,
21
+ "required": false,
22
+ "settings": {},
23
+ "sortable": false,
24
+ "type": "string",
25
+ "unique": false
26
+ },
27
+ {
28
+ "apiKey": "description",
29
+ "deprecated": false,
30
+ "editor": {
31
+ "id": "textarea",
32
+ "options": {}
33
+ },
34
+ "filterable": false,
35
+ "id": "6d1917f3-94a2-4391-ba1f-a98d14577b93",
36
+ "label": "Description",
37
+ "localized": false,
38
+ "public": true,
39
+ "required": false,
40
+ "settings": {},
41
+ "sortable": false,
42
+ "type": "text",
43
+ "unique": false
44
+ },
45
+ {
46
+ "apiKey": "image",
47
+ "deprecated": false,
48
+ "editor": {
49
+ "id": "mediaPicker",
50
+ "options": {}
51
+ },
52
+ "filterable": false,
53
+ "id": "d27c9354-4afc-4583-93b4-367214a7714a",
54
+ "label": "Social image",
55
+ "localized": false,
56
+ "public": true,
57
+ "required": false,
58
+ "settings": {
59
+ "allowedKinds": [
60
+ "image"
61
+ ],
62
+ "multiple": false
63
+ },
64
+ "sortable": false,
65
+ "type": "media",
66
+ "unique": false
67
+ },
68
+ {
69
+ "apiKey": "canonical",
70
+ "deprecated": false,
71
+ "editor": {
72
+ "id": "textInput",
73
+ "options": {}
74
+ },
75
+ "filterable": false,
76
+ "id": "17b374b6-cd85-444e-8403-20c7458ed29e",
77
+ "label": "Canonical URL",
78
+ "localized": false,
79
+ "public": true,
80
+ "required": false,
81
+ "settings": {
82
+ "protocols": [
83
+ "https",
84
+ "http"
85
+ ]
86
+ },
87
+ "sortable": false,
88
+ "type": "url",
89
+ "unique": false
90
+ },
91
+ {
92
+ "apiKey": "noindex",
93
+ "deprecated": false,
94
+ "editor": {
95
+ "id": "toggle",
96
+ "options": {}
97
+ },
98
+ "filterable": false,
99
+ "id": "e8346f99-dc3c-4b5c-906f-4854e03c9a57",
100
+ "label": "Hide from search engines",
101
+ "localized": false,
102
+ "public": true,
103
+ "required": false,
104
+ "settings": {},
105
+ "sortable": false,
106
+ "type": "boolean",
107
+ "unique": false
108
+ }
109
+ ],
110
+ "icon": "search",
111
+ "id": "f3905a00-d2a3-4c62-899a-c894bfd6cb9b",
112
+ "kind": "component",
113
+ "label": "SEO"
114
+ }
@@ -140,6 +140,27 @@
140
140
  "sortable": true,
141
141
  "type": "date",
142
142
  "unique": false
143
+ },
144
+ {
145
+ "apiKey": "seo",
146
+ "deprecated": false,
147
+ "editor": {
148
+ "id": "seoEditor",
149
+ "options": {}
150
+ },
151
+ "filterable": false,
152
+ "id": "a3f1c0de-7b52-4e0a-9d1e-5c2b8e4f6a01",
153
+ "label": "SEO",
154
+ "localized": true,
155
+ "public": true,
156
+ "required": false,
157
+ "settings": {
158
+ "component": "f3905a00-d2a3-4c62-899a-c894bfd6cb9b",
159
+ "repeatable": false
160
+ },
161
+ "sortable": false,
162
+ "type": "component",
163
+ "unique": false
143
164
  }
144
165
  ],
145
166
  "id": "1679d89c-747f-4dfc-955f-7d47bd5e4f6b",
@@ -83,6 +83,27 @@
83
83
  "sortable": false,
84
84
  "type": "dynamiczone",
85
85
  "unique": false
86
+ },
87
+ {
88
+ "apiKey": "seo",
89
+ "deprecated": false,
90
+ "editor": {
91
+ "id": "seoEditor",
92
+ "options": {}
93
+ },
94
+ "filterable": false,
95
+ "id": "b7e2d1af-3c64-4f1b-8e2a-6d3c9f5a7b02",
96
+ "label": "SEO",
97
+ "localized": true,
98
+ "public": true,
99
+ "required": false,
100
+ "settings": {
101
+ "component": "f3905a00-d2a3-4c62-899a-c894bfd6cb9b",
102
+ "repeatable": false
103
+ },
104
+ "sortable": false,
105
+ "type": "component",
106
+ "unique": false
86
107
  }
87
108
  ],
88
109
  "id": "4c09af05-f302-4d8f-ba03-bb838d3afbd2",
@@ -1,23 +1,49 @@
1
1
  ---
2
2
  import '../styles/site.css';
3
- import { getSiteSettings } from '../lib/shapio.js';
4
- import { articlesPath, colophonPath, HOME_SLUG, otherLocale, pagePath, stringsFor, type Locale } from '../lib/site.js';
3
+ import type { SeoResolved } from '@shapio/client';
4
+ import { seoHead, type SeoHead } from '../lib/seo.js';
5
+ import { getSite, getSiteSettings } from '../lib/shapio.js';
6
+ import {
7
+ articlesPath,
8
+ colophonPath,
9
+ DEFAULT_LOCALE,
10
+ HOME_SLUG,
11
+ otherLocale,
12
+ pagePath,
13
+ stringsFor,
14
+ type Locale,
15
+ } from '../lib/site.js';
5
16
 
6
17
  type Props = {
18
+ /** The page's own title; entry pages pass their SEO fields too, which win. */
7
19
  title: string;
8
- description?: string | null;
20
+ /** The entry's SEO fields, read with `seo=resolved`. */
21
+ seo?: SeoResolved | null;
22
+ type?: SeoHead['type'];
9
23
  locale: Locale;
10
24
  /** This page in the other locale. */
11
25
  alternatePath?: string;
12
26
  noindex?: boolean;
13
27
  };
14
28
 
15
- const { title, description, locale, alternatePath, noindex = false } = Astro.props;
29
+ const { title, seo, type, locale, alternatePath, noindex = false } = Astro.props;
16
30
  const strings = stringsFor(locale);
17
31
  const other = otherLocale(locale);
18
32
  // The name, tagline and footer come from the siteSettings singleton (the built-in strings until it is published).
19
33
  const settings = await getSiteSettings(locale);
20
34
  const siteName = settings?.siteName ?? strings.siteName;
35
+ // Title, description, social tags and canonical: the entry's SEO fields over the site's SEO defaults.
36
+ const head = seoHead({
37
+ seo: seo ?? null,
38
+ site: await getSite(),
39
+ locale,
40
+ localeChain: [locale, DEFAULT_LOCALE],
41
+ title,
42
+ path: Astro.url.pathname,
43
+ siteUrl: Astro.site?.href,
44
+ ...(type ? { type } : {}),
45
+ });
46
+ const robotsNoindex = noindex || head.noindex;
21
47
  ---
22
48
 
23
49
  <!doctype html>
@@ -25,9 +51,25 @@ const siteName = settings?.siteName ?? strings.siteName;
25
51
  <head>
26
52
  <meta charset="utf-8" />
27
53
  <meta name="viewport" content="width=device-width, initial-scale=1" />
28
- <title>{title} · {siteName}</title>
29
- {description ? <meta name="description" content={description} /> : null}
30
- {noindex ? <meta name="robots" content="noindex" /> : null}
54
+ <title>{head.title}</title>
55
+ {head.description ? <meta name="description" content={head.description} /> : null}
56
+ {robotsNoindex ? <meta name="robots" content="noindex" /> : null}
57
+ {head.canonical ? <link rel="canonical" href={head.canonical} /> : null}
58
+ <meta property="og:type" content={head.type} />
59
+ <meta property="og:title" content={head.title} />
60
+ <meta property="og:locale" content={head.locale} />
61
+ {head.siteName ? <meta property="og:site_name" content={head.siteName} /> : null}
62
+ {head.description ? <meta property="og:description" content={head.description} /> : null}
63
+ {head.canonical ? <meta property="og:url" content={head.canonical} /> : null}
64
+ {head.image ? <meta property="og:image" content={head.image.url} /> : null}
65
+ {head.image?.width ? <meta property="og:image:width" content={String(head.image.width)} /> : null}
66
+ {head.image?.height ? <meta property="og:image:height" content={String(head.image.height)} /> : null}
67
+ {head.image?.alt ? <meta property="og:image:alt" content={head.image.alt} /> : null}
68
+ <meta name="twitter:card" content={head.image ? 'summary_large_image' : 'summary'} />
69
+ {head.twitterHandle ? <meta name="twitter:site" content={head.twitterHandle} /> : null}
70
+ <meta name="twitter:title" content={head.title} />
71
+ {head.description ? <meta name="twitter:description" content={head.description} /> : null}
72
+ {head.image ? <meta name="twitter:image" content={head.image.url} /> : null}
31
73
  {alternatePath ? <link rel="alternate" hreflang={other} href={alternatePath} /> : null}
32
74
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
33
75
  </head>
@@ -44,6 +44,7 @@ export const loadPreview = async (
44
44
  const client = createClient({ baseUrl: shapioUrl, token: request.token });
45
45
  const params = new URLSearchParams({
46
46
  locale: request.locale,
47
+ richText: 'html',
47
48
  ...(request.modelKey === 'articles' ? { populate: 'author' } : {}),
48
49
  });
49
50
  const { data } = await client.request<DeliveryItem<Page | Article>>(