Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export default defineConfig({
{ text: '文章封面图', link: '/user-guide/config/notion-next-image-cover' },
{ text: '缓存配置', link: '/user-guide/config/cache-of-notion-next' },
{ text: 'URL 自定义', link: '/user-guide/config/url-customize' },
{ text: 'PWA 安装入口', link: '/user-guide/config/pwa-install' },
{ text: '站点公告', link: '/user-guide/config/notionnext-notice' },
{ text: '二级菜单', link: '/user-guide/menu-secondary' },
{ text: '网页字体', link: '/user-guide/config/notion-next-web-font' },
Expand Down
70 changes: 70 additions & 0 deletions __tests__/lib/pwa.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { buildPwaManifest, getPwaConfig } from '@/lib/pwa'

describe('PWA helpers', () => {
it('uses site info for install metadata', () => {
expect(
getPwaConfig({
siteInfo: { title: 'Site title', icon: '/favicon.png' },
notionConfig: {}
})
).toMatchObject({
name: 'Site title',
shortName: 'Site title',
description: 'Site title',
icon: '/favicon.png'
})
})

it('keeps explicit PWA fields as optional fallbacks', () => {
expect(
getPwaConfig({
siteInfo: { title: 'Site title', icon: '/avatar.png' },
notionConfig: {
PWA_NAME: 'Install title',
PWA_SHORT_NAME: 'Install',
PWA_ICON: '/favicon.png'
}
})
).toMatchObject({
name: 'Install title',
shortName: 'Install',
icon: '/favicon.png'
})
})

it('falls back to fixed install defaults', () => {
expect(
getPwaConfig({
siteInfo: { title: 'Example Blog', icon: '/favicon.png' }
})
).toMatchObject({
name: 'Example Blog',
shortName: 'Example Blog',
icon: '/favicon.png',
themeColor: '#ffffff'
})
})

it('builds a fixed-path installable manifest from site defaults', () => {
expect(
buildPwaManifest({
siteInfo: {
title: 'Example Blog',
description: 'Notes and tutorials',
icon: '/avatar.png'
}
})
).toMatchObject({
name: 'Example Blog',
short_name: 'Example Blog',
description: 'Notes and tutorials',
start_url: '/',
scope: '/',
display: 'standalone',
icons: [
{ src: '/avatar.png', sizes: '192x192' },
{ src: '/avatar.png', sizes: '512x512' }
]
})
})
})
6 changes: 6 additions & 0 deletions blog.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ const BLOG = {
LINK: process.env.NEXT_PUBLIC_LINK || 'https://tangly1024.com', // 网站地址
KEYWORDS: process.env.NEXT_PUBLIC_KEYWORD || 'Notion, 博客', // 网站关键词 英文逗号隔开
BLOG_FAVICON: process.env.NEXT_PUBLIC_FAVICON || '/favicon.ico', // blog favicon 配置, 默认使用 /public/favicon.ico,支持在线图片,如 https://img.imesong.com/favicon.png
PWA_ENABLE: process.env.NEXT_PUBLIC_PWA_ENABLE || false, // 是否启用 PWA 安装入口;也可在 Notion_Config 中配置 PWA_ENABLE=true
PWA_NAME: process.env.NEXT_PUBLIC_PWA_NAME || '', // PWA 安装名称;默认读取站点标题,通常无需单独配置
PWA_SHORT_NAME: process.env.NEXT_PUBLIC_PWA_SHORT_NAME || '', // PWA 短名称;默认读取站点标题,通常无需单独配置
PWA_ICON: process.env.NEXT_PUBLIC_PWA_ICON || '', // PWA 安装图标;默认读取站点图标,通常无需单独配置
PWA_THEME_COLOR: process.env.NEXT_PUBLIC_PWA_THEME_COLOR || '', // PWA 主题色
PWA_BACKGROUND_COLOR: process.env.NEXT_PUBLIC_PWA_BACKGROUND_COLOR || '', // PWA 启动画面背景色
BEI_AN: process.env.NEXT_PUBLIC_BEI_AN || '', // 备案号 闽ICP备XXXXXX
BEI_AN_LINK: process.env.NEXT_PUBLIC_BEI_AN_LINK || 'https://beian.miit.gov.cn/', // 备案查询链接,如果用了萌备等备案请在这里填写
BEI_AN_GONGAN: process.env.NEXT_PUBLIC_BEI_AN_GONGAN || '', // 公安备案号,例如 '浙公网安备3xxxxxxxx8号'
Expand Down
32 changes: 32 additions & 0 deletions components/PWAInstaller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { siteConfig } from '@/lib/config'
import { useEffect } from 'react'

const PWAInstaller = ({ NOTION_CONFIG }) => {
const enabled = siteConfig('PWA_ENABLE', false, NOTION_CONFIG)

useEffect(() => {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return
}

if (!enabled) {
navigator.serviceWorker
.getRegistration('/sw.js')
.then(registration => registration?.unregister())
.catch(() => {})
return
}

window.addEventListener(
'load',
() => {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
},
{ once: true }
)
}, [enabled])

return null
}

export default PWAInstaller
15 changes: 14 additions & 1 deletion components/SEO.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import { getPwaConfig } from '@/lib/pwa'
import { createSiteUrl, normalizeSiteUrl } from '@/lib/sitemap-utils'
import { isHttpLink, loadExternalResource } from '@/lib/utils'
import Head from 'next/head'
Expand Down Expand Up @@ -88,6 +89,8 @@ const SEO = props => {
)

const BLOG_FAVICON = siteConfig('BLOG_FAVICON', null, NOTION_CONFIG)
const pwaEnabled = siteConfig('PWA_ENABLE', false, NOTION_CONFIG)
const pwaConfig = getPwaConfig({ siteInfo, notionConfig: NOTION_CONFIG })

const COMMENT_WEBMENTION_ENABLE = siteConfig(
'COMMENT_WEBMENTION_ENABLE',
Expand Down Expand Up @@ -120,7 +123,10 @@ const SEO = props => {
<Head>
<link rel='icon' href={favicon} />
<title>{title}</title>
<meta name='theme-color' content={BACKGROUND_DARK} />
<meta
name='theme-color'
content={pwaEnabled ? pwaConfig.themeColor : BACKGROUND_DARK}
/>
<meta
name='viewport'
content='width=device-width, initial-scale=1.0, maximum-scale=5.0, minimum-scale=1.0'
Expand All @@ -132,6 +138,13 @@ const SEO = props => {
<meta name='apple-mobile-web-app-capable' content='yes' />
<meta name='apple-mobile-web-app-status-bar-style' content='default' />
<meta name='apple-mobile-web-app-title' content={title} />
{pwaEnabled && (
<>
<link rel='manifest' href='/manifest.json' />
<meta name='application-name' content={pwaConfig.name} />
<link rel='apple-touch-icon' href={pwaConfig.icon} />
</>
)}

{/* 搜索引擎验证 */}
{SEO_GOOGLE_SITE_VERIFICATION && (
Expand Down
13 changes: 12 additions & 1 deletion docs/user-guide/changelog/latest.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@

## 4.10.10 发布要点

`4.10.10` 是一次小版本维护发布,集中收录 `v4.10.9` 之后的近期主线改动。普通站点同步最新 `main` 后重新部署即可;只有需要内嵌子页面层级 URL 的站点,才需要新增可选配置。
`4.10.10` 是一次小版本维护发布,集中收录 `v4.10.9` 之后的近期主线改动。普通站点同步最新 `main` 后重新部署即可;只有需要内嵌子页面层级 URL 或 PWA 安装入口的站点,才需要新增可选配置。

### PWA 安装入口

- 新增可选配置 `PWA_ENABLE=true`,开启后 Android Chrome 可将博客安装到桌面。
- 安装入口由 Notion Config 或环境变量 `PWA_ENABLE` 控制,主题色可通过 `PWA_THEME_COLOR` 配置。
- manifest 使用固定路径 `/manifest.json`,默认指向首页 `/`,并使用站点标题、描述和站点图标生成安装信息。
- `PWA_NAME`、`PWA_SHORT_NAME`、`PWA_ICON` 可作为少数站点的备用覆盖项;默认情况下无需单独配置。
- 功能默认关闭,不影响未开启站点。

使用方法见 [PWA 安装入口](../config/pwa-install.md)。

### Notion 内嵌子页面 URL

Expand Down Expand Up @@ -43,6 +53,7 @@
### 升级说明

- 普通 Vercel / Netlify / Cloudflare Pages / Docker 站点:同步最新 `main` 后重新部署即可。
- 如果要启用 Android Chrome PWA 安装入口,推荐在 Notion Config 添加 `PWA_ENABLE=true`。
- 如果要启用内嵌子页面父路径 URL,推荐在 Notion Config 添加 `INNER_PAGE_URL_PARENT_PATH=true`。
- 也可以在部署平台添加 `NEXT_PUBLIC_INNER_PAGE_URL_PARENT_PATH=true` 后重新部署。
- 如果你依赖 Docker 镜像,请等待本版本 GitHub Release 对应的 GHCR 镜像发布完成后再拉取。
Expand Down
1 change: 1 addition & 0 deletions docs/user-guide/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
| --- | --- |
| [基础功能](./site-basics.md) | 站点开关与常用项 |
| [URL 自定义](./url-customize.md) | 路径、重定向 |
| [PWA 安装入口](./pwa-install.md) | Android Chrome 安装到桌面 |
| [Algolia](./algolia.md) | 站内搜索 |
| [按文章控制是否允许复制](./copy-permission.md) | 单篇覆盖全站复制开关 |

Expand Down
50 changes: 50 additions & 0 deletions docs/user-guide/config/pwa-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# PWA 安装入口

NotionNext 可以为博客开启 PWA 安装能力。开启后,Android Chrome 会在访问博客首页且满足 HTTPS、固定 manifest、service worker 等条件时显示「安装应用」入口;用户安装后,桌面会出现使用站点图标和站点名称的快捷方式。

## 开启方式

推荐在 Notion Config 表中新增配置:

| key | value | 说明 |
| --- | --- | --- |
| `PWA_ENABLE` | `true` | 开启 PWA 安装入口 |
| `PWA_THEME_COLOR` | `#111827` | Android Chrome 状态栏和安装主题色 |
| `PWA_NAME` | `Tangly 的学习笔记` | 备用覆盖项;默认读取站点标题,通常不用单独配置 |
| `PWA_SHORT_NAME` | `Tangly` | 备用覆盖项;默认读取站点标题,通常不用单独配置 |
| `PWA_ICON` | `/favicon.png` | 备用覆盖项;默认读取站点图标,通常不用单独配置 |

也可以在部署平台环境变量中开启:

```bash
NEXT_PUBLIC_PWA_ENABLE=true
NEXT_PUBLIC_PWA_THEME_COLOR=#111827
```

配置完成后重新部署站点。manifest 固定输出到 `/manifest.json`,默认指向首页 `/`,名称、描述和图标会优先使用站点自身的标题、描述和站点图标。静态导出时,构建过程会把这些站点信息写入固定 manifest 文件。

## 图标要求

- 优先使用正方形 PNG 图标,建议至少 512x512。
- 如果使用相对路径,请把文件放在 `public` 目录下,例如 `public/favicon.png` 对应 `/favicon.png`。
- 如果使用备用 `PWA_ICON`,请保持图标地址能被公网访问。

## Android Chrome 安装

1. 用 Android Chrome 打开你的博客首页。
2. 等页面加载完成后,打开 Chrome 右上角菜单。
3. 点击「安装应用」或「添加到主屏幕」。
4. 确认安装后,桌面会出现站点快捷方式。

如果没有出现安装入口,请先检查:

- 站点是否使用 HTTPS。
- `PWA_ENABLE` 是否为 `true`。
- 浏览器能否访问 `/manifest.json` 和 `/sw.js`。
- `/manifest.json` 中的图标地址是否能正常打开。

## 注意事项

- PWA 开关默认关闭,不会影响未开启站点。
- 该功能用于安装博客快捷方式,不会把站点变成离线应用;当前 service worker 只用于满足安装条件和后续扩展。
- 如果关闭 `PWA_ENABLE`,新访问的浏览器不会继续注册 service worker;已经安装到桌面的快捷方式需要用户自行卸载。
3 changes: 3 additions & 0 deletions docs/user-guide/reference/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
| `CAN_COPY` | 是否允许复制正文;文章可用 `CAN_COPY` 或 `ext.CAN_COPY` 单独覆盖 |
| `GREETING_WORDS` | 欢迎语打字(部分主题) |
| `LAYOUT_SIDEBAR_REVERSE` | 侧栏左右反转(hexo/next 等) |
| `PWA_ENABLE` | 开启 Android Chrome PWA 安装入口 |
| `PWA_THEME_COLOR` | 配置 PWA 安装入口启用后的主题色 |
| `PWA_NAME` / `PWA_SHORT_NAME` / `PWA_ICON` | 备用覆盖 PWA 安装名称和图标;默认读取站点信息 |
| `UUID_REDIRECT` | UUID 重定向到 slug |
| `CUSTOM_EXTERNAL_JS` / `CSS` | 外链脚本样式 |
| `BEI_AN` / `BEI_AN_GONGAN` | 备案号 |
Expand Down
2 changes: 2 additions & 0 deletions lib/db/SiteDataApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import { filterCollectionViewData } from './notion/filterCollectionViewData'
import { fetchPageFromNotion } from './notion/getNotionPost'
import { processPostData } from '../utils/post'
import { writePwaManifest } from '../pwa.server'
import { adapterNotionBlockMap } from '../utils/notion.util'
import { sortPinnedPostsByLatestUpdate } from '@/lib/utils/pinnedPosts'
import { fetchMembersFromOfficialAPI } from './notion/memberDataSource'
Expand Down Expand Up @@ -596,6 +597,7 @@ async function convertNotionToSiteData(
}

const siteInfo = getSiteInfo({ collection, block, rawMetadata, NOTION_CONFIG })
writePwaManifest({ siteInfo, notionConfig: NOTION_CONFIG })
const stepEnd9 = Date.now()
console.log(`[${traceId}] ⏱ 配置站点信息耗时: ${stepEnd9 - stepStart9}ms @ ${new Date().toISOString()}`)

Expand Down
63 changes: 63 additions & 0 deletions lib/pwa.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const DEFAULT_ICON = '/favicon.png'

export const getPwaConfig = ({ siteInfo = {}, notionConfig = {} } = {}) => {
const title = notionConfig.PWA_NAME || siteInfo.title || notionConfig.TITLE || 'NotionNext'
const description =
notionConfig.PWA_DESCRIPTION ||
siteInfo.description ||
notionConfig.DESCRIPTION ||
title
const icon = notionConfig.PWA_ICON || siteInfo.icon || DEFAULT_ICON
const backgroundColor = notionConfig.PWA_BACKGROUND_COLOR || '#ffffff'
const themeColor = notionConfig.PWA_THEME_COLOR || backgroundColor

return {
name: title,
shortName: notionConfig.PWA_SHORT_NAME || title,
description,
icon,
backgroundColor,
themeColor,
startUrl: '/',
scope: '/',
display: 'standalone'
}
}

export const buildPwaManifest = ({ siteInfo = {}, notionConfig = {} } = {}) => {
const pwa = getPwaConfig({ siteInfo, notionConfig })

return {
name: pwa.name,
short_name: pwa.shortName,
description: pwa.description,
start_url: pwa.startUrl,
scope: pwa.scope,
display: pwa.display,
background_color: pwa.backgroundColor,
theme_color: pwa.themeColor,
icons: [
{
src: pwa.icon,
sizes: '192x192',
type: getIconMimeType(pwa.icon),
purpose: 'any maskable'
},
{
src: pwa.icon,
sizes: '512x512',
type: getIconMimeType(pwa.icon),
purpose: 'any maskable'
}
]
}
}

const getIconMimeType = icon => {
if (typeof icon !== 'string') return 'image/png'
const normalized = icon.split('?')[0].toLowerCase()
if (normalized.endsWith('.svg')) return 'image/svg+xml'
if (normalized.endsWith('.ico')) return 'image/x-icon'
if (normalized.endsWith('.webp')) return 'image/webp'
return 'image/png'
}
15 changes: 15 additions & 0 deletions lib/pwa.server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import fs from 'node:fs'
import path from 'node:path'
import { buildPwaManifest } from './pwa'

let manifestWritten = false

export const writePwaManifest = ({ siteInfo = {}, notionConfig = {} } = {}) => {
if (manifestWritten || process.env.BUILD_MODE !== 'true') return

const manifestPath = path.join(process.cwd(), 'public', 'manifest.json')
const manifest = buildPwaManifest({ siteInfo, notionConfig })

fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
manifestWritten = true
}
2 changes: 2 additions & 0 deletions pages/_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import ErrorHandler from '@/lib/utils/errorHandler'
// 各种扩展插件 这个要阻塞引入
import BLOG from '@/blog.config'
import ExternalPlugins from '@/components/ExternalPlugins'
import PWAInstaller from '@/components/PWAInstaller'
import SEO from '@/components/SEO'
import { zhCN } from '@clerk/localizations'
import dynamic from 'next/dynamic'
Expand Down Expand Up @@ -89,6 +90,7 @@ const MyApp = ({ Component, pageProps }) => {
<SEO {...pageProps} />
<Component {...pageProps} />
</GLayout>
<PWAInstaller NOTION_CONFIG={pageProps?.NOTION_CONFIG} />
<ExternalPlugins {...pageProps} />
</GlobalContextProvider>
</AppErrorBoundary>
Expand Down
24 changes: 24 additions & 0 deletions public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "NotionNext Blog",
"short_name": "NotionNext",
"description": "A blog powered by NotionNext",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/favicon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/favicon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Loading
Loading