WordPress

Относитесь к WordPress как к headless-CMS и выкачайте сайт целиком: пройдите постранично по /wp-json/wp/v2/posts, создайте по HTML-файлу на каждый slug и кэшируйте ответы API между сборками.

Что вы получаете

Структура проекта

my-site/
  sitelo.config.js
  src/
    lib/
      wordpress.js       # помощники REST WP (постраничная выкачка)
    index.ht.js          # главная — список последних записей
    blog/
      index.ht.js        # /blog — полный архив
      [slug].ht.js       # /blog/:slug — каждая запись
    css/
      styles.css
export default {
  site: 'https://example.com',
  // тысячи страниц? поднимите параллелизм
  renderConcurrency: 16,
  renderBatchSize: 128,
}

1. Укажите свой сайт WordPress

В современном WordPress REST API включён по умолчанию. Проверьте это по адресу https://ваш-сайт.com/wp-json/wp/v2/posts.

Задайте WP_URL в окружении (или впишите его прямо в код, пока экспериментируете):

WP_URL=https://your-wordpress-site.com

2. Общие помощники WordPress

getAllPosts() читает X-WP-TotalPages и обходит все страницы (WordPress ограничивает per_page сотней). Пропускайте _embed при сборе slug — запрашивайте вложения только для отдельных записей.

import { fetchWithCache } from 'sitelo'

const WP_URL = process.env.WP_URL ?? 'https://your-wordpress-site.com'
const PER_PAGE = 100 // максимум WP для /wp/v2/posts

async function wpFetch(path, query = {}) {
  const url = new URL(`/wp-json/wp/v2${path}`, WP_URL)
  for (const [key, value] of Object.entries(query)) {
    if (value != null) url.searchParams.set(key, String(value))
  }

  const res = await fetchWithCache(url, undefined, {
    maxAge: 3600,
    cache: 'auto',
  })

  if (!res.ok) {
    throw new Error(`WordPress ${res.status}: ${url}`)
  }

  return {
    data: await res.json(),
    totalPages: Number(res.headers.get('X-WP-TotalPages') ?? 1),
    total: Number(res.headers.get('X-WP-Total') ?? 0),
  }
}

export async function getPosts({ page = 1, perPage = 20, embed = true } = {}) {
  const { data } = await wpFetch('/posts', {
    page,
    per_page: perPage,
    _embed: embed ? '1' : undefined,
  })
  return data
}

/** Проходит все страницы /posts, пока сайт не будет выкачан целиком. */
export async function getAllPosts({
  perPage = PER_PAGE,
  embed = false,
  onPage,
} = {}) {
  const first = await wpFetch('/posts', {
    page: 1,
    per_page: perPage,
    _embed: embed ? '1' : undefined,
  })

  const posts = [...first.data]
  onPage?.(1, first.totalPages, posts.length)

  for (let page = 2; page <= first.totalPages; page += 1) {
    const next = await wpFetch('/posts', {
      page,
      per_page: perPage,
      _embed: embed ? '1' : undefined,
    })
    posts.push(...next.data)
    onPage?.(page, first.totalPages, posts.length)
  }

  return posts
}

export async function getPostBySlug(slug) {
  const { data } = await wpFetch('/posts', {
    slug,
    _embed: '1',
  })
  return data[0] ?? null
}

export function postPath(post) {
  return `/blog/${post.slug}`
}

export function featuredImage(post) {
  return post._embedded?.['wp:featuredmedia']?.[0]?.source_url
}

3. Главная страница

import { html, head, title, link, body, h1, ul, li, a, p } from 'javascript-to-html'
import { getPosts, postPath } from './lib/wordpress.js'

export async function data() {
  const posts = await getPosts({ perPage: 5 })
  return { posts }
}

export default ({ data }) =>
  html({ lang: 'ru' },
    head(
      title('Мой сайт'),
      link({ rel: 'stylesheet', href: '/styles.css' }),
    ),
    body(
      h1('Последнее в блоге'),
      ul(
        ...data.posts.map((post) =>
          li(a({ href: postPath(post) }, post.title.rendered)),
        ),
      ),
      p(a({ href: '/blog' }, 'Все записи')),
    ),
  )

4. Индекс блога

Используйте getAllPosts(), чтобы архив не обрывался на 50–100 элементах.

import { html, head, title, link, body, h1, ul, li, a, time } from 'javascript-to-html'
import { getAllPosts, postPath } from '../lib/wordpress.js'

export async function data() {
  // Полный архив — постранично по всему сайту WP
  const posts = await getAllPosts({ embed: false })
  return { posts }
}

export default ({ data }) =>
  html({ lang: 'ru' },
    head(
      title('Блог'),
      link({ rel: 'stylesheet', href: '/styles.css' }),
    ),
    body(
      h1(`Блог (${data.posts.length})`),
      ul(
        ...data.posts.map((post) =>
          li(
            a({ href: postPath(post) }, post.title.rendered),
            time(post.date.slice(0, 10)),
          ),
        ),
      ),
    ),
  )

5. Превратите все записи в статические страницы

generateStaticParams должен вернуть все slug, которые вы хотите видеть в dist/. Пройдите API постранично прямо здесь — не вызывайте getPosts({ perPage: 100 }) один раз и на этом не останавливайтесь.

import {
  html, head, title, link, body, article, p, a, h1, time, img, div,
} from 'javascript-to-html'
import {
  getAllPosts,
  getPostBySlug,
  featuredImage,
} from '../lib/wordpress.js'

export async function generateStaticParams() {
  const ripStarted = performance.now()
  console.log(
    `[wordpress] выкачиваю записи… (${process.uptime().toFixed(1)}s с момента запуска)`,
  )

  // Выкачивает все опубликованные записи (тысячи — не проблема, по 100 за запрос)
  const posts = await getAllPosts({
    embed: false, // только slug; пропускаем _embed ради скорости
    onPage: (page, totalPages, count) => {
      console.log(`[wordpress] страница ${page}/${totalPages} (${count} записей)`)
    },
  })

  const ripSeconds = ((performance.now() - ripStarted) / 1000).toFixed(1)
  console.log(
    `[wordpress] выкачано ${posts.length} записей за ${ripSeconds}s` +
      ` (${process.uptime().toFixed(1)}s с момента запуска)`,
  )

  return posts.map((post) => ({ slug: post.slug }))
}

export async function data({ params }) {
  const post = await getPostBySlug(params.slug)
  if (!post) throw new Error(`Запись не найдена: ${params.slug}`)
  return { post }
}

export default ({ data }) => {
  const { post } = data
  const image = featuredImage(post)

  return html({ lang: 'ru' },
    head(
      title(post.title.rendered),
      link({ rel: 'stylesheet', href: '/styles.css' }),
    ),
    body(
      article(
        p(a({ href: '/blog' }, '← Блог')),
        h1(post.title.rendered),
        time(post.date.slice(0, 10)),
        image ? img({ src: image, alt: '' }) : '',
        div({ class: 'content' }, post.content.rendered),
      ),
    ),
  )
}

6. Сборка

WP_URL=https://your-wordpress-site.com sitelo build

Первая сборка один раз проходит через WordPress и наполняет кэш fetch. Последующие переиспользуют закэшированные ответы списков и деталей (cache: 'auto' → файловая система в продакшне), пока не истечёт maxAge. Поднимите renderConcurrency в sitelo.config.js, если рендерите тысячи страниц записей.

Заметки

HTML из WordPress

title.rendered и content.rendered — это строки HTML из WP. Вставляйте их в шаблон как есть (как выше) или очищайте, если не доверяете CMS полностью.

Закрытый контент

Публичные REST-маршруты отдают только опубликованные записи. Для черновиков или своей авторизации передайте заголовки во втором аргументе fetchWithCache (обычный init для fetch) и используйте стабильный cacheKey.

Документация по загрузке данных · Документация по маршрутизации