release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
96
app/journal/__tests__/locked-gate-title.test.ts
Normal file
96
app/journal/__tests__/locked-gate-title.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
/**
|
||||
* A password-protected journal entry is hidden by name everywhere a visitor
|
||||
* can look before unlocking it: the index, the prev/next links and the page
|
||||
* metadata (GHSA-fvgv-97g3-wjr7). The gate itself printed the real title,
|
||||
* which undid all of that for anyone who guessed or was sent the URL.
|
||||
*/
|
||||
|
||||
const readJournalEntry = vi.fn();
|
||||
|
||||
vi.mock('@/lib/admin/journal-service', () => ({
|
||||
readJournalEntry: (slug: string) => readJournalEntry(slug),
|
||||
listJournalEntries: async () => [],
|
||||
}));
|
||||
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({ get: () => undefined }),
|
||||
}));
|
||||
vi.mock('next/navigation', () => ({
|
||||
notFound: () => {
|
||||
throw new Error('notFound');
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({ authSecret: 'test-secret' }),
|
||||
getConfigOrNull: () => ({ lang: 'en' }),
|
||||
}));
|
||||
vi.mock('@/app/[...path]/essayPayload', () => ({ buildEssayPayload: vi.fn() }));
|
||||
vi.mock('@/app/[...path]/EssayView', () => ({ EssayView: () => null }));
|
||||
vi.mock('@/components/JournalNav', () => ({ JournalNav: () => null }));
|
||||
vi.mock('@/components/BackLink', () => ({ BackLink: () => null }));
|
||||
vi.mock('@/components/PasswordGate', () => ({ default: function PasswordGate() {} }));
|
||||
|
||||
const { default: JournalDetailPage } = await import('../[slug]/page');
|
||||
const { default: PasswordGate } = await import('@/components/PasswordGate');
|
||||
const { getServerDictionary } = await import('@/lib/i18n/server');
|
||||
|
||||
describe('journal entry password gate', () => {
|
||||
beforeEach(() => {
|
||||
readJournalEntry.mockReset();
|
||||
readJournalEntry.mockResolvedValue({
|
||||
slug: 'secret-trip',
|
||||
parsed: {
|
||||
frontmatter: { title: 'Weekend with Alex in Lisbon', password: 'hunter2' },
|
||||
blocks: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the generic journal title, not the entry title', async () => {
|
||||
const el = (await JournalDetailPage({
|
||||
params: Promise.resolve({ slug: 'secret-trip' }),
|
||||
})) as ReactElement<{ title: string; slug: string; type: string }>;
|
||||
|
||||
expect(el.type).toBe(PasswordGate);
|
||||
expect(el.props.type).toBe('journal');
|
||||
expect(el.props.slug).toBe('secret-trip');
|
||||
expect(el.props.title).toBe(getServerDictionary().journal.title);
|
||||
expect(el.props.title).not.toContain('Lisbon');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A missing entry and a draft both answer 404. Their metadata used to keep the
|
||||
* root layout's `index, follow` next to the noindex Next adds to a 404, and a
|
||||
* draft was titled like a live entry.
|
||||
*/
|
||||
describe('journal entry metadata on a 404', () => {
|
||||
const meta = async (slug: string) => {
|
||||
const { generateMetadata } = await import('../[slug]/page');
|
||||
return generateMetadata({ params: Promise.resolve({ slug }) });
|
||||
};
|
||||
|
||||
it('describes a missing entry as not found, with no robots tag of its own', async () => {
|
||||
readJournalEntry.mockResolvedValue(null);
|
||||
const m = await meta('no-such-entry');
|
||||
|
||||
expect(m.title).toBe(getServerDictionary().journal.notFound);
|
||||
expect(m.robots).toBeNull();
|
||||
expect(JSON.stringify(m)).not.toContain('no-such-entry');
|
||||
});
|
||||
|
||||
it('describes a draft as not found, not by its title', async () => {
|
||||
readJournalEntry.mockResolvedValue({
|
||||
slug: 'draft-trip',
|
||||
parsed: { frontmatter: { title: 'Unpublished Draft', draft: true }, blocks: [] },
|
||||
});
|
||||
const m = await meta('draft-trip');
|
||||
|
||||
expect(m.title).toBe(getServerDictionary().journal.notFound);
|
||||
expect(m.robots).toBeNull();
|
||||
expect(JSON.stringify(m)).not.toContain('Unpublished');
|
||||
});
|
||||
});
|
||||
141
app/journal/__tests__/request-memo.test.ts
Normal file
141
app/journal/__tests__/request-memo.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* One render of a journal page used to read the journal directory twice —
|
||||
* the header nav in the root layout listed every entry, then the page listed
|
||||
* them again — and an entry's file twice, once for `generateMetadata()` and
|
||||
* once for the page body. The home page loaded the whole site nav twice, for
|
||||
* the header and for the hero. Those reads now go through React's per-request
|
||||
* `cache()`.
|
||||
*
|
||||
* Outside a server render `cache()` does not memoise, so the test stands in a
|
||||
* memo that lasts until `newRequest()`: what it checks is that every read goes
|
||||
* through `cache()`, and that a new request reads the files again.
|
||||
*/
|
||||
|
||||
const memo = vi.hoisted(() => ({ current: new Map<unknown, Map<string, unknown>>() }));
|
||||
const newRequest = () => memo.current.clear();
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const react = await importOriginal<typeof import('react')>();
|
||||
return {
|
||||
...react,
|
||||
cache:
|
||||
<A extends unknown[], R>(fn: (...args: A) => R) =>
|
||||
(...args: A): R => {
|
||||
let results = memo.current.get(fn);
|
||||
if (!results) memo.current.set(fn, (results = new Map()));
|
||||
const key = JSON.stringify(args);
|
||||
if (!results.has(key)) results.set(key, fn(...args));
|
||||
return results.get(key) as R;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const calls = vi.hoisted(() => ({ list: 0, read: 0, pages: 0, subpages: 0 }));
|
||||
|
||||
const ENTRIES = ['gamma', 'beta', 'alpha'].map((slug) => ({
|
||||
slug,
|
||||
filename: `${slug}.md`,
|
||||
frontmatter: { title: slug.toUpperCase(), date: '2026-01-01' },
|
||||
excerpt: '',
|
||||
wordCount: 1,
|
||||
readingTimeMinutes: 1,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/journal-service', () => ({
|
||||
listJournalEntries: async () => {
|
||||
calls.list++;
|
||||
return ENTRIES;
|
||||
},
|
||||
readJournalEntry: async (slug: string) => {
|
||||
calls.read++;
|
||||
return {
|
||||
slug,
|
||||
rawMarkdown: '',
|
||||
parsed: { frontmatter: { title: slug.toUpperCase() }, blocks: [] },
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/pages-service', () => ({
|
||||
listPages: async () => {
|
||||
calls.pages++;
|
||||
return [];
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: {
|
||||
getSubpages: async () => {
|
||||
calls.subpages++;
|
||||
return [];
|
||||
},
|
||||
getStandaloneAlbums: async () => [],
|
||||
getAssetInfo: async () => null,
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
authSecret: 'x'.repeat(32),
|
||||
nav: [],
|
||||
aboutEnabled: false,
|
||||
map: false,
|
||||
watermark: undefined,
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/lib/i18n/server', async () => {
|
||||
const { getDictionary } = await import('@/lib/i18n');
|
||||
return { getServerDictionary: () => getDictionary('en') };
|
||||
});
|
||||
vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) }));
|
||||
vi.mock('next/navigation', () => ({
|
||||
notFound: () => {
|
||||
throw new Error('notFound');
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
|
||||
vi.mock('@/app/[...path]/essayPayload', () => ({
|
||||
buildEssayPayload: async () => ({ essay: { frontmatter: {}, blocks: [] }, images: [] }),
|
||||
}));
|
||||
// Client components the page only hands props to.
|
||||
vi.mock('@/app/[...path]/EssayView', () => ({ EssayView: () => null }));
|
||||
vi.mock('@/components/JournalNav', () => ({ JournalNav: () => null }));
|
||||
vi.mock('@/components/PasswordGate', () => ({ default: () => null }));
|
||||
vi.mock('@/components/BackLink', () => ({ BackLink: () => null }));
|
||||
vi.mock('next/link', () => ({ default: () => null }));
|
||||
vi.mock('next/image', () => ({ default: () => null }));
|
||||
|
||||
import JournalEntryPage, { generateMetadata } from '../[slug]/page';
|
||||
import JournalIndexPage from '../page';
|
||||
import { loadSiteNav } from '@/lib/siteNav.server';
|
||||
|
||||
const params = { params: Promise.resolve({ slug: 'beta' }) };
|
||||
|
||||
beforeEach(() => {
|
||||
newRequest();
|
||||
Object.assign(calls, { list: 0, read: 0, pages: 0, subpages: 0 });
|
||||
});
|
||||
|
||||
describe('journal reads per request', () => {
|
||||
it('an entry page lists the journal once and reads its file once', async () => {
|
||||
// The order a render takes: layout nav, metadata, page body.
|
||||
await Promise.all([loadSiteNav(), generateMetadata(params), JournalEntryPage(params)]);
|
||||
expect(calls).toMatchObject({ list: 1, read: 1 });
|
||||
});
|
||||
|
||||
it('the journal index lists the journal once', async () => {
|
||||
await Promise.all([loadSiteNav(), JournalIndexPage()]);
|
||||
expect(calls.list).toBe(1);
|
||||
});
|
||||
|
||||
it('the home page loads the site nav once for the header and the hero', async () => {
|
||||
await Promise.all([loadSiteNav(), loadSiteNav()]);
|
||||
expect(calls).toMatchObject({ list: 1, pages: 1, subpages: 1 });
|
||||
});
|
||||
|
||||
it('reads the files again on the next request', async () => {
|
||||
await Promise.all([loadSiteNav(), generateMetadata(params), JournalEntryPage(params)]);
|
||||
newRequest();
|
||||
await Promise.all([loadSiteNav(), generateMetadata(params), JournalEntryPage(params)]);
|
||||
expect(calls).toMatchObject({ list: 2, read: 2 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user