このコミットが含まれているのは:
2026-10-20 20:29:18 +09:00
コミット 7afb96d226
21個のファイルの変更、617行の追加、66行の削除
+15 -1
ファイルの表示
@@ -5,7 +5,7 @@ import { request as requestHTTPS } from 'node:https'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { render } from './dist/server/entry-server.js'
import { render, renderSitemap } from './dist/server/entry-server.js'
const root = fileURLToPath (new URL ('./dist/client/', import.meta.url))
const template = await readFile (path.join (root, 'index.html'), 'utf8')
@@ -61,6 +61,20 @@ const server = createServer (async (request, response) => {
{
const url = new URL (request.url, origin)
const pathname = decodeURIComponent (url.pathname)
if (pathname === '/sitemap.xml' || pathname.startsWith ('/sitemap-'))
{
const result = await renderSitemap (url.pathname, origin, apiBaseURL)
response.writeHead (result.status, { 'Content-Type': 'application/xml; charset=utf-8' })
response.end (request.method === 'HEAD' ? undefined : result.body)
return
}
if (pathname === '/robots.txt')
{
response.writeHead (200, { 'Content-Type': 'text/plain; charset=utf-8' })
response.end (request.method === 'HEAD'
? undefined : `Sitemap: ${new URL ('/sitemap.xml', origin).href}\n`)
return
}
const file = path.resolve (root, '.' + pathname)
const allowed = file.startsWith (root)
&& !pathname.split ('/').some (segment => segment.startsWith ('.'))
+22 -16
ファイルの表示
@@ -124,32 +124,34 @@ export const pageRoutes = (
{ path: '', element: <LocaleRedirect to="/posts"/>,
handle: { redirectTo: '/posts' } },
{ path: 'posts', element: <PostListPage/>,
handle: { preload: PostListPage.preload, switchLocale: switchPostFilterLocale } },
handle: { preload: PostListPage.preload, switchLocale: switchPostFilterLocale,
sitemap: ['static'] } },
{ path: 'posts/new', element: <PostNewPage user={user}/>,
handle: { preload: PostNewPage.preload, robots: 'noindex' } },
{ path: 'posts/search', element: <PostSearchPage/>,
handle: { preload: PostSearchPage.preload, switchLocale: switchPostFilterLocale } },
{ path: 'posts/:id', element: <PostDetailRoute user={user}/>,
handle: { preload: PostDetailPage.preload } },
handle: { preload: PostDetailPage.preload, sitemap: ['posts'] } },
{ path: 'posts/changes', element: <PostHistoryPage/>,
handle: { preload: PostHistoryPage.preload } },
{ path: 'tags', element: <TagListPage/>, handle: { preload: TagListPage.preload } },
{ path: 'tags', element: <TagListPage/>,
handle: { preload: TagListPage.preload, sitemap: ['static'] } },
{ path: 'tags/:id', element: <TagDetailPage/>,
handle: { preload: TagDetailPage.preload } },
handle: { preload: TagDetailPage.preload, sitemap: ['tags'] } },
{ path: 'tags/:id/deerjikists', element: <DeerjikistDetailPage/>,
handle: { preload: DeerjikistDetailPage.preload } },
handle: { preload: DeerjikistDetailPage.preload, sitemap: ['deerjikists'] } },
{ path: 'tags/nico', element: <NicoTagListPage user={user}/>,
handle: { preload: NicoTagListPage.preload } },
handle: { preload: NicoTagListPage.preload, sitemap: ['static'] } },
{ path: 'nico/tags', element: <NicoTagListPage user={user}/>,
handle: { preload: NicoTagListPage.preload } },
{ path: 'tags/changes', element: <TagHistoryPage/>,
handle: { preload: TagHistoryPage.preload } },
{ path: 'theatres/:id', element: <TheatreDetailPage user={user}/>,
handle: { preload: TheatreDetailPage.preload } },
handle: { preload: TheatreDetailPage.preload, sitemap: ['theatres'] } },
{ path: 'materials', element: <MaterialBasePage/>,
handle: { preload: MaterialBasePage.preload }, children: [
{ index: true, element: <MaterialListPage/>,
handle: { preload: MaterialListPage.preload } },
handle: { preload: MaterialListPage.preload, sitemap: ['static'] } },
{ path: 'changes', element: <MaterialHistoryPage/>,
handle: { preload: MaterialHistoryPage.preload } },
{ path: 'new', element: <MaterialNewPage/>,
@@ -158,11 +160,12 @@ export const pageRoutes = (
handle: { preload: MaterialSyncSuppressionsPage.preload,
robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: <MaterialDetailPage/>,
handle: { preload: MaterialDetailPage.preload } }] },
handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] },
{ path: 'wiki', element: <WikiSearchPage/>,
handle: { preload: WikiSearchPage.preload } },
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
{ path: 'wiki/:title', element: <WikiDetailPage/>,
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale } },
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale,
sitemap: ['descriptions', 'tag_groups'] } },
{ path: 'wiki/new', element: <WikiNewPage user={user}/>,
handle: { preload: WikiNewPage.preload, robots: 'noindex' } },
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
@@ -174,22 +177,25 @@ export const pageRoutes = (
{ path: 'wiki/changes', element: <WikiHistoryPage/>,
handle: { preload: WikiHistoryPage.preload,
switchLocale: request => switchWikiLocale (request, 'history') } },
{ path: 'help', element: <HelpListPage/>, handle: { preload: HelpListPage.preload } },
{ path: 'help', element: <HelpListPage/>,
handle: { preload: HelpListPage.preload, sitemap: ['static'] } },
{ path: 'help/changes', element: <HelpChangesPage/>,
handle: { preload: HelpChangesPage.preload } },
{ path: 'help/:identifier/diff', element: <HelpDiffPage/>,
handle: { preload: HelpDiffPage.preload } },
{ id: 'help.detail', path: 'help/:identifier', element: <HelpDetailPage/>,
handle: { prepare: prepareHelpPage } },
handle: { prepare: prepareHelpPage, sitemap: ['help'] } },
{ path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/>,
handle: { preload: SettingPage.preload, robots: 'noindex', authentication: 'user' } },
{ path: 'settings', element: <LocaleRedirect to="/users/settings"/>,
handle: { redirectTo: '/users/settings' } },
{ path: 'tos', element: <TOSPage/>, handle: { preload: TOSPage.preload } },
{ path: 'tos', element: <TOSPage/>,
handle: { preload: TOSPage.preload, sitemap: ['static'] } },
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja', preload: locale => locale === 'ja'
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'more', element: <MorePage/>, handle: { preload: MorePage.preload } },
{ path: 'more', element: <MorePage/>,
handle: { preload: MorePage.preload, sitemap: ['static'] } },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }]
+17 -23
ファイルの表示
@@ -483,9 +483,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
key={item.to}
initial={false}
className="shrink-0 whitespace-nowrap"
{...(animationsOff
? { }
: { layoutId: `menu-${ menu.indexOf (item) }` })}
layoutId={`menu-${ menu.indexOf (item) }`}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
animate={layoutReady ? { opacity: moreVsbl ? 0 : 1 } : undefined}
transition={{ opacity: opacityTransition,
@@ -548,8 +546,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<motion.div
key="submenu-shell"
initial={false}
{...(animationsOff ? { }
: { layout: true })}
layout
className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu"
animate={{ height: desktop == null ? 'auto' : submenuHeight }}
@@ -571,13 +568,12 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<div key={i} className="contents">
<motion.div
transition={topNavTransition}
{...(animationsOff
? { }
: ((item.visible ?? true)
{...((item.visible ?? true)
? { layoutId: `menu-${ menu.indexOf (item) }` }
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))}
: (animationsOff ? { }
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
className="z-10 min-w-0 px-3 py-2 font-bold">
<h2>
@@ -596,13 +592,12 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
key={`c-${ i }-${ j }`}
className="min-w-0 max-w-full"
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx] === item)
{...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
: (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{subItem.component}
</motion.div>)
: (
@@ -610,13 +605,12 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
key={`l-${ i }-${ j }`}
className="min-w-0 max-w-full"
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx] === item)
{...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
: (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
+2
ファイルの表示
@@ -19,6 +19,8 @@ import type { QueryObserverOptions } from '@tanstack/react-query'
import type { RenderContext } from '@/lib/renderContext'
import type { User } from '@/types'
export { renderSitemap } from '@/lib/sitemap.server'
type RenderedPage = {
status: number
html: string
+5
ファイルの表示
@@ -15,6 +15,10 @@ export type LocaleSwitchRequest = {
locale: LocaleCode
targetLocale: LocaleCode }
export type SitemapSource =
'static' | 'help' | 'posts' | 'tags' | 'deerjikists' | 'materials'
| 'descriptions' | 'tag_groups' | 'theatres'
export type PageRouteHandle = {
preload?: (locale: LocaleCode) => Promise<unknown>
prepare?: (request: PagePreparation) => Promise<number>
@@ -22,6 +26,7 @@ export type PageRouteHandle = {
locale?: LocaleCode
robots?: 'noindex'
authentication?: 'user'
sitemap?: SitemapSource[]
redirectTo?: string
notFound?: boolean }
+152
ファイルの表示
@@ -0,0 +1,152 @@
import { generatePath } from 'react-router-dom'
import { pageRoutes } from '@/App'
import { apiGet, isApiError } from '@/lib/api'
import { parseHelpEdition } from '@/lib/help'
import { fetchLocales, isLocaleCode, localeLanguage } from '@/lib/locales'
import { withRenderContext } from '@/lib/renderContext.server'
import type { LocaleCode } from '@/lib/locales'
import type { PageRoute, SitemapSource } from '@/lib/routeLocales'
type SitemapRoute = { path: string; source: SitemapSource }
type SitemapResponse = { status: number; body: string }
type ResourceCounts = {
resources: { resource: SitemapSource; count: number }[]
pageSize: number }
const PAGE_SIZE = 1_000
const sitemapRoutes = (locale: LocaleCode): SitemapRoute[] => {
const collect = (routes: PageRoute[], parent = ''): SitemapRoute[] =>
routes.flatMap (route => {
const handle = route.handle
if (handle?.robots === 'noindex' || handle?.authentication != null
|| handle?.redirectTo != null || handle?.notFound
|| (handle?.locale != null && handle.locale !== locale))
return []
const path = [parent, route.path].filter (Boolean).join ('/')
return [
...(handle?.sitemap ?? []).map (source => ({ path, source })),
...collect (route.children ?? [], path)]
})
return collect (pageRoutes (null, () => { }))
}
const helpIdentifiers = async (locale: LocaleCode): Promise<string[]> => {
const { default: articles } = await import ('virtual:help/articles')
const language = localeLanguage (locale)
return Object.values (articles).filter (article => {
const edition = article.languages[language]
return edition != null && edition.publishedEditions.includes (edition.currentEdition)
&& parseHelpEdition (edition.currentEdition) != null
}).map (article => article.identifier).sort ()
}
const escapeXML = (value: string): string =>
value.replace (/&/g, '&amp;').replace (/</g, '&lt;').replace (/>/g, '&gt;')
.replace (/"/g, '&quot;').replace (/'/g, '&apos;')
const sitemapXML = (urls: string[], index = false): string => {
if (urls.length > 50_000)
throw new Error ('Sitemap URL limit exceeded')
const root = index ? 'sitemapindex' : 'urlset'
const item = index ? 'sitemap' : 'url'
return '<?xml version="1.0" encoding="UTF-8"?>\n'
+ `<${root} xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`
+ urls.map (url => ` <${item}><loc>${escapeXML (url)}</loc></${item}>`).join ('\n')
+ `\n</${root}>\n`
}
export const renderSitemap = async (
address: string,
origin: string,
apiBaseURL: string,
): Promise<SitemapResponse> =>
withRenderContext ({ pathname: address, origin, apiBaseURL }, async () => {
const url = new URL (address, origin)
const index = url.pathname === '/sitemap.xml'
const matched = url.pathname.match (/^\/sitemap-(.+)-([a-z_]+)-([1-9]\d*)\.xml$/)
if (!index && matched == null)
return { status: 404, body: '' }
// Sitemap requests are always public; incoming user cookies are not forwarded.
const available = await fetchLocales ()
const locales = available.locales.map (entry => entry.code).filter (isLocaleCode)
if (index)
{
const urls: string[] = []
for (const locale of locales)
{
const sources = new Set (sitemapRoutes (locale).map (route => route.source))
const counts = await apiGet<ResourceCounts> ('/sitemap_entries', { locale })
for (const source of sources)
{
const count =
source === 'static'
? 1
: (source === 'help'
? (await helpIdentifiers (locale)).length
: counts.resources.find (entry => entry.resource === source)?.count ?? 0)
const pageSize = source === 'help' ? PAGE_SIZE : counts.pageSize
if (!Number.isSafeInteger (pageSize) || pageSize <= 0
|| !Number.isSafeInteger (count) || count < 0)
throw new Error ('Invalid sitemap pagination')
const pages = Math.ceil (count / pageSize)
if (urls.length + pages > 50_000)
throw new Error ('Sitemap index limit exceeded')
for (let page = 1; page <= pages; ++page)
urls.push (new URL (`/sitemap-${locale}-${source}-${page}.xml`, origin).href)
}
}
return { status: 200, body: sitemapXML (urls, true) }
}
const [, locale, source, pageValue] = matched!
const page = Number (pageValue)
if (!isLocaleCode (locale) || !locales.includes (locale)
|| !Number.isSafeInteger (page))
return { status: 404, body: '' }
const routes = sitemapRoutes (locale).filter (route => route.source === source)
if (routes.length === 0)
return { status: 404, body: '' }
const parameters: Record<string, string>[] = []
if (source === 'static')
{
if (page !== 1)
return { status: 404, body: '' }
parameters.push ({ })
}
else if (source === 'help')
{
const identifiers = await helpIdentifiers (locale)
parameters.push (...identifiers.slice ((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
.map (identifier => ({ identifier: encodeURIComponent (identifier) })))
}
else
{
try
{
const { ids } = await apiGet<{ ids: number[] }> (
`/sitemap_entries/${source}`, { locale, params: { page } })
parameters.push (...ids.map (id => ({ id: String (id), title: String (id) })))
}
catch (error)
{
if (isApiError (error) && error.response?.status === 404)
return { status: 404, body: '' }
throw error
}
}
if (parameters.length === 0)
return { status: 404, body: '' }
const urls = routes.flatMap (route => parameters.map (params =>
new URL (generatePath (`/${locale}/${route.path}`, params), origin).href))
return { status: 200, body: sitemapXML ([...new Set (urls)]) }
})