This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/* eslint-env node */
|
||||
const tailwindConfig = `${__dirname}/tailwind.config.cjs`;
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'plugin:ghost/ts',
|
||||
'plugin:react/recommended',
|
||||
'plugin:i18next/recommended'
|
||||
],
|
||||
plugins: [
|
||||
'ghost',
|
||||
'tailwindcss',
|
||||
'i18next'
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// Sort multiple import lines into alphabetical groups
|
||||
'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
|
||||
memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple']
|
||||
}],
|
||||
|
||||
// Enforce kebab-case (lowercase with hyphens) for all filenames
|
||||
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false],
|
||||
|
||||
// TODO: fix + remove this
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
|
||||
// Suppress errors for missing 'import React' in JSX files, as we don't need it
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
// Ignore prop-types for now
|
||||
'react/prop-types': 'off',
|
||||
|
||||
// custom react rules
|
||||
'react/jsx-sort-props': ['error', {
|
||||
reservedFirst: true,
|
||||
callbacksLast: true,
|
||||
shorthandLast: true,
|
||||
locale: 'en'
|
||||
}],
|
||||
'react/button-has-type': 'error',
|
||||
'react/no-array-index-key': 'error',
|
||||
|
||||
'tailwindcss/classnames-order': ['error', {config: tailwindConfig}],
|
||||
'tailwindcss/enforces-negative-arbitrary-values': ['warn', {config: tailwindConfig}],
|
||||
'tailwindcss/enforces-shorthand': ['warn', {config: tailwindConfig}],
|
||||
'tailwindcss/migration-from-tailwind-2': ['warn', {config: tailwindConfig}],
|
||||
'tailwindcss/no-arbitrary-value': 'off',
|
||||
'tailwindcss/no-custom-classname': 'off',
|
||||
'tailwindcss/no-contradicting-classname': ['error', {config: tailwindConfig}],
|
||||
|
||||
// This rule doesn't work correctly with TypeScript, and TypeScript has its own better version
|
||||
'no-undef': 'off'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2013-2026 Ghost Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Comments UI
|
||||
|
||||
Comments widget that is embedded at the bottom of posts in Ghost.
|
||||
|
||||
## Development
|
||||
|
||||
### Pre-requisites
|
||||
|
||||
- Run `pnpm` in Ghost monorepo root
|
||||
|
||||
### Running via Ghost `pnpm dev` in root folder
|
||||
|
||||
Comments UI runs automatically when using Ghost's development command from the monorepo root:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Release
|
||||
|
||||
A patch release can be rolled out instantly in production, whereas a minor/major release requires the Ghost monorepo to be updated and released. In either case, you need sufficient permissions to release `@tryghost` packages on NPM.
|
||||
|
||||
### Patch release
|
||||
|
||||
1. Run `pnpm ship` and select a patch version when prompted
|
||||
2. Merge the release commit to `main`
|
||||
|
||||
### Minor / major release
|
||||
|
||||
1. Run `pnpm ship` and select a minor or major version when prompted
|
||||
2. Merge the release commit to `main`
|
||||
3. Wait until a new version of Ghost is released
|
||||
|
||||
### JsDelivr cache
|
||||
If the CI doesn't clear JsDelivr cache to get the new version out instantly, you may want to do it yourself manually ([docs](https://www.notion.so/ghost/How-to-clear-jsDelivr-CDN-cache-2930bdbac02946eca07ac23ab3199bfa?pvs=4)). Typically, you'll need to open `https://purge.jsdelivr.net/ghost/comments-ui@~${COMMENTS_UI_VERSION}/umd/comments-ui.min.js` and
|
||||
`https://purge.jsdelivr.net/ghost/comments-ui@~${COMMENTS_UI_VERSION}/umd/main.css` in your browser, where `COMMENTS_UI_VERSION` is the latest minor version in `ghost/core/core/shared/config/defaults.json` ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L198))
|
||||
|
||||
# Copyright & License
|
||||
|
||||
Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE).
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "@tryghost/comments-ui",
|
||||
"version": "1.4.6",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/TryGhost/Ghost",
|
||||
"author": "Ghost Foundation",
|
||||
"unpkg": "umd/comments-ui.umd.js",
|
||||
"files": [
|
||||
"umd/",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently \"pnpm preview --host -l silent\" \"pnpm build:watch\"",
|
||||
"dev:test": "vite build && vite preview --port 7175",
|
||||
"build": "vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"preview": "vite preview",
|
||||
"test": "pnpm test:unit && pnpm test:e2e",
|
||||
"test:unit": "vitest run --coverage",
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test",
|
||||
"test:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=1000 pnpm test:e2e --headed",
|
||||
"test:e2e:full": "ALL_BROWSERS=1 pnpm test:e2e",
|
||||
"lint": "eslint src --ext .js,.ts,.jsx,.tsx --cache",
|
||||
"preship": "pnpm lint",
|
||||
"ship": "node ../../.github/scripts/release-apps.js",
|
||||
"prepublishOnly": "pnpm build"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@doist/react-interpolate": "2.2.1",
|
||||
"@headlessui/react": "1.7.19",
|
||||
"@tiptap/core": "2.26.3",
|
||||
"@tiptap/extension-blockquote": "2.26.3",
|
||||
"@tiptap/extension-document": "2.26.3",
|
||||
"@tiptap/extension-hard-break": "2.26.3",
|
||||
"@tiptap/extension-link": "2.26.3",
|
||||
"@tiptap/extension-paragraph": "2.26.3",
|
||||
"@tiptap/extension-placeholder": "2.26.3",
|
||||
"@tiptap/extension-text": "2.26.3",
|
||||
"@tiptap/pm": "2.26.3",
|
||||
"@tiptap/react": "2.26.3",
|
||||
"@tryghost/debug": "0.1.40",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-string-replace": "1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.59.1",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@tryghost/i18n": "workspace:*",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"@vitest/coverage-v8": "0.34.6",
|
||||
"autoprefixer": "10.4.21",
|
||||
"bson-objectid": "2.0.4",
|
||||
"concurrently": "8.2.2",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-i18next": "6.1.3",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-react-refresh": "0.4.24",
|
||||
"eslint-plugin-tailwindcss": "3.18.2",
|
||||
"jsdom": "28.1.0",
|
||||
"moment": "2.30.1",
|
||||
"postcss": "8.5.6",
|
||||
"sinon": "^21.1.1",
|
||||
"tailwindcss": "3.4.18",
|
||||
"vite": "5.4.21",
|
||||
"vite-plugin-css-injected-by-js": "3.5.2",
|
||||
"vite-plugin-svgr": "3.3.0",
|
||||
"vitest": "1.6.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {defineConfig, devices} from '@playwright/test';
|
||||
|
||||
export const E2E_PORT = 7175;
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './test/e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Hardcode to use all cores in CI */
|
||||
workers: process.env.CI ? '100%' : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: process.env.PLAYWRIGHT_REPORTER ?? 'html',
|
||||
timeout: process.env.PLAYWRIGHT_SLOWMO ? 100000 : 20000,
|
||||
expect: {
|
||||
timeout: process.env.PLAYWRIGHT_SLOWMO ? 100000 : 5000
|
||||
},
|
||||
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
launchOptions: {
|
||||
slowMo: parseInt(process.env.PLAYWRIGHT_SLOWMO ?? '') || 0,
|
||||
// force GPU hardware acceleration
|
||||
// (even in headless mode)
|
||||
args: ['--use-gl=egl']
|
||||
},
|
||||
permissions: ['local-network-access']
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {...devices['Desktop Chrome']}
|
||||
},
|
||||
|
||||
...(process.env.ALL_BROWSERS ? [{
|
||||
name: 'firefox',
|
||||
use: {...devices['Desktop Firefox']}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: {...devices['Desktop Safari']}
|
||||
}] : [])
|
||||
],
|
||||
|
||||
/* Run local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: `pnpm dev:test`,
|
||||
url: `http://localhost:${E2E_PORT}/comments-ui.min.js`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 20000
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,544 @@
|
||||
import {AddComment, Comment, CommentsOptions, DispatchActionType, EditableAppContext, OpenCommentForm} from './app-context';
|
||||
import {AdminApi} from './utils/admin-api';
|
||||
import {GhostApi} from './utils/api';
|
||||
import {Page} from './pages';
|
||||
|
||||
async function loadMoreComments({state, api, options, order}: {state: EditableAppContext, api: GhostApi, options: CommentsOptions, order?:string}): Promise<Partial<EditableAppContext>> {
|
||||
let page = 1;
|
||||
if (state.pagination && state.pagination.page) {
|
||||
page = state.pagination.page + 1;
|
||||
}
|
||||
let data;
|
||||
if (state.admin && state.adminApi) {
|
||||
data = await state.adminApi.browse({page, postId: options.postId, order: order || state.order, memberUuid: state.member?.uuid});
|
||||
} else {
|
||||
data = await api.comments.browse({page, postId: options.postId, order: order || state.order});
|
||||
}
|
||||
|
||||
const updatedComments = [...state.comments, ...data.comments];
|
||||
const dedupedComments = updatedComments.filter((comment, index, self) => self.findIndex(c => c.id === comment.id) === index);
|
||||
|
||||
// Note: we store the comments from new to old, and show them in reverse order
|
||||
return {
|
||||
comments: dedupedComments,
|
||||
pagination: data.meta.pagination
|
||||
};
|
||||
}
|
||||
|
||||
function setCommentsIsLoading({data: isLoading}: {data: boolean | null}) {
|
||||
return {
|
||||
commentsIsLoading: isLoading
|
||||
};
|
||||
}
|
||||
|
||||
async function setOrder({state, data: {order}, options, api, dispatchAction}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi, dispatchAction: DispatchActionType}) {
|
||||
dispatchAction('setCommentsIsLoading', true);
|
||||
|
||||
try {
|
||||
let data;
|
||||
if (state.admin && state.adminApi) {
|
||||
data = await state.adminApi.browse({page: 1, postId: options.postId, order, memberUuid: state.member?.uuid});
|
||||
} else {
|
||||
data = await api.comments.browse({page: 1, postId: options.postId, order});
|
||||
}
|
||||
|
||||
return {
|
||||
comments: [...data.comments],
|
||||
pagination: data.meta.pagination,
|
||||
order,
|
||||
commentsIsLoading: false
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to set order:', error); // eslint-disable-line no-console
|
||||
state.commentsIsLoading = false;
|
||||
throw error; // Rethrow the error to allow upstream handling
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreReplies({state, api, data: {comment, limit}, isReply}: {state: EditableAppContext, api: GhostApi, data: {comment: Comment, limit?: number | 'all'}, isReply: boolean}): Promise<Partial<EditableAppContext>> {
|
||||
const fetchReplies = async (afterReplyId: string | undefined, requestLimit: number) => {
|
||||
if (state.admin && state.adminApi && !isReply) { // we don't want the admin api to load reply data for replying to a reply, so we pass isReply: true
|
||||
return await state.adminApi.replies({commentId: comment.id, afterReplyId, limit: requestLimit, memberUuid: state.member?.uuid});
|
||||
} else {
|
||||
return await api.comments.replies({commentId: comment.id, afterReplyId, limit: requestLimit});
|
||||
}
|
||||
};
|
||||
|
||||
let afterReplyId: string | undefined = comment.replies && comment.replies.length > 0
|
||||
? comment.replies[comment.replies.length - 1]?.id
|
||||
: undefined;
|
||||
|
||||
let allComments: Comment[] = [];
|
||||
|
||||
if (limit === 'all') {
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const data = await fetchReplies(afterReplyId, 100);
|
||||
allComments.push(...data.comments);
|
||||
hasMore = !!data.meta?.pagination?.next;
|
||||
|
||||
if (data.comments && data.comments.length > 0) {
|
||||
afterReplyId = data.comments[data.comments.length - 1]?.id;
|
||||
} else {
|
||||
// If no comments returned, stop pagination to prevent infinite loop
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const data = await fetchReplies(afterReplyId, limit as number || 100);
|
||||
allComments = data.comments;
|
||||
}
|
||||
|
||||
// Note: we store the comments from new to old, and show them in reverse order
|
||||
return {
|
||||
comments: state.comments.map((c) => {
|
||||
if (c.id === comment.id) {
|
||||
return {
|
||||
...comment,
|
||||
replies: [...comment.replies, ...allComments]
|
||||
};
|
||||
}
|
||||
return c;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function addComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: AddComment}) {
|
||||
const data = await api.comments.add({comment});
|
||||
const newComment = data.comments[0];
|
||||
|
||||
return {
|
||||
comments: [newComment, ...state.comments],
|
||||
commentCount: state.commentCount + 1,
|
||||
commentIdToScrollTo: newComment.id
|
||||
};
|
||||
}
|
||||
|
||||
async function addReply({state, api, data: {reply, parent}}: {state: EditableAppContext, api: GhostApi, data: {reply: any, parent: any}}) {
|
||||
const data = await api.comments.add({
|
||||
comment: {...reply, parent_id: parent.id}
|
||||
});
|
||||
const newComment = data.comments[0];
|
||||
|
||||
const allReplies = await api.comments.replies({commentId: parent.id, limit: 'all'});
|
||||
|
||||
return {
|
||||
comments: state.comments.map((c) => {
|
||||
if (c.id === parent.id) {
|
||||
return {
|
||||
...c,
|
||||
replies: allReplies.comments,
|
||||
count: {
|
||||
...c.count,
|
||||
replies: allReplies.comments.length
|
||||
}
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
commentCount: state.commentCount + 1,
|
||||
commentIdToScrollTo: newComment.id
|
||||
};
|
||||
}
|
||||
|
||||
async function hideComment({state, data: comment}: {state: EditableAppContext, adminApi: any, data: {id: string}}) {
|
||||
if (state.adminApi) {
|
||||
await state.adminApi.hideComment(comment.id);
|
||||
}
|
||||
return {
|
||||
comments: state.comments.map((c) => {
|
||||
const replies = c.replies.map((r) => {
|
||||
if (r.id === comment.id) {
|
||||
return {
|
||||
...r,
|
||||
status: 'hidden'
|
||||
};
|
||||
}
|
||||
|
||||
return r;
|
||||
});
|
||||
|
||||
if (c.id === comment.id) {
|
||||
return {
|
||||
...c,
|
||||
status: 'hidden',
|
||||
replies
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
replies
|
||||
};
|
||||
}),
|
||||
commentCount: state.commentCount - 1
|
||||
};
|
||||
}
|
||||
|
||||
async function showComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, adminApi: any, data: {id: string}}) {
|
||||
if (state.adminApi) {
|
||||
await state.adminApi.showComment({id: comment.id});
|
||||
}
|
||||
// We need to refetch the comment, to make sure we have an up to date HTML content
|
||||
// + all relations are loaded as the current member (not the admin)
|
||||
let data;
|
||||
if (state.admin && state.adminApi) {
|
||||
data = await state.adminApi.read({commentId: comment.id, memberUuid: state.member?.uuid});
|
||||
} else {
|
||||
data = await api.comments.read(comment.id);
|
||||
}
|
||||
|
||||
const updatedComment = data.comments[0];
|
||||
|
||||
return {
|
||||
comments: state.comments.map((c) => {
|
||||
const replies = c.replies.map((r) => {
|
||||
if (r.id === comment.id) {
|
||||
return updatedComment;
|
||||
}
|
||||
|
||||
return r;
|
||||
});
|
||||
|
||||
if (c.id === comment.id) {
|
||||
return updatedComment;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
replies
|
||||
};
|
||||
}),
|
||||
commentCount: state.commentCount + 1
|
||||
};
|
||||
}
|
||||
|
||||
async function updateCommentLikeState({state, data: comment}: {state: EditableAppContext, data: {id: string, liked: boolean}}) {
|
||||
return {
|
||||
comments: state.comments.map((c) => {
|
||||
const replies = c.replies.map((r) => {
|
||||
if (r.id === comment.id) {
|
||||
return {
|
||||
...r,
|
||||
liked: comment.liked,
|
||||
count: {
|
||||
...r.count,
|
||||
likes: comment.liked ? r.count.likes + 1 : r.count.likes - 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return r;
|
||||
});
|
||||
|
||||
if (c.id === comment.id) {
|
||||
return {
|
||||
...c,
|
||||
liked: comment.liked,
|
||||
replies,
|
||||
count: {
|
||||
...c.count,
|
||||
likes: comment.liked ? c.count.likes + 1 : c.count.likes - 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
replies
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function likeComment({api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
|
||||
dispatchAction('updateCommentLikeState', {id: comment.id, liked: true});
|
||||
try {
|
||||
await api.comments.like({comment});
|
||||
return {};
|
||||
} catch {
|
||||
dispatchAction('updateCommentLikeState', {id: comment.id, liked: false});
|
||||
}
|
||||
}
|
||||
|
||||
async function unlikeComment({api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
|
||||
dispatchAction('updateCommentLikeState', {id: comment.id, liked: false});
|
||||
|
||||
try {
|
||||
await api.comments.unlike({comment});
|
||||
return {};
|
||||
} catch {
|
||||
dispatchAction('updateCommentLikeState', {id: comment.id, liked: true});
|
||||
}
|
||||
}
|
||||
|
||||
async function reportComment({api, data: comment}: {api: GhostApi, data: {id: string}}) {
|
||||
await api.comments.report({comment});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async function deleteComment({state, api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
|
||||
await api.comments.edit({
|
||||
comment: {
|
||||
id: comment.id,
|
||||
status: 'deleted'
|
||||
}
|
||||
});
|
||||
|
||||
// If we're deleting a top-level comment with no replies we refresh the
|
||||
// whole comments section to maintain correct pagination
|
||||
const commentToDelete = state.comments.find(c => c.id === comment.id);
|
||||
if (commentToDelete && (!commentToDelete.replies || commentToDelete.replies.length === 0)) {
|
||||
dispatchAction('setOrder', {order: state.order});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
comments: state.comments.map((topLevelComment) => {
|
||||
// If the comment has replies we want to keep it so the replies are
|
||||
// still visible, but mark the comment as deleted. Otherwise remove it.
|
||||
if (topLevelComment.id === comment.id) {
|
||||
if (topLevelComment.replies.length > 0) {
|
||||
return {
|
||||
...topLevelComment,
|
||||
status: 'deleted'
|
||||
};
|
||||
} else {
|
||||
return null; // Will be filtered out later
|
||||
}
|
||||
}
|
||||
|
||||
const originalLength = topLevelComment.replies.length;
|
||||
const updatedReplies = topLevelComment.replies.filter(reply => reply.id !== comment.id);
|
||||
const hasDeletedReply = originalLength !== updatedReplies.length;
|
||||
|
||||
const updatedTopLevelComment = {
|
||||
...topLevelComment,
|
||||
replies: updatedReplies
|
||||
};
|
||||
|
||||
// When a reply is deleted we need to update the parent's count so
|
||||
// pagination displays the correct number of replies still to load
|
||||
if (hasDeletedReply && topLevelComment.count?.replies) {
|
||||
topLevelComment.count.replies = topLevelComment.count.replies - 1;
|
||||
}
|
||||
|
||||
return updatedTopLevelComment;
|
||||
}).filter(Boolean),
|
||||
commentCount: state.commentCount - 1
|
||||
};
|
||||
}
|
||||
|
||||
async function editComment({state, api, data: {comment, parent}}: {state: EditableAppContext, api: GhostApi, data: {comment: Partial<Comment> & {id: string}, parent?: Comment}}) {
|
||||
const data = await api.comments.edit({
|
||||
comment
|
||||
});
|
||||
comment = data.comments[0];
|
||||
|
||||
// Replace the comment in the state with the new one
|
||||
return {
|
||||
comments: state.comments.map((c) => {
|
||||
if (parent && parent.id === c.id) {
|
||||
return {
|
||||
...c,
|
||||
replies: c.replies.map((r) => {
|
||||
if (r.id === comment.id) {
|
||||
return comment;
|
||||
}
|
||||
return r;
|
||||
})
|
||||
};
|
||||
} else if (c.id === comment.id) {
|
||||
return comment;
|
||||
}
|
||||
|
||||
return c;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function updateMember({data, state, api}: {data: {name: string, expertise: string}, state: EditableAppContext, api: GhostApi}) {
|
||||
const {name, expertise} = data;
|
||||
const patchData: {name?: string, expertise?: string} = {};
|
||||
|
||||
const originalName = state?.member?.name;
|
||||
|
||||
if (name && originalName !== name) {
|
||||
patchData.name = name;
|
||||
}
|
||||
|
||||
const originalExpertise = state?.member?.expertise;
|
||||
if (expertise !== undefined && originalExpertise !== expertise) {
|
||||
// Allow to set it to an empty string or to null
|
||||
patchData.expertise = expertise;
|
||||
}
|
||||
|
||||
if (Object.keys(patchData).length > 0) {
|
||||
try {
|
||||
const member = await api.member.update(patchData);
|
||||
if (!member) {
|
||||
throw new Error('Failed to update member');
|
||||
}
|
||||
return {
|
||||
member,
|
||||
success: true
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function openPopup({data}: {data: Page}) {
|
||||
return {
|
||||
popup: data
|
||||
};
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
return {
|
||||
popup: null
|
||||
};
|
||||
}
|
||||
|
||||
async function openCommentForm({data: newForm, api, state}: {data: OpenCommentForm, api: GhostApi, state: EditableAppContext}) {
|
||||
let otherStateChanges = {};
|
||||
|
||||
// When opening a reply form, load all replies for the parent comment so the
|
||||
// reply appears in the correct position after posting
|
||||
const topLevelCommentId = newForm.parent_id || newForm.id;
|
||||
if (newForm.type === 'reply' && !state.openCommentForms.some(f => f.id === topLevelCommentId || f.parent_id === topLevelCommentId)) {
|
||||
const comment = state.comments.find(c => c.id === topLevelCommentId);
|
||||
|
||||
if (comment) {
|
||||
try {
|
||||
const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}, isReply: true});
|
||||
otherStateChanges = {...otherStateChanges, ...newCommentsState};
|
||||
} catch (e) {
|
||||
// If loading replies fails, continue anyway - the form should still open
|
||||
// and replies will be loaded when the user submits
|
||||
console.error('[Comments] Failed to load replies before opening form:', e); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We want to keep the number of displayed forms to a minimum so when opening a
|
||||
// new form, we close any existing forms that are empty or have had no changes
|
||||
const openFormsAfterAutoclose = state.openCommentForms.filter(form => form.hasUnsavedChanges);
|
||||
|
||||
// avoid multiple forms being open for the same id
|
||||
// (e.g. if "Reply" is hit on two different replies, we don't want two forms open at the bottom of that comment thread)
|
||||
const openFormIndexForId = openFormsAfterAutoclose.findIndex(form => form.id === newForm.id);
|
||||
if (openFormIndexForId > -1) {
|
||||
openFormsAfterAutoclose[openFormIndexForId] = newForm;
|
||||
return {openCommentForms: openFormsAfterAutoclose, ...otherStateChanges};
|
||||
} else {
|
||||
return {openCommentForms: [...openFormsAfterAutoclose, newForm], ...otherStateChanges};
|
||||
}
|
||||
}
|
||||
|
||||
function setHighlightComment({data: commentId}: {data: string | null}) {
|
||||
return {
|
||||
commentIdToHighlight: commentId
|
||||
};
|
||||
}
|
||||
|
||||
function highlightComment({
|
||||
data: {commentId},
|
||||
dispatchAction
|
||||
|
||||
}: {
|
||||
data: { commentId: string | null };
|
||||
state: EditableAppContext;
|
||||
dispatchAction: DispatchActionType;
|
||||
}) {
|
||||
setTimeout(() => {
|
||||
dispatchAction('setHighlightComment', null);
|
||||
}, 3000);
|
||||
return {
|
||||
commentIdToHighlight: commentId
|
||||
};
|
||||
}
|
||||
|
||||
function setCommentFormHasUnsavedChanges({data: {id, hasUnsavedChanges}, state}: {data: {id: string, hasUnsavedChanges: boolean}, state: EditableAppContext}) {
|
||||
const updatedForms = state.openCommentForms.map((f) => {
|
||||
if (f.id === id) {
|
||||
return {...f, hasUnsavedChanges};
|
||||
} else {
|
||||
return {...f};
|
||||
};
|
||||
});
|
||||
|
||||
return {openCommentForms: updatedForms};
|
||||
}
|
||||
|
||||
function closeCommentForm({data: id, state}: {data: string, state: EditableAppContext}) {
|
||||
return {openCommentForms: state.openCommentForms.filter(f => f.id !== id)};
|
||||
};
|
||||
|
||||
function setScrollTarget({data: commentId}: {data: string | null}) {
|
||||
return {commentIdToScrollTo: commentId};
|
||||
}
|
||||
|
||||
// Sync actions make use of setState((currentState) => newState), to avoid 'race' conditions
|
||||
export const SyncActions = {
|
||||
openPopup,
|
||||
closePopup,
|
||||
closeCommentForm,
|
||||
setCommentFormHasUnsavedChanges,
|
||||
setScrollTarget
|
||||
};
|
||||
|
||||
export type SyncActionType = keyof typeof SyncActions;
|
||||
|
||||
export const Actions = {
|
||||
addComment,
|
||||
editComment,
|
||||
hideComment,
|
||||
deleteComment,
|
||||
showComment,
|
||||
likeComment,
|
||||
unlikeComment,
|
||||
reportComment,
|
||||
addReply,
|
||||
loadMoreComments,
|
||||
loadMoreReplies,
|
||||
openCommentForm,
|
||||
updateMember,
|
||||
setOrder,
|
||||
highlightComment,
|
||||
setHighlightComment,
|
||||
setCommentsIsLoading,
|
||||
updateCommentLikeState
|
||||
};
|
||||
|
||||
export type ActionType = keyof typeof Actions;
|
||||
|
||||
export function isSyncAction(action: string): action is SyncActionType {
|
||||
return !!(SyncActions as any)[action];
|
||||
}
|
||||
|
||||
/** Handle actions in the App, returns updated state */
|
||||
export async function ActionHandler({action, data, state, api, adminApi, options, dispatchAction}: {action: ActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi, dispatchAction: DispatchActionType}): Promise<Partial<EditableAppContext>> {
|
||||
const handler = Actions[action];
|
||||
if (handler) {
|
||||
return await handler({data, state, api, adminApi, options, dispatchAction} as any) || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Handle actions in the App, returns updated state */
|
||||
export function SyncActionHandler({action, data, state, api, adminApi, options}: {action: SyncActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi}): Partial<EditableAppContext> {
|
||||
const handler = SyncActions[action];
|
||||
if (handler) {
|
||||
// Do not await here
|
||||
return handler({data, state, api, adminApi, options} as any) || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Ref: https://reactjs.org/docs/context.html
|
||||
import React, {useContext} from 'react';
|
||||
import {ActionType, Actions, SyncActionType, SyncActions} from './actions';
|
||||
import {AdminApi} from './utils/admin-api';
|
||||
import {Page} from './pages';
|
||||
|
||||
export type Member = {
|
||||
id: string,
|
||||
uuid: string,
|
||||
name: string,
|
||||
avatar_image: string,
|
||||
expertise: string,
|
||||
can_comment?: boolean
|
||||
}
|
||||
|
||||
export type Comment = {
|
||||
id: string,
|
||||
post_id: string,
|
||||
parent_id?: string,
|
||||
in_reply_to_id: string,
|
||||
in_reply_to_snippet: string,
|
||||
replies: Comment[],
|
||||
status: string,
|
||||
liked: boolean,
|
||||
count: {
|
||||
replies: number,
|
||||
likes: number,
|
||||
},
|
||||
member: Member | null,
|
||||
edited_at: string,
|
||||
created_at: string,
|
||||
html: string | null
|
||||
}
|
||||
|
||||
export type OpenCommentForm = {
|
||||
id: string,
|
||||
parent_id?: string,
|
||||
in_reply_to_id?: string,
|
||||
in_reply_to_snippet?: string,
|
||||
type: 'reply' | 'edit',
|
||||
hasUnsavedChanges: boolean
|
||||
}
|
||||
|
||||
export type AddComment = {
|
||||
post_id: string,
|
||||
status: string,
|
||||
html: string
|
||||
}
|
||||
|
||||
export type LabsContextType = {
|
||||
[key: string]: boolean | undefined
|
||||
}
|
||||
|
||||
export type CommentsOptions = {
|
||||
locale: string,
|
||||
siteUrl: string,
|
||||
apiKey: string | undefined,
|
||||
apiUrl: string | undefined,
|
||||
postId: string,
|
||||
adminUrl: string | undefined,
|
||||
colorScheme: string | undefined,
|
||||
avatarSaturation: number | undefined,
|
||||
accentColor: string,
|
||||
commentsEnabled: string | undefined,
|
||||
title: string | null,
|
||||
showCount: boolean,
|
||||
publication: string
|
||||
};
|
||||
|
||||
export type EditableAppContext = {
|
||||
initStatus: string,
|
||||
member: null | any,
|
||||
admin: null | any,
|
||||
comments: Comment[],
|
||||
pagination: {
|
||||
page: number,
|
||||
limit: number,
|
||||
pages: number,
|
||||
total: number
|
||||
} | null,
|
||||
commentCount: number,
|
||||
openCommentForms: OpenCommentForm[],
|
||||
popup: Page | null,
|
||||
labs: LabsContextType,
|
||||
order: string,
|
||||
adminApi: AdminApi | null,
|
||||
commentsIsLoading?: boolean,
|
||||
commentIdToHighlight: string | null,
|
||||
commentIdToScrollTo: string | null,
|
||||
showMissingCommentNotice: boolean,
|
||||
pageUrl: string,
|
||||
supportEmail: string | null,
|
||||
isMember: boolean,
|
||||
isAdmin: boolean,
|
||||
isPaidOnly: boolean,
|
||||
hasRequiredTier: boolean,
|
||||
isCommentingDisabled: boolean
|
||||
}
|
||||
|
||||
export type TranslationFunction = (key: string, replacements?: Record<string, string | number>) => string;
|
||||
|
||||
export type AppContextType = EditableAppContext & CommentsOptions & {
|
||||
// This part makes sure we can add automatic data and return types to the actions when using context.dispatchAction('actionName', data)
|
||||
t: TranslationFunction,
|
||||
dispatchAction: <T extends ActionType | SyncActionType>(action: T, data: Parameters<(typeof Actions & typeof SyncActions)[T]>[0] extends { data: any } ? Parameters<(typeof Actions & typeof SyncActions)[T]>[0]['data'] : any) => T extends ActionType ? Promise<void> : void,
|
||||
openFormCount: number
|
||||
}
|
||||
|
||||
// Copy time from AppContextType
|
||||
export type DispatchActionType = AppContextType['dispatchAction'];
|
||||
export const AppContext = React.createContext<AppContextType>({} as any);
|
||||
|
||||
export const AppContextProvider = AppContext.Provider;
|
||||
|
||||
export const useAppContext = () => useContext(AppContext);
|
||||
|
||||
export const useOrderChange = () => {
|
||||
const context = useAppContext();
|
||||
const dispatchAction = context.dispatchAction;
|
||||
return (order: string) => {
|
||||
dispatchAction('setOrder', {order});
|
||||
};
|
||||
};
|
||||
|
||||
export const useLabs = () => {
|
||||
try {
|
||||
const context = useAppContext();
|
||||
return context.labs || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
/* eslint-disable no-shadow */
|
||||
|
||||
import AuthFrame from './auth-frame';
|
||||
import ContentBox from './components/content-box';
|
||||
import PopupBox from './components/popup-box';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import i18nLib from '@tryghost/i18n';
|
||||
import setupGhostApi from './utils/api';
|
||||
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
|
||||
import {AppContext, Comment, DispatchActionType, EditableAppContext} from './app-context';
|
||||
import {CommentsFrame} from './components/frame';
|
||||
import {setupAdminAPI} from './utils/admin-api';
|
||||
import {useOptions} from './utils/options';
|
||||
|
||||
type AppProps = {
|
||||
scriptTag: HTMLElement;
|
||||
initialCommentId: string | null;
|
||||
pageUrl: string;
|
||||
};
|
||||
|
||||
const ALLOWED_MODERATORS = ['Owner', 'Administrator', 'Super Editor'];
|
||||
|
||||
/**
|
||||
* Check if a comment ID exists in the comments array (either as a top-level comment or reply)
|
||||
*/
|
||||
function isCommentLoaded(comments: Comment[], targetId: string): boolean {
|
||||
return comments.some(c => c.id === targetId || c.replies?.some(r => r.id === targetId));
|
||||
}
|
||||
|
||||
const App: React.FC<AppProps> = ({scriptTag, initialCommentId, pageUrl}) => {
|
||||
const options = useOptions(scriptTag);
|
||||
const [state, setFullState] = useState<EditableAppContext>({
|
||||
initStatus: 'running',
|
||||
member: null,
|
||||
admin: null,
|
||||
comments: [],
|
||||
pagination: null,
|
||||
commentCount: 0,
|
||||
openCommentForms: [],
|
||||
popup: null,
|
||||
labs: {},
|
||||
order: 'count__likes desc, created_at desc',
|
||||
adminApi: null,
|
||||
commentsIsLoading: false,
|
||||
commentIdToHighlight: null,
|
||||
commentIdToScrollTo: initialCommentId,
|
||||
showMissingCommentNotice: false,
|
||||
pageUrl,
|
||||
supportEmail: null,
|
||||
isMember: false,
|
||||
isAdmin: false,
|
||||
isPaidOnly: false,
|
||||
hasRequiredTier: true,
|
||||
isCommentingDisabled: false
|
||||
});
|
||||
|
||||
const iframeRef = React.createRef<HTMLIFrameElement>();
|
||||
|
||||
const api = React.useMemo(() => {
|
||||
return setupGhostApi({
|
||||
siteUrl: options.siteUrl,
|
||||
apiUrl: options.apiUrl!,
|
||||
apiKey: options.apiKey!
|
||||
});
|
||||
}, [options]);
|
||||
|
||||
const setState = useCallback((newState: Partial<EditableAppContext> | ((state: EditableAppContext) => Partial<EditableAppContext>)) => {
|
||||
setFullState((state) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState = newState(state);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
...newState
|
||||
};
|
||||
});
|
||||
}, [setFullState]);
|
||||
|
||||
const dispatchAction = useCallback(async (action, data) => {
|
||||
if (isSyncAction(action)) {
|
||||
// Makes sure we correctly handle the old state
|
||||
// because updates to state may be asynchronous
|
||||
// so calling dispatchAction('counterUp') multiple times, may yield unexpected results if we don't use a callback function
|
||||
setState((state) => {
|
||||
return SyncActionHandler({action, data, state, api, adminApi: state.adminApi!, options});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// This is a bit a ugly hack, but only reliable way to make sure we can get the latest state asynchronously
|
||||
// without creating infinite rerenders because dispatchAction needs to change on every state change
|
||||
// So state shouldn't be a dependency of dispatchAction
|
||||
//
|
||||
// Wrapped in a Promise so that callers of `dispatchAction` can await the action completion. setState doesn't
|
||||
// allow for async actions within it's updater function so this is the best option.
|
||||
return new Promise((resolve) => {
|
||||
setState((state) => {
|
||||
ActionHandler({action, data, state, api, adminApi: state.adminApi!, options, dispatchAction: dispatchAction as DispatchActionType}).then((updatedState) => {
|
||||
const newState = {...updatedState};
|
||||
resolve(newState);
|
||||
setState(newState);
|
||||
}).catch(console.error); // eslint-disable-line no-console
|
||||
|
||||
// No immediate changes
|
||||
return {};
|
||||
});
|
||||
});
|
||||
}, [api, options]); // Do not add state or context as a dependency here -> infinite render loop
|
||||
|
||||
const i18n = useMemo(() => {
|
||||
return i18nLib(options.locale, 'comments');
|
||||
}, [options.locale]);
|
||||
|
||||
const context = {
|
||||
...options,
|
||||
...state,
|
||||
t: i18n.t,
|
||||
dispatchAction: dispatchAction as DispatchActionType,
|
||||
openFormCount: useMemo(() => state.openCommentForms.length, [state.openCommentForms])
|
||||
};
|
||||
|
||||
const initAdminAuth = async () => {
|
||||
if (state.adminApi || !options.adminUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const adminApi = setupAdminAPI({
|
||||
adminUrl: options.adminUrl
|
||||
});
|
||||
|
||||
let admin = null;
|
||||
try {
|
||||
admin = await adminApi.getUser();
|
||||
|
||||
// remove 'admin' for any roles (author, contributor, editor) who can't moderate comments
|
||||
if (!admin || !(admin.roles.some(role => ALLOWED_MODERATORS.includes(role.name)))) {
|
||||
admin = null;
|
||||
}
|
||||
|
||||
if (admin) {
|
||||
// this is a bit of a hack, but we need to fetch the comments fully populated if the user is an admin
|
||||
const adminComments = await adminApi.browse({page: 1, postId: options.postId, order: state.order, memberUuid: state.member?.uuid});
|
||||
setState((currentState) => {
|
||||
// Don't overwrite comments when initSetup loaded extra data
|
||||
// for permalink scrolling (multiple pages or expanded replies)
|
||||
if ((currentState.pagination && currentState.pagination.page > 1) || initialCommentId) {
|
||||
return {
|
||||
adminApi,
|
||||
admin,
|
||||
isAdmin: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
adminApi,
|
||||
admin,
|
||||
isAdmin: true,
|
||||
comments: adminComments.comments,
|
||||
pagination: adminComments.meta.pagination
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Loading of admin failed. Could be not signed in, or a different error (not important)
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[Comments] Failed to fetch admin endpoint:`, e);
|
||||
}
|
||||
|
||||
setState({
|
||||
adminApi,
|
||||
admin,
|
||||
isAdmin: !!admin
|
||||
});
|
||||
} catch (e) {
|
||||
/* eslint-disable no-console */
|
||||
console.error(`[Comments] Failed to initialize admin authentication:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
/** Fetch first few comments */
|
||||
const fetchComments = async () => {
|
||||
const dataPromise = api.comments.browse({page: 1, postId: options.postId, order: state.order});
|
||||
const countPromise = api.comments.count({postId: options.postId});
|
||||
|
||||
const [data, count] = await Promise.all([dataPromise, countPromise]);
|
||||
|
||||
return {
|
||||
comments: data.comments,
|
||||
pagination: data.meta.pagination,
|
||||
count: count
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the target comment and verify it exists and is published.
|
||||
* Returns null if the comment doesn't exist or isn't accessible.
|
||||
*/
|
||||
const fetchScrollTarget = async (targetId: string): Promise<Comment | null> => {
|
||||
try {
|
||||
const response = await api.comments.read(targetId);
|
||||
const comment = response.comments?.[0];
|
||||
return (comment && comment.status === 'published') ? comment : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Paginate through comments until the target (or its parent) is found.
|
||||
*/
|
||||
const paginateToComment = async (
|
||||
targetId: string,
|
||||
parentId: string | undefined,
|
||||
initialComments: Comment[],
|
||||
initialPagination: {page: number; pages: number}
|
||||
): Promise<{comments: Comment[]; pagination: typeof initialPagination}> => {
|
||||
let comments = initialComments;
|
||||
let pagination = initialPagination;
|
||||
|
||||
while (!isCommentLoaded(comments, targetId) && pagination.page < pagination.pages) {
|
||||
if (parentId && comments.some(c => c.id === parentId)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextPage = await api.comments.browse({
|
||||
page: pagination.page + 1,
|
||||
postId: options.postId,
|
||||
order: state.order
|
||||
});
|
||||
comments = [...comments, ...nextPage.comments];
|
||||
pagination = nextPage.meta.pagination;
|
||||
}
|
||||
|
||||
return {comments, pagination};
|
||||
};
|
||||
|
||||
/**
|
||||
* Load additional comment pages and/or replies until the scroll
|
||||
* target is found. After paginating to the parent comment, if the
|
||||
* target reply isn't in the inline replies (partial API response),
|
||||
* fetch all replies from the server.
|
||||
*/
|
||||
const loadScrollTarget = async (
|
||||
targetId: string,
|
||||
targetComment: Comment,
|
||||
initialComments: Comment[],
|
||||
initialPagination: {page: number; pages: number}
|
||||
): Promise<{comments: Comment[]; pagination: typeof initialPagination; found: boolean}> => {
|
||||
const parentId = targetComment.parent_id;
|
||||
|
||||
const {comments: paginatedComments, pagination} = await paginateToComment(targetId, parentId, initialComments, initialPagination);
|
||||
let comments = paginatedComments;
|
||||
|
||||
if (parentId && !isCommentLoaded(comments, targetId)) {
|
||||
const {comments: allReplies} = await api.comments.replies({commentId: parentId, limit: 'all'});
|
||||
comments = comments.map(c => (c.id === parentId ? {...c, replies: allReplies} : c));
|
||||
}
|
||||
|
||||
return {comments, pagination, found: isCommentLoaded(comments, targetId)};
|
||||
};
|
||||
|
||||
/** Initialize comments setup once in viewport, fetch data and setup state */
|
||||
const initSetup = async () => {
|
||||
try {
|
||||
const {member, labs, supportEmail} = await api.init();
|
||||
const {count, comments: initialComments, pagination: initialPagination} = await fetchComments();
|
||||
|
||||
let comments = initialComments;
|
||||
let pagination = initialPagination;
|
||||
let scrollTargetFound = false;
|
||||
|
||||
const shouldFindScrollTarget = initialCommentId && pagination;
|
||||
if (shouldFindScrollTarget) {
|
||||
const targetComment = await fetchScrollTarget(initialCommentId);
|
||||
if (targetComment) {
|
||||
const result = await loadScrollTarget(initialCommentId, targetComment, comments, pagination);
|
||||
comments = result.comments;
|
||||
pagination = result.pagination;
|
||||
scrollTargetFound = result.found;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute tier access values
|
||||
const isMember = !!member;
|
||||
const isPaidOnly = options.commentsEnabled === 'paid';
|
||||
const isPaidMember = !!member?.paid;
|
||||
const hasRequiredTier = isPaidMember || !isPaidOnly;
|
||||
|
||||
setState({
|
||||
member,
|
||||
initStatus: 'success',
|
||||
comments,
|
||||
pagination,
|
||||
commentCount: count,
|
||||
order: 'count__likes desc, created_at desc',
|
||||
labs: labs,
|
||||
commentsIsLoading: false,
|
||||
commentIdToHighlight: null,
|
||||
commentIdToScrollTo: scrollTargetFound ? initialCommentId : null,
|
||||
showMissingCommentNotice: !!initialCommentId && !scrollTargetFound,
|
||||
supportEmail,
|
||||
isMember,
|
||||
isPaidOnly,
|
||||
hasRequiredTier,
|
||||
isCommentingDisabled: member?.can_comment === false
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[Comments] Failed to initialize:`, e);
|
||||
/* eslint-enable no-console */
|
||||
setState({
|
||||
initStatus: 'failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Delay initialization until comments block is in viewport (unless permalink present) */
|
||||
useEffect(() => {
|
||||
// If we have a permalink, load immediately (skip lazy loading)
|
||||
if (initialCommentId) {
|
||||
initSetup();
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
initSetup();
|
||||
if (iframeRef.current) {
|
||||
observer.unobserve(iframeRef.current);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
if (iframeRef.current) {
|
||||
observer.observe(iframeRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (iframeRef.current) {
|
||||
observer.unobserve(iframeRef.current);
|
||||
}
|
||||
};
|
||||
}, [iframeRef.current, initialCommentId]);
|
||||
|
||||
const done = state.initStatus === 'success';
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={context}>
|
||||
<CommentsFrame ref={iframeRef}>
|
||||
<ContentBox done={done} />
|
||||
</CommentsFrame>
|
||||
{state.comments.length > 0 ? <AuthFrame adminUrl={options.adminUrl} onLoad={initAdminAuth}/> : null}
|
||||
<PopupBox />
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,14 @@
|
||||
type Props = {
|
||||
adminUrl: string|undefined;
|
||||
onLoad: () => void;
|
||||
};
|
||||
const AuthFrame: React.FC<Props> = ({adminUrl, onLoad}) => {
|
||||
const iframeStyle = {
|
||||
display: 'none'
|
||||
};
|
||||
|
||||
return (
|
||||
<iframe data-frame="admin-auth" src={adminUrl + 'auth-frame/'} style={iframeStyle} title="auth-frame" onLoad={onLoad}></iframe>
|
||||
);
|
||||
};
|
||||
export default AuthFrame;
|
||||
@@ -0,0 +1,75 @@
|
||||
import App from './app';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {ROOT_DIV_ID} from './utils/constants';
|
||||
import {parseCommentIdFromHash} from './utils/helpers';
|
||||
|
||||
function getScriptTag(): HTMLElement {
|
||||
let scriptTag = document.currentScript as HTMLElement | null;
|
||||
|
||||
if (!scriptTag && import.meta.env.DEV) {
|
||||
// In development mode, use any script tag (because in ESM mode, document.currentScript is not set)
|
||||
scriptTag = document.querySelector('script[data-ghost-comments]');
|
||||
}
|
||||
|
||||
if (!scriptTag) {
|
||||
throw new Error('[Comments-UI] Cannot find current script tag');
|
||||
}
|
||||
|
||||
return scriptTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a div to mount the React application into, creating it if necessary
|
||||
*/
|
||||
function getRootDiv(scriptTag: HTMLElement) {
|
||||
if (scriptTag.previousElementSibling && scriptTag.previousElementSibling.id === ROOT_DIV_ID) {
|
||||
return scriptTag.previousElementSibling;
|
||||
}
|
||||
|
||||
if (!scriptTag.parentElement) {
|
||||
throw new Error('[Comments-UI] Script tag does not have a parent element');
|
||||
}
|
||||
|
||||
const elem = document.createElement('div');
|
||||
elem.id = ROOT_DIV_ID;
|
||||
scriptTag.parentElement.insertBefore(elem, scriptTag);
|
||||
return elem;
|
||||
}
|
||||
|
||||
function handleTokenUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get('token')) {
|
||||
url.searchParams.delete('token');
|
||||
window.history.replaceState({}, document.title, url.href);
|
||||
}
|
||||
}
|
||||
|
||||
function getPageUrl(): string {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function init() {
|
||||
const scriptTag = getScriptTag();
|
||||
const root = getRootDiv(scriptTag);
|
||||
const initialCommentId = parseCommentIdFromHash(window.location.hash);
|
||||
const pageUrl = getPageUrl();
|
||||
|
||||
try {
|
||||
handleTokenUrl();
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
{<App initialCommentId={initialCommentId} pageUrl={pageUrl} scriptTag={scriptTag} />}
|
||||
</React.StrictMode>,
|
||||
root
|
||||
);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,29 @@
|
||||
import AddDetailsPopup from './components/popups/add-details-popup';
|
||||
import CTAPopup from './components/popups/cta-popup';
|
||||
import DeletePopup from './components/popups/delete-popup';
|
||||
import React from 'react';
|
||||
import ReportPopup from './components/popups/report-popup';
|
||||
|
||||
/** List of all available pages in Comments-UI, mapped to their UI component
|
||||
* Any new page added to comments-ui needs to be mapped here
|
||||
*/
|
||||
export const Pages = {
|
||||
addDetailsPopup: AddDetailsPopup,
|
||||
reportPopup: ReportPopup,
|
||||
ctaPopup: CTAPopup,
|
||||
deletePopup: DeletePopup
|
||||
};
|
||||
export type PageName = keyof typeof Pages;
|
||||
|
||||
type PageTypes = {
|
||||
[name in PageName]: {
|
||||
type: name,
|
||||
/**
|
||||
* Called when closing the popup
|
||||
* @param succeeded False if normal cancel/close buttons are used
|
||||
*/
|
||||
callback?: (succeeded: boolean) => void,
|
||||
} & React.ComponentProps<typeof Pages[name]>
|
||||
}
|
||||
|
||||
export type Page = PageTypes[keyof PageTypes]
|
||||
@@ -0,0 +1,17 @@
|
||||
import {afterEach} from 'vitest';
|
||||
import {cleanup} from '@testing-library/react';
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}));
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare module '*.svg' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import React = require('react');
|
||||
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,190 @@
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
heartbeat: 'heartbeat 0.35s ease-in-out forwards',
|
||||
pulse: 'pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite'
|
||||
},
|
||||
keyframes: {
|
||||
heartbeat: {
|
||||
'0%, 100%': {transform: 'scale(1)'},
|
||||
'50%': {transform: 'scale(1.3)'}
|
||||
}
|
||||
}
|
||||
},
|
||||
screens: {
|
||||
sm: '481px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
'2xl': '1400px'
|
||||
},
|
||||
spacing: {
|
||||
px: '1px',
|
||||
0: '0px',
|
||||
0.5: '0.2rem',
|
||||
1: '0.4rem',
|
||||
1.5: '0.6rem',
|
||||
2: '0.8rem',
|
||||
2.5: '1rem',
|
||||
3: '1.2rem',
|
||||
3.5: '1.4rem',
|
||||
4: '1.6rem',
|
||||
5: '2rem',
|
||||
6: '2.4rem',
|
||||
7: '2.8rem',
|
||||
8: '3.2rem',
|
||||
9: '3.6rem',
|
||||
10: '4rem',
|
||||
11: '4.4rem',
|
||||
12: '4.8rem',
|
||||
14: '5.6rem',
|
||||
16: '6.4rem',
|
||||
20: '8rem',
|
||||
24: '9.6rem',
|
||||
28: '11.2rem',
|
||||
32: '12.8rem',
|
||||
36: '14.4rem',
|
||||
40: '16rem',
|
||||
44: '17.6rem',
|
||||
48: '19.2rem',
|
||||
52: '20.8rem',
|
||||
56: '22.4rem',
|
||||
60: '24rem',
|
||||
64: '25.6rem',
|
||||
72: '28.8rem',
|
||||
80: '32rem',
|
||||
96: '38.4rem'
|
||||
},
|
||||
maxWidth: {
|
||||
none: 'none',
|
||||
0: '0rem',
|
||||
xs: '32rem',
|
||||
sm: '38.4rem',
|
||||
md: '44.8rem',
|
||||
lg: '51.2rem',
|
||||
xl: '57.6rem',
|
||||
'2xl': '67.2rem',
|
||||
'3xl': '76.8rem',
|
||||
'4xl': '89.6rem',
|
||||
'5xl': '102.4rem',
|
||||
'6xl': '115.2rem',
|
||||
'7xl': '128rem',
|
||||
'8xl': '140rem',
|
||||
'9xl': '156rem',
|
||||
full: '100%',
|
||||
min: 'min-content',
|
||||
max: 'max-content',
|
||||
fit: 'fit-content',
|
||||
prose: '65ch'
|
||||
},
|
||||
minWidth: {
|
||||
none: 'none',
|
||||
0: '0rem',
|
||||
xs: '32rem',
|
||||
sm: '38.4rem',
|
||||
md: '44.8rem',
|
||||
lg: '51.2rem',
|
||||
xl: '57.6rem',
|
||||
'2xl': '67.2rem',
|
||||
'3xl': '76.8rem',
|
||||
'4xl': '89.6rem',
|
||||
'5xl': '102.4rem',
|
||||
'6xl': '115.2rem',
|
||||
'7xl': '128rem',
|
||||
'8xl': '140rem',
|
||||
'9xl': '156rem',
|
||||
full: '100%',
|
||||
min: 'min-content',
|
||||
max: 'max-content',
|
||||
fit: 'fit-content',
|
||||
prose: '65ch'
|
||||
},
|
||||
borderRadius: {
|
||||
sm: '0.2rem',
|
||||
DEFAULT: '0.4rem',
|
||||
md: '0.6rem',
|
||||
lg: '0.8rem',
|
||||
xl: '1.2rem',
|
||||
'2xl': '1.6rem',
|
||||
'3xl': '2.4rem',
|
||||
full: '9999px'
|
||||
},
|
||||
fontSize: {
|
||||
xs: '1.2rem',
|
||||
base: '1.3rem',
|
||||
sm: '1.4rem',
|
||||
md: '1.5rem',
|
||||
lg: '1.65rem',
|
||||
xl: '2rem',
|
||||
'2xl': '2.4rem',
|
||||
'3xl': '3rem',
|
||||
'4xl': '3.6rem',
|
||||
'5xl': ['4.8rem', '1.15'],
|
||||
'6xl': ['6rem', '1'],
|
||||
'7xl': ['7.2rem', '1'],
|
||||
'8xl': ['9.6rem', '1'],
|
||||
'9xl': ['12.8rem', '1']
|
||||
},
|
||||
letterSpacing: {
|
||||
tightest: '-.075em',
|
||||
tighter: '-.05em',
|
||||
tight: '-.018em',
|
||||
normal: '0',
|
||||
wide: '.018em',
|
||||
wider: '.05em',
|
||||
widest: '.1em'
|
||||
},
|
||||
boxShadow: {
|
||||
lg: [
|
||||
'rgba(0, 0, 0, 0.06) 0px 0px 0px 1px',
|
||||
'rgba(0, 0, 0, 0.04) 0px 2px 2px -1px',
|
||||
'rgba(0, 0, 0, 0.04) 0px 3px 3px -1px',
|
||||
'rgba(0, 0, 0, 0.03) 0px 5px 5px -2px',
|
||||
'rgba(0, 0, 0, 0.03) 0px 10px 10px -3px',
|
||||
'rgba(0, 0, 0, 0.03) 0px 24px 24px -8px'
|
||||
],
|
||||
xl: [
|
||||
'0px 0px 1px rgba(0, 0, 0, 0.12)',
|
||||
'0px 13px 20px rgba(0, 0, 0, 0.04)',
|
||||
'0px 14px 57px rgba(0, 0, 0, 0.06)'
|
||||
],
|
||||
form: [
|
||||
'0px 78px 57px -57px rgba(0, 0, 0, 0.1)',
|
||||
'0px 15px 20px -8px rgba(0, 0, 0, 0.08)',
|
||||
'0px 0px 1px 0px rgba(0,0,0,0.32)'
|
||||
],
|
||||
formxl: [
|
||||
'0px 78px 57px -57px rgba(0, 0, 0, 0.125)',
|
||||
'0px 15px 20px -8px rgba(0, 0, 0, 0.1)',
|
||||
'0px 0px 1px 0px rgba(0, 0, 0, 0.32)'
|
||||
],
|
||||
modal: [
|
||||
'0 3.8px 2.2px rgba(0, 0, 0, 0.028)',
|
||||
'0 9.2px 5.3px rgba(0, 0, 0, 0.04)',
|
||||
'0 17.3px 10px rgba(0, 0, 0, 0.05)',
|
||||
'0 30.8px 17.9px rgba(0, 0, 0, 0.06)',
|
||||
'0 57.7px 33.4px rgba(0, 0, 0, 0.072)',
|
||||
'0 138px 80px rgba(0, 0, 0, 0.1)'
|
||||
]
|
||||
},
|
||||
animation: {
|
||||
heartbeat: 'heartbeat 0.35s ease-in-out forwards',
|
||||
highlight: 'highlight 1s steps(1) forwards'
|
||||
},
|
||||
keyframes: {
|
||||
heartbeat: {
|
||||
'0%, 100%': {transform: 'scale(1)'},
|
||||
'50%': {transform: 'scale(1.3)'}
|
||||
},
|
||||
highlight: {
|
||||
'100%': {backgroundColor: 'transparent'}
|
||||
}
|
||||
}
|
||||
},
|
||||
content: [
|
||||
'./src/**/*.{js,jsx,ts,tsx}'
|
||||
],
|
||||
plugins: []
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Vitest */
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.mts", "vite-plugin-strip-fingerprinting.ts", "package.json"]
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import type {Plugin} from 'vite';
|
||||
|
||||
interface Replacement {
|
||||
search: string;
|
||||
replace: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PatternGroup {
|
||||
filePattern: RegExp;
|
||||
replacements: Replacement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that patches ProseMirror and tiptap browser detection to avoid
|
||||
* accessing high-entropy fingerprinting APIs (navigator.vendor,
|
||||
* navigator.platform, navigator.maxTouchPoints).
|
||||
*
|
||||
* DuckDuckGo's Tracker Radar classifies scripts that access these APIs as
|
||||
* fingerprinting (score 3 = maximum). Safari's Advanced Fingerprinting
|
||||
* Protection (on by default since Safari 26, Sept 2025) uses this data to
|
||||
* restrict API access and storage for scripts from flagged domains like
|
||||
* cdn.jsdelivr.net.
|
||||
*
|
||||
* This plugin replaces those API accesses with equivalent checks using only
|
||||
* navigator.userAgent, which has a much lower fingerprinting weight.
|
||||
*
|
||||
* Affected packages:
|
||||
* - prosemirror-view: navigator.vendor, navigator.platform, navigator.maxTouchPoints
|
||||
* - prosemirror-keymap: navigator.platform
|
||||
* - prosemirror-commands: navigator.platform
|
||||
* - @tiptap/core: navigator.platform
|
||||
* - w3c-keyname: navigator.platform
|
||||
*/
|
||||
export function stripFingerprintingPlugin(): Plugin {
|
||||
const patternGroups: PatternGroup[] = [
|
||||
{
|
||||
filePattern: /prosemirror-view[\\/]dist[\\/]index\.js$/,
|
||||
replacements: [
|
||||
{
|
||||
// Safari detection: nav.vendor → userAgent check
|
||||
// Original: checks if vendor is "Apple Computer"
|
||||
// Patched: checks UA for Safari without Chrome/Chromium
|
||||
search: '/Apple Computer/.test(nav.vendor)',
|
||||
replace: '/Safari\\//.test(agent) && !/Chrome\\//.test(agent) && !/Chromium\\//.test(agent)',
|
||||
description: 'prosemirror-view: safari detection (nav.vendor)'
|
||||
},
|
||||
{
|
||||
// iOS detection: remove nav.maxTouchPoints fallback
|
||||
// Original: detects iPadOS via maxTouchPoints > 2
|
||||
// Patched: relies on Mobile/xxx in UA only
|
||||
// Trade-off: iPadOS 13+ sends desktop Mac UA, so it won't
|
||||
// be detected as iOS. This is acceptable — iPad works fine
|
||||
// with desktop Mac editor handling.
|
||||
search: ' || !!nav && nav.maxTouchPoints > 2',
|
||||
replace: '',
|
||||
description: 'prosemirror-view: iOS detection (nav.maxTouchPoints)'
|
||||
},
|
||||
{
|
||||
// Mac detection: nav.platform → userAgent check
|
||||
search: 'nav ? /Mac/.test(nav.platform) : false',
|
||||
replace: '/Macintosh/.test(agent)',
|
||||
description: 'prosemirror-view: mac detection (nav.platform)'
|
||||
},
|
||||
{
|
||||
// Windows detection: nav.platform → userAgent check
|
||||
search: 'nav ? /Win/.test(nav.platform) : false',
|
||||
replace: '/Windows/.test(agent)',
|
||||
description: 'prosemirror-view: windows detection (nav.platform)'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
filePattern: /prosemirror-keymap[\\/]dist[\\/]index\.js$/,
|
||||
replacements: [
|
||||
{
|
||||
search: '/Mac|iP(hone|[oa]d)/.test(navigator.platform)',
|
||||
replace: '/Macintosh|iPhone|iPad|iPod/.test(navigator.userAgent)',
|
||||
description: 'prosemirror-keymap: mac/iOS detection (navigator.platform)'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
filePattern: /prosemirror-commands[\\/]dist[\\/]index\.js$/,
|
||||
replacements: [
|
||||
{
|
||||
search: '/Mac|iP(hone|[oa]d)/.test(navigator.platform)',
|
||||
replace: '/Macintosh|iPhone|iPad|iPod/.test(navigator.userAgent)',
|
||||
description: 'prosemirror-commands: mac/iOS detection (navigator.platform)'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
filePattern: /w3c-keyname[\\/]index\.js$/,
|
||||
replacements: [
|
||||
{
|
||||
search: '/Mac/.test(navigator.platform)',
|
||||
replace: '/Macintosh/.test(navigator.userAgent)',
|
||||
description: 'w3c-keyname: mac detection (navigator.platform)'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
filePattern: /@tiptap[\\/]core[\\/]dist[\\/]index\.js$/,
|
||||
replacements: [
|
||||
{
|
||||
// isAndroid: remove navigator.platform === 'Android' check,
|
||||
// keep the userAgent fallback which already handles this
|
||||
search: 'navigator.platform === \'Android\' || ',
|
||||
replace: '',
|
||||
description: '@tiptap/core: isAndroid (navigator.platform)'
|
||||
},
|
||||
{
|
||||
// isiOS: replace navigator.platform array check with UA
|
||||
// The array ['iPad Simulator', 'iPhone Simulator', ...] is
|
||||
// still present but .includes() on it becomes a no-op.
|
||||
// The UA check catches real iPhone/iPod devices.
|
||||
// iPadOS 13+ is handled by the next line in tiptap:
|
||||
// navigator.userAgent.includes('Mac') && 'ontouchend' in document
|
||||
search: '].includes(navigator.platform)',
|
||||
replace: '].length === 0 || /iPhone|iPod/.test(navigator.userAgent)',
|
||||
description: '@tiptap/core: isiOS (navigator.platform)'
|
||||
},
|
||||
{
|
||||
// isMacOS: replace navigator.platform with userAgent
|
||||
search: '/Mac/.test(navigator.platform)',
|
||||
replace: '/Macintosh/.test(navigator.userAgent)',
|
||||
description: '@tiptap/core: isMacOS (navigator.platform)'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const appliedReplacements = new Map<string, Set<string>>();
|
||||
|
||||
return {
|
||||
name: 'strip-fingerprinting',
|
||||
enforce: 'pre',
|
||||
|
||||
buildStart() {
|
||||
appliedReplacements.clear();
|
||||
},
|
||||
|
||||
transform(code: string, id: string) {
|
||||
const normalizedId = id.replace(/\\/g, '/');
|
||||
|
||||
const group = patternGroups.find(g => g.filePattern.test(normalizedId));
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let transformed = code;
|
||||
let hasChanges = false;
|
||||
|
||||
for (const replacement of group.replacements) {
|
||||
if (transformed.includes(replacement.search)) {
|
||||
transformed = transformed.replaceAll(replacement.search, replacement.replace);
|
||||
hasChanges = true;
|
||||
|
||||
if (!appliedReplacements.has(normalizedId)) {
|
||||
appliedReplacements.set(normalizedId, new Set());
|
||||
}
|
||||
appliedReplacements.get(normalizedId)!.add(replacement.description);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
return {code: transformed, map: null};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
buildEnd() {
|
||||
const allDescriptions = patternGroups.flatMap(g => g.replacements.map(r => r.description));
|
||||
const applied = new Set(
|
||||
[...appliedReplacements.values()].flatMap(s => [...s])
|
||||
);
|
||||
|
||||
const missing = allDescriptions.filter(d => !applied.has(d));
|
||||
|
||||
if (missing.length > 0) {
|
||||
this.warn(
|
||||
`strip-fingerprinting: ${missing.length} replacement(s) did not match. ` +
|
||||
`Dependencies may have been updated. Unmatched:\n` +
|
||||
missing.map(d => ` - ${d}`).join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import pkg from './package.json';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
import {SUPPORTED_LOCALES} from '@tryghost/i18n';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
import {resolve} from 'path';
|
||||
import {stripFingerprintingPlugin} from './vite-plugin-strip-fingerprinting';
|
||||
|
||||
const outputFileName = pkg.name[0] === '@' ? pkg.name.slice(pkg.name.indexOf('/') + 1) : pkg.name;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default (function viteConfig() {
|
||||
return defineConfig({
|
||||
logLevel: process.env.CI ? 'info' : 'warn',
|
||||
plugins: [
|
||||
stripFingerprintingPlugin(),
|
||||
svgr(),
|
||||
react()
|
||||
],
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
|
||||
'process.env.VITEST_SEGFAULT_RETRY': 3
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: true, // allows domain-name proxies to the preview server
|
||||
port: 7173,
|
||||
cors: true
|
||||
},
|
||||
server: {
|
||||
port: 5368
|
||||
},
|
||||
build: {
|
||||
reportCompressedSize: false,
|
||||
outDir: resolve(__dirname, 'umd'),
|
||||
emptyOutDir: true,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
cssCodeSplit: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.tsx'),
|
||||
formats: ['umd'],
|
||||
name: pkg.name,
|
||||
fileName(format) {
|
||||
if (format === 'umd') {
|
||||
return `${outputFileName}.min.js`;
|
||||
}
|
||||
|
||||
return `${outputFileName}.js`;
|
||||
}
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {}
|
||||
},
|
||||
commonjsOptions: {
|
||||
include: [/ghost/, /node_modules/],
|
||||
dynamicRequireRoot: '../../',
|
||||
dynamicRequireTargets: SUPPORTED_LOCALES.map(locale => `../../ghost/i18n/locales/${locale}/comments.json`)
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
// comments-ui uses React 17 while the monorepo hoists React 18;
|
||||
// dedupe + alias ensures all deps (including @tiptap/react) use
|
||||
// the same React 17 instance from comments-ui's node_modules
|
||||
dedupe: ['react', 'react-dom', '@tryghost/debug'],
|
||||
alias: {
|
||||
'react': resolve(__dirname, 'node_modules/react'),
|
||||
'react-dom': resolve(__dirname, 'node_modules/react-dom')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true, // required for @testing-library/jest-dom extensions
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/setup-tests.ts',
|
||||
include: ['test/unit/**/*.test.{js,jsx,ts,tsx}'],
|
||||
testTimeout: process.env.TIMEOUT ? parseInt(process.env.TIMEOUT) : 10000,
|
||||
server: {
|
||||
deps: {
|
||||
// Inline all deps so Vite's resolve.alias applies to their
|
||||
// React imports (prevents duplicate React 17 instances when
|
||||
// the monorepo hoists React 18)
|
||||
inline: [/@tiptap/, /@headlessui/]
|
||||
}
|
||||
},
|
||||
...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674
|
||||
minThreads: 1,
|
||||
maxThreads: 2
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user