このコミットが含まれているのは:
@@ -0,0 +1,190 @@
|
|||||||
|
import { readFile, readdir } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { normalizePath as normalisePath } from 'vite'
|
||||||
|
import { parse as parseYAML } from 'yaml'
|
||||||
|
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
type Language = { currentEdition: string; publishedEditions: string[] }
|
||||||
|
type Article = {
|
||||||
|
identifier: string
|
||||||
|
canonicalLanguage: string
|
||||||
|
languages: Record<string, Language> }
|
||||||
|
type Text = { title: string; body: string }
|
||||||
|
type SearchEntry = Text & { identifier: string; edition: string }
|
||||||
|
type Change = {
|
||||||
|
identifier: string
|
||||||
|
edition: string
|
||||||
|
title: string
|
||||||
|
pred: string | null
|
||||||
|
change: 'new' | 'update'
|
||||||
|
date: string }
|
||||||
|
type RawArticle = {
|
||||||
|
identifier: string
|
||||||
|
canonical_language: string
|
||||||
|
languages: Record<string, { current_edition: string }> }
|
||||||
|
|
||||||
|
const prefix = 'virtual:help/'
|
||||||
|
|
||||||
|
|
||||||
|
const compareEditions = (a: string, b: string): number => {
|
||||||
|
const [dateA, revisionA = '1'] = a.split ('.')
|
||||||
|
const [dateB, revisionB = '1'] = b.split ('.')
|
||||||
|
return dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const validateEdition = (edition: string): void => {
|
||||||
|
const [date, revision = '1'] = edition.split ('.')
|
||||||
|
if (!(/^\d{4}-\d{2}-\d{2}(?:\.[1-9]\d*)?$/.test (edition))
|
||||||
|
|| new Date (date).toISOString ().slice (0, 10) !== date
|
||||||
|
|| !(Number.isSafeInteger (Number (revision))))
|
||||||
|
throw new Error (`Invalid Help edition: ${ edition }`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const generateHelpAssets = async (root: string) => {
|
||||||
|
const modules = new Map<string, string> ()
|
||||||
|
const files: string[] = [root]
|
||||||
|
const articles: Record<string, Article> = Object.create (null)
|
||||||
|
const search: Record<string, SearchEntry[]> = Object.create (null)
|
||||||
|
const changes: Record<string, Change[]> = Object.create (null)
|
||||||
|
const titles: Record<string, Record<string, string[]>> = Object.create (null)
|
||||||
|
const components: string[] = []
|
||||||
|
const sources: string[] = []
|
||||||
|
const texts: string[] = []
|
||||||
|
const directories = (await readdir (root, { withFileTypes: true }))
|
||||||
|
.filter (entry => entry.isDirectory () && !(entry.name.startsWith ('.')))
|
||||||
|
.sort ((a, b) => a.name.localeCompare (b.name))
|
||||||
|
|
||||||
|
for (const directory of directories)
|
||||||
|
{
|
||||||
|
const articlePath = path.join (root, directory.name, 'article.yml')
|
||||||
|
files.push (articlePath)
|
||||||
|
const raw = parseYAML (await readFile (articlePath, 'utf8')) as RawArticle | null
|
||||||
|
if (raw == null || raw.identifier !== directory.name
|
||||||
|
|| typeof raw.canonical_language !== 'string'
|
||||||
|
|| raw.languages == null || typeof raw.languages !== 'object'
|
||||||
|
|| !(Object.hasOwn (raw.languages, raw.canonical_language)))
|
||||||
|
throw new Error (`Invalid Help metadata: ${ articlePath }`)
|
||||||
|
|
||||||
|
const article: Article = {
|
||||||
|
identifier: raw.identifier,
|
||||||
|
canonicalLanguage: raw.canonical_language,
|
||||||
|
languages: Object.create (null) }
|
||||||
|
articles[raw.identifier] = article
|
||||||
|
|
||||||
|
for (const [language, metadata] of Object.entries (raw.languages))
|
||||||
|
{
|
||||||
|
if (!(/^[a-zA-Z0-9-]+$/.test (language))
|
||||||
|
|| typeof metadata?.current_edition !== 'string')
|
||||||
|
throw new Error (`Invalid Help language: ${ articlePath }/${ language }`)
|
||||||
|
|
||||||
|
const currentEdition = metadata.current_edition
|
||||||
|
validateEdition (currentEdition)
|
||||||
|
const editionRoot = path.join (root, directory.name, 'editions', language)
|
||||||
|
files.push (editionRoot)
|
||||||
|
const editions = (await readdir (editionRoot)).filter (name => name.endsWith ('.mdx'))
|
||||||
|
.map (name => name.slice (0, -4))
|
||||||
|
editions.forEach (validateEdition)
|
||||||
|
if (!(editions.includes (currentEdition)))
|
||||||
|
throw new Error (`Missing current Help edition: ${ editionRoot }/${ currentEdition }`)
|
||||||
|
|
||||||
|
const publishedEditions = editions
|
||||||
|
.filter (edition => compareEditions (edition, currentEdition) <= 0)
|
||||||
|
.sort (compareEditions)
|
||||||
|
article.languages[language] = { currentEdition, publishedEditions }
|
||||||
|
search[language] ??= []
|
||||||
|
changes[language] ??= []
|
||||||
|
titles[language] ??= Object.create (null)
|
||||||
|
|
||||||
|
for (const [index, edition] of publishedEditions.entries ())
|
||||||
|
{
|
||||||
|
const sourcePath = path.join (editionRoot, `${ edition }.mdx`)
|
||||||
|
files.push (sourcePath)
|
||||||
|
const mdx = (await readFile (sourcePath, 'utf8')).replace (/^\uFEFF/, '')
|
||||||
|
const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/)
|
||||||
|
if (heading == null || !(heading[1].trim ()))
|
||||||
|
throw new Error (`Missing leading Help H1: ${ sourcePath }`)
|
||||||
|
|
||||||
|
const text = { title: heading[1].trim (), body: mdx.slice (heading[0].length) }
|
||||||
|
const key = JSON.stringify (`${ raw.identifier }/${ language }/${ edition }`)
|
||||||
|
const textId = `${ prefix }text/${ raw.identifier }/${ language }/${ edition }`
|
||||||
|
const componentId = JSON.stringify (normalisePath (sourcePath))
|
||||||
|
const sourceId = JSON.stringify (normalisePath (sourcePath) + '?raw')
|
||||||
|
modules.set (textId, `export default ${ JSON.stringify (text) }`)
|
||||||
|
texts.push (`${ key }: () => import(${ JSON.stringify (textId) })`)
|
||||||
|
components.push (`${ key }: () => import(${ componentId })`)
|
||||||
|
sources.push (`${ key }: () => import(${ sourceId })`)
|
||||||
|
|
||||||
|
if (edition === currentEdition)
|
||||||
|
search[language].push ({ identifier: raw.identifier, edition, ...text })
|
||||||
|
changes[language].push ({ identifier: raw.identifier, edition, title: text.title,
|
||||||
|
pred: publishedEditions[index - 1] ?? null,
|
||||||
|
change: index === 0 ? 'new' : 'update', date: edition.split ('.')[0] })
|
||||||
|
const title = text.title.trim ().normalize ('NFKC').toLowerCase ()
|
||||||
|
const identifiers = titles[language][title] ??= []
|
||||||
|
if (!(identifiers.includes (raw.identifier)))
|
||||||
|
identifiers.push (raw.identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys (articles).length === 0)
|
||||||
|
throw new Error (`No Help articles found: ${ root }`)
|
||||||
|
|
||||||
|
Object.values (changes).forEach (rows => rows.sort ((a, b) =>
|
||||||
|
compareEditions (b.edition, a.edition) || a.identifier.localeCompare (b.identifier)))
|
||||||
|
for (const [name, data] of Object.entries ({ articles, search, changes, titles }))
|
||||||
|
{
|
||||||
|
modules.set (`${ prefix }${ name }`,
|
||||||
|
`export default JSON.parse(${ JSON.stringify (JSON.stringify (data)) })`)
|
||||||
|
}
|
||||||
|
modules.set (`${ prefix }loaders`, [
|
||||||
|
`export const components = { ${ components.join (',\n') } };`,
|
||||||
|
`export const sources = { ${ sources.join (',\n') } };`,
|
||||||
|
`export const texts = { ${ texts.join (',\n') } };`].join ('\n'))
|
||||||
|
return { modules, files }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const helpAssets = (): Plugin => {
|
||||||
|
const state: { root: string; data?: ReturnType<typeof generateHelpAssets> } = { root: '' }
|
||||||
|
const data = () => state.data ??= generateHelpAssets (state.root)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'help-assets',
|
||||||
|
configResolved (config) {
|
||||||
|
state.root = path.resolve (config.root, 'help')
|
||||||
|
},
|
||||||
|
async buildStart () {
|
||||||
|
state.data = generateHelpAssets (state.root)
|
||||||
|
const generated = await data ()
|
||||||
|
generated.files.forEach (file => this.addWatchFile (file))
|
||||||
|
},
|
||||||
|
resolveId (id) {
|
||||||
|
return id.startsWith (prefix) ? `\0${ id }` : null
|
||||||
|
},
|
||||||
|
async load (id) {
|
||||||
|
if (!(id.startsWith (`\0${ prefix }`)))
|
||||||
|
return null
|
||||||
|
const source = (await data ()).modules.get (id.slice (1))
|
||||||
|
if (source == null)
|
||||||
|
throw new Error (`Unknown Help asset: ${ id.slice (1) }`)
|
||||||
|
return source
|
||||||
|
},
|
||||||
|
configureServer (server) {
|
||||||
|
server.watcher.add (state.root)
|
||||||
|
server.watcher.on ('all', (_event, file) => {
|
||||||
|
if (!(file.startsWith (state.root + path.sep)) || !(/\.(mdx|yml)$/.test (file)))
|
||||||
|
return
|
||||||
|
state.data = undefined
|
||||||
|
for (const module of server.moduleGraph.idToModuleMap.values ())
|
||||||
|
{
|
||||||
|
if (module.id?.startsWith (`\0${ prefix }`))
|
||||||
|
server.moduleGraph.invalidateModule (module)
|
||||||
|
}
|
||||||
|
server.ws.send ({ type: 'full-reload' })
|
||||||
|
})
|
||||||
|
} }
|
||||||
|
}
|
||||||
@@ -219,18 +219,19 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="top-nav-root px-3 flex justify-between items-center w-full">
|
<nav className="top-nav-root px-3 flex flex-wrap justify-between items-center w-full">
|
||||||
<div className="flex items-center gap-2 h-12">
|
<div className="flex shrink-0 items-center gap-2 h-12">
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to="/posts"
|
to="/posts"
|
||||||
className="top-nav-brand-link mx-4 text-xl font-bold"
|
className="top-nav-brand-link shrink-0 whitespace-nowrap mx-2 text-base
|
||||||
|
xl:mx-4 xl:text-xl font-bold"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
scroll (0, 0)
|
scroll (0, 0)
|
||||||
}}>
|
}}>
|
||||||
ぼざクリ タグ広場
|
ぼざクリ タグ広場
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
|
|
||||||
<div ref={navRef} className="relative hidden md:flex h-12 items-center">
|
<div ref={navRef} className="relative hidden xl:flex shrink-0 h-12 items-center">
|
||||||
<div aria-hidden
|
<div aria-hidden
|
||||||
className={cn ('absolute inset-y-0 h-12',
|
className={cn ('absolute inset-y-0 h-12',
|
||||||
'top-nav-highlight',
|
'top-nav-highlight',
|
||||||
@@ -242,6 +243,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
{visibleMenu.map ((item, i) => (
|
{visibleMenu.map ((item, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={item.to}
|
key={item.to}
|
||||||
|
className="shrink-0 whitespace-nowrap"
|
||||||
{...(animationsOff
|
{...(animationsOff
|
||||||
? { }
|
? { }
|
||||||
: { layoutId: `menu-${ item.name }` })}
|
: { layoutId: `menu-${ item.name }` })}
|
||||||
@@ -271,6 +273,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
measure (-1)
|
measure (-1)
|
||||||
}}
|
}}
|
||||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
|
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
|
||||||
|
'shrink-0 whitespace-nowrap',
|
||||||
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
||||||
その他 »
|
その他 »
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
@@ -281,7 +284,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="top-nav-mobile-toggle md:hidden ml-auto border-0 bg-transparent pr-4"
|
className="top-nav-mobile-toggle xl:hidden shrink-0 whitespace-nowrap
|
||||||
|
ml-auto border-0 bg-transparent pr-4"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMenuOpen (!(menuOpen))
|
setMenuOpen (!(menuOpen))
|
||||||
}}>
|
}}>
|
||||||
@@ -294,7 +298,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
key="submenu-shell"
|
key="submenu-shell"
|
||||||
{...(animationsOff ? { }
|
{...(animationsOff ? { }
|
||||||
: { layout: true })}
|
: { layout: true })}
|
||||||
className="relative z-20 hidden md:block overflow-hidden top-nav-submenu"
|
className="relative z-20 hidden xl:block shrink-0 overflow-hidden top-nav-submenu"
|
||||||
animate={{ height: submenuHeight }}
|
animate={{ height: submenuHeight }}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (moreVsbl)
|
if (moreVsbl)
|
||||||
@@ -308,7 +312,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
? (
|
? (
|
||||||
moreMenu.map ((item, i) => (
|
moreMenu.map ((item, i) => (
|
||||||
<div key={i} className="relative h-[40px]">
|
<div key={i} className="relative h-[40px]">
|
||||||
<div className="absolute inset-0 flex items-center px-3">
|
<div className="absolute inset-0 flex items-center px-3
|
||||||
|
overflow-x-auto whitespace-nowrap">
|
||||||
<motion.div
|
<motion.div
|
||||||
transition={topNavTransition}
|
transition={topNavTransition}
|
||||||
{...(animationsOff
|
{...(animationsOff
|
||||||
@@ -318,7 +323,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||||
animate: { x: 0, y: 0, opacity: 1 },
|
animate: { x: 0, y: 0, opacity: 1 },
|
||||||
exit: { x: 40, y: -40, opacity: 0 } }))}
|
exit: { x: 40, y: -40, opacity: 0 } }))}
|
||||||
className="z-10 h-full flex items-center px-3 font-bold w-28">
|
className="z-10 h-full flex shrink-0 items-center px-3 font-bold w-28">
|
||||||
<h2>{item.name}</h2>
|
<h2>{item.name}</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
{item.subMenu
|
{item.subMenu
|
||||||
@@ -366,7 +371,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
<div className="relative h-[40px]">
|
<div className="relative h-[40px]">
|
||||||
{animationsOff
|
{animationsOff
|
||||||
? (
|
? (
|
||||||
<div className="absolute inset-0 flex items-center px-3">
|
<div className="absolute inset-0 flex items-center px-3
|
||||||
|
overflow-x-auto whitespace-nowrap">
|
||||||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||||
.filter (item => item.visible ?? true)
|
.filter (item => item.visible ?? true)
|
||||||
.map ((item, i) => (
|
.map ((item, i) => (
|
||||||
@@ -395,7 +401,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||||
centre: { y: 0, opacity: 1 },
|
centre: { y: 0, opacity: 1 },
|
||||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||||
className="absolute inset-0 flex items-center px-3"
|
className="absolute inset-0 flex items-center px-3
|
||||||
|
overflow-x-auto whitespace-nowrap"
|
||||||
initial="enter"
|
initial="enter"
|
||||||
animate="centre"
|
animate="centre"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
@@ -435,7 +442,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
? (
|
? (
|
||||||
menuOpen && (
|
menuOpen && (
|
||||||
<div
|
<div
|
||||||
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
|
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col xl:hidden',
|
||||||
'items-start')}>
|
'items-start')}>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
{visibleMenu.map ((item, i) => (
|
{visibleMenu.map ((item, i) => (
|
||||||
@@ -479,9 +486,6 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
</Fragment>))}
|
</Fragment>))}
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to="/more"
|
to="/more"
|
||||||
ref={(el: (HTMLAnchorElement | null)) => {
|
|
||||||
itemsRef.current[visibleMenu.length] = el
|
|
||||||
}}
|
|
||||||
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
||||||
((openItemIdx < 0)
|
((openItemIdx < 0)
|
||||||
&& 'top-nav-mobile-active font-bold'))}>
|
&& 'top-nav-mobile-active font-bold'))}>
|
||||||
@@ -495,7 +499,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="spmenu"
|
key="spmenu"
|
||||||
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
|
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col xl:hidden',
|
||||||
'items-start')}
|
'items-start')}
|
||||||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||||||
height: 0 },
|
height: 0 },
|
||||||
@@ -561,9 +565,6 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
</Fragment>))}
|
</Fragment>))}
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to="/more"
|
to="/more"
|
||||||
ref={(el: (HTMLAnchorElement | null)) => {
|
|
||||||
itemsRef.current[visibleMenu.length] = el
|
|
||||||
}}
|
|
||||||
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
||||||
((openItemIdx < 0)
|
((openItemIdx < 0)
|
||||||
&& 'top-nav-mobile-active font-bold'))}>
|
&& 'top-nav-mobile-active font-bold'))}>
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
const className = cn ((sp
|
const className = cn ((sp
|
||||||
? 'md:hidden w-full min-h-[40px] flex items-center pl-8'
|
? 'xl:hidden w-full min-h-[40px] flex items-center pl-8'
|
||||||
: 'hidden md:block ml-auto pr-4'),
|
: 'hidden xl:block shrink-0 max-w-full break-words ml-auto pr-4'),
|
||||||
'font-bold text-red-600 hover:text-red-400',
|
'font-bold text-red-600 hover:text-red-400',
|
||||||
'dark:text-yellow-400 dark:hover:text-yellow-200')
|
'dark:text-yellow-400 dark:hover:text-yellow-200')
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ body
|
|||||||
background: var(--top-nav-root-bg-mobile);
|
background: var(--top-nav-root-bg-mobile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px)
|
@media (min-width: 1280px)
|
||||||
{
|
{
|
||||||
.top-nav-root
|
.top-nav-root
|
||||||
{
|
{
|
||||||
|
|||||||
+36
-109
@@ -1,15 +1,12 @@
|
|||||||
import { parse as parseYAML } from 'yaml'
|
|
||||||
|
|
||||||
import type { ComponentType } from 'react'
|
import type { ComponentType } from 'react'
|
||||||
|
|
||||||
type ArticleLanguage = { currentEdition: string }
|
type ArticleLanguage = { currentEdition: string; publishedEditions: string[] }
|
||||||
|
|
||||||
type ArticleMeta = {
|
export type ArticleMeta = {
|
||||||
identifier: string
|
identifier: string
|
||||||
|
canonicalLanguage: string
|
||||||
languages: Record<string, ArticleLanguage> }
|
languages: Record<string, ArticleLanguage> }
|
||||||
|
|
||||||
type MDXModule = { default: ComponentType }
|
|
||||||
|
|
||||||
export type HelpEditionText = {
|
export type HelpEditionText = {
|
||||||
title: string
|
title: string
|
||||||
body: string }
|
body: string }
|
||||||
@@ -21,7 +18,11 @@ export type HelpSearchEntry = HelpEditionText & {
|
|||||||
export type HelpEditionContent = HelpEditionText & { content: ComponentType }
|
export type HelpEditionContent = HelpEditionText & { content: ComponentType }
|
||||||
|
|
||||||
export type HelpEditionSource = HelpEditionText & { source: string }
|
export type HelpEditionSource = HelpEditionText & { source: string }
|
||||||
export type HelpChange = HelpSearchEntry & { pred: string | null }
|
export type HelpChange = Pick<HelpSearchEntry, 'identifier' | 'edition' | 'title'> & {
|
||||||
|
pred: string | null
|
||||||
|
change: 'new' | 'update'
|
||||||
|
date: string }
|
||||||
|
export type HelpTitleIndex = Record<string, string[]>
|
||||||
export type HelpDiffLine = {
|
export type HelpDiffLine = {
|
||||||
type: 'added' | 'removed' | 'unchanged'
|
type: 'added' | 'removed' | 'unchanged'
|
||||||
content: string }
|
content: string }
|
||||||
@@ -30,70 +31,26 @@ export type HelpDiff = {
|
|||||||
to: HelpEditionSource
|
to: HelpEditionSource
|
||||||
lines: HelpDiffLine[] }
|
lines: HelpDiffLine[] }
|
||||||
|
|
||||||
const articleFiles = import.meta.glob<string> ('../../help/*/article.yml',
|
|
||||||
{ query: '?raw',
|
|
||||||
import: 'default',
|
|
||||||
eager: true })
|
|
||||||
|
|
||||||
const editionFiles = import.meta.glob<MDXModule> ('../../help/*/editions/*/*.mdx')
|
export const getHelpArticle = async (identifier: string): Promise<ArticleMeta | null> => {
|
||||||
|
const { default: articles } = await import ('virtual:help/articles')
|
||||||
const rawEditionFiles = import.meta.glob<string> ('../../help/*/editions/*/*.mdx',
|
return Object.hasOwn (articles, identifier) ? articles[identifier] : null
|
||||||
{ query: '?raw',
|
|
||||||
import: 'default' })
|
|
||||||
|
|
||||||
|
|
||||||
const cameliseArticle = (
|
|
||||||
value: { identifier: string
|
|
||||||
languages: Record<string, { ['current_edition']: string }> },
|
|
||||||
): ArticleMeta => (
|
|
||||||
{ identifier: value.identifier,
|
|
||||||
languages: Object.fromEntries (
|
|
||||||
Object.entries (value.languages).map (([language, data]) => [
|
|
||||||
language, { currentEdition: data['current_edition'] }])) })
|
|
||||||
|
|
||||||
|
|
||||||
const compareEditions = (a: string, b: string): number => {
|
|
||||||
const [dateA, revisionA = '1'] = a.split ('.')
|
|
||||||
const [dateB, revisionB = '1'] = b.split ('.')
|
|
||||||
|
|
||||||
return dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const getHelpArticle = (identifier: string): ArticleMeta | null => {
|
export const getHelpEditions = async (
|
||||||
const path = `../../help/${ identifier }/article.yml`
|
|
||||||
const source = articleFiles[path]
|
|
||||||
|
|
||||||
if (!(source))
|
|
||||||
return null
|
|
||||||
|
|
||||||
return cameliseArticle (parseYAML (source))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const getHelpEditions = (
|
|
||||||
identifier: string,
|
identifier: string,
|
||||||
language: string,
|
language: string,
|
||||||
): string[] => {
|
): Promise<string[]> => {
|
||||||
const prefix = `../../help/${ identifier }/editions/${ language }/`
|
const article = await getHelpArticle (identifier)
|
||||||
|
return article?.languages[language]?.publishedEditions ?? []
|
||||||
return (Object.keys (editionFiles)
|
|
||||||
.filter (path => path.startsWith (prefix))
|
|
||||||
.map (path => path.slice (prefix.length, -'.mdx'.length))
|
|
||||||
.sort (compareEditions))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const getHelpPublishedEditions = (
|
export const getHelpPublishedEditions = async (
|
||||||
identifier: string,
|
identifier: string,
|
||||||
language: string,
|
language: string,
|
||||||
): string[] => {
|
): Promise<string[]> => await getHelpEditions (identifier, language)
|
||||||
const currentEdition = getHelpArticle (identifier)?.languages[language]?.currentEdition
|
|
||||||
return currentEdition == null
|
|
||||||
? []
|
|
||||||
: getHelpEditions (identifier, language)
|
|
||||||
.filter (edition => compareEditions (edition, currentEdition) <= 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const getHelpEdition = async (
|
export const getHelpEdition = async (
|
||||||
@@ -101,8 +58,8 @@ export const getHelpEdition = async (
|
|||||||
language: string,
|
language: string,
|
||||||
edition: string,
|
edition: string,
|
||||||
): Promise<ComponentType | null> => {
|
): Promise<ComponentType | null> => {
|
||||||
const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx`
|
const { components } = await import ('virtual:help/loaders')
|
||||||
const loader = editionFiles[path]
|
const loader = components[`${ identifier }/${ language }/${ edition }`]
|
||||||
|
|
||||||
if (!(loader))
|
if (!(loader))
|
||||||
return null
|
return null
|
||||||
@@ -116,22 +73,15 @@ export const getHelpEditionSource = async (
|
|||||||
language: string,
|
language: string,
|
||||||
edition: string,
|
edition: string,
|
||||||
): Promise<HelpEditionSource | null> => {
|
): Promise<HelpEditionSource | null> => {
|
||||||
const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx`
|
const { sources } = await import ('virtual:help/loaders')
|
||||||
const loader = rawEditionFiles[path]
|
const loader = sources[`${ identifier }/${ language }/${ edition }`]
|
||||||
|
|
||||||
if (!(loader))
|
if (!(loader))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
const source = await loader ()
|
const [source, text] = await Promise.all ([
|
||||||
const mdx = source.replace (/^\uFEFF/, '')
|
loader (), getHelpEditionText (identifier, language, edition)])
|
||||||
const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/)
|
return text == null ? null : { source: source.default, ...text }
|
||||||
|
|
||||||
if (!(heading))
|
|
||||||
throw new Error (`Help MDX の先頭に H1 がありません: ${ path }`)
|
|
||||||
|
|
||||||
return { source,
|
|
||||||
title: heading[1].trim (),
|
|
||||||
body: mdx.slice (heading[0].length) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -140,31 +90,17 @@ export const getHelpEditionText = async (
|
|||||||
language: string,
|
language: string,
|
||||||
edition: string,
|
edition: string,
|
||||||
): Promise<HelpEditionText | null> => {
|
): Promise<HelpEditionText | null> => {
|
||||||
const content = await getHelpEditionSource (identifier, language, edition)
|
const { texts } = await import ('virtual:help/loaders')
|
||||||
return content == null ? null : { title: content.title, body: content.body }
|
const loader = texts[`${ identifier }/${ language }/${ edition }`]
|
||||||
|
return loader == null ? null : (await loader ()).default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const getHelpSearchEntries = async (
|
export const getHelpSearchEntries = async (
|
||||||
language: string,
|
language: string,
|
||||||
): Promise<HelpSearchEntry[]> => {
|
): Promise<HelpSearchEntry[]> => {
|
||||||
const entries = await Promise.all (
|
const { default: search } = await import ('virtual:help/search')
|
||||||
Object.values (articleFiles).map (async source => {
|
return search[language] ?? []
|
||||||
const article = cameliseArticle (parseYAML (source))
|
|
||||||
const edition = article.languages[language]?.currentEdition
|
|
||||||
|
|
||||||
if (!(edition))
|
|
||||||
return null
|
|
||||||
|
|
||||||
const content = await getHelpEditionText (article.identifier, language, edition)
|
|
||||||
|
|
||||||
if (!(content))
|
|
||||||
throw new Error (`Help edition が見つかりません: ${ article.identifier }/${ edition }`)
|
|
||||||
|
|
||||||
return { identifier: article.identifier, edition, ...content }
|
|
||||||
}))
|
|
||||||
|
|
||||||
return entries.filter ((entry): entry is HelpSearchEntry => entry != null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -182,23 +118,14 @@ export const getHelpEditionContent = async (
|
|||||||
|
|
||||||
|
|
||||||
export const getHelpChanges = async (language: string): Promise<HelpChange[]> => {
|
export const getHelpChanges = async (language: string): Promise<HelpChange[]> => {
|
||||||
const articles = Object.values (articleFiles).map (source =>
|
const { default: changes } = await import ('virtual:help/changes')
|
||||||
cameliseArticle (parseYAML (source)))
|
return changes[language] ?? []
|
||||||
const changes = await Promise.all (articles.flatMap (article => {
|
}
|
||||||
const editions = getHelpPublishedEditions (article.identifier, language)
|
|
||||||
return editions.map (async (edition, index) => {
|
|
||||||
const text = await getHelpEditionText (article.identifier, language, edition)
|
|
||||||
if (text == null)
|
|
||||||
throw new Error (`Help edition が見つかりません: ${ article.identifier }/${ edition }`)
|
|
||||||
|
|
||||||
return { identifier: article.identifier,
|
|
||||||
edition, pred: editions[index - 1] ?? null, ...text }
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
|
|
||||||
return changes.sort ((a, b) =>
|
export const getHelpTitleIndex = async (language: string): Promise<HelpTitleIndex> => {
|
||||||
compareEditions (b.edition, a.edition)
|
const { default: titles } = await import ('virtual:help/titles')
|
||||||
|| a.identifier.localeCompare (b.identifier))
|
return titles[language] ?? { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -252,7 +179,7 @@ export const getHelpDiff = async (
|
|||||||
from: string,
|
from: string,
|
||||||
to: string,
|
to: string,
|
||||||
): Promise<HelpDiff | null> => {
|
): Promise<HelpDiff | null> => {
|
||||||
const editions = getHelpPublishedEditions (identifier, language)
|
const editions = await getHelpPublishedEditions (identifier, language)
|
||||||
if (!(editions.includes (from)) || !(editions.includes (to)))
|
if (!(editions.includes (from)) || !(editions.includes (to)))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const wikiKeys = {
|
|||||||
export const helpKeys = {
|
export const helpKeys = {
|
||||||
root: ['help'] as const,
|
root: ['help'] as const,
|
||||||
index: (language: string) => ['help', 'index', language] as const,
|
index: (language: string) => ['help', 'index', language] as const,
|
||||||
|
titles: (language: string) => ['help', 'titles', language] as const,
|
||||||
article: (identifier: string) => ['help', 'article', identifier] as const,
|
article: (identifier: string) => ['help', 'article', identifier] as const,
|
||||||
edition: (identifier: string, language: string, edition: string) =>
|
edition: (identifier: string, language: string, edition: string) =>
|
||||||
['help', 'edition', identifier, language, edition] as const,
|
['help', 'edition', identifier, language, edition] as const,
|
||||||
|
|||||||
@@ -1,37 +1,78 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
|
import Pagination from '@/components/common/Pagination'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { getHelpChanges } from '@/lib/help'
|
import { getHelpChanges } from '@/lib/help'
|
||||||
import { helpKeys } from '@/lib/queryKeys'
|
import { helpKeys } from '@/lib/queryKeys'
|
||||||
|
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
|
||||||
import { dateString } from '@/lib/utils'
|
import { dateString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
|
||||||
const HelpChangesPage: FC = () => {
|
const HelpChangesPage: FC = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams ()
|
||||||
|
const identifier = searchParams.get ('identifier') ?? ''
|
||||||
|
const pageValue = searchParams.get ('page') ?? '1'
|
||||||
|
const pageNumber = Number (pageValue)
|
||||||
|
const requestedPage =
|
||||||
|
(/^[1-9]\d*$/.test (pageValue) && Number.isSafeInteger (pageNumber))
|
||||||
|
? pageNumber : 1
|
||||||
|
const limit = LIST_LIMIT_OPTIONS.find (
|
||||||
|
value => String (value) === searchParams.get ('limit')) ?? 20
|
||||||
const { data: changes = [], isLoading, error } = useQuery ({
|
const { data: changes = [], isLoading, error } = useQuery ({
|
||||||
queryKey: helpKeys.changes ('ja'),
|
queryKey: helpKeys.changes ('ja'),
|
||||||
queryFn: () => getHelpChanges ('ja'),
|
queryFn: () => getHelpChanges ('ja'),
|
||||||
staleTime: Infinity })
|
staleTime: Infinity })
|
||||||
|
|
||||||
|
const results = useMemo (() => identifier
|
||||||
|
? changes.filter (entry => entry.identifier === identifier)
|
||||||
|
: changes, [changes, identifier])
|
||||||
|
const totalPages = Math.ceil (results.length / limit)
|
||||||
|
const page = requestedPage <= Math.max (1, totalPages) ? requestedPage : 1
|
||||||
|
const pageResults = results.slice ((page - 1) * limit, page * limit)
|
||||||
|
|
||||||
|
const handleLimit = (value: string) => {
|
||||||
|
const next = new URLSearchParams (searchParams)
|
||||||
|
next.set ('limit', value)
|
||||||
|
next.set ('page', '1')
|
||||||
|
setSearchParams (next)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea className="min-w-0 max-w-full">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`ヘルプ 変更履歴 | ${ SITE_TITLE }`}</title>
|
<title>{`ヘルプ 変更履歴 | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<PageTitle>変更履歴</PageTitle>
|
<PageTitle>変更履歴</PageTitle>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>件数</span>
|
||||||
|
<select value={limit} onChange={ev => handleLimit (ev.target.value)}
|
||||||
|
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||||
|
text-foreground">
|
||||||
|
{LIST_LIMIT_OPTIONS.map (value => (
|
||||||
|
<option key={value} value={value}>{value}</option>))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading
|
{isLoading
|
||||||
? <p className="mt-4">読み込み中...</p>
|
? <p className="mt-4">読み込み中...</p>
|
||||||
: (error
|
: (error
|
||||||
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
|
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
|
||||||
: (
|
: (
|
||||||
<div className="overflow-x-auto">
|
<div className="mt-4">
|
||||||
<table className="table-auto w-full whitespace-nowrap border-collapse">
|
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
|
||||||
|
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||||
|
<table className="table-auto w-full min-w-[900px] whitespace-nowrap
|
||||||
|
border-collapse">
|
||||||
<thead className="border-b-2 border-black dark:border-white">
|
<thead className="border-b-2 border-black dark:border-white">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-2 text-left">差分</th>
|
<th className="p-2 text-left">差分</th>
|
||||||
@@ -42,11 +83,12 @@ const HelpChangesPage: FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{changes.map (entry => {
|
{pageResults.map (entry => {
|
||||||
const path = `/help/${ encodeURIComponent (entry.identifier) }`
|
const path = `/help/${ encodeURIComponent (entry.identifier) }`
|
||||||
const diffQuery = new URLSearchParams ({
|
const diffQuery = new URLSearchParams ({
|
||||||
from: entry.pred ?? '', to: entry.edition })
|
from: entry.pred ?? '', to: entry.edition })
|
||||||
const updatedAt = `${ entry.edition.split ('.')[0] }T00:00:00`
|
const diffTo = `${ path }/diff?${ diffQuery.toString () }`
|
||||||
|
const updatedAt = `${ entry.date }T00:00:00`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={`${ entry.identifier }/${ entry.edition }`}
|
<tr key={`${ entry.identifier }/${ entry.edition }`}
|
||||||
@@ -54,7 +96,7 @@ const HelpChangesPage: FC = () => {
|
|||||||
dark:even:bg-gray-700 dark:text-gray-100">
|
dark:even:bg-gray-700 dark:text-gray-100">
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{entry.pred != null && (
|
{entry.pred != null && (
|
||||||
<PrefetchLink to={`${ path }/diff?${ diffQuery.toString () }`}>
|
<PrefetchLink to={diffTo}>
|
||||||
差分
|
差分
|
||||||
</PrefetchLink>)}
|
</PrefetchLink>)}
|
||||||
</td>
|
</td>
|
||||||
@@ -64,12 +106,14 @@ const HelpChangesPage: FC = () => {
|
|||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">{entry.edition}</td>
|
<td className="p-2">{entry.edition}</td>
|
||||||
<td className="p-2">{entry.pred == null ? '新規' : '更新'}</td>
|
<td className="p-2">{entry.change === 'new' ? '新規' : '更新'}</td>
|
||||||
<td className="p-2">{dateString (updatedAt, 'hour')}</td>
|
<td className="p-2">{dateString (updatedAt, 'hour')}</td>
|
||||||
</tr>)
|
</tr>)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
{totalPages > 0 && <Pagination page={page} totalPages={totalPages}/>}
|
||||||
</div>))}
|
</div>))}
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useLocation, useParams } from 'react-router-dom'
|
import { useLocation, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { getHelpArticle, getHelpEditionContent, getHelpEditions } from '@/lib/help'
|
import { getHelpArticle, getHelpEditionContent } from '@/lib/help'
|
||||||
import { helpKeys } from '@/lib/queryKeys'
|
import { helpKeys } from '@/lib/queryKeys'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
|
|
||||||
@@ -32,8 +31,7 @@ const HelpDetailPage: FC = () => {
|
|||||||
|
|
||||||
const edition = requestedEdition ?? languageMeta?.currentEdition
|
const edition = requestedEdition ?? languageMeta?.currentEdition
|
||||||
|
|
||||||
const editions = useMemo (() => getHelpEditions (identifier, language),
|
const editions = languageMeta?.publishedEditions ?? []
|
||||||
[identifier, language])
|
|
||||||
|
|
||||||
const index = edition ? editions.indexOf (edition) : -1
|
const index = edition ? editions.indexOf (edition) : -1
|
||||||
const pred = index > 0 ? editions[index - 1] : undefined
|
const pred = index > 0 ? editions[index - 1] : undefined
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const HelpDiffPage: FC = () => {
|
|||||||
const path = `/help/${ encodeURIComponent (identifier) }`
|
const path = `/help/${ encodeURIComponent (identifier) }`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea className="min-w-0 max-w-full">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`ヘルプ 差分: ${ title } | ${ SITE_TITLE }`}</title>
|
<title>{`ヘルプ 差分: ${ title } | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import PrefetchLink from '@/components/PrefetchLink'
|
|||||||
import SortHeader from '@/components/SortHeader'
|
import SortHeader from '@/components/SortHeader'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
|
import Pagination from '@/components/common/Pagination'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { getHelpSearchEntries } from '@/lib/help'
|
import { getHelpSearchEntries } from '@/lib/help'
|
||||||
import { helpKeys } from '@/lib/queryKeys'
|
import { helpKeys } from '@/lib/queryKeys'
|
||||||
|
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
|
||||||
import { dateString, inputClass } from '@/lib/utils'
|
import { dateString, inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FormEvent, FC } from 'react'
|
import type { FormEvent, FC } from 'react'
|
||||||
@@ -25,6 +27,13 @@ const defaultDirection: Record<HelpOrderField, 'asc' | 'desc'> = {
|
|||||||
|
|
||||||
const HelpSearchPage: FC = () => {
|
const HelpSearchPage: FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams ()
|
const [searchParams, setSearchParams] = useSearchParams ()
|
||||||
|
const pageValue = searchParams.get ('page') ?? '1'
|
||||||
|
const pageNumber = Number (pageValue)
|
||||||
|
const requestedPage =
|
||||||
|
(/^[1-9]\d*$/.test (pageValue) && Number.isSafeInteger (pageNumber))
|
||||||
|
? pageNumber : 1
|
||||||
|
const limit = LIST_LIMIT_OPTIONS.find (
|
||||||
|
value => String (value) === searchParams.get ('limit')) ?? 20
|
||||||
const requestedOrder = searchParams.get ('order') ?? 'updated_at:desc'
|
const requestedOrder = searchParams.get ('order') ?? 'updated_at:desc'
|
||||||
const order: HelpOrder =
|
const order: HelpOrder =
|
||||||
['title:asc', 'title:desc', 'updated_at:asc', 'updated_at:desc'].includes (requestedOrder)
|
['title:asc', 'title:desc', 'updated_at:asc', 'updated_at:desc'].includes (requestedOrder)
|
||||||
@@ -68,6 +77,17 @@ const HelpSearchPage: FC = () => {
|
|||||||
}))
|
}))
|
||||||
}, [entries, conditions, order])
|
}, [entries, conditions, order])
|
||||||
|
|
||||||
|
const totalPages = Math.ceil (results.length / limit)
|
||||||
|
const page = requestedPage <= Math.max (1, totalPages) ? requestedPage : 1
|
||||||
|
const pageResults = results.slice ((page - 1) * limit, page * limit)
|
||||||
|
|
||||||
|
const handleLimit = (value: string) => {
|
||||||
|
const next = new URLSearchParams (searchParams)
|
||||||
|
next.set ('limit', value)
|
||||||
|
next.set ('page', '1')
|
||||||
|
setSearchParams (next)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearch = (ev: FormEvent<HTMLFormElement>) => {
|
const handleSearch = (ev: FormEvent<HTMLFormElement>) => {
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
const next = new URLSearchParams (searchParams)
|
const next = new URLSearchParams (searchParams)
|
||||||
@@ -79,17 +99,29 @@ const HelpSearchPage: FC = () => {
|
|||||||
next.set ('text', text)
|
next.set ('text', text)
|
||||||
else
|
else
|
||||||
next.delete ('text')
|
next.delete ('text')
|
||||||
|
next.set ('page', '1')
|
||||||
setSearchParams (next)
|
setSearchParams (next)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea className="min-w-0 max-w-full">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`ヘルプ | ${ SITE_TITLE }`}</title>
|
<title>{`ヘルプ | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<PageTitle>ヘルプ</PageTitle>
|
<PageTitle>ヘルプ</PageTitle>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>件数</span>
|
||||||
|
<select value={limit} onChange={ev => handleLimit (ev.target.value)}
|
||||||
|
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||||
|
text-foreground">
|
||||||
|
{LIST_LIMIT_OPTIONS.map (value => (
|
||||||
|
<option key={value} value={value}>{value}</option>))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<form onSubmit={handleSearch} className="space-y-2">
|
<form onSubmit={handleSearch} className="space-y-2">
|
||||||
<FormField label="タイトル">
|
<FormField label="タイトル">
|
||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
@@ -125,7 +157,9 @@ const HelpSearchPage: FC = () => {
|
|||||||
: (
|
: (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
|
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
|
||||||
<table className="table-auto w-full border-collapse">
|
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||||
|
<table className="table-auto w-full min-w-[640px] whitespace-nowrap
|
||||||
|
border-collapse text-gray-900 dark:text-gray-100">
|
||||||
<thead className="border-b-2 border-black dark:border-white">
|
<thead className="border-b-2 border-black dark:border-white">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-2 text-left">
|
<th className="p-2 text-left">
|
||||||
@@ -138,19 +172,20 @@ const HelpSearchPage: FC = () => {
|
|||||||
<th className="p-2 text-left">
|
<th className="p-2 text-left">
|
||||||
<SortHeader<HelpOrderField>
|
<SortHeader<HelpOrderField>
|
||||||
by="updated_at"
|
by="updated_at"
|
||||||
label="最終更新"
|
label="有効開始年月日"
|
||||||
currentOrder={order}
|
currentOrder={order}
|
||||||
defaultDirection={defaultDirection}/>
|
defaultDirection={defaultDirection}/>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{results.map (entry => {
|
{pageResults.map (entry => {
|
||||||
const updatedAt = new Date(entry.edition.replace (/\..*$/, ''))
|
const updatedAt = new Date (entry.edition.replace (/\..*$/, ''))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={entry.identifier}
|
<tr key={entry.identifier}
|
||||||
className="even:bg-gray-100 dark:even:bg-gray-700">
|
className="even:bg-gray-100 text-gray-900
|
||||||
|
dark:even:bg-gray-700 dark:text-gray-100">
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={`/help/${encodeURIComponent (entry.identifier)}`}>
|
to={`/help/${encodeURIComponent (entry.identifier)}`}>
|
||||||
@@ -162,6 +197,8 @@ const HelpSearchPage: FC = () => {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
{totalPages > 0 && <Pagination page={page} totalPages={totalPages}/>}
|
||||||
{results.length === 0 && <p className="mt-4">該当する記事がありません.</p>}
|
{results.length === 0 && <p className="mt-4">該当する記事がありません.</p>}
|
||||||
</div>))}
|
</div>))}
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import TabGroup, { Tab } from '@/components/common/TabGroup'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||||||
import { getHelpChanges } from '@/lib/help'
|
import { getHelpTitleIndex } from '@/lib/help'
|
||||||
import { fetchPosts } from '@/lib/posts'
|
import { fetchPosts } from '@/lib/posts'
|
||||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||||
import { fetchTagByName } from '@/lib/tags'
|
import { fetchTagByName } from '@/lib/tags'
|
||||||
@@ -49,17 +49,15 @@ const WikiDetailPage: FC = () => {
|
|||||||
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
|
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
|
||||||
|
|
||||||
void queryClient.fetchQuery ({
|
void queryClient.fetchQuery ({
|
||||||
queryKey: helpKeys.changes ('ja'),
|
queryKey: helpKeys.titles ('ja'),
|
||||||
queryFn: () => getHelpChanges ('ja'),
|
queryFn: () => getHelpTitleIndex ('ja'),
|
||||||
staleTime: Infinity }).then (changes => {
|
staleTime: Infinity }).then (index => {
|
||||||
if (!(active))
|
if (!(active))
|
||||||
return
|
return
|
||||||
|
|
||||||
const identifiers = new Set (changes
|
const identifiers = Object.hasOwn (index, lookupTitle) ? index[lookupTitle] : []
|
||||||
.filter (entry => entry.title.trim ().normalize ('NFKC').toLowerCase () === lookupTitle)
|
if (identifiers.length === 1)
|
||||||
.map (entry => entry.identifier))
|
navigate (`/help/${ encodeURIComponent (identifiers[0]) }`, { replace: true })
|
||||||
if (identifiers.size === 1)
|
|
||||||
navigate (`/help/${ encodeURIComponent ([...identifiers][0]) }`, { replace: true })
|
|
||||||
}).catch (() => {
|
}).catch (() => {
|
||||||
// A failed Help lookup leaves the original missing Wiki URL in place.
|
// A failed Help lookup leaves the original missing Wiki URL in place.
|
||||||
})
|
})
|
||||||
|
|||||||
ベンダーファイル
+28
@@ -1 +1,29 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module 'virtual:help/articles' {
|
||||||
|
const articles: Record<string, import ('./lib/help').ArticleMeta>
|
||||||
|
export default articles
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'virtual:help/search' {
|
||||||
|
const search: Record<string, import ('./lib/help').HelpSearchEntry[]>
|
||||||
|
export default search
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'virtual:help/changes' {
|
||||||
|
const changes: Record<string, import ('./lib/help').HelpChange[]>
|
||||||
|
export default changes
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'virtual:help/titles' {
|
||||||
|
const titles: Record<string, import ('./lib/help').HelpTitleIndex>
|
||||||
|
export default titles
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'virtual:help/loaders' {
|
||||||
|
export const components: Record<string,
|
||||||
|
() => Promise<{ default: import ('react').ComponentType }>>
|
||||||
|
export const sources: Record<string, () => Promise<{ default: string }>>
|
||||||
|
export const texts: Record<string,
|
||||||
|
() => Promise<{ default: import ('./lib/help').HelpEditionText }>>
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import path from 'path'
|
|||||||
import remarkGFM from 'remark-gfm'
|
import remarkGFM from 'remark-gfm'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
import { helpAssets } from './scripts/help-assets'
|
||||||
|
|
||||||
const mdxPlugin = mdx ({ providerImportSource: '@/mdx-components',
|
const mdxPlugin = mdx ({ providerImportSource: '@/mdx-components',
|
||||||
remarkPlugins: [remarkGFM] })
|
remarkPlugins: [remarkGFM] })
|
||||||
const originalTransform = mdxPlugin.transform
|
const originalTransform = mdxPlugin.transform
|
||||||
@@ -20,7 +22,7 @@ if (typeof originalTransform === 'function')
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig ({
|
export default defineConfig ({
|
||||||
plugins: [mdxPlugin, react ()],
|
plugins: [helpAssets (), mdxPlugin, react ()],
|
||||||
resolve: { alias: { '@': path.resolve (__dirname, './src') } },
|
resolve: { alias: { '@': path.resolve (__dirname, './src') } },
|
||||||
server: { host: true,
|
server: { host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする