From 05f7d4d13d062c319564f4cb131273323d6cee1b Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Sun, 8 Feb 2026 21:46:57 +0700 Subject: [PATCH] Migrate sitemap to Next.js native app/sitemap.ts (#258) * sitemap migration to next * robots.txt migration * rss refactoring * claude upd * add test --- CLAUDE.md | 2 -- app/feed.json/route.ts | 23 +++++++++++++ app/robots.ts | 13 ++++++++ app/sitemap.ts | 25 ++++++++++++++ next-env.d.ts | 3 +- package-lock.json | 51 ----------------------------- package.json | 4 --- pages/_app.tsx | 4 +-- public/robots.txt | 4 --- scripts/feed.js | 46 -------------------------- scripts/sitemap.js | 48 --------------------------- src/const.ts | 1 + src/remote/api.test.ts | 67 ++++++++++++++++++++++++++++++++++++++ src/remote/api.ts | 10 ++++-- src/remote/factory.test.ts | 52 +++++++++++++++++++++++++++++ src/remote/factory.ts | 14 +++++++- src/types.ts | 8 +++++ 17 files changed, 214 insertions(+), 161 deletions(-) create mode 100644 app/feed.json/route.ts create mode 100644 app/robots.ts create mode 100644 app/sitemap.ts delete mode 100644 public/robots.txt delete mode 100644 scripts/feed.js delete mode 100644 scripts/sitemap.js create mode 100644 src/remote/api.test.ts create mode 100644 src/remote/factory.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1d5cffb3..02eb65a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/app/feed.json/route.ts b/app/feed.json/route.ts new file mode 100644 index 00000000..2db10003 --- /dev/null +++ b/app/feed.json/route.ts @@ -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) +} diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 00000000..c6597eaf --- /dev/null +++ b/app/robots.ts @@ -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`, + } +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 00000000..d4be4bd5 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,25 @@ +import type { MetadataRoute } from "next" +import { siteUrl } from "@/const" +import { getUrls } from "@/remote/api" + +export default async function sitemap(): Promise { + 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] +} diff --git a/next-env.d.ts b/next-env.d.ts index 254b73c1..36a4fe48 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,7 @@ /// /// +/// /// // 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. diff --git a/package-lock.json b/package-lock.json index f031ee6f..75cfd9de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 321e3d59..4994ff0f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/_app.tsx b/pages/_app.tsx index 23ec58e3..74481d6f 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -33,9 +33,9 @@ export default function MyApp({ Component, pageProps }: AppProps) { {process.env.NEXT_PUBLIC_YMETRIKA_ACCOUNT && ( diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index 0c23d0a3..00000000 --- a/public/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * -Allow: / -Host: https://art.shlisselburg.org -Sitemap: https://art.shlisselburg.org/sitemap.xml diff --git a/scripts/feed.js b/scripts/feed.js deleted file mode 100644 index a8be2691..00000000 --- a/scripts/feed.js +++ /dev/null @@ -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" }) -})() - diff --git a/scripts/sitemap.js b/scripts/sitemap.js deleted file mode 100644 index 22429117..00000000 --- a/scripts/sitemap.js +++ /dev/null @@ -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" }) -})() diff --git a/src/const.ts b/src/const.ts index 26b737e5..b8abafd6 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1 +1,2 @@ export const yearRange = [2012, 2026].join("—") +export const siteUrl = "https://art.shlisselburg.org" diff --git a/src/remote/api.test.ts b/src/remote/api.test.ts new file mode 100644 index 00000000..70db6cdc --- /dev/null +++ b/src/remote/api.test.ts @@ -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([]) + }) +}) diff --git a/src/remote/api.ts b/src/remote/api.ts index a04603e2..020ff455 100644 --- a/src/remote/api.ts +++ b/src/remote/api.ts @@ -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 { return menu } +export async function getRecentPages(limit: number = 30): Promise { + const url = `${backendUrl}/pages?_sort=date:DESC&_limit=${limit}` + const pages = await apiGet(createFeedPages)(url, () => []) + return pages +} + diff --git a/src/remote/factory.test.ts b/src/remote/factory.test.ts new file mode 100644 index 00000000..79063fac --- /dev/null +++ b/src/remote/factory.test.ts @@ -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 { + 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") + }) +}) diff --git a/src/remote/factory.ts b/src/remote/factory.ts index 18f5b769..5a14d6ac 100644 --- a/src/remote/factory.ts +++ b/src/remote/factory.ts @@ -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 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, + })) +} diff --git a/src/types.ts b/src/types.ts index aea19e59..388bde14 100644 --- a/src/types.ts +++ b/src/types.ts @@ -156,3 +156,11 @@ export type Token = | YoutubeToken | InstagramToken | GridToken + +export type FeedPage = { + id: string + title: string + url: string + date: string + excerpt: string +}