feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -0,0 +1,41 @@
import applyTranslations from './applyTranslations';
describe('applyTranslations', () => {
it('should apply translations', async () => {
const tests = [
{
html: '<div><span translate>Translate me</span></div>',
translations: {
'Translate me': ['Traduis moi'],
},
htmlTranslated: '<div>\n<span translate>\nTraduis moi\n</span>\n</div>',
},
{
html: '<div><span translate>Missing translation</span></div>',
translations: {},
htmlTranslated: '<div>\n<span translate>\nMissing translation\n</span>\n</div>',
},
{
html: '<h1 translate class="text-center">\nFree your <span class="frame-bg frame-bg-blue">notes</span>\n</h1>',
translations: {
'Free your <span class="frame-bg frame-bg-blue">notes</span>': ['Libérez vos <span class="frame-bg frame-bg-blue">notes</span>'],
},
htmlTranslated: '<h1 translate class="text-center">\nLibérez vos <span class="frame-bg frame-bg-blue">notes</span>\n</h1>',
},
{
html: '<div translate>Save <span class="frame-bg frame-bg-blue">web pages</span> <br />as notes</div>',
translations: {
'Save <span class="frame-bg frame-bg-blue">web pages</span> <br>as notes': ['Sauvegardez vos <span class="frame-bg frame-bg-blue">pages web</span> <br>en notes'],
},
htmlTranslated: '<div translate>\nSauvegardez vos <span class="frame-bg frame-bg-blue">pages web</span> <br>en notes\n</div>',
},
];
for (const test of tests) {
const actual = applyTranslations(test.html, 'fr_FR', test.translations);
expect(actual).toEqual(test.htmlTranslated);
}
});
});

查看文件

@@ -0,0 +1,148 @@
import { unique } from '@joplin/lib/ArrayUtils';
import { attributesHtml, isSelfClosingTag } from '@joplin/renderer/htmlUtils';
import { Translations } from '../../utils/translation';
import { AllHtmlEntities as Entities } from 'html-entities';
const htmlentities = new Entities().encode;
import * as htmlparser2 from '@joplin/fork-htmlparser2';
const trimHtml = (content: string) => {
return content
.replace(/\n/g, '')
.replace(/^(&tab;)+/i, '')
.replace(/^(&nbsp;)+/i, '')
.replace(/(&tab;)+$/i, '')
.replace(/(&nbsp;)+$/i, '')
.replace(/^\t+/, '')
.replace(/\t+$/, '');
};
const findTranslation = (englishString: string, translations: Translations): string => {
const stringsToTry = unique([
englishString,
englishString.replace(/<br\/>/gi, '<br>'),
englishString.replace(/<br \/>/gi, '<br>'),
englishString
.replace(/&apos;/gi, '\'')
.replace(/&quot;/gi, '"'),
]) as string[];
for (const stringToTry of stringsToTry) {
// Note that we don't currently support plural forms for the website
if (translations[stringToTry] && translations[stringToTry].length) return translations[stringToTry][0];
}
return englishString;
};
const encodeHtml = (decodedText: string): string => {
return htmlentities(decodedText)
.replace(/&Tab;/gi, '\t')
.replace(/{{&gt; /gi, '{{> '); // Don't break Mustache partials
};
export default (html: string, _languageCode: string, translations: Translations) => {
const output: string[] = [];
interface State {
// When inside a block that needs to be translated, this array
// accumulates the opening tags. For example, this text:
//
// <div translate>Hello <b>world</b></div>
//
// will have the tags ['div', 'b']
//
// This is used to track when we've processed all the content, including
// HTML content, within a translatable block. Once that stack is empty,
// we reached the end, and can translate the string that we got.
translateStack: string[];
// Keep a reference to the opening tag. For example in:
//
// <div translate>Hello <b>world</b></div>
//
// The opening tag is "div".
currentTranslationTag: string[];
// Once we finished processing the translatable block, this will contain
// the string to be translated. It may contain HTML.
currentTranslationContent: string[];
// Tells if we're at the beginning of a translatable block.
translateIsOpening: boolean;
inScript: boolean;
}
const state: State = {
translateStack: [],
currentTranslationTag: [],
currentTranslationContent: [],
translateIsOpening: false,
inScript: false,
};
const pushContent = (state: State, content: string) => {
if (state.translateStack.length) {
if (state.translateIsOpening) {
state.currentTranslationTag.push(content);
} else {
state.currentTranslationContent.push(content);
}
} else {
output.push(content);
}
};
const parser = new htmlparser2.Parser({
onopentag: (name: string, attrs: Record<string, string>) => {
if (name === 'script') state.inScript = true;
if ('translate' in attrs) {
if (state.translateStack.length) throw new Error(`Cannot have a translate block within another translate block. At tag "${name}" attrs: ${JSON.stringify(attrs)}`);
state.translateStack.push(name);
state.currentTranslationContent = [];
state.currentTranslationTag = [];
state.translateIsOpening = true;
} else if (state.translateStack.length) {
state.translateStack.push(name);
}
let attrHtml = attributesHtml(attrs);
if (attrHtml) attrHtml = ` ${attrHtml}`;
const closingSign = isSelfClosingTag(name) ? '/>' : '>';
pushContent(state, `<${name}${attrHtml}${closingSign}`);
state.translateIsOpening = false;
},
ontext: (decodedText: string) => {
const encodedText = state.inScript ? decodedText : encodeHtml(decodedText);
pushContent(state, encodedText);
},
onclosetag: (name: string) => {
if (state.translateStack.length) {
state.translateStack.pop();
if (!state.translateStack.length) {
const stringToTranslate = trimHtml(state.currentTranslationContent.join(''));
const translation = findTranslation(stringToTranslate, translations);
output.push(state.currentTranslationTag[0]);
output.push(translation);
}
}
if (name === 'script') state.inScript = false;
if (isSelfClosingTag(name)) return;
pushContent(state, `</${name}>`);
},
}, { decodeEntities: true });
parser.write(html);
parser.end();
return output.join('\n');
};

查看文件

@@ -0,0 +1,26 @@
import convertLinksToLocale from './convertLinksToLocale';
import { Locale } from './types';
describe('convertLinksToLocale', () => {
it('should convert links', async () => {
const tests: [string, Partial<Locale>, string][] = [
[
'test [link](/help/link)',
{ pathPrefix: 'fr' },
'test [link](/fr/help/link)',
],
[
'test [link](/help/link) [link2](/link2)',
{ pathPrefix: 'fr' },
'test [link](/fr/help/link) [link2](/fr/link2)',
],
];
for (const [input, locale, expected] of tests) {
const actual = convertLinksToLocale(input, locale as Locale);
expect(actual).toBe(expected);
}
});
});

查看文件

@@ -0,0 +1,9 @@
import { Locale } from './types';
export default (md: string, locale: Locale) => {
if (locale.lang === 'en-gb') return md;
md = md.replace(/\(\//g, `(/${locale.pathPrefix}/`);
return md;
};

查看文件

@@ -0,0 +1,58 @@
import { compileWithFrontMatter, FrontMatter, stripOffFrontMatter } from './frontMatter';
import moment from 'moment';
const testCases: [string, FrontMatter, string, string][] = [
// =============================================================
[
`
---
tweet: Introducing the "GitHub Action Raw Log Viewer" extension for Chrome
forum_url: https://discourse.joplinapp.org/t/29139
created: 2019-09-29T14:28:34.000+00:00
updated: 2019-09-29T14:30:50.000+00:00
---
Body
`
,
{
tweet: 'Introducing the "GitHub Action Raw Log Viewer" extension for Chrome',
forum_url: 'https://discourse.joplinapp.org/t/29139',
created: moment('2019-09-29T14:28:34.000+00:00').toDate(),
updated: moment('2019-09-29T14:30:50.000+00:00').toDate(),
},
'Body',
`
---
tweet: Introducing the "GitHub Action Raw Log Viewer" extension for Chrome
forum_url: https://discourse.joplinapp.org/t/29139
created: 2019-09-29T14:28:34.000Z
updated: 2019-09-29T14:30:50.000Z
---
Body
`,
],
];
describe('frontMatter', () => {
it('should strip-off FrontMatter', async () => {
for (const [doc, frontMatter, body, compiled] of testCases) {
const actual = stripOffFrontMatter(doc.trim());
expect(actual.doc).toBe(body);
expect(actual.header).toEqual(frontMatter);
const actualCompiled = compileWithFrontMatter(actual);
expect(actualCompiled).toBe(compiled.trim());
}
});
});

查看文件

@@ -0,0 +1,102 @@
import * as yaml from 'js-yaml';
import moment from 'moment';
export interface FrontMatter {
created?: Date;
updated?: Date;
source_url?: string;
forum_url?: string;
tweet?: string;
// Docusaurus
sidebar_label?: string;
sidebar_position?: number;
date?: string;
title?: string;
description?: string;
image?: string;
}
export interface MarkdownAndFrontMatter {
doc: string;
header: FrontMatter;
}
export const stripOffFrontMatter = (md: string): MarkdownAndFrontMatter => {
if (md.indexOf('---') !== 0) return { doc: md, header: {} };
let state = 'start';
const lines = md.split('\n');
const docLines: string[] = [];
const headerLines: string[] = [];
for (const line of lines) {
if (state === 'start') {
if (line !== '---') throw new Error('Expected front matter block to start with "---"');
state = 'in';
continue;
}
if (state === 'in') {
if (line === '---') {
state = 'out';
continue;
}
const propLine = line.trim();
headerLines.push(propLine);
}
if (state === 'out') {
if (line.trim()) state = 'doc';
}
if (state === 'doc') {
docLines.push(line);
}
}
if (state !== 'doc') throw new Error('Front matter block was not closed with "---"');
const header = yaml.load(headerLines.join('\n'), { schema: yaml.FAILSAFE_SCHEMA }) as FrontMatter;
if (header.created) header.created = moment(header.created).toDate();
if (header.updated) header.updated = moment(header.updated).toDate();
if ('sidebar_position' in header) header.sidebar_position = Number(header.sidebar_position);
return { header, doc: docLines.join('\n') };
};
// ---
// created: 2021-07-05T09:42:47.000+00:00
// source_url: https://www.patreon.com/posts/any-ideas-for-53317699
// ---
const formatFrontMatterValue = (key: string, value: FrontMatter[keyof FrontMatter]) => {
if (['created', 'updated'].includes(key)) {
return moment((value as Date)).toISOString();
} else {
return value.toString();
}
};
export const compileWithFrontMatter = (md: MarkdownAndFrontMatter): string => {
const output: string[] = [];
if (Object.keys(md.header).length) {
const header: Record<string, string> = {};
for (const [key, value] of Object.entries(md.header)) {
header[key] = formatFrontMatterValue(key, value);
}
const headerString = yaml.dump(header, { noCompatMode: true, schema: yaml.FAILSAFE_SCHEMA, lineWidth: 100000 });
output.push('---');
output.push(headerString.trim());
output.push('---');
output.push('');
}
output.push(md.doc);
return output.join('\n');
};

查看文件

@@ -0,0 +1,22 @@
/* eslint-disable import/prefer-default-export */
import { FrontMatter } from './frontMatter';
import { basename } from 'path';
import moment from 'moment';
export const getNewsDateString = (info: FrontMatter, mdFilePath: string): string => {
return moment(getNewsDate(info, mdFilePath)).format('D MMM YYYY');
};
export const getNewsDate = (info: FrontMatter, mdFilePath: string): Date => {
// If the date is set in the metadata, we get it from there. Otherwise we
// derive it from the filename (eg. 20220224-release-2-7.md)
if (info.created) {
return info.created;
} else {
const filenameNoExt = basename(mdFilePath, '.md');
const s = filenameNoExt.split('-');
return moment.utc(s[0], 'YYYYMMDD').toDate();
}
};

查看文件

@@ -0,0 +1,46 @@
import { extractOpenGraphTags, OpenGraphTags } from './openGraph';
import { tempFilePath } from '@joplin/lib/testing/test-utils';
import { writeFile } from 'fs-extra';
describe('openGraph', () => {
it('should extract the Open Graph tags', async () => {
const tests: [string, OpenGraphTags][] = [
['# My title\n\nMy note description **with bold text**', {
title: 'My title',
description: 'My note description with bold text',
url: 'https://test.com',
}],
['# My very very very very very very very very very very very very long title\n\nMy description that is over 200 characters. My description that is over 200 characters. My description that is over 200 characters. My description that is over 200 characters. My description that is over 200 characters.', {
title: 'My very very very very very very very very very very very very long...',
description: 'My description that is over 200 characters. My description that is over 200 characters. My description that is over 200 characters. My description that is over 200 characters. My description that i...',
url: 'https://test.com',
}],
['# Page with an image\n\nSome text followed by an image. ![](https://test.com/hello1.png) ![](https://test.com/hello2.png)', {
title: 'Page with an image',
description: 'Some text followed by an image.',
url: 'https://test.com',
image: 'https://test.com/hello1.png',
}],
['# Image without domain\n\n![](/hello1.png)', {
title: 'Image without domain',
description: '',
url: 'https://test.com',
image: 'https://joplinapp.org/hello1.png',
}],
];
for (const test of tests) {
const [input, expected] = test;
const filePath = await tempFilePath('md');
await writeFile(filePath, input);
const actual = await extractOpenGraphTags(filePath, 'https://test.com');
expect(actual).toEqual(expected);
}
});
});

查看文件

@@ -0,0 +1,45 @@
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import { readmeFileTitleAndBody, replaceGitHubByWebsiteLinks } from './parser';
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
export interface OpenGraphTags {
title: string;
description: string;
url: string;
image?: string;
}
let markupToHtml_: MarkupToHtml = null;
const markupToHtml = (): MarkupToHtml => {
if (!markupToHtml_) markupToHtml_ = new MarkupToHtml();
return markupToHtml_;
};
const getImageUrl = (content: string): string | null => {
const imageUrls = markupLanguageUtils.extractImageUrls(MarkupLanguage.Any, content);
if (!imageUrls.length) return null;
let imageUrl = replaceGitHubByWebsiteLinks(imageUrls[0]);
if (!imageUrl.startsWith('https:')) {
if (!imageUrl.startsWith('/')) imageUrl = `/${imageUrl}`;
imageUrl = `https://joplinapp.org${imageUrl}`;
}
return imageUrl;
};
export const extractOpenGraphTags = async (sourcePath: string, url?: string): Promise<OpenGraphTags> => {
const doc = await readmeFileTitleAndBody(sourcePath);
const output: OpenGraphTags = {
title: substrWithEllipsis(doc.title, 0, 70),
description: substrWithEllipsis(markupToHtml().stripMarkup(MarkupLanguage.Markdown, doc.body), 0, 200),
url: url ? url : '',
};
const imageUrl = getImageUrl(doc.body);
if (imageUrl) output.image = imageUrl;
return output;
};

查看文件

@@ -0,0 +1,42 @@
import { readFile } from 'fs-extra';
import { stripOffFrontMatter } from './frontMatter';
interface ReadmeDoc {
title: string;
body: string;
}
export async function readmeFileTitleAndBody(sourcePath: string): Promise<ReadmeDoc> {
try {
let md = await readFile(sourcePath, 'utf8');
md = stripOffFrontMatter(md).doc.trim();
const r = md.match(/^# (.*)/);
if (!r) {
throw new Error('Could not determine title');
} else {
const lines = md.split('\n');
lines.splice(0, 1);
return {
title: r[1],
body: lines.join('\n'),
};
}
} catch (error) {
error.message = `On ${sourcePath}: ${error.message}`;
throw error;
}
}
export async function readmeFileTitle(sourcePath: string) {
const r = await readmeFileTitleAndBody(sourcePath);
return r.title;
}
export function replaceGitHubByWebsiteLinks(md: string) {
return md
.replace(/https:\/\/github.com\/laurent22\/joplin\/blob\/dev\/readme\/(.*?)\/index\.md(#[^\s)]+|)/g, '/$1/$2')
.replace(/https:\/\/github.com\/laurent22\/joplin\/blob\/dev\/readme\/(.*?)\.md(#[^\s)]+|)/g, '/$1/$2')
.replace(/https:\/\/github.com\/laurent22\/joplin\/blob\/dev\/README\.md(#[^\s)]+|)/g, '/help/$1')
.replace(/https:\/\/raw.githubusercontent.com\/laurent22\/joplin\/dev\/Assets\/WebsiteAssets\/(.*?)/g, '/$1');
}

查看文件

@@ -0,0 +1,30 @@
/* eslint-disable import/prefer-default-export */
export function pressCarouselItems() {
return [
{
active: 'active',
body: 'It lets you create multiple types of notes, reminders, and alarms, all of which can be synced. The app also includes a web clipper too, but in our opinion, Joplin’s best feature is the built-in end-to-end encryption for keeping your notes private.',
author: 'Brendan Hesse',
source: 'Life Hacker, "The Best Note-Taking Apps"',
imageName: 'in-the-press-life-hacker.png',
url: 'https://lifehacker.com/the-best-note-taking-apps-1837842880',
},
{
active: '',
body: 'Joplin is single handedly the best pick for an open-source note-taking app, making it an Editors\' Choice winner for that category. Unlike some open-source tools, which are incredibly difficult to use, Joplin is surprisingly user friendly, even in setting up storage and syncing.',
author: 'Jill Duffy',
source: 'PCMag, "The Best Open-Source Note-Taking App"',
imageName: 'in-the-press-life-pcmag.png',
url: 'https://www.pcmag.com/reviews/joplin',
},
{
active: '',
body: 'Joplin is an excellent open source note taking application with plenty of features. You can take notes, make to-do list and sync your notes across devices by linking it with cloud services. The synchronization is protected with end to end encryption.',
author: 'Abhishek Prakash',
source: 'It\'s FOSS, "Joplin: Open source note organizer"',
imageName: 'in-the-press-its-foss.png',
url: 'https://itsfoss.com/joplin/',
},
];
}

查看文件

@@ -0,0 +1,13 @@
import { mkdirp, readFile, writeFile } from 'fs-extra';
import { dirname } from 'path';
import { Translations } from '../../utils/translation';
import applyTranslations from './applyTranslations';
export default async (englishFilePath: string, translatedFilePath: string, languageCode: string, translations: Translations) => {
let content = await readFile(englishFilePath, 'utf8');
content = content.replace('<html lang="en-gb">', `<html lang="${languageCode}">`);
const translatedContent = await applyTranslations(content, languageCode, translations);
const translatedDirname = dirname(translatedFilePath);
await mkdirp(translatedDirname);
await writeFile(translatedFilePath, translatedContent, 'utf8');
};

查看文件

@@ -0,0 +1,64 @@
import * as Mustache from 'mustache';
import { filename } from '@joplin/lib/path-utils';
import * as fs from 'fs-extra';
import { Partials, TemplateParams } from './types';
import { headerAnchor } from '@joplin/renderer';
import MarkdownIt from 'markdown-it';
import StateCore = require('markdown-it/lib/rules_core/state_core');
export async function loadMustachePartials(partialDir: string) {
const output: Partials = {};
const files = await fs.readdir(partialDir);
for (const f of files) {
const name = filename(f);
const templateContent = await fs.readFile(`${partialDir}/${f}`, 'utf8');
output[name] = templateContent;
}
return output;
}
export function renderMustache(contentHtml: string, templateParams: TemplateParams) {
return Mustache.render(templateParams.templateHtml, {
...templateParams,
contentHtml,
}, templateParams.partials);
}
export function getMarkdownIt() {
const markdownIt = new MarkdownIt({
breaks: true,
linkify: true,
html: true,
});
markdownIt.core.ruler.push('tableClass', (state: StateCore) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === 'table_open') {
token.attrs = [
['class', 'table'],
];
}
}
});
return markdownIt;
}
export function markdownToPageHtml(md: string, templateParams: TemplateParams): string {
const html = markdownToHtml(md);
return renderMustache(html, templateParams);
}
export const markdownToHtml = (md: string): string => {
const markdownIt = getMarkdownIt();
markdownIt.use(headerAnchor);
markdownIt.linkify.set({
'fuzzyLink': false,
'fuzzyIP': false,
'fuzzyEmail': false,
});
return markdownIt.render(md);
};

查看文件

@@ -0,0 +1,67 @@
export interface SupportedLocale {
// Locale identifier (e.g., 'en_GB', 'fr_FR')
id: string;
// HTML lang attribute (e.g., 'en-gb', 'fr-fr')
lang: string;
// URL path prefix (e.g., '', 'fr', 'de', 'cn')
pathPrefix: string;
// Short code shown in the language switcher button (e.g., 'EN', 'FR')
code: string;
// Full language name in its native form (e.g., 'English', 'Français')
name: string;
// hreflang code for SEO (e.g., 'en', 'fr', 'de', 'zh')
hreflang: string;
}
export const supportedLocales: SupportedLocale[] = [
{
id: 'en_GB',
lang: 'en-gb',
pathPrefix: '',
code: 'EN',
name: 'English',
hreflang: 'en',
},
{
id: 'fr_FR',
lang: 'fr-fr',
pathPrefix: 'fr',
code: 'FR',
name: 'Français',
hreflang: 'fr',
},
{
id: 'de_DE',
lang: 'de-de',
pathPrefix: 'de',
code: 'DE',
name: 'Deutsch',
hreflang: 'de',
},
{
id: 'hr_HR',
lang: 'hr-hr',
pathPrefix: 'hr',
code: 'HR',
name: 'Hrvatski',
hreflang: 'hr',
},
{
id: 'zh_CN',
lang: 'zh-cn',
pathPrefix: 'cn',
code: 'CN',
name: '中文',
hreflang: 'zh',
},
];
export const defaultLocale = supportedLocales[0];
export const getLocaleById = (id: string): SupportedLocale | undefined => {
return supportedLocales.find(l => l.id === id);
};
export const getLocaleByPathPrefix = (prefix: string): SupportedLocale | undefined => {
return supportedLocales.find(l => l.pathPrefix === prefix);
};

查看文件

@@ -0,0 +1,97 @@
import { Plan, StripePublicConfig } from '@joplin/lib/utils/joplinCloud';
import { Sponsors } from '../../utils/loadSponsors';
import { Translations } from '../../utils/translation';
import { OpenGraphTags } from './openGraph';
export enum Env {
Dev = 'dev',
Prod = 'prod',
}
export interface Locale {
id: string;
htmlTranslations: Translations;
lang: string;
pathPrefix: string;
code: string;
name: string;
hreflang: string;
}
export interface GithubUser {
id: string;
}
interface PressCarouselItem {
active: string;
body: string;
author: string;
source: string;
imageName: string;
url: string;
}
interface PressCarousel {
id: string;
items: PressCarouselItem[];
}
interface NavBar {
isFrontPage: boolean;
}
export interface AssetUrls {
css: Record<string, string>;
js: Record<string, string>;
}
export interface TemplateParams {
env?: Env;
baseUrl?: string;
pageName?: string;
imageBaseUrl?: string;
cssBaseUrl?: string;
jsBaseUrl?: string;
tocHtml?: string;
sourceMarkdownFile?: string;
title?: string;
donateLinksMd?: string;
pageTitle?: string;
yyyy?: string;
templateHtml?: string;
partials?: Record<string, string>;
forumUrl?: string;
showToc?: boolean;
pressCarouselRegular?: PressCarousel;
pressCarouselMobile?: PressCarousel;
sponsors?: Sponsors;
showImproveThisDoc?: boolean;
contentHtml?: string;
navbar?: NavBar;
showJoplinCloudLinks?: boolean;
assetUrls: AssetUrls;
showBottomLinks?: boolean;
openGraph: OpenGraphTags;
isNews?: boolean;
locale?: Locale;
currentPath?: string;
availableLocales?: AvailableLocale[];
}
// Used in templates for language switcher and hreflang tags
export interface AvailableLocale {
code: string;
name: string;
pathPrefix: string;
hreflang: string;
isActive: boolean;
}
export interface PlanPageParams extends TemplateParams {
plans: Record<string, Plan>;
faqHtml: string;
featureListHtml: string;
stripeConfig: StripePublicConfig;
}
export type Partials = Record<string, string>;