このコミットが含まれているのは:
2026-10-12 15:16:58 +09:00
コミット 1a4cbe36fb
13個のファイルの変更、451行の追加、225行の削除
+190
ファイルの表示
@@ -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' })
})
} }
}
+19 -18
ファイルの表示
@@ -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')}>
その他 &raquo; その他 &raquo;
</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'))}>
+2 -2
ファイルの表示
@@ -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')
+1 -1
ファイルの表示
@@ -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
+1
ファイルの表示
@@ -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,
+51 -7
ファイルの表示
@@ -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>)
} }
+2 -4
ファイルの表示
@@ -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
+1 -1
ファイルの表示
@@ -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>
+43 -6
ファイルの表示
@@ -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>)
+7 -9
ファイルの表示
@@ -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 }>>
}
+3 -1
ファイルの表示
@@ -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,