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
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,34 @@ export default async function Page() {

<CodeBlock>sudo usermod -aG docker $USER</CodeBlock>

<Callout tone="warning" title="This gives your user full admin power">
<p>
Docker's own docs say the docker group{" "}
<a
href="https://docs.docker.com/engine/install/linux-postinstall/"
rel="noreferrer"
target="_blank"
>
grants root-level privileges
</a>
. Anyone who logs in as your user, or any script running as you, can take full control
of the server through Docker. Only add an account you'd trust with <code>sudo</code>{" "}
anyway.
</p>
<p>
If you'd rather not give that power away, Docker can run without root. It's called{" "}
<a href="https://docs.docker.com/engine/security/rootless/" rel="noreferrer" target="_blank">
rootless mode
</a>
, and it takes a few more setup steps. For a home server that only you log in to, the
docker group is the usual choice.
</p>
</Callout>

<p>
Log out and back in for that to take effect. Then check the whole thing works by running
a tiny test container:
Log out and back in for that to take effect. If you're on a virtual machine and it still
doesn't work, restart the machine. Then check the whole thing works by running a tiny
test container:
</p>

<CodeBlock>docker run hello-world</CodeBlock>
Expand Down
86 changes: 43 additions & 43 deletions app/(site)/(directories)/ad-networks/network-facts.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Gumroad vs Payhip vs Etsy: what you keep from one sale

Payhip's free plan leaves you the most at every price below, and Gumroad leaves you the least. Gumroad's fixed 50¢ fee hurts most on cheap products.

<Example title="What you keep from one download sold to a US buyer by card, rates checked 1 November 2026">

| Price | Gumroad | Payhip free plan | Etsy |
| --- | --- | --- | --- |
| $5 | $3.55 | $4.30 | $4.07 |
| $20 | $16.62 | $18.12 | $17.65 |
| $100 | $86.30 | $91.80 | $90.05 |

Gumroad takes 10% plus 50¢, then card processing takes 2.9% plus 30¢. Payhip takes 5%, then Stripe takes its standard 2.9% plus 30¢. Etsy takes a 6.5% transaction fee, 3% plus 25¢ for payment processing, and the 20¢ listing fee. Totals are rounded to the cent.

</Example>

These numbers are for a sale you bring in yourself. Each platform charges differently when it finds the buyer for you:

- **Gumroad Discover:** 30% of the sale, card fees included.
- **Etsy Offsite Ads:** 15% of the order, or 12% once you pass $10,000 in yearly sales, up to $100 per order. You pay it only when a sale comes from one of Etsy's ads.
- **Payhip marketplace:** no extra fee on top of your plan's fee.

Payhip's paid plans cut the per-sale fee. Plus costs $29 a month and takes 2%, so it beats the free plan once you sell more than about $967 a month. Pro costs $99 a month and takes nothing, so it beats Plus above $3,500 a month. Etsy may also charge a one-time fee to open a shop, depending on where you live.

All three collect and pay US sales tax for you. Gumroad is the merchant of record, so it handles sales tax and VAT. Payhip has handled US sales tax since 1 July 2026, and it also handles EU and UK VAT on digital products. Etsy collects US sales tax where state law requires it, and VAT on digital items in the countries it lists.

Sources: [Gumroad pricing](https://gumroad.com/pricing), [Gumroad fees](https://gumroad.com/help/article/66-gumroads-fees), [Payhip pricing](https://payhip.com/pricing), [Payhip taxes](https://payhip.com/features/vat-taxes), [Etsy fees](https://help.etsy.com/hc/en-us/articles/115014483627-What-are-the-Fees-and-Taxes-for-Selling-on-Etsy), [Etsy payment processing](https://help.etsy.com/hc/en-us/articles/115015628847-What-are-Payment-Processing-Fees-for-Selling-on-Etsy), and [Etsy Offsite Ads](https://help.etsy.com/hc/en-us/articles/360000338367-How-Etsy-s-Offsite-Ads-Work).
14 changes: 11 additions & 3 deletions app/(site)/(directories)/platform-fees/[section]/content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CreativeAssetsIntro from "./creative-assets/intro.mdx";
import CreatorsAndCommunitiesArticle from "./creators-and-communities/article.mdx";
import CreatorsAndCommunitiesIntro from "./creators-and-communities/intro.mdx";
import DigitalProductsAndStoresArticle from "./digital-products-and-stores/article.mdx";
import DigitalProductsAndStoresComparison from "./digital-products-and-stores/comparison.mdx";
import DigitalProductsAndStoresIntro from "./digital-products-and-stores/intro.mdx";
import MusicAndAudioArticle from "./music-and-audio/article.mdx";
import MusicAndAudioIntro from "./music-and-audio/intro.mdx";
Expand All @@ -16,12 +17,19 @@ import TeachingAndCoursesIntro from "./teaching-and-courses/intro.mdx";
import WritingAndBooksArticle from "./writing-and-books/article.mdx";
import WritingAndBooksIntro from "./writing-and-books/intro.mdx";

/** Each fee category's own words, keyed by its slug. Add an entry when a category is added. */
export const feeCategoryContent: Record<string, { Intro: MDXContent; Article?: MDXContent }> = {
/**
* Each fee category's own words, keyed by its slug. Add an entry when a category is added.
* `Lead` renders above the listing, for an answer the page title promises.
*/
export const feeCategoryContent: Record<string, { Intro: MDXContent; Lead?: MDXContent; Article?: MDXContent }> = {
"apps-and-software": { Intro: AppsAndSoftwareIntro, Article: AppsAndSoftwareArticle },
"creative-assets": { Intro: CreativeAssetsIntro, Article: CreativeAssetsArticle },
"creators-and-communities": { Intro: CreatorsAndCommunitiesIntro, Article: CreatorsAndCommunitiesArticle },
"digital-products-and-stores": { Intro: DigitalProductsAndStoresIntro, Article: DigitalProductsAndStoresArticle },
"digital-products-and-stores": {
Intro: DigitalProductsAndStoresIntro,
Lead: DigitalProductsAndStoresComparison,
Article: DigitalProductsAndStoresArticle,
},
"music-and-audio": { Intro: MusicAndAudioIntro, Article: MusicAndAudioArticle },
"payments": { Intro: PaymentsIntro, Article: PaymentsArticle },
"teaching-and-courses": { Intro: TeachingAndCoursesIntro, Article: TeachingAndCoursesArticle },
Expand Down
3 changes: 2 additions & 1 deletion app/(site)/(directories)/platform-fees/[section]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ export default async function FeeCategoryPage({ params }: { params: Promise<{ se
};
});

const { Intro, Article } = content;
const { Intro, Lead, Article } = content;
return <FeeShell
directoryPage={directoryPage}
intro={<Intro />}
lead={Lead ? <Lead /> : undefined}
article={Article ? <Article /> : undefined}
group={group}
anchors={blocks.map(block => ({
Expand Down
5 changes: 4 additions & 1 deletion app/(site)/(directories)/platform-fees/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ export function SectionIcon({ icon, size = 22 }: { icon: FeeSection['icon']; siz
}

/** `intro` and `article` are the page's own MDX content. */
export async function FeeShell({ directoryPage, intro, article, children, group, anchors = [] }: {
export async function FeeShell({ directoryPage, intro, lead, article, children, group, anchors = [] }: {
directoryPage: Stack127DirectoryPage;
intro: ReactNode;
/** Content shown above the listing. */
lead?: ReactNode;
article?: ReactNode;
children: ReactNode;
group?: FeeGroup;
Expand Down Expand Up @@ -73,6 +75,7 @@ export async function FeeShell({ directoryPage, intro, article, children, group,
</PageHeader>
</EntryHero>
<PageCanvas flow className={styles.content}>
{lead ? <div className={`${proseClassName} ${proseFlowClassName} ${styles.prose}`}>{lead}</div> : null}
{children}
{article ? <div className={`${proseClassName} ${proseFlowClassName} ${styles.prose}`}>{article}</div> : null}
</PageCanvas>
Expand Down
10 changes: 5 additions & 5 deletions lib/shortcuts/apps/discord.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import type { AppShortcuts, Shortcut, ShortcutKey } from "../types.ts";

const key = (platform: "windows" | "macos" | "linux", keys: string[], alternatives?: string[][]): ShortcutKey => ({ platform, keys, ...(alternatives ? { alternatives } : {}) });
const pair = (id: string, action: string, win: string[], mac: string[], winAlt?: string[][], macAlt?: string[][]): Shortcut => ({ id, action, keys: [key("windows", win, winAlt), key("macos", mac, macAlt), key("linux", win, winAlt)] });
const key = (platform: "windows" | "macos", keys: string[], alternatives?: string[][]): ShortcutKey => ({ platform, keys, ...(alternatives ? { alternatives } : {}) });
const pair = (id: string, action: string, win: string[], mac: string[], winAlt?: string[][], macAlt?: string[][]): Shortcut => ({ id, action, keys: [key("windows", win, winAlt), key("macos", mac, macAlt)] });
const shared = (id: string, action: string, keys: string[], alternatives?: string[][]): Shortcut => pair(id, action, keys, keys, alternatives, alternatives);

export const discord: AppShortcuts = {
slug: "discord",
name: "Discord",
category: "Communication",
platforms: ["windows", "macos", "linux"],
platforms: ["windows", "macos"],
versionContext:
"Current Discord commands, shortcuts, and navigation guide updated April 14, 2025 and checked October 10, 2026. The documented shortcuts apply to browser and desktop Discord, not the mobile app. The guide labels Windows and Mac bindings; Linux uses the Windows modifier column.",
"Current Discord commands, shortcuts, and navigation guide updated April 14, 2025 and checked November 1, 2026. The documented shortcuts apply to browser and desktop Discord, not the mobile app. The guide labels Windows and Mac bindings; Linux shortcuts are not listed.",
sourceUrl: "https://support.discord.com/hc/en-us/articles/31232432266647-Discord-Commands-Shortcuts-and-Navigation-Guide",
sourceLabel: "Discord commands, shortcuts, and navigation guide",
lastVerified: "2026-10-10",
lastVerified: "2026-11-01",
groups: [
{
id: "general",
Expand Down
19 changes: 14 additions & 5 deletions lib/shortcuts/apps/figma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,13 @@ export const figma: AppShortcuts = {
{ id: "comment", action: "Comment", keys: combo(platforms, ["C"]) },
{
id: "eyedropper",
action: "Pick a color from anywhere",
keys: combo(platforms, ["Ctrl", "C"], ["Cmd", "C"]),
note: "Hold it to sample while dragging; works outside the canvas too.",
action: "Pick a color with the eyedropper",
keys: platforms.map((platform) =>
platform === "macos"
? { platform, keys: ["Control", "C"], alternatives: [["I"]] }
: { platform, keys: ["I"] },
),
note: "Sampling outside the canvas works only in the Figma desktop app on macOS.",
},
{
id: "focus-toolbar",
Expand Down Expand Up @@ -158,10 +162,15 @@ export const figma: AppShortcuts = {
intro: "Select through layers, move objects, change the canvas view, and keep the right panels in reach.",
shortcuts: [
{
id: "show-layers",
action: "Show or hide the layers panel",
id: "add-auto-layout",
action: "Add auto layout to the selection",
keys: combo(platforms, ["Shift", "A"]),
},
{
id: "remove-auto-layout",
action: "Remove auto layout",
keys: combo(platforms, ["Alt", "Shift", "A"], ["Option", "Shift", "A"]),
},
{
id: "zoom-to-fit",
action: "Zoom to fit everything",
Expand Down
6 changes: 3 additions & 3 deletions lib/shortcuts/apps/google-sheets.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { combo, perPlatform } from "../build.ts";
import type { AppShortcuts } from "../types.ts";

const platforms = ["windows", "macos", "linux"] as const;
const platforms = ["windows", "macos"] as const;

export const googleSheets: AppShortcuts = {
slug: "google-sheets",
name: "Google Sheets",
category: "Spreadsheet",
platforms: [...platforms],
versionContext:
"Google's official Google Sheets Help page checked in August 2026. Google labels Windows and ChromeOS as PC; Linux browsers generally use the same PC Ctrl and Alt bindings, while browser language, keyboard layout, and compatible spreadsheet shortcuts can change behavior.",
"Google's official Google Sheets Help page checked in August 2026. Google labels Windows and ChromeOS as PC and documents Mac separately. Browser language, keyboard layout, and compatible spreadsheet shortcuts can change behavior.",
sourceUrl: "https://support.google.com/docs/answer/181110",
sourceLabel: "official Google Sheets keyboard shortcuts",
lastVerified: "2026-08-26",
groups: [
{
id: "common-actions",
title: "Common actions",
intro: "Select, edit, save, print, copy, paste, and find in the grid with the bindings Google documents for PC, Mac, and Linux browsers.",
intro: "Select, edit, save, print, copy, paste, and find in the grid with the bindings Google documents for PC and Mac.",
shortcuts: [
{ id: "select-column", action: "Select the current column", keys: perPlatform(platforms, { windows: ["Ctrl", "Space"], macos: ["Ctrl", "Space"], linux: ["Ctrl", "Space"] }) },
{ id: "select-row", action: "Select the current row", keys: combo(platforms, ["Shift", "Space"], ["Shift", "Space"]) },
Expand Down
2 changes: 1 addition & 1 deletion lib/shortcuts/apps/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const slack: AppShortcuts = {
{ id: "threads-view", action: "Open the Threads view", keys: combo(platforms, ["Ctrl", "Shift", "T"], ["Cmd", "Shift", "T"]), note: "Desktop app only." },
{ id: "channels-view", action: "Browse channels", keys: combo(platforms, ["Ctrl", "Shift", "L"], ["Cmd", "Shift", "L"]), note: "Desktop app only." },
{ id: "conversation-details", action: "Open conversation details", keys: combo(platforms, ["Ctrl", "Shift", "I"], ["Cmd", "Shift", "I"]) },
{ id: "quick-switcher", action: "Jump to a conversation", keys: combo(platforms, ["Ctrl", "K", "Ctrl", "T"]), note: "Windows and Linux desktop apps." },
{ id: "quick-switcher", action: "Jump to a conversation", keys: perPlatform(platforms, { windows: ["Ctrl", "K"], linux: ["Ctrl", "K"] }).map((entry) => ({ ...entry, alternatives: [["Ctrl", "T"]] })), note: "Ctrl+T works only in the desktop app." },
{ id: "focus-next-section", action: "Move focus to the next section", keys: combo(platforms, ["F6"]) },
{ id: "focus-previous-section", action: "Move focus to the previous section", keys: combo(platforms, ["Shift", "F6"]) },
{ id: "collapse-sidebar-sections", action: "Expand or collapse all sidebar sections", keys: perPlatform(platforms, { windows: ["Alt", "Click"], macos: ["Option", "Click"], linux: ["Alt", "Click"] }) },
Expand Down
8 changes: 3 additions & 5 deletions lib/shortcuts/catalog.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,8 +605,7 @@ export const shortcutAppCatalog = [
"category": "Communication",
"platforms": [
"windows",
"macos",
"linux"
"macos"
],
"shortcutCount": 24
},
Expand Down Expand Up @@ -682,7 +681,7 @@ export const shortcutAppCatalog = [
"macos",
"linux"
],
"shortcutCount": 28
"shortcutCount": 29
},
{
"slug": "filezilla",
Expand Down Expand Up @@ -938,8 +937,7 @@ export const shortcutAppCatalog = [
"category": "Spreadsheet",
"platforms": [
"windows",
"macos",
"linux"
"macos"
],
"shortcutCount": 110
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"self-host-apps:qa-gate": "node scripts/self-host-apps/qa-gate.mjs",
"self-host-apps:icon-integrity": "node scripts/self-host-apps/icon-integrity.mjs",
"self-host-apps:apply-qa-overrides": "node scripts/self-host-apps/apply-qa-overrides.mjs",
"self-host-apps:apply-artifact-cleanup": "node scripts/self-host-apps/apply-artifact-cleanup.mjs",
"self-host-apps:audit-summary-quality": "node scripts/self-host-apps/audit-summary-quality.mjs",
"self-host-apps:sync-prod-to-local": "node scripts/self-host-apps/sync-prod-to-local.mjs",
"self-host-apps:sync-local-to-prod": "node scripts/self-host-apps/sync-local-to-prod.mjs",
Expand Down
34 changes: 34 additions & 0 deletions scripts/platform-fees/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Platform fee records

Run from the repository root. `sync-dataset.mjs` validates `data/directory.json`
before connecting. It reports every section and entry as insert, update (with
changed field names), delete, or unchanged. Preview uses a read-only transaction.

```bash
node --experimental-strip-types scripts/platform-fees/sync-dataset.mjs --environment development
node --experimental-strip-types scripts/platform-fees/sync-dataset.mjs --environment production --confirm stack127-production
node --experimental-strip-types scripts/platform-fees/sync-dataset.mjs --environment production --confirm stack127-production --apply
```

Connections follow the shared importer: development reads `.env.local`, production
reads `.env.database`, preferring `DATABASE_POOLER_URL` over `DATABASE_URL`.
Production preview also requires the confirmation flag. No credentials are printed.

Apply locks both record tables, checks for deletions, and syncs in one transaction.
Any failure rolls back. Missing records cause a refusal before any write unless
`--allow-delete` is also passed. The wording release needs no deletions; stop and
check the preview if it shows any. This command never calls `ensurePlatformFeePages`
or inserts page rows. Existing triggers may update public dates and queue refreshes.

Changed records use the existing `stack127_touch_directory_records` triggers to
queue `platform_fees` events. The cache worker maps these to
`stack127-platform-fees` in `scripts/stack127/cache/targets.mjs`. Triggers stay enabled;
no separate tag refresh call is needed. Identical records don't queue changes.
In Cloudflare hosting mode, the worker dispatches `stack127-frontend-refresh.yml`;
wait for that run to finish and verify the changed public pages before calling the
release complete. A failed refresh leaves events queued. The import's success only
confirms the database commit. See [caching](../../stack127_docs/caching.md) and
[Cloudflare database edits](../../stack127_docs/cloudflare.md#database-edits-in-cloudflare-mode).

`sync-development.mjs` remains the development page setup command; it can create
missing page rows. Use the records-only command above for reviewed dataset imports.
Loading
Loading