Migrate sitemap to Next.js native app/sitemap.ts (#258)

* sitemap migration to next

* robots.txt migration

* rss refactoring

* claude upd

* add test
This commit is contained in:
tmshv authored and GitHub committed 2026-02-08 21:46:57 +07:00
1 parent 0fd75a3b9a
commit 05f7d4d13d
17 files changed
+214 -161

No files matched your search

-2
View File
@@ -12,8 +12,6 @@ Website for the Art School of Shlisselburg ([art.shlisselburg.org](https://art.s
- `npm run build` — production build
- `npm run lint` — ESLint checks
- `npm run format` — auto-fix lint issues
- `npm run sitemap` — generate sitemap.xml
- `npm run feed` — generate RSS feed
- `npm test` — run unit tests (Vitest, non-watch mode)
Node 22 required (see `mise.toml`). Deployed on Vercel.
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server"
import { siteUrl } from "@/const"
import { getRecentPages } from "@/remote/api"
export async function GET() {
const pages = await getRecentPages()
const feed = {
version: "https://jsonfeed.org/version/1.1",
title: "Шлиссельбургская ДХШ",
home_page_url: siteUrl,
feed_url: `${siteUrl}/feed.json`,
items: pages.map((page) => ({
id: `${siteUrl}${page.url}`,
url: `${siteUrl}${page.url}`,
title: page.title,
date_published: new Date(page.date).toISOString(),
summary: page.excerpt,
})),
}
return NextResponse.json(feed)
}
+13
View File
@@ -0,0 +1,13 @@
import type { MetadataRoute } from "next"
import { siteUrl } from "@/const"
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
host: siteUrl,
sitemap: `${siteUrl}/sitemap.xml`,
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { MetadataRoute } from "next"
import { siteUrl } from "@/const"
import { getUrls } from "@/remote/api"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const urls = await getUrls()
const staticPages: MetadataRoute.Sitemap = [
{
url: siteUrl,
changeFrequency: "daily",
},
{
url: `${siteUrl}/collective`,
changeFrequency: "daily",
},
]
const dynamicPages: MetadataRoute.Sitemap = urls.map((path) => ({
url: `${siteUrl}${path}`,
changeFrequency: "daily",
}))
return [...staticPages, ...dynamicPages]
}
+2 -1
View File
@@ -1,6 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-51
View File
@@ -21,7 +21,6 @@
"react-dom": "^18.3.0",
"react-icons": "^4.2.0",
"sharp": "^0.34.0",
"sitemap": "^1.5.0",
"typograf": "^6.11.1",
"valtio": "^2.1.7"
},
@@ -36,7 +35,6 @@
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.0.0",
"eslint-config-next": "^15.5.0",
"feed": "^4.1.0",
"globals": "^16.0.0",
"happy-dom": "^20.5.0",
"typescript": "^5.9.0",
@@ -4249,18 +4247,6 @@
}
}
},
"node_modules/feed": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/feed/-/feed-4.1.0.tgz",
"integrity": "sha512-dAXWXM8QMxZ1DRnAxDmy1MaWZFlh1Ku7TU3onbXgHrVJynsxkNGPUed1AxszVW8AXo43xExronVkIqK+ACsoBA==",
"dev": true,
"dependencies": {
"xml-js": "^1.6.11"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6080,12 +6066,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"dev": true
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -6232,15 +6212,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/sitemap": {
"version": "1.13.0",
"resolved": "http://storage.mds.yandex.net/get-npm/66641/sitemap-1.13.0.tgz",
"integrity": "sha1-Vpy+IYAgKSamKiZs094Jyc60P4M=",
"dependencies": {
"underscore": "^1.7.0",
"url-join": "^1.1.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6719,11 +6690,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/underscore": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
"integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -6741,11 +6707,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-join": {
"version": "1.1.0",
"resolved": "http://storage.mds.yandex.net/get-npm/69187/url-join-1.1.0.tgz",
"integrity": "sha1-dBxsL0WWxIMNZxhGCSDQySIC3Hg="
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz",
@@ -7123,18 +7084,6 @@
}
}
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"dev": true,
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
-4
View File
@@ -10,8 +10,6 @@
"build": "next build",
"lint": "eslint .",
"format": "eslint --fix .",
"sitemap": "node scripts/sitemap.js",
"feed": "node scripts/feed.js",
"test": "vitest run"
},
"repository": {
@@ -33,7 +31,6 @@
"react-dom": "^18.3.0",
"react-icons": "^4.2.0",
"sharp": "^0.34.0",
"sitemap": "^1.5.0",
"typograf": "^6.11.1",
"valtio": "^2.1.7"
},
@@ -48,7 +45,6 @@
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.0.0",
"eslint-config-next": "^15.5.0",
"feed": "^4.1.0",
"globals": "^16.0.0",
"happy-dom": "^20.5.0",
"typescript": "^5.9.0",
+2 -2
View File
@@ -33,9 +33,9 @@ export default function MyApp({ Component, pageProps }: AppProps) {
<link
rel="alternate"
type="application/rss+xml"
type="application/feed+json"
title="Шлиссельбургская Детская Художественная Школа"
href="/feed.xml"
href="/feed.json"
/>
{process.env.NEXT_PUBLIC_YMETRIKA_ACCOUNT && (
-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Host: https://art.shlisselburg.org
Sitemap: https://art.shlisselburg.org/sitemap.xml
-46
View File
@@ -1,46 +0,0 @@
const { Feed } = require("feed")
const axios = require("axios")
const fs = require("fs").promises
const api = axios.create({
baseURL: "https://api.tmshv.com/hudozka",
})
async function getArticles(limit) {
const query = ["post", "event", "album"]
.map(t => `tag=${t}`)
.join("&")
const url = `/api/pages/tags?${query}&sortBy=-date&skip=0&pageSize=${limit}`
const res = await api.get(url)
return res.data.items
}
(async () => {
const articles = await getArticles(30)
const url = "https://art.shlisselburg.org"
const feed = new Feed({
title: "Шлиссельбургская ДХШ",
id: url,
link: url,
feedLinks: {
atom: "https://art.shlisselburg.org/feed.xml",
},
})
articles.forEach(article => {
feed.addItem({
title: article.title,
date: new Date(article.date),
id: article.id,
link: article.url,
content: article.data,
})
})
const content = feed.atom1()
await fs.writeFile("dist/feed.xml", content, { encoding: "utf-8" })
})()
-48
View File
@@ -1,48 +0,0 @@
const sitemap = require("sitemap")
const axios = require("axios")
const fs = require("fs").promises
const homeUrl = "https://art.shlisselburg.org"
const sitemapCacheTime = 600000
const api = axios.create({
baseURL: "https://api.tmshv.com/hudozka",
})
async function getMenuUrls(frequency = "daily") {
const menu = [
"/",
"/collective",
]
return menu
// .filter(i => 'url' in i)
.map(url => ({
url,
changefreq: frequency,
}))
}
async function getPageUrls(frequency = "daily") {
const res = await api.get("/api/pages/urls")
return res.data.items.map(url => ({
changefreq: frequency,
url,
}))
}
(async () => {
let urls = await Promise.all([
getMenuUrls(),
getPageUrls(),
])
urls = urls.reduce((urls, i) => urls.concat(i))
let map = sitemap.createSitemap({
hostname: homeUrl,
cacheTime: sitemapCacheTime,
urls,
})
const content = map.toString()
await fs.writeFile("dist/sitemap.xml", content, { encoding: "utf-8" })
})()
+1
View File
@@ -1 +1,2 @@
export const yearRange = [2012, 2026].join("—")
export const siteUrl = "https://art.shlisselburg.org"
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { apiGet, getRecentPages } from "./api"
function mockFetchJson(data: unknown, ok = true) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok,
json: async () => data,
} as Response)
}
beforeEach(() => {
vi.restoreAllMocks()
})
describe("apiGet", () => {
it("should call factory with fetched data on success", async () => {
const data = { value: 1 }
mockFetchJson(data)
const factory = vi.fn((d: typeof data) => d.value)
const result = await apiGet(factory)("https://example.com/api", () => -1)
expect(factory).toHaveBeenCalledWith(data)
expect(result).toBe(1)
})
it("should return default on non-ok response", async () => {
mockFetchJson(null, false)
const factory = vi.fn()
const result = await apiGet(factory)("https://example.com/api", () => "default")
expect(factory).not.toHaveBeenCalled()
expect(result).toBe("default")
})
it("should return default and log error on network failure", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"))
const spy = vi.spyOn(console, "error").mockImplementation(() => {})
const factory = vi.fn()
const result = await apiGet(factory)("https://example.com/api", () => "fallback")
expect(factory).not.toHaveBeenCalled()
expect(result).toBe("fallback")
expect(spy).toHaveBeenCalled()
})
})
describe("getRecentPages", () => {
it("should fetch with default limit of 30", async () => {
const spy = mockFetchJson([])
await getRecentPages()
expect(spy).toHaveBeenCalledWith(
"https://hudozka.tmshv.com/pages?_sort=date:DESC&_limit=30",
)
})
it("should fetch with custom limit", async () => {
const spy = mockFetchJson([])
await getRecentPages(10)
expect(spy).toHaveBeenCalledWith(
"https://hudozka.tmshv.com/pages?_sort=date:DESC&_limit=10",
)
})
it("should return empty array on failure", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fail"))
vi.spyOn(console, "error").mockImplementation(() => {})
const result = await getRecentPages()
expect(result).toEqual([])
})
})
+8 -2
View File
@@ -1,5 +1,5 @@
import { createHomeCards, createMenu, createPage, createPageUrls } from "@/remote/factory"
import type { MenuItem, Page, PageCardDto } from "@/types"
import { createHomeCards, createMenu, createPage, createPageUrls, createFeedPages } from "@/remote/factory"
import type { MenuItem, Page, PageCardDto, FeedPage } from "@/types"
const backendUrl = "https://hudozka.tmshv.com"
@@ -62,3 +62,9 @@ export async function getMenu(): Promise<MenuItem[]> {
return menu
}
export async function getRecentPages(limit: number = 30): Promise<FeedPage[]> {
const url = `${backendUrl}/pages?_sort=date:DESC&_limit=${limit}`
const pages = await apiGet(createFeedPages)(url, () => [])
return pages
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest"
import { createFeedPages } from "./factory"
import type { StrapiPage, StrapiTag } from "./types"
function stubPage(overrides: Partial<StrapiPage> = {}): StrapiPage {
return {
id: 1,
title: "Test",
excerpt: "Excerpt",
slug: "/test",
date: "2024-01-15",
published_at: "",
created_at: "",
updated_at: "",
content: [],
tags: [] as StrapiTag[],
...overrides,
}
}
describe("createFeedPages", () => {
it("should map strapi pages to feed pages", () => {
const pages = [stubPage({ id: 42, title: "Hello", slug: "/hello", date: "2024-03-01", excerpt: "Summary" })]
const result = createFeedPages(pages)
expect(result).toEqual([{
id: "42",
title: "Hello",
url: "/hello",
date: "2024-03-01",
excerpt: "Summary",
}])
})
it("should filter out pages without date", () => {
const pages = [
stubPage({ id: 1, date: "2024-01-01" }),
stubPage({ id: 2, date: null }),
]
const result = createFeedPages(pages)
expect(result).toHaveLength(1)
expect(result[0].id).toBe("1")
})
it("should return empty array for empty input", () => {
expect(createFeedPages([])).toEqual([])
})
it("should convert numeric id to string", () => {
const result = createFeedPages([stubPage({ id: 99 })])
expect(result[0].id).toBe("99")
})
})
+13 -1
View File
@@ -1,4 +1,4 @@
import type { MenuItem, Page, Tag, PageCardDto, Pic, Token } from "@/types"
import type { MenuItem, Page, Tag, PageCardDto, Pic, Token, FeedPage } from "@/types"
import { asItem } from "./lib"
import type { StrapiComponentEmbed, StrapiComponent, StrapiHome, StrapiPage, StrapiPageCard, StrapiTag, StrapiMedia, StrapiMenu } from "./types"
import { typograf, markdownToHtml } from "@/lib/text"
@@ -213,3 +213,15 @@ export async function createHomeCards(data: StrapiHome): Promise<PageCardDto[]>
return items
.filter(x => !!x.url)
}
export function createFeedPages(pages: StrapiPage[]): FeedPage[] {
return pages
.filter(page => page.date)
.map(page => ({
id: `${page.id}`,
title: page.title,
url: page.slug,
date: page.date!,
excerpt: page.excerpt,
}))
}
+8
View File
@@ -156,3 +156,11 @@ export type Token =
| YoutubeToken
| InstagramToken
| GridToken
export type FeedPage = {
id: string
title: string
url: string
date: string
excerpt: string
}