first commit
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled

This commit is contained in:
2026-04-22 19:51:20 +07:00
commit 93d1b7c3d3
579 changed files with 99797 additions and 0 deletions
+59
View File
@@ -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'
}
};
+21
View File
@@ -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.
+39
View File
@@ -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).
+89
View File
@@ -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"
}
}
+64
View File
@@ -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
}
});
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {}
}
};
+544
View File
@@ -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 {};
}
+132
View File
@@ -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 {};
}
};
+363
View File
@@ -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;
+14
View File
@@ -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;
+75
View File
@@ -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();
+29
View File
@@ -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]
+17
View File
@@ -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()
}));
+7
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+190
View File
@@ -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: []
};
+30
View File
@@ -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" }]
}
+10
View File
@@ -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')
);
}
}
};
}
+91
View File
@@ -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
})
}
});
});