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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 84 additions & 45 deletions .lintstagedrc.cjs
Original file line number Diff line number Diff line change
@@ -1,69 +1,108 @@
const path = require('path');
const fs = require('fs');

const SCOPED_WORKSPACES = [
'e2e',
'apps/admin',
'apps/posts',
'apps/shade'
];
const ROOT = process.cwd();

function normalize(file) {
return file.split(path.sep).join('/');
}

function isInWorkspace(file, workspace) {
const normalizedFile = normalize(path.relative(process.cwd(), file));
const normalizedWorkspace = normalize(workspace);

return normalizedFile === normalizedWorkspace || normalizedFile.startsWith(`${normalizedWorkspace}/`);
function normalize(p) {
return p.split(path.sep).join('/');
}

function shellQuote(value) {
return `'${value.replace(/'/g, `'\\''`)}'`;
}

function buildScopedEslintCommand(workspace, files) {
if (files.length === 0) {
return null;
// Parse the `packages:` list from pnpm-workspace.yaml. We only need the simple
// glob shapes pnpm allows here (`apps/*`, `ghost/*`, `e2e`); anything fancier
// would warrant a real YAML parser.
function loadWorkspacePatterns() {
const yaml = fs.readFileSync(path.join(ROOT, 'pnpm-workspace.yaml'), 'utf8');
const lines = yaml.split('\n');
const start = lines.findIndex(line => /^packages:\s*$/.test(line));
if (start === -1) {
return [];
}
const patterns = [];
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i];
if (/^\s+-\s+/.test(line)) {
const match = line.match(/^\s+-\s+['"]?([^'"\s]+)['"]?\s*$/);
if (match) {
patterns.push(match[1]);
}
} else if (line.trim() !== '' && !/^\s/.test(line)) {
break;
}
}
return patterns;
}

const relativeFiles = files
.map(file => normalize(path.relative(workspace, file)))
.map(shellQuote)
.join(' ');

return `pnpm --dir ${shellQuote(workspace)} exec eslint --cache ${relativeFiles}`;
function expandPattern(pattern) {
const segments = pattern.split('/');
let candidates = [''];
for (const segment of segments) {
const next = [];
for (const base of candidates) {
const dir = base ? path.join(ROOT, base) : ROOT;
if (segment === '*') {
if (!fs.existsSync(dir)) {
continue;
}
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
if (entry.isDirectory()) {
next.push(base ? `${base}/${entry.name}` : entry.name);
}
}
} else {
const candidate = base ? `${base}/${segment}` : segment;
if (fs.existsSync(path.join(ROOT, candidate))) {
next.push(candidate);
}
}
}
candidates = next;
}
return candidates;
}

function buildRootEslintCommand(files) {
if (files.length === 0) {
return null;
const WORKSPACES = new Set(
loadWorkspacePatterns().flatMap(expandPattern)
);

function findWorkspace(file) {
let dir = path.dirname(path.resolve(file));
while (dir.startsWith(ROOT) && dir !== ROOT) {
const rel = normalize(path.relative(ROOT, dir));
if (WORKSPACES.has(rel)) {
return rel;
}
dir = path.dirname(dir);
}
return null;
}

const quotedFiles = files.map(file => shellQuote(normalize(file))).join(' ');
return `eslint --cache ${quotedFiles}`;
function buildCommand(workspace, files) {
const base = workspace ? path.join(ROOT, workspace) : ROOT;
const relativeFiles = files
.map(file => normalize(path.relative(base, file)))
.map(shellQuote)
.join(' ');
const dirArg = workspace ? `--dir ${shellQuote(workspace)} ` : '';
return `pnpm ${dirArg}exec eslint --cache -- ${relativeFiles}`;
}

module.exports = {
'*.{js,ts,tsx,jsx,cjs}': (files) => {
const workspaceGroups = new Map(SCOPED_WORKSPACES.map(workspace => [workspace, []]));
const rootFiles = [];

const groups = new Map();
for (const file of files) {
const workspace = SCOPED_WORKSPACES.find(candidate => isInWorkspace(file, candidate));

if (workspace) {
workspaceGroups.get(workspace).push(file);
} else {
rootFiles.push(file);
const workspace = findWorkspace(file);
const key = workspace ?? '';
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(file);
}

return [
...SCOPED_WORKSPACES
.map(workspace => buildScopedEslintCommand(workspace, workspaceGroups.get(workspace)))
.filter(Boolean),
buildRootEslintCommand(rootFiles)
].filter(Boolean);
return [...groups.entries()].map(([workspace, wsFiles]) =>
buildCommand(workspace || null, wsFiles)
);
}
};
10 changes: 7 additions & 3 deletions apps/activitypub/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tryghost/activitypub",
"version": "3.1.16",
"version": "3.1.19",
"license": "MIT",
"repository": {
"type": "git",
Expand Down Expand Up @@ -42,10 +42,14 @@
"@types/jest": "29.5.14",
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.24",
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
"jest": "29.7.0",
"tailwindcss": "^4.2.2",
"vite": "5.4.21",
"vitest": "1.6.1"
"vite": "7.3.2",
"vitest": "4.1.2"
},
"nx": {
"targets": {
Expand Down
8 changes: 5 additions & 3 deletions apps/activitypub/src/hooks/use-activity-pub-queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {beforeEach, describe, expect, it, vi} from 'vitest';
import {renderHook, waitFor} from '@testing-library/react';
import {useReplyChainForUser} from './use-activity-pub-queries';

global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
site: {url: 'https://test.com'}
})
Expand Down Expand Up @@ -39,7 +39,7 @@ describe('useReplyChainForUser', () => {
beforeEach(() => {
vi.clearAllMocks();

(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
json: vi.fn().mockResolvedValue({
site: {url: 'https://test.com'}
})
Expand All @@ -50,7 +50,9 @@ describe('useReplyChainForUser', () => {
getPost: vi.fn()
};

(ActivityPubAPI as ReturnType<typeof vi.fn>).mockImplementation(() => mockApi);
(ActivityPubAPI as ReturnType<typeof vi.fn>).mockImplementation(function () {
return mockApi;
});
(isApiError as unknown as ReturnType<typeof vi.fn>).mockReturnValue(true);
});

Expand Down
2 changes: 1 addition & 1 deletion apps/activitypub/test/unit/utils/screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('takeScreenshot', function () {
});

afterEach(function () {
vi.clearAllMocks();
vi.restoreAllMocks();
});

it('calls html2canvas with correct default options', async function () {
Expand Down
1 change: 1 addition & 0 deletions apps/admin-x-design-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"@types/validator": "13.15.10",
"@typescript-eslint/parser": "8.49.0",
"@vitejs/plugin-react": "4.7.0",
"autoprefixer": "10.4.21",
"c8": "10.1.3",
Expand Down
9 changes: 5 additions & 4 deletions apps/admin-x-framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,20 @@
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"@vitejs/plugin-react": "4.7.0",
"@vitest/coverage-v8": "^1.6.1",
"@vitest/coverage-v8": "^4.1.2",
"c8": "10.1.3",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.24",
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
"glob": "^10.5.0",
"jsdom": "28.1.0",
"msw": "2.12.14",
"typescript": "5.9.3",
"vite": "5.4.21",
"vite": "7.3.2",
"vite-plugin-css-injected-by-js": "3.5.2",
"vite-plugin-svgr": "3.3.0",
"vitest": "1.6.1"
"vite-plugin-svgr": "4.5.0",
"vitest": "4.1.2"
},
"dependencies": {
"@ebay/nice-modal-react": "1.2.13",
Expand Down
6 changes: 6 additions & 0 deletions apps/admin-x-framework/src/test/acceptance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ export async function mockApi<Requests extends Record<string, MockRequestConfig>
return {lastApiRequests};
}

export async function waitForApiRequest<Requests extends Record<string, MockRequestConfig>>(lastApiRequests: {[key in keyof Requests]?: RequestRecord}, requestName: keyof Requests) {
await expect.poll(() => lastApiRequests[requestName]).toBeTruthy();

return lastApiRequests[requestName]!;
}

export function updatedSettingsResponse(newSettings: Array<{ key: string, value: string | boolean | null, is_read_only?: boolean }>) {
return {
...responseFixtures.settings,
Expand Down
32 changes: 16 additions & 16 deletions apps/admin-x-framework/test/unit/utils/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,15 @@ describe('helpers utils', () => {
});

describe('blobDownload', () => {
let originalFetch: typeof global.fetch;
let originalFetch: typeof globalThis.fetch;
let originalCreateObjectURL: typeof URL.createObjectURL;
let originalRevokeObjectURL: typeof URL.revokeObjectURL;
let appendChildSpy: MockInstance<[node: Node], Node>;
let appendChildSpy: MockInstance<(node: Node) => Node>;
let removeElementSpy: ReturnType<typeof vi.fn>;
let clickSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
originalFetch = global.fetch;
originalFetch = globalThis.fetch;
originalCreateObjectURL = URL.createObjectURL;
originalRevokeObjectURL = URL.revokeObjectURL;

Expand All @@ -168,7 +168,7 @@ describe('helpers utils', () => {
});

afterEach(() => {
global.fetch = originalFetch;
globalThis.fetch = originalFetch;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
appendChildSpy.mockRestore();
Expand All @@ -177,14 +177,14 @@ describe('helpers utils', () => {

it('fetches the URL and triggers a download', async () => {
const mockBlob = new Blob(['test,data'], {type: 'text/csv'});
global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(mockBlob)
});

await blobDownload('https://example.com/export.csv', 'members.csv');

expect(global.fetch).toHaveBeenCalledWith('https://example.com/export.csv', {method: 'GET'});
expect(globalThis.fetch).toHaveBeenCalledWith('https://example.com/export.csv', {method: 'GET'});
expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob);
expect(clickSpy).toHaveBeenCalled();
expect(removeElementSpy).toHaveBeenCalled();
Expand All @@ -193,7 +193,7 @@ describe('helpers utils', () => {

it('sets the correct filename on the download link', async () => {
const mockBlob = new Blob(['test'], {type: 'text/csv'});
global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(mockBlob)
});
Expand All @@ -215,7 +215,7 @@ describe('helpers utils', () => {
});

it('throws on non-ok response', async () => {
global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error'
Expand All @@ -226,20 +226,20 @@ describe('helpers utils', () => {
});

it('propagates fetch network errors', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));

await expect(blobDownload('https://example.com/fail', 'test.csv'))
.rejects.toThrow('Network error');
});
});

describe('blobDownloadFromEndpoint', () => {
let originalFetch: typeof global.fetch;
let originalFetch: typeof globalThis.fetch;
let originalCreateObjectURL: typeof URL.createObjectURL;
let originalRevokeObjectURL: typeof URL.revokeObjectURL;

beforeEach(() => {
originalFetch = global.fetch;
originalFetch = globalThis.fetch;
originalCreateObjectURL = URL.createObjectURL;
originalRevokeObjectURL = URL.revokeObjectURL;
window.location.pathname = '/ghost/settings/';
Expand All @@ -258,22 +258,22 @@ describe('helpers utils', () => {
});

afterEach(() => {
global.fetch = originalFetch;
globalThis.fetch = originalFetch;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
vi.restoreAllMocks();
});

it('constructs the full URL from apiRoot and path', async () => {
const mockBlob = new Blob(['data'], {type: 'text/csv'});
global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(mockBlob)
});

await blobDownloadFromEndpoint('/members/upload/?limit=all', 'members.csv');

expect(global.fetch).toHaveBeenCalledWith(
expect(globalThis.fetch).toHaveBeenCalledWith(
'/ghost/api/admin/members/upload/?limit=all',
{method: 'GET'}
);
Expand All @@ -282,14 +282,14 @@ describe('helpers utils', () => {
it('includes subdirectory in the URL', async () => {
window.location.pathname = '/blog/ghost/settings/';
const mockBlob = new Blob(['data'], {type: 'text/csv'});
global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(mockBlob)
});

await blobDownloadFromEndpoint('/members/upload/?limit=all', 'members.csv');

expect(global.fetch).toHaveBeenCalledWith(
expect(globalThis.fetch).toHaveBeenCalledWith(
'/blog/ghost/api/admin/members/upload/?limit=all',
{method: 'GET'}
);
Expand Down
Loading