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:
1 parent
0fd75a3b9a
commit
05f7d4d13d
17 files changed
+214
-161
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
Vendored
+2
-1
@@ -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.
|
||||
Generated
-51
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Host: https://art.shlisselburg.org
|
||||
Sitemap: https://art.shlisselburg.org/sitemap.xml
|
||||
@@ -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" })
|
||||
})()
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
export const yearRange = [2012, 2026].join("—")
|
||||
export const siteUrl = "https://art.shlisselburg.org"
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -156,3 +156,11 @@ export type Token =
|
||||
| YoutubeToken
|
||||
| InstagramToken
|
||||
| GridToken
|
||||
|
||||
export type FeedPage = {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
date: string
|
||||
excerpt: string
|
||||
}
|
||||
Reference in new issue
Block a user