starlight_help: Move help-beta over to starlight_help.

We are starting the cutover process and starlight_help is the directory
we have agreed on to place our new help center project. We do not want
to use `starlight_help` as the URL for the project, but this commit
changes the url from `help-beta` to `starlight_help` temporarily since
we can only change URL once we get rid of the current help center
project. That will be done in a future commit.
This commit is contained in:
Shubham Padia
2025-08-06 11:05:27 +00:00
committed by Tim Abbott
parent fb49e5e420
commit 3e60b16ac1
33 changed files with 39 additions and 51 deletions

24
starlight_help/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.mdx
*.md
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

View File

@@ -0,0 +1,478 @@
import * as fs from "node:fs";
import starlight from "@astrojs/starlight";
import {defineConfig, envField} from "astro/config";
import Icons from "unplugin-icons/vite";
// https://astro.build/config
export default defineConfig({
base: "starlight_help",
vite: {
plugins: [
// eslint-disable-next-line new-cap
Icons({
compiler: "astro",
customCollections: {
// unplugin-icons has a FileSystemIconLoader which is more
// versatile. But it only supports one directory path for
// a single set of icons. We should start using that loader
// if they add support for multiple paths in the future.
async "zulip-icon"(iconName) {
const sharedIconsPath = `../web/shared/icons/${iconName}.svg`;
const webOnlyIconsPath = `../web/images/icons/${iconName}.svg`;
if (fs.existsSync(sharedIconsPath)) {
return await fs.promises.readFile(sharedIconsPath, "utf8");
} else if (fs.existsSync(webOnlyIconsPath)) {
return await fs.promises.readFile(webOnlyIconsPath, "utf8");
}
throw new Error("Zulip icon not found.");
},
},
iconCustomizer(collection, icon, props) {
if (collection === "zulip-icon" || collection === "fa") {
// We need to override some default starlight behaviour to make
// icons look nice, see the css for this class to see the reasoning
// for each individual override of the default css.
props.class = "zulip-unplugin-icon";
if (collection === "zulip-icon" && icon.startsWith("user-circle-")) {
const iconSuffix = icon.replace("user-circle-", "");
props.class = `zulip-unplugin-icon user-circle user-circle-${iconSuffix}`;
}
}
},
}),
],
},
env: {
schema: {
SHOW_RELATIVE_LINKS: envField.boolean({
context: "client",
access: "public",
optional: true,
default: true,
}),
CORPORATE_ENABLED: envField.boolean({
context: "client",
access: "public",
optional: true,
default: true,
}),
SUPPORT_EMAIL: envField.string({
context: "client",
access: "public",
optional: true,
default: "zulip-admin@example.com",
}),
},
},
integrations: [
starlight({
title: "Zulip help center",
components: {
Footer: "./src/components/Footer.astro",
Head: "./src/components/Head.astro",
},
pagination: false,
routeMiddleware: "./src/route_data.ts",
customCss: ["./src/styles/main.css"],
sidebar: [
{
label: "Zulip homepage",
link: "https://zulip.com",
},
{
label: "Help center home",
slug: "index",
},
{
label: "Guides",
items: [
"getting-started-with-zulip",
{
label: "Choosing a team chat app",
link: "https://blog.zulip.com/2024/11/04/choosing-a-team-chat-app/",
},
{
label: "Why Zulip",
link: "https://zulip.com/why-zulip/",
},
"trying-out-zulip",
"zulip-cloud-or-self-hosting",
"moving-to-zulip",
"moderating-open-organizations",
"setting-up-zulip-for-a-class",
"using-zulip-for-a-class",
"using-zulip-via-email",
],
},
{
label: "Getting started",
items: [
"join-a-zulip-organization",
"set-up-your-account",
"introduction-to-topics",
{
label: "Starting a new topic",
link: "/introduction-to-topics#how-to-start-a-new-topic",
},
"finding-a-conversation-to-read",
"reading-conversations",
"starting-a-new-direct-message",
"replying-to-messages",
"messaging-tips",
"keyboard-shortcuts",
],
},
{
label: "Setting up your organization",
items: [
"migrating-from-other-chat-tools",
"create-your-organization-profile",
"create-user-groups",
"customize-organization-settings",
"create-channels",
"customize-settings-for-new-users",
"invite-users-to-join",
"set-up-integrations",
],
},
{
label: "Account basics",
items: [
"edit-your-profile",
"change-your-name",
"change-your-email-address",
"change-your-profile-picture",
"change-your-password",
"configure-email-visibility",
"logging-in",
"logging-out",
"switching-between-organizations",
"import-your-settings",
"review-your-settings",
"deactivate-your-account",
],
},
{
label: "Preferences",
items: [
"dark-theme",
"font-size",
"line-spacing",
"configure-send-message-keys",
"change-your-language",
"change-your-timezone",
"change-the-time-format",
"configure-emoticon-translations",
"configure-home-view",
"enable-full-width-display",
"manage-your-uploaded-files",
],
},
{
label: "Writing messages",
items: [
"format-your-message-using-markdown",
"mention-a-user-or-group",
"link-to-a-message-or-conversation",
"format-a-quote",
"quote-or-forward-a-message",
"emoji-and-emoticons",
"insert-a-link",
"saved-snippets",
"share-and-upload-files",
"animated-gifs-from-giphy",
"text-emphasis",
"paragraph-and-section-formatting",
"bulleted-lists",
"numbered-lists",
"tables",
"code-blocks",
"latex",
"spoilers",
"me-action-messages",
"create-a-poll",
"collaborative-to-do-lists",
"global-times",
"start-a-call",
],
},
{
label: "Sending messages",
items: [
"open-the-compose-box",
"mastering-the-compose-box",
"resize-the-compose-box",
"typing-notifications",
"preview-your-message-before-sending",
"verify-your-message-was-successfully-sent",
"edit-a-message",
"delete-a-message",
"view-and-edit-your-message-drafts",
"schedule-a-message",
"message-a-channel-by-email",
],
},
{
label: "Reading messages",
items: [
"reading-strategies",
"inbox",
"recent-conversations",
"combined-feed",
"channel-feed",
"list-of-topics",
"left-sidebar",
"message-actions",
"marking-messages-as-read",
"marking-messages-as-unread",
"configure-unread-message-counters",
"configure-where-you-land",
"emoji-reactions",
"view-your-mentions",
"star-a-message",
"schedule-a-reminder",
"view-images-and-videos",
"view-messages-sent-by-a-user",
"link-to-a-message-or-conversation",
"search-for-messages",
"printing-messages",
"view-the-markdown-source-of-a-message",
"view-the-exact-time-a-message-was-sent",
"view-a-messages-edit-history",
"collapse-a-message",
"read-receipts",
],
},
{
label: "People",
items: [
"introduction-to-users",
"user-list",
"status-and-availability",
"user-cards",
"view-someones-profile",
"direct-messages",
"find-administrators",
],
},
{
label: "Groups",
items: ["user-groups", "view-group-members"],
},
{
label: "Channels",
items: [
"introduction-to-channels",
{
label: "Subscribe to a channel",
link: "/introduction-to-channels#browse-and-subscribe-to-channels",
},
"create-a-channel",
"pin-a-channel",
"change-the-color-of-a-channel",
"channel-folders",
"unsubscribe-from-a-channel",
"manage-inactive-channels",
"move-content-to-another-channel",
"view-channel-information",
"view-channel-subscribers",
],
},
{
label: "Topics",
items: [
"introduction-to-topics",
"rename-a-topic",
"resolve-a-topic",
"move-content-to-another-topic",
"general-chat-topic",
"delete-a-topic",
],
},
{
label: "Notifications",
items: [
"channel-notifications",
"topic-notifications",
"follow-a-topic",
"dm-mention-alert-notifications",
"mute-a-channel",
"mute-a-topic",
"mute-a-user",
"email-notifications",
"desktop-notifications",
"mobile-notifications",
"do-not-disturb",
],
},
{
label: "Apps",
items: [
{
label: "Download apps for every platform",
link: "https://zulip.com/apps/",
},
"mobile-app-install-guide",
"desktop-app-install-guide",
"supported-browsers",
"configure-how-links-open",
"connect-through-a-proxy",
"custom-certificates",
],
},
{
label: "Zulip administration",
link: "#",
attrs: {
class: "non-clickable-sidebar-heading",
},
},
{
label: "Organization profile",
items: [
"organization-type",
"communities-directory",
"linking-to-zulip",
"change-organization-url",
"deactivate-your-organization",
],
},
{
label: "Import an organization",
items: [
"import-from-mattermost",
"import-from-slack",
"import-from-rocketchat",
"export-your-organization",
],
},
{
label: "Account creation and authentication",
items: [
"configure-default-new-user-settings",
"custom-profile-fields",
"invite-new-users",
"restrict-account-creation",
"configure-authentication-methods",
"saml-authentication",
"scim",
],
},
{
label: "User management",
items: [
"manage-a-user",
"deactivate-or-reactivate-a-user",
"change-a-users-name",
"manage-user-channel-subscriptions",
"manage-user-group-membership",
],
},
{
label: "Channel management",
items: [
"create-a-channel",
{
label: "Private channels",
link: "/channel-permissions#private-channels",
},
{
label: "Public channels",
link: "/channel-permissions#public-channels",
},
"public-access-option",
"general-chat-channels",
"manage-channel-folders",
"channel-permissions",
"channel-posting-policy",
"configure-who-can-administer-a-channel",
"configure-who-can-create-channels",
"configure-who-can-subscribe",
"configure-who-can-invite-to-channels",
"configure-who-can-unsubscribe-others",
"subscribe-users-to-a-channel",
"unsubscribe-users-from-a-channel",
"set-default-channels-for-new-users",
"rename-a-channel",
"change-the-channel-description",
"pin-information",
"change-the-privacy-of-a-channel",
"archive-a-channel",
],
},
{
label: "Permissions management",
items: [
"manage-permissions",
"manage-user-groups",
"deactivate-a-user-group",
"user-roles",
"guest-users",
"restrict-direct-messages",
"restrict-wildcard-mentions",
"restrict-message-editing-and-deletion",
"restrict-message-edit-history-access",
"restrict-moving-messages",
"restrict-resolving-topics",
"restrict-name-and-email-changes",
"restrict-profile-picture-changes",
"restrict-permissions-of-new-members",
],
},
{
label: "Organization settings",
items: [
"configure-organization-language",
"custom-emoji",
"configure-call-provider",
"add-a-custom-linkifier",
"require-topics",
"image-video-and-website-previews",
"hide-message-content-in-emails",
"message-retention-policy",
"digest-emails",
"disable-welcome-emails",
"configure-automated-notices",
"configure-multi-language-search",
"analytics",
],
},
{
label: "Bots & integrations",
items: [
"bots-overview",
"integrations-overview",
"add-a-bot-or-integration",
"generate-integration-url",
"manage-a-bot",
"deactivate-or-reactivate-a-bot",
"request-an-integration",
"restrict-bot-creation",
"view-your-bots",
"view-all-bots-in-your-organization",
],
},
{
label: "Support",
items: [
"view-zulip-version",
"zulip-cloud-billing",
"self-hosted-billing",
"gdpr-compliance",
"move-to-zulip-cloud",
"support-zulip-project",
"linking-to-zulip-website",
"contact-support",
],
},
{
label: "◀ Back to Zulip",
link: "../",
},
],
}),
],
});

View File

@@ -0,0 +1,54 @@
{
"name": "starlight_help",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"format": "remark --ext mdx --frail --output --quiet --use remark-mdx -- .",
"format-silent": "remark --ext mdx --output --silent --use remark-mdx -- ."
},
"dependencies": {
"@astrojs/check": "^0.9.3",
"@astrojs/starlight": "^0.35.1",
"@iconify-json/fa": "^1.2.1",
"@types/hast": "^3.0.4",
"astro": "^5.1.2",
"hast-util-from-html": "^2.0.3",
"hast-util-to-html": "^9.0.5",
"mdast-util-to-string": "^4.0.0",
"remark": "^15.0.1",
"remark-mdx": "^3.1.0",
"sharp": "^0.34.1",
"typescript": "^5.4.5",
"unist-util-visit": "^5.0.0",
"unplugin-icons": "^22.1.0",
"zod": "^3.25.67"
},
"devDependencies": {
"@types/mdast": "^4.0.4",
"remark-cli": "^12.0.1",
"remark-frontmatter": "^5.0.0",
"remark-lint-fenced-code-flag": "^4.2.0",
"remark-lint-file-extension": "^3.0.1",
"remark-lint-final-definition": "^4.0.2",
"remark-lint-heading-increment": "^4.0.1",
"remark-lint-list-item-indent": "^4.0.1",
"remark-lint-list-item-spacing": "^5.0.1",
"remark-lint-maximum-heading-length": "^4.1.1",
"remark-lint-maximum-line-length": "^4.1.1",
"remark-lint-no-duplicate-definitions": "^4.0.1",
"remark-lint-no-duplicate-headings": "^4.0.1",
"remark-lint-no-file-name-irregular-characters": "^3.0.1",
"remark-lint-no-file-name-mixed-case": "^3.0.1",
"remark-lint-no-unused-definitions": "^4.0.2",
"remark-lint-unordered-list-marker-style": "^4.0.1",
"remark-preset-lint-markdown-style-guide": "^6.0.1",
"remark-preset-lint-recommended": "^7.0.1",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5"
}
}

View File

@@ -0,0 +1,61 @@
// We are using remarkLintRulesLintRecommended and
// remarkPresentLintMarkdownStyleGuide as our starting set of rules.
// None of the rules were giving an error on the starting set, but some
// rules were giving lots of warnings on the generated mdx. They are
// set to false in this file, we can add them back later as and when
// required.
/**
* @import {Preset} from 'unified'
*/
import remarkFrontmatter from "remark-frontmatter";
import remarkLintFencedCodeFlag from "remark-lint-fenced-code-flag";
import remarkLintFileExtension from "remark-lint-file-extension";
import remarkLintFinalDefinition from "remark-lint-final-definition";
import remarkLintHeadingIncrement from "remark-lint-heading-increment";
import remarkLintListItemIndent from "remark-lint-list-item-indent";
import remarkLintListItemSpacing from "remark-lint-list-item-spacing";
import remarkLintMaximumHeadingLength from "remark-lint-maximum-heading-length";
import remarkLintMaximumLineLength from "remark-lint-maximum-line-length";
import remarkLintNoDuplicateDefinitions from "remark-lint-no-duplicate-definitions";
import remarkLintNoDuplicateHeadings from "remark-lint-no-duplicate-headings";
import remarkLintNoFileNameIrregularCharacters from "remark-lint-no-file-name-irregular-characters";
import remarkLintNoFileNameMixedCase from "remark-lint-no-file-name-mixed-case";
import remarkLintNoUnusedDefinitions from "remark-lint-no-unused-definitions";
import remarkLintUnorderedListMarkerStyle from "remark-lint-unordered-list-marker-style";
import remarkPresentLintMarkdownStyleGuide from "remark-preset-lint-markdown-style-guide";
import remarkLintRulesLintRecommended from "remark-preset-lint-recommended";
import remarkStringify from "remark-stringify";
const remarkLintRules = {
plugins: [
remarkLintRulesLintRecommended,
remarkPresentLintMarkdownStyleGuide,
[remarkLintFinalDefinition, false],
[remarkLintListItemSpacing, false],
[remarkLintFileExtension, ["mdx"]],
[remarkLintNoUnusedDefinitions, false],
[remarkLintMaximumLineLength, false],
[remarkLintListItemIndent, false],
[remarkLintFencedCodeFlag, false],
[remarkLintNoFileNameIrregularCharacters, false],
[remarkLintNoFileNameMixedCase, false],
[remarkLintMaximumHeadingLength, false],
[remarkLintNoDuplicateHeadings, false],
[remarkLintHeadingIncrement, false],
[remarkLintNoDuplicateDefinitions, false],
[remarkLintUnorderedListMarkerStyle, "*"],
],
};
/** @type {Preset} */
const config = {
plugins: [
[remarkFrontmatter, ["yaml"]],
remarkLintRules,
[remarkStringify, {incrementListMarker: false}],
],
};
export default config;

View File

@@ -0,0 +1,36 @@
---
import EmojiCodes from "../../../static/generated/emoji/emoji_codes.json";
const nameToCodePoint: Record<string, string> = EmojiCodes.name_to_codepoint;
const rowHTML = (emoticon: string, codepoint: string, name: string) => `
<tr>
<td><code>${emoticon}</code></td>
<td>
<img
src="/static/generated/emoji/images-google-64/${codepoint}.png"
alt="${name}"
class="emoji-big">
</td>
</tr>
`;
let body = "";
const emoticonConversions: Record<string, string> =
EmojiCodes.emoticon_conversions;
for (const name of Object.keys(emoticonConversions)) {
const emoticon: string = emoticonConversions[name]!;
body += rowHTML(name, nameToCodePoint[emoticon.slice(1, -1)]!, emoticon);
}
---
<table>
<thead>
<tr>
<th>Emoticon</th>
<th>Emoji</th>
</tr>
</thead>
<tbody>
<Fragment set:html={body} />
</tbody>
</table>

View File

@@ -0,0 +1,43 @@
---
import assert from "node:assert/strict";
import {Steps} from "@astrojs/starlight/components";
import {fromHtml} from "hast-util-from-html";
import {toHtml} from "hast-util-to-html";
const tree = fromHtml(await Astro.slots.render("default"), {fragment: true});
const tree_with_removed_newlines = {
type: "root",
children: tree.children.filter((child) => {
if (child.type === "text" && child.value === "\n") {
return false;
}
return true;
}),
};
const first_element = tree_with_removed_newlines.children[0];
assert.ok(
first_element?.type === "element" &&
["ol", "ul"].includes(first_element.tagName),
);
const flattened = {
...first_element,
children: tree_with_removed_newlines.children.flatMap((other) => {
if (other.type === "comment") {
return [];
}
assert.ok(other.type === "element");
// Flatten only in case of matching tagName, for the rest, we
// return the elements without flattening since asides, code
// blocks and other elements can be part of a single list item
// and we do not want to flatten them.
if (other.tagName === first_element.tagName) {
return other.children;
}
return [other];
}),
};
---
<Steps><Fragment set:html={toHtml(flattened)} /></Steps>

View File

@@ -0,0 +1,22 @@
---
import {CORPORATE_ENABLED, SUPPORT_EMAIL} from "astro:env/client";
let footer_html = `<p>Don't see an answer to your question? <a href="mailto:{{ ${SUPPORT_EMAIL} }}">Contact this Zulip server's administrators</a> for support.</p>`;
if (CORPORATE_ENABLED) {
footer_html = `<p>Your feedback helps us make Zulip better for everyone! Please <a href="/starlight_help/contact-support">contact us</a> with questions, suggestions, and feature requests.</p>`;
}
---
<footer class="sl-flex">
<hr />
<Fragment set:html={footer_html} />
</footer>
<style>
@layer starlight.core {
footer {
flex-direction: column;
gap: 1.5rem;
}
}
</style>

View File

@@ -0,0 +1,6 @@
---
import Default from "@astrojs/starlight/components/Head.astro";
---
<script src="../scripts/client/adjust_mac_kbd_tags.ts"></script>
<Default><slot /></Default>

View File

@@ -0,0 +1,50 @@
---
import assert from "node:assert/strict";
import type {Element} from "hast";
import {fromHtml} from "hast-util-from-html";
import {toHtml} from "hast-util-to-html";
import keyboard_svg from "../../../web/shared/icons/keyboard.svg?raw";
const keyboard_icon_fragment = fromHtml(keyboard_svg, {fragment: true});
const keyboard_icon_first_child = keyboard_icon_fragment.children[0]!;
assert.ok(
keyboard_icon_first_child.type === "element" &&
keyboard_icon_first_child.tagName === "svg",
);
keyboard_icon_first_child.properties.class = "zulip-unplugin-icon";
const prefix_element_list = [
keyboard_icon_first_child,
{
type: "element",
tagName: "strong",
properties: {},
children: [
{
type: "text",
// Whitespace before the text to ensure space between
// this text and the preceding icon.
value: " Keyboard tip: ",
},
],
} as Element,
];
const tree = fromHtml(await Astro.slots.render("default"), {fragment: true});
const first_element = tree.children[0];
assert.ok(first_element?.type === "element");
first_element.children = [...prefix_element_list, ...first_element.children];
tree.children[0] = first_element;
---
<aside
aria-label="Keyboard tip"
class=`starlight-aside starlight-aside--tip keyboard-tip`
>
<div class="starlight-aside__content">
<Fragment set:html={toHtml(tree)} />
</div>
</aside>

View File

@@ -0,0 +1,416 @@
---
import assert from "node:assert";
import {CORPORATE_ENABLED, SHOW_RELATIVE_LINKS} from "astro:env/client";
/* eslint-disable import/extensions */
import RawBarChartIcon from "~icons/zulip-icon/bar-chart?raw";
import RawBuildingIcon from "~icons/zulip-icon/building?raw";
import RawCreditCardIcon from "~icons/zulip-icon/credit-card?raw";
import RawEditIcon from "~icons/zulip-icon/edit?raw";
import RawGearIcon from "~icons/zulip-icon/gear?raw";
import RawGitPullRequestIcon from "~icons/zulip-icon/git-pull-request?raw";
import RawHashIcon from "~icons/zulip-icon/hash?raw";
import RawHelpIcon from "~icons/zulip-icon/help?raw";
import RawInfoIcon from "~icons/zulip-icon/info?raw";
import RawKeyboardIcon from "~icons/zulip-icon/keyboard?raw";
import RawManageSearchIcon from "~icons/zulip-icon/manage-search?raw";
import RawRocketIcon from "~icons/zulip-icon/rocket?raw";
import RawToolIcon from "~icons/zulip-icon/tool?raw";
import RawUserGroupCogIcon from "~icons/zulip-icon/user-group-cog?raw";
/* eslint-enable import/extensions */
const PERSONAL_SETTINGS_TYPE = "Personal settings";
const ORGANIZATION_SETTINGS_TYPE = "Organization settings";
const SHOW_BILLING_HELP_LINKS = CORPORATE_ENABLED;
// This list has been transformed one-off from `help_settings_links.py`, we
// have added a comment in that file to update this list in case of any
// changes.
const setting_link_mapping: Record<
string,
{
setting_type: string;
setting_name: string;
setting_link: string;
}
> = {
// a mapping from the setting target that is the same as the final URL
// breadcrumb to that setting to the name of its setting type, the setting
// name as it appears in the user interface, and a relative link that can
// be used to get to that setting
profile: {
setting_type: PERSONAL_SETTINGS_TYPE,
setting_name: "Profile",
setting_link: "/#settings/profile",
},
"account-and-privacy": {
setting_type: PERSONAL_SETTINGS_TYPE,
setting_name: "Account & privacy",
setting_link: "/#settings/account-and-privacy",
},
preferences: {
setting_type: PERSONAL_SETTINGS_TYPE,
setting_name: "Preferences",
setting_link: "/#settings/preferences",
},
notifications: {
setting_type: PERSONAL_SETTINGS_TYPE,
setting_name: "Notifications",
setting_link: "/#settings/notifications",
},
"your-bots": {
setting_type: PERSONAL_SETTINGS_TYPE,
setting_name: "Bots",
setting_link: "/#settings/your-bots",
},
"alert-words": {
setting_type: PERSONAL_SETTINGS_TYPE,
setting_name: "Alert words",
setting_link: "/#settings/alert-words",
},
"uploaded-files": {
setting_type: PERSONAL_SETTINGS_TYPE,
setting_name: "Uploaded files",
setting_link: "/#settings/uploaded-files",
},
topics: {
setting_type: PERSONAL_SETTINGS_TYPE,
setting_name: "Topics",
setting_link: "/#settings/topics",
},
"muted-users": {
setting_type: PERSONAL_SETTINGS_TYPE,
setting_name: "Muted users",
setting_link: "/#settings/muted-users",
},
"organization-profile": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Organization profile",
setting_link: "/#organization/organization-profile",
},
"organization-settings": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Organization settings",
setting_link: "/#organization/organization-settings",
},
"organization-permissions": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Organization permissions",
setting_link: "/#organization/organization-permissions",
},
"default-user-settings": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Default user settings",
setting_link: "/#organization/organization-level-user-defaults",
},
"emoji-settings": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Custom emoji",
setting_link: "/#organization/emoji-settings",
},
"auth-methods": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Authentication methods",
setting_link: "/#organization/auth-methods",
},
users: {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Users",
setting_link: "/#organization/users/active",
},
deactivated: {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Users",
setting_link: "/#organization/users/deactivated",
},
invitations: {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Users",
setting_link: "/#organization/users/invitations",
},
"bot-list-admin": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Bots",
setting_link: "/#organization/bot-list-admin",
},
"default-channels-list": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Default channels",
setting_link: "/#organization/default-channels-list",
},
"linkifier-settings": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Linkifiers",
setting_link: "/#organization/linkifier-settings",
},
"playground-settings": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Code playgrounds",
setting_link: "/#organization/playground-settings",
},
"profile-field-settings": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Custom profile fields",
setting_link: "/#organization/profile-field-settings",
},
"channel-folder-settings": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Channel folders",
setting_link: "/#organization/channel-folders",
},
"data-exports-admin": {
setting_type: ORGANIZATION_SETTINGS_TYPE,
setting_name: "Data exports",
setting_link: "/#organization/data-exports-admin",
},
};
type RelativeLinkInfo = {
label: string;
relative_link: string;
icon?: string | undefined;
};
const default_template_for_relative_links = `
<ol>
<li>Click on the <strong>gear</strong> (${RawGearIcon}) icon in the upper right corner of the web or desktop app.</li>
<li class="navigation-step-relative-type">Select {item}.</li>
</ol>
`;
const relative_link_mapping: Record<
string,
{
data: Record<string, RelativeLinkInfo>;
template: string;
is_link_relative: () => boolean;
}
> = {
gear: {
data: {
"channel-settings": {
label: "Channel settings",
relative_link: "/#channels/subscribed",
icon: `${RawHashIcon}`,
},
settings: {
label: "Personal Settings",
relative_link: "/#settings/profile",
icon: `${RawToolIcon}`,
},
"organization-settings": {
label: "Organization settings",
relative_link: "/#organization/organization-profile",
icon: `${RawBuildingIcon}`,
},
"group-settings": {
label: "Group settings",
relative_link: "/#groups/your",
icon: `${RawUserGroupCogIcon}`,
},
stats: {
label: "Usage statistics",
relative_link: "/stats",
icon: `${RawBarChartIcon}`,
},
integrations: {
label: "Integrations",
relative_link: "/integrations/",
icon: `${RawGitPullRequestIcon}`,
},
"about-zulip": {
label: "About Zulip",
relative_link: "/#about-zulip",
},
},
template: default_template_for_relative_links,
is_link_relative: () => SHOW_RELATIVE_LINKS,
},
"gear-billing": {
data: {
plans: {
label: "Plans and pricing",
relative_link: "/plans/",
icon: `${RawRocketIcon}`,
},
billing: {
label: "Billing",
relative_link: "/billing/",
icon: `${RawCreditCardIcon}`,
},
},
template: default_template_for_relative_links,
is_link_relative: () => SHOW_RELATIVE_LINKS && SHOW_BILLING_HELP_LINKS,
},
help: {
data: {
"keyboard-shortcuts": {
label: "Keyboard shortcuts",
relative_link: "/#keyboard-shortcuts",
icon: `${RawKeyboardIcon}`,
},
"message-formatting": {
label: "Message formatting",
relative_link: "/#message-formatting",
icon: `${RawEditIcon}`,
},
"search-filters": {
label: "Search filters",
relative_link: "/#search-operators",
icon: `${RawManageSearchIcon}`,
},
"about-zulip": {
label: "About Zulip",
relative_link: "/#about-zulip",
icon: `${RawInfoIcon}`,
},
},
template: `
<ol>
<li>Click on the <strong>Help menu</strong> (${RawHelpIcon}) icon in the upper right corner of the app.</li>
<li class="navigation-step-relative-type">Select {item}.</li>
</ol>
`,
is_link_relative: () => SHOW_RELATIVE_LINKS,
},
channel: {
data: {
all: {
label: "All",
relative_link: "/#channels/all",
},
"not-subscribed": {
label: "Not subscribed",
relative_link: "/#channels/notsubscribed",
},
},
template: `
<ol>
<li>Click on the <strong>gear</strong> (${RawGearIcon}) icon in the upper right corner of the web or desktop app.</li>
<li>Select ${RawHashIcon} <strong>Channel settings</strong>.</li>
<li>Click {item} in the upper left.</li>
</ol>
`,
is_link_relative: () => SHOW_RELATIVE_LINKS,
},
group: {
data: {
all: {
label: "All groups",
relative_link: "/#groups/all",
},
},
template: `
<ol>
<li>Click on the <strong>gear</strong> (${RawGearIcon}) icon in the upper right corner of the web or desktop app.</li>
<li>Select ${RawUserGroupCogIcon} <strong>Group settings</strong>.</li>
<li>Click {item} in the upper left.</li>
</ol>
`,
is_link_relative: () => SHOW_RELATIVE_LINKS,
},
};
const getSettingsMarkdown = (
setting_type: string,
setting_type_icon: string,
setting_name: string,
) => `
<ol>
<li>
Click on the <b>gear</b> (${RawGearIcon}) icon in the upper
right corner of the web or desktop app.
</li>
<li>
Select <b>${setting_type_icon} ${setting_type}</b>.
</li>
<li>
On the left, click <b>${setting_name}</b>.
</li>
</ol>
`;
const getSettingsHTML = (
setting_key: string,
SHOW_RELATIVE_LINKS: boolean,
): string => {
const {setting_type, setting_name, setting_link} =
setting_link_mapping[setting_key]!;
if (!SHOW_RELATIVE_LINKS) {
const setting_type_icon =
setting_type === ORGANIZATION_SETTINGS_TYPE
? `${RawBuildingIcon}`.trim()
: `${RawToolIcon}`.trim();
return getSettingsMarkdown(
setting_type,
setting_type_icon,
setting_name,
);
}
const relativeLink = `<a href="${setting_link}">${setting_name}</a>`;
// The "Bots" label appears in both Personal and
// Organization settings in the user interface, so we need special
// text for this setting.
// As for the the case of "Users", it refers to the Users tab in
// organization settings. Since the users tab has multiple sub tabs
// like active, deactivated etc., we need a way to point to them.
const label =
setting_name === "Bots" || setting_name === "Users"
? `Navigate to the ${relativeLink} tab of the <b>${setting_type}</b> menu.`
: `Go to ${relativeLink}.`;
return `<ol>
<li>${label}</li>
</ol>`;
};
const RELATIVE_NAVIGATION_HANDLERS_BY_TYPE: Record<
string,
(key: string) => string
> = {};
for (const type of Object.keys(relative_link_mapping)) {
const {data, template, is_link_relative} = relative_link_mapping[type]!;
RELATIVE_NAVIGATION_HANDLERS_BY_TYPE[type] = (key: string) => {
const {label, relative_link, icon} = data[key]!;
let formattedLabel = label;
if (icon !== undefined) {
const trimmedIcon = `${icon}`.trim();
formattedLabel = trimmedIcon + label;
}
const formattedItem = is_link_relative()
? `<a href="${relative_link}">${formattedLabel}</a>`
: `<strong>${formattedLabel}</strong>`;
return template.replace("{item}", formattedItem);
};
}
const {target} = Astro.props;
const navigation_link_type = target.split("/")[0];
if (
navigation_link_type !== "settings" &&
navigation_link_type !== "relative"
) {
throw new Error(
"Invalid navigation link type. Only `settings` or `relative` is allowed.",
);
}
let resultHTML: string | undefined;
if (navigation_link_type === "settings") {
resultHTML = getSettingsHTML(target.split("/")[1], SHOW_RELATIVE_LINKS);
} else {
const link_type = target.split("/")[1];
const key = target.split("/")[2];
resultHTML = RELATIVE_NAVIGATION_HANDLERS_BY_TYPE[link_type]!(key);
}
assert.ok(resultHTML !== undefined);
---
<Fragment set:html={resultHTML} />

View File

@@ -0,0 +1,11 @@
<!--
We wanted to have our note without a header that is the ⓘ followed by a title.
We can make the title disappear by making the title a single space: " ".
The default aside component provided by starlight will always have an icon however.
That is why we needed this custom components.
-->
<aside aria-label="Note" class={`starlight-aside starlight-aside--note`}>
<div class="starlight-aside__content">
<slot />
</div>
</aside>

View File

@@ -0,0 +1,62 @@
---
import assert from "node:assert/strict";
import type {Element} from "hast";
import {fromHtml} from "hast-util-from-html";
import {toHtml} from "hast-util-to-html";
import lightbulb_svg from "../../../web/shared/icons/lightbulb.svg?raw";
const lightbulb_icon_fragment = fromHtml(lightbulb_svg, {fragment: true});
const lightbulb_icon_first_child = lightbulb_icon_fragment.children[0]!;
assert.ok(
lightbulb_icon_first_child.type === "element" &&
lightbulb_icon_first_child.tagName === "svg",
);
lightbulb_icon_first_child.properties.class =
"zulip-unplugin-icon aside-icon-lightbulb";
let prefix_element_list = [
lightbulb_icon_first_child,
{
type: "element",
tagName: "strong",
properties: {},
children: [
{
type: "text",
// Whitespace before the text to ensure space between
// this text and the preceding icon.
value: " Tip: ",
},
],
} as Element,
];
const tree = fromHtml(await Astro.slots.render("default"), {fragment: true});
const first_element = tree.children[0];
assert.ok(first_element?.type === "element");
// This is currently happening only in one case, for _ImportSelfHostedServerTips.mdx
// where the tip contains an unordered list. Just placing the element as is without
// a paragraph does not look good in that case.
if (first_element.tagName !== "p") {
prefix_element_list = [
{
type: "element",
tagName: "p",
properties: {},
children: [...prefix_element_list],
} as Element,
];
}
first_element.children = [...prefix_element_list, ...first_element.children];
tree.children[0] = first_element;
---
<aside aria-label="Tip" class=`starlight-aside starlight-aside--tip`>
<div class="starlight-aside__content">
<Fragment set:html={toHtml(tree)} />
</div>
</aside>

View File

@@ -0,0 +1,7 @@
import {docsLoader} from "@astrojs/starlight/loaders";
import {docsSchema} from "@astrojs/starlight/schema";
import {defineCollection} from "astro:content";
export const collections = {
docs: defineCollection({loader: docsLoader(), schema: docsSchema()}),
};

4
starlight_help/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View File

@@ -0,0 +1,36 @@
import assert from "node:assert";
import {defineRouteMiddleware} from "@astrojs/starlight/route-data";
import type {Paragraph} from "mdast";
import {toString} from "mdast-util-to-string";
import {remark} from "remark";
import remarkMdx from "remark-mdx";
import {visit} from "unist-util-visit";
function extractFirstParagraph(content: string): string | undefined {
const tree = remark().use(remarkMdx).parse(content);
let firstParagraph: string | undefined;
visit(tree, "paragraph", (node: Paragraph) => {
if (!firstParagraph) {
// We need to convert the node to string so that links, emphasis, etc.
// are converted to plain text.
firstParagraph = toString(node);
firstParagraph = firstParagraph.replaceAll(/\s+/g, " ").trim();
}
});
return firstParagraph;
}
export const onRequest = defineRouteMiddleware((context) => {
assert.ok(typeof context.locals.starlightRoute.entry.body === "string");
context.locals.starlightRoute.head.push({
tag: "meta",
attrs: {
name: "description",
content: extractFirstParagraph(context.locals.starlightRoute.entry.body),
},
});
});

View File

@@ -0,0 +1,66 @@
// Any changes to this file should be followed by a check for changes
// needed to make to adjust_mac_kbd_tags of web/src/common.ts.
const keys_map = new Map<string, string>([
["Backspace", "Delete"],
["Enter", "Return"],
["Ctrl", "⌘"],
["Alt", "⌥"],
]);
function has_mac_keyboard(): boolean {
"use strict";
// eslint-disable-next-line @typescript-eslint/no-deprecated
return /mac/i.test(navigator.platform);
}
// We convert the <kbd> tags used for keyboard shortcuts to mac equivalent
// key combinations, when we detect that the user is using a mac-style keyboard.
function adjust_mac_kbd_tags(): void {
"use strict";
if (!has_mac_keyboard()) {
return;
}
const elements = document.querySelectorAll<HTMLElement>("kbd");
for (const element of elements) {
let key_text: string = element.textContent ?? "";
// We use data-mac-key attribute to override the default key in case
// of exceptions:
// - There are 2 shortcuts (for navigating back and forth in browser
// history) which need "⌘" instead of the expected mapping ("Opt")
// for the "Alt" key, so we use this attribute to override "Opt"
// with "⌘".
// - The "Ctrl" + "[" shortcuts (which match the Vim keybinding behavior
// of mapping to "Esc") need to display "Ctrl" for all users, so we
// use this attribute to override "⌘" with "Ctrl".
const replace_key: string | undefined = element.dataset.macKey ?? keys_map.get(key_text);
if (replace_key !== undefined) {
key_text = replace_key;
}
element.textContent = key_text;
// In case of shortcuts, the Mac equivalent of which involves extra keys,
// we use data-mac-following-key attribute to append the extra key to the
// previous key. Currently, this is used to append Opt to Cmd for the Paste
// as plain text shortcut.
const following_key: string | undefined = element.dataset.macFollowingKey;
if (following_key !== undefined) {
const kbd_elem: HTMLElement = document.createElement("kbd");
kbd_elem.textContent = following_key;
element.after(kbd_elem);
element.after(" + ");
}
// In web/src/common.ts, we use zulip icon for ⌘ due to centering
// problems, we don't have that problem in the new help center and
// thus don't do that transformation here.
}
}
adjust_mac_kbd_tags();

View File

@@ -0,0 +1,243 @@
:root {
/* Starlight base style headings are huge and distract from reading the text.
This is OK for some sites, but we are sparing with text and really want to
encourage users to read it. */
--sl-text-h1: 2rem;
--sl-text-h2: 1.4rem;
--sl-text-h3: 1.2rem;
--sl-text-h4: 1rem;
--sl-text-h5: 1rem;
/* Changed from 1.2 to 1 */
--sl-line-height-headings: 1;
/* Changed from 1.75 to make text easier to read. */
--sl-line-height: 1.5;
/* User circles */
/* stylelint-disable color-no-hex */
--color-user-circle-active: light-dark(#43a35e, #4cdc75);
--color-user-circle-idle: light-dark(#f5b266, #ae640a);
--color-user-circle-offline: light-dark(#c1c6d7, #454854);
--color-user-circle-deactivated: hsl(0deg 0% 50%);
/* stylelint-enable color-no-hex */
/* NOTE: These colors are also used in zulip web app for banner
colors. Do grep for these variables when changing them and
confirm on CZO on whether the colors there need to change as
well. */
/* Banners - Neutral Variant */
--color-text-neutral-banner: light-dark(
hsl(229deg 12% 25%),
hsl(231deg 11% 76%)
);
--color-border-neutral-banner: light-dark(
color-mix(in oklch, hsl(240deg 2% 30%) 40%, transparent),
color-mix(in oklch, hsl(240deg 7% 66%) 40%, transparent)
);
--color-background-neutral-banner: light-dark(
hsl(240deg 7% 93%),
hsl(240deg 7% 17%)
);
/* Banners - Brand Variant */
--color-text-brand-banner: light-dark(
hsl(264deg 95% 34%),
hsl(244deg 96% 82%)
);
--color-border-brand-banner: light-dark(
color-mix(in oklch, hsl(254deg 60% 50%) 40%, transparent),
color-mix(in oklch, hsl(253deg 70% 89%) 40%, transparent)
);
--color-background-brand-banner: light-dark(
hsl(254deg 42% 94%),
hsl(253deg 49% 16%)
);
/* Banners - Info Variant */
--color-text-info-banner: light-dark(
hsl(241deg 95% 25%),
hsl(221deg 93% 89%)
);
--color-border-info-banner: light-dark(
color-mix(in oklch, hsl(204deg 49% 29%) 40%, transparent),
color-mix(in oklch, hsl(205deg 58% 69%) 40%, transparent)
);
--color-background-info-banner: light-dark(
hsl(204deg 58% 92%),
hsl(204deg 100% 12%)
);
/* Keyboard shortcuts */
--color-keyboard-shortcuts: light-dark(
hsl(225deg 57.09% 42.9%),
hsl(225deg 100% 84%)
);
}
.non-clickable-sidebar-heading {
font-size: 1.15rem;
pointer-events: none;
cursor: default;
}
/* Eliminate the border inserted between the title and the rest of
the content. */
.content-panel + .content-panel {
border-top: 0;
}
/* Decrease padding for the content panel from 1.5rem to 1rem since
the padding looked too big after removing the content panel border. */
.content-panel {
padding: 1rem var(--sl-content-pad-x);
}
.zulip-unplugin-icon {
/* Make sure the icon does not occupy it's own row. */
display: inline;
vertical-align: text-bottom;
/* unplugin-icons sets height and width by itself.
It was setting the height to 1024 and 960 for some
icons. It is better to set the height explicitly. */
height: 1em;
width: 1em;
/* Some css rules in starlight insert these margins to tags
that fit certain criteria, e.g. if it's a first child of
an li item and similar cases, and the icon disturbs the
spacing of everything around it just because it was an
svg tag. We set this explicitly to zero to avoid those
issues. */
margin-bottom: 0;
margin-top: 0;
/* We need to specify this for dark mode. */
fill: currentcolor;
}
.navigation-step-relative-type .zulip-unplugin-icon {
/* There's no space between the icon and text for navigation
step labels because of any text decoration rules when these
steps have a relative link. So we add a right margin to the
icon to add the white space without any text decoration. */
margin-right: 4px;
}
.starlight-aside--tip {
--sl-color-asides-text-accent: var(--color-text-brand-banner);
--sl-color-asides-border: var(--color-border-brand-banner);
background-color: var(--color-background-brand-banner);
}
.starlight-aside--note {
--sl-color-asides-text-accent: var(--color-text-neutral-banner);
--sl-color-asides-border: var(--color-border-neutral-banner);
background-color: var(--color-background-neutral-banner);
}
.keyboard-tip {
--sl-color-asides-text-accent: var(--color-text-info-banner);
--sl-color-asides-border: var(--color-border-info-banner);
background-color: var(--color-background-info-banner);
}
.aside-icon-lightbulb {
/* We need to make the fill transparent for the bulb to look hollow
and the default vertical-align of text-bottom was not looking
good beside the `Tip` text. */
vertical-align: text-top;
fill: transparent;
stroke: currentcolor;
/* In cases where content spanned across multiple lines, the
icon and the content just below it did not look aligned. */
margin-left: -3px;
/* Using any of the default vertical-align did not give desired
results, text-top + this margin looked the best. */
margin-top: 2px;
}
.user-circle {
font-size: 0.7em;
display: inline-block;
vertical-align: middle;
}
.user-circle-active {
color: var(--color-user-circle-active);
}
.user-circle-idle {
color: var(--color-user-circle-idle);
}
.user-circle-offline {
color: var(--color-user-circle-offline);
}
.user-circle-deactivated {
color: var(--color-user-circle-deactivated);
}
.sl-markdown-content {
img {
vertical-align: top;
box-shadow: 0 0 4px hsl(0deg 0% 0% / 5%);
border: 1px solid hsl(0deg 0% 87%);
border-radius: 4px;
margin-top: 0;
&.emoji-small {
display: inline-block;
width: 1.25em;
box-shadow: none;
border: none;
vertical-align: text-top;
}
&.emoji-big {
display: inline-block;
width: 1.5em;
box-shadow: none;
border: none;
vertical-align: text-top;
}
&.help-center-icon {
display: inline-block;
width: 1.25em;
box-shadow: none;
border: none;
vertical-align: text-top;
}
}
li > ul,
li > ol {
margin-top: 0.25rem;
}
& .sl-heading-wrapper:has(> :first-child:target) {
/* Increase the highlighted space around the text... */
/* We are trying to recreate `padding: 6px 0 6px 8px` below
using box-shadow since we don't want padding to affect the
layout. A spread of 6px will make sure of the 6px part of
the padding, and -2px will ensure a padding of 8px is
recreated on the left side. */
box-shadow: -2px 0 0 6px var(--sl-color-accent-low);
background-color: var(--sl-color-accent-low);
}
& kbd {
font-size: 1.2em;
padding: 0.15em 0.4em 0.05em;
border: 1px solid var(--color-keyboard-shortcuts);
border-radius: 3px;
color: var(--color-keyboard-shortcuts);
text-align: center;
white-space: nowrap;
}
}
footer {
font-size: 0.85em;
}

View File

@@ -0,0 +1,35 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
/* Type Checking */
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strict": true,
/* Modules */
"module": "preserve",
/* Emit */
"noEmit": true,
/* Interop Constraints */
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
/* Language and Environment */
"target": "esnext",
/* Projects */
"composite": true,
"types": ["unplugin-icons/types/astro"],
},
"include": [".astro/types.d.ts", "**/*", "src/.remarkrc.js"],
"exclude": ["dist"],
}