mirror of
https://github.com/reonokiy/blog.nokiy.net.git
synced 2025-06-17 03:56:19 +02:00
refactor: update language configuration
This commit is contained in:
parent
ff59dc1a7c
commit
22dc899a95
25 changed files with 53 additions and 188 deletions
|
@ -24,7 +24,7 @@ import remarkSectionize from 'remark-sectionize'
|
||||||
// Project configuration and utilities
|
// Project configuration and utilities
|
||||||
import UnoCSS from 'unocss/astro'
|
import UnoCSS from 'unocss/astro'
|
||||||
import { themeConfig } from './src/config'
|
import { themeConfig } from './src/config'
|
||||||
import { langMap } from './src/i18n/ui'
|
import { langMap } from './src/i18n/config'
|
||||||
import { AdmonitionComponent } from './src/plugins/rehype-component-admonition'
|
import { AdmonitionComponent } from './src/plugins/rehype-component-admonition'
|
||||||
import { GithubCardComponent } from './src/plugins/rehype-component-github-card'
|
import { GithubCardComponent } from './src/plugins/rehype-component-github-card'
|
||||||
import { parseDirectiveNode } from './src/plugins/remark-directive-rehype'
|
import { parseDirectiveNode } from './src/plugins/remark-directive-rehype'
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
---
|
---
|
||||||
import { themeConfig } from '@/config'
|
import { defaultLocale, themeConfig } from '@/config'
|
||||||
import { defaultLocale } from '@/i18n/config'
|
import { walineLocaleMap } from '@/i18n/config'
|
||||||
import { walineLocaleMap } from '@/i18n/ui'
|
|
||||||
|
|
||||||
// Theme color configuration
|
// Theme color configuration
|
||||||
const {
|
const {
|
||||||
|
|
|
@ -24,7 +24,7 @@ const year = Number(startYear) === currentYear
|
||||||
className,
|
className,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<!-- only show on desktop -->
|
<!-- Only show on desktop -->
|
||||||
<div class="mb-11.5 ml-1.5 hidden gap-7 lg:flex">
|
<div class="mb-11.5 ml-1.5 hidden gap-7 lg:flex">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<LanguageSwitcher supportedLangs={supportedLangs} />
|
<LanguageSwitcher supportedLangs={supportedLangs} />
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
import PostTime from '@/components/PostTime.astro'
|
import PostTime from '@/components/PostTime.astro'
|
||||||
import { defaultLocale } from '@/i18n/config'
|
import { defaultLocale } from '@/config'
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
data: {
|
data: {
|
||||||
|
@ -16,7 +16,6 @@ interface Post {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get post list and page language parameter from props
|
|
||||||
const { posts, lang } = Astro.props
|
const { posts, lang } = Astro.props
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
@ -24,9 +23,8 @@ export interface Props {
|
||||||
lang?: string
|
lang?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get multilingual post URL path
|
|
||||||
function getPostPath(post: Post) {
|
function getPostPath(post: Post) {
|
||||||
// If abbrlink is set, it will be used instead of slug
|
// Prioritize abbrlink over slug
|
||||||
const postPath = post.data.abbrlink || post.slug
|
const postPath = post.data.abbrlink || post.slug
|
||||||
// Add language prefix to URL if current page is in a language subdirectory and not the default language
|
// Add language prefix to URL if current page is in a language subdirectory and not the default language
|
||||||
return lang && lang !== defaultLocale ? `/${lang}/posts/${postPath}/` : `/posts/${postPath}/`
|
return lang && lang !== defaultLocale ? `/${lang}/posts/${postPath}/` : `/posts/${postPath}/`
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<button
|
<button
|
||||||
id="theme-toggle"
|
|
||||||
aria-label="Switch light/dark theme"
|
aria-label="Switch light/dark theme"
|
||||||
class="button-theme-toggle w-4.2 uno-button"
|
class="button-theme-toggle w-4.2 uno-button"
|
||||||
>
|
>
|
||||||
|
|
|
@ -171,3 +171,7 @@ export const themeConfig: ThemeConfig = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default themeConfig
|
export default themeConfig
|
||||||
|
|
||||||
|
export const defaultLocale = themeConfig.global.locale
|
||||||
|
export const moreLocales = themeConfig.global.moreLocales
|
||||||
|
export const allLocales = [defaultLocale, ...moreLocales]
|
||||||
|
|
|
@ -1,5 +1,23 @@
|
||||||
import { themeConfig } from '@/config'
|
// Global Language Map
|
||||||
|
export const langMap: Record<string, string[]> = {
|
||||||
|
'zh': ['zh-CN'],
|
||||||
|
'zh-tw': ['zh-TW'],
|
||||||
|
'ja': ['ja-JP'],
|
||||||
|
'en': ['en-US'],
|
||||||
|
'es': ['es-ES'],
|
||||||
|
'ru': ['ru-RU'],
|
||||||
|
}
|
||||||
|
|
||||||
export const defaultLocale = themeConfig.global.locale
|
// Waline Language Map
|
||||||
export const moreLocales = themeConfig.global.moreLocales
|
// docs: https://waline.js.org/guide/i18n.html
|
||||||
export const allLocales = [defaultLocale, ...moreLocales]
|
export const walineLocaleMap: Record<string, string> = {
|
||||||
|
'zh': 'zh-CN',
|
||||||
|
'zh-tw': 'zh-TW',
|
||||||
|
'ja': 'jp-JP', // Waline uses jp-JP not ja-JP
|
||||||
|
'en': 'en-US',
|
||||||
|
'es': 'es-ES',
|
||||||
|
'ru': 'ru-RU',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supported Languages
|
||||||
|
export const supportedLangs = Object.keys(langMap).flat()
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { allLocales, defaultLocale, moreLocales } from '@/i18n/config'
|
import { allLocales, defaultLocale, moreLocales } from '@/config'
|
||||||
|
|
||||||
// Gets the language code from the current path
|
// Gets the language code from the current path
|
||||||
export function getLangFromPath(path: string) {
|
export function getLangFromPath(path: string) {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { defaultLocale } from '@/i18n/config'
|
import { defaultLocale } from '@/config'
|
||||||
import { getLangFromPath, getNextLang } from '@/i18n/lang'
|
import { getLangFromPath, getNextLang } from '@/i18n/lang'
|
||||||
import { cleanPath } from '@/utils/page'
|
import { cleanPath } from '@/utils/page'
|
||||||
|
|
||||||
|
|
|
@ -1,125 +0,0 @@
|
||||||
import type { CollectionEntry } from 'astro:content'
|
|
||||||
import { allLocales, defaultLocale, moreLocales } from '@/i18n/config'
|
|
||||||
|
|
||||||
// 生成默认语言标签页面的路由配置
|
|
||||||
export function getTagRoutes(tags: string[]) {
|
|
||||||
return tags.map(tag => ({
|
|
||||||
params: { tag },
|
|
||||||
props: { tag },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成默认语言文章页面的路由配置
|
|
||||||
export function getPostRoutes(posts: CollectionEntry<'posts'>[]) {
|
|
||||||
// 创建slug到语言的映射
|
|
||||||
const slugToLangs: Record<string, string[]> = {}
|
|
||||||
|
|
||||||
// 填充映射
|
|
||||||
posts.forEach((post) => {
|
|
||||||
const slug = post.data.abbrlink || post.slug
|
|
||||||
const lang = post.data.lang || defaultLocale
|
|
||||||
|
|
||||||
// 如果文章没有指定语言,初始化为所有支持的语言
|
|
||||||
if (!slugToLangs[slug]) {
|
|
||||||
if (!post.data.lang) {
|
|
||||||
slugToLangs[slug] = [...allLocales] // 文章支持所有语言
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
slugToLangs[slug] = [defaultLocale] // 仅默认语言和指定语言
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!slugToLangs[slug].includes(lang)) {
|
|
||||||
slugToLangs[slug].push(lang)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return posts.map(post => ({
|
|
||||||
params: {
|
|
||||||
slug: post.data.abbrlink || post.slug,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
post,
|
|
||||||
supportedLangs: slugToLangs[post.data.abbrlink || post.slug] || [],
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成更多语言静态路由
|
|
||||||
export function getMultiLangRoutes() {
|
|
||||||
return moreLocales.map(lang => ({
|
|
||||||
params: { lang },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成更多语言标签页面的路由配置
|
|
||||||
export function getMultiLangTagRoutes(tags: string[]) {
|
|
||||||
return moreLocales.flatMap(lang => (
|
|
||||||
tags.map(tag => ({
|
|
||||||
params: { lang, tag },
|
|
||||||
props: { tag },
|
|
||||||
}))
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成更多语言文章页面的路由配置
|
|
||||||
export function getMultiLangPostRoutes(posts: CollectionEntry<'posts'>[]) {
|
|
||||||
// 创建slug到语言的映射
|
|
||||||
const slugToLangs: Record<string, string[]> = {}
|
|
||||||
|
|
||||||
// 填充映射
|
|
||||||
posts.forEach((post) => {
|
|
||||||
const slug = post.data.abbrlink || post.slug
|
|
||||||
const lang = post.data.lang || defaultLocale
|
|
||||||
|
|
||||||
// 如果文章没有指定语言,初始化为所有支持的语言
|
|
||||||
if (!slugToLangs[slug]) {
|
|
||||||
if (!post.data.lang) {
|
|
||||||
slugToLangs[slug] = [...allLocales] // 文章支持所有语言
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
slugToLangs[slug] = [defaultLocale] // 仅默认语言和指定语言
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!slugToLangs[slug].includes(lang)) {
|
|
||||||
slugToLangs[slug].push(lang)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
interface PathResult {
|
|
||||||
params: {
|
|
||||||
lang: string
|
|
||||||
slug: string
|
|
||||||
}
|
|
||||||
props: {
|
|
||||||
post: CollectionEntry<'posts'>
|
|
||||||
supportedLangs: string[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return posts.flatMap((post) => {
|
|
||||||
const result: PathResult[] = []
|
|
||||||
const slug = post.data.abbrlink || post.slug
|
|
||||||
|
|
||||||
// 确定文章的语言支持
|
|
||||||
const postLang = post.data.lang && typeof post.data.lang === 'string' && post.data.lang.trim() !== ''
|
|
||||||
? [post.data.lang]
|
|
||||||
: moreLocales
|
|
||||||
|
|
||||||
// 获取这篇文章支持的所有语言
|
|
||||||
const supportedLangs = slugToLangs[slug] || []
|
|
||||||
|
|
||||||
// 添加非默认语言路径
|
|
||||||
postLang.forEach((lang) => {
|
|
||||||
if (lang !== defaultLocale) {
|
|
||||||
result.push({
|
|
||||||
params: { lang, slug },
|
|
||||||
props: { post, supportedLangs },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,27 +1,3 @@
|
||||||
// Global Language Map
|
|
||||||
export const langMap: Record<string, string[]> = {
|
|
||||||
'zh': ['zh-CN'],
|
|
||||||
'zh-tw': ['zh-TW'],
|
|
||||||
'ja': ['ja-JP'],
|
|
||||||
'en': ['en-US'],
|
|
||||||
'es': ['es-ES'],
|
|
||||||
'ru': ['ru-RU'],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Waline Language Map
|
|
||||||
// docs: https://waline.js.org/guide/i18n.html
|
|
||||||
export const walineLocaleMap: Record<string, string> = {
|
|
||||||
'zh': 'zh-CN',
|
|
||||||
'zh-tw': 'zh-TW',
|
|
||||||
'ja': 'jp-JP', // Waline uses jp-JP not ja-JP
|
|
||||||
'en': 'en-US',
|
|
||||||
'es': 'es-ES',
|
|
||||||
'ru': 'ru-RU',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supported Languages
|
|
||||||
export const supportedLangs = Object.keys(langMap).flat()
|
|
||||||
|
|
||||||
export const ui = {
|
export const ui = {
|
||||||
'zh': {
|
'zh': {
|
||||||
posts: '文章',
|
posts: '文章',
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
---
|
---
|
||||||
import themeConfig from '@/config'
|
import { allLocales, defaultLocale, themeConfig } from '@/config'
|
||||||
import { allLocales, defaultLocale } from '@/i18n/config'
|
|
||||||
import { ClientRouter } from 'astro:transitions'
|
import { ClientRouter } from 'astro:transitions'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
@ -80,7 +80,7 @@ const footerMarginClass = isPost && themeConfig.comment?.waline?.serverURL
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--=============================================== Theme Toggle ===============================================-->
|
<!-- Theme toggle >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -->
|
||||||
<script define:vars={{ lightMode, darkMode }}>
|
<script define:vars={{ lightMode, darkMode }}>
|
||||||
// Update theme
|
// Update theme
|
||||||
function updateTheme() {
|
function updateTheme() {
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
---
|
---
|
||||||
import { allLocales, defaultLocale } from '@/i18n/config'
|
import { allLocales, defaultLocale } from '@/config'
|
||||||
import Layout from '@/layouts/Layout.astro'
|
import Layout from '@/layouts/Layout.astro'
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
// 定义路径数组的类型
|
|
||||||
type PathItem = {
|
type PathItem = {
|
||||||
params: { about: string }
|
params: { about: string }
|
||||||
props: { lang: string }
|
props: { lang: string }
|
||||||
|
@ -11,13 +10,13 @@ export async function getStaticPaths() {
|
||||||
|
|
||||||
const paths: PathItem[] = []
|
const paths: PathItem[] = []
|
||||||
|
|
||||||
// 默认语言的关于页面
|
// Default locale
|
||||||
paths.push({
|
paths.push({
|
||||||
params: { about: 'about' },
|
params: { about: 'about' },
|
||||||
props: { lang: defaultLocale },
|
props: { lang: defaultLocale },
|
||||||
})
|
})
|
||||||
|
|
||||||
// 更多语言的关于页面
|
// More locales
|
||||||
allLocales.forEach((lang: string) => {
|
allLocales.forEach((lang: string) => {
|
||||||
if (lang !== defaultLocale) {
|
if (lang !== defaultLocale) {
|
||||||
paths.push({
|
paths.push({
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
import PostList from '@/components/PostList.astro'
|
import PostList from '@/components/PostList.astro'
|
||||||
import { allLocales, defaultLocale } from '@/i18n/config'
|
import { allLocales, defaultLocale } from '@/config'
|
||||||
import Layout from '@/layouts/Layout.astro'
|
import Layout from '@/layouts/Layout.astro'
|
||||||
import { getPinnedPosts, getPostsByYear } from '@/utils/content'
|
import { getPinnedPosts, getPostsByYear } from '@/utils/content'
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
import Comments from '@/components/Comments/index.astro'
|
import Comments from '@/components/Comments/index.astro'
|
||||||
import PostTime from '@/components/PostTime.astro'
|
import PostTime from '@/components/PostTime.astro'
|
||||||
import { allLocales, defaultLocale } from '@/i18n/config'
|
import { allLocales, defaultLocale } from '@/config'
|
||||||
import Layout from '@/layouts/Layout.astro'
|
import Layout from '@/layouts/Layout.astro'
|
||||||
import { checkSlugDuplication } from '@/utils/content'
|
import { checkSlugDuplication } from '@/utils/content'
|
||||||
import { generateDescription } from '@/utils/description'
|
import { generateDescription } from '@/utils/description'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import { allLocales, defaultLocale } from '@/i18n/config'
|
import { allLocales, defaultLocale } from '@/config'
|
||||||
import Layout from '@/layouts/Layout.astro'
|
import Layout from '@/layouts/Layout.astro'
|
||||||
import { getAllTags } from '@/utils/content'
|
import { getAllTags } from '@/utils/content'
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
import PostList from '@/components/PostList.astro'
|
import PostList from '@/components/PostList.astro'
|
||||||
import { allLocales, defaultLocale } from '@/i18n/config'
|
import { allLocales, defaultLocale } from '@/config'
|
||||||
import Layout from '@/layouts/Layout.astro'
|
import Layout from '@/layouts/Layout.astro'
|
||||||
import { getAllTags, getPostsByTag } from '@/utils/content'
|
import { getAllTags, getPostsByTag } from '@/utils/content'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { APIContext } from 'astro'
|
import type { APIContext } from 'astro'
|
||||||
import { moreLocales } from '@/i18n/config'
|
import { moreLocales } from '@/config'
|
||||||
import { generateRSS } from '@/utils/rss'
|
import { generateRSS } from '@/utils/rss'
|
||||||
|
|
||||||
// Generate static paths for all supported languages
|
// Generate static paths for all supported languages
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { APIContext } from 'astro'
|
import type { APIContext } from 'astro'
|
||||||
import { moreLocales } from '@/i18n/config'
|
import { moreLocales } from '@/config'
|
||||||
import { generateRSS } from '@/utils/rss'
|
import { generateRSS } from '@/utils/rss'
|
||||||
|
|
||||||
// Generate static paths for all supported languages
|
// Generate static paths for all supported languages
|
||||||
|
|
2
src/types/index.d.ts
vendored
2
src/types/index.d.ts
vendored
|
@ -1,4 +1,4 @@
|
||||||
import type { supportedLangs } from '@/i18n/ui'
|
import type { supportedLangs } from '@/i18n/config'
|
||||||
|
|
||||||
type Exclude<T, U> = T extends U ? never : T
|
type Exclude<T, U> = T extends U ? never : T
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { CollectionEntry } from 'astro:content'
|
import type { CollectionEntry } from 'astro:content'
|
||||||
import themeConfig from '@/config'
|
import themeConfig from '@/config'
|
||||||
import { supportedLangs } from '@/i18n/ui'
|
import { supportedLangs } from '@/i18n/config'
|
||||||
import { getCollection } from 'astro:content'
|
import { getCollection } from 'astro:content'
|
||||||
|
|
||||||
// Type definitions
|
// Type definitions
|
||||||
|
|
|
@ -19,8 +19,7 @@ export function generateExcerpt(content: string, length: number = 98): string {
|
||||||
const normalizedText = plainText.replace(/\s+/g, ' ')
|
const normalizedText = plainText.replace(/\s+/g, ' ')
|
||||||
const excerpt = normalizedText.slice(0, length).trim()
|
const excerpt = normalizedText.slice(0, length).trim()
|
||||||
// Add ellipsis if text was truncated
|
// Add ellipsis if text was truncated
|
||||||
const needsEllipsis = normalizedText.length > length
|
return normalizedText.length > length ? `${excerpt}...` : excerpt
|
||||||
return needsEllipsis ? `${excerpt}...` : excerpt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically generate a description for the article
|
// Automatically generate a description for the article
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { moreLocales } from '@/i18n/config'
|
import { moreLocales } from '@/config'
|
||||||
import { getLangFromPath } from '@/i18n/lang'
|
import { getLangFromPath } from '@/i18n/lang'
|
||||||
import { getLocalizedPath } from '@/i18n/path'
|
import { getLocalizedPath } from '@/i18n/path'
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import type { CollectionEntry } from 'astro:content'
|
import type { CollectionEntry } from 'astro:content'
|
||||||
import themeConfig from '@/config'
|
import themeConfig, { defaultLocale } from '@/config'
|
||||||
import { defaultLocale } from '@/i18n/config'
|
|
||||||
import rss from '@astrojs/rss'
|
import rss from '@astrojs/rss'
|
||||||
import { getCollection } from 'astro:content'
|
import { getCollection } from 'astro:content'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
|
@ -10,7 +9,7 @@ const parser = new MarkdownIt()
|
||||||
const { title, description, url } = themeConfig.site
|
const { title, description, url } = themeConfig.site
|
||||||
const followConfig = themeConfig.seo?.follow
|
const followConfig = themeConfig.seo?.follow
|
||||||
|
|
||||||
// Returns first 50 chars with proper truncation
|
// Returns first 98 chars with proper truncation
|
||||||
function getExcerpt(content: string): string {
|
function getExcerpt(content: string): string {
|
||||||
if (!content)
|
if (!content)
|
||||||
return ''
|
return ''
|
||||||
|
@ -19,8 +18,8 @@ function getExcerpt(content: string): string {
|
||||||
allowedTags: [],
|
allowedTags: [],
|
||||||
allowedAttributes: {},
|
allowedAttributes: {},
|
||||||
})
|
})
|
||||||
const excerpt = plainText.slice(0, 50).trim()
|
const excerpt = plainText.slice(0, 98).trim()
|
||||||
return excerpt.length === 50 ? `${excerpt}...` : excerpt
|
return excerpt.length === 98 ? `${excerpt}...` : excerpt
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GenerateRSSOptions {
|
interface GenerateRSSOptions {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue