mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
524c4037e2 | ||
|
|
4a9c50b9c9 | ||
|
|
3bb9d81520 | ||
|
|
f98495c4b3 | ||
|
|
b920243c74 |
+9
-6
@@ -281,23 +281,26 @@ Drafts do not appear in GitHub's releases feed. Updater clients continue to see
|
||||
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
|
||||
### Instant-admit release (rollout_hours=0 from publish)
|
||||
### Instant-admit release (rollout_hours=0)
|
||||
|
||||
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
|
||||
`npm run release:patch` and `npm run release:minor` tag-push, and tag-push runs always stamp 36 hours. To admit everyone immediately (hotfix, doc-only change, a release you want out fast), cut the release normally, wait for it to publish, then flip the manifests:
|
||||
|
||||
```bash
|
||||
# 1. Cut and publish (default 36h ramp from tag push).
|
||||
# 1. Cut and publish (36h ramp from tag push).
|
||||
npm run release:patch
|
||||
|
||||
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
|
||||
# 2. Wait for Desktop Release to publish the GitHub Release.
|
||||
gh run watch <desktop-release-run-id> --exit-status
|
||||
|
||||
# 3. Flip the published manifests to instant admit.
|
||||
gh workflow run desktop-rollout.yml \
|
||||
-f tag=v0.1.64 \
|
||||
-f rollout_hours=0
|
||||
```
|
||||
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
Dispatch only after `finalize-rollout` has published the release. Until then the release is a draft, and a draft has no tag: `desktop-rollout.yml` looks the release up by tag and fails with `release not found`. The concurrency group `desktop-rollout-<tag>` that the two workflows share orders them only once both are queued; it does not hold an early dispatch until the draft is published.
|
||||
|
||||
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
|
||||
The first public manifests carry `rolloutHours=36` for the minutes between publish and the flip. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
|
||||
### Adjusting an already-published release
|
||||
|
||||
|
||||
@@ -81,7 +81,10 @@ export async function scrollWorkspaceList(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||
const card = page.getByTestId("workspace-hover-card");
|
||||
// Moving keyboard focus can leave the previous card mounted during its exit animation.
|
||||
await expect(card).toHaveCount(1);
|
||||
await expect(card).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { within, waitFor } from "@testing-library/dom";
|
||||
import { EnrichedMarkdownText } from "react-native-enriched-markdown";
|
||||
import { expect, it } from "vitest";
|
||||
import { normalizeMarkdownHtml } from "./html";
|
||||
|
||||
const FLAGS = { hardSoftBreaks: true, latexMath: false };
|
||||
|
||||
it.each([false, true])(
|
||||
"renders table-cell breaks on separate lines (details: %s)",
|
||||
async (details) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const table =
|
||||
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
|
||||
const source = details ? `<details><summary>Results</summary>\n${table}\n</details>` : table;
|
||||
try {
|
||||
act(() =>
|
||||
root.render(
|
||||
<EnrichedMarkdownText markdown={normalizeMarkdownHtml(source)} md4cFlags={FLAGS} />,
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(within(container).getAllByRole("row")).toHaveLength(4));
|
||||
for (const row of within(container).getAllByRole("row").slice(1)) {
|
||||
const cell = within(row).getAllByRole("cell")[1];
|
||||
expect(cell.innerText).toBe("first\nsecond");
|
||||
const first = document.createRange();
|
||||
first.selectNodeContents(cell.firstChild!);
|
||||
const second = document.createRange();
|
||||
second.selectNodeContents(cell.lastChild!);
|
||||
expect(second.getBoundingClientRect().top).toBeGreaterThan(
|
||||
first.getBoundingClientRect().top,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Parser } from "htmlparser2";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { gfmTable } from "micromark-extension-gfm-table";
|
||||
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
||||
import { decodeString } from "micromark-util-decode-string";
|
||||
import type { Nodes } from "mdast";
|
||||
|
||||
@@ -34,25 +36,35 @@ const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "
|
||||
export function normalizeMarkdownHtml(source: string): string {
|
||||
if (!source.includes("<")) return source;
|
||||
const htmlRanges: { start: number; end: number }[] = [];
|
||||
const tableRanges: { start: number; end: number }[] = [];
|
||||
function findHtml(node: Nodes) {
|
||||
if (node.type === "html" && node.position) {
|
||||
htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
|
||||
} else if ("children" in node) node.children.forEach(findHtml);
|
||||
}
|
||||
findHtml(fromMarkdown(source));
|
||||
findHtml(
|
||||
fromMarkdown(source, {
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}),
|
||||
);
|
||||
const codeRanges: { start: number; end: number }[] = [];
|
||||
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
|
||||
// Disable HTML parsing so code and tables inside <details> remain visible.
|
||||
const tree = fromMarkdown(source, {
|
||||
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }],
|
||||
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }, gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
});
|
||||
function protectCode(node: Nodes) {
|
||||
function findMarkdownRanges(node: Nodes) {
|
||||
if (node.type === "table" && node.position) {
|
||||
tableRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
|
||||
}
|
||||
if (node.type === "code" || node.type === "inlineCode") {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) codeRanges.push({ start, end });
|
||||
} else if ("children" in node) node.children.forEach(protectCode);
|
||||
} else if ("children" in node) node.children.forEach(findMarkdownRanges);
|
||||
}
|
||||
protectCode(tree);
|
||||
findMarkdownRanges(tree);
|
||||
const links: (string | undefined)[] = [];
|
||||
let previousTag: { name: string; end: number } | null = null;
|
||||
const convert = (raw: string, offset: number): string => {
|
||||
@@ -84,6 +96,13 @@ export function normalizeMarkdownHtml(source: string): string {
|
||||
: /^\s*$/.test(source.slice(0, offset));
|
||||
previousTag = { name, end: offset + raw.length };
|
||||
if (EMPHASIS[name]) return EMPHASIS[name];
|
||||
if (name === "br") {
|
||||
if (closing) return "";
|
||||
// A Unicode line separator keeps the row intact; its space ends GFM autolinks.
|
||||
return tableRanges.some(({ start, end }) => offset >= start && offset < end)
|
||||
? " \u2028"
|
||||
: " \n";
|
||||
}
|
||||
const boundary = layoutBoundary(name, source, raw, offset, closing, startsAfterBoundary);
|
||||
if (boundary !== null) return boundary;
|
||||
if (name === "a") return markdownAnchor(raw, closing, links);
|
||||
@@ -168,7 +187,6 @@ function layoutBoundary(
|
||||
closing: boolean,
|
||||
startsAfterBoundary: boolean,
|
||||
): string | null {
|
||||
if (name === "br") return closing ? "" : " \n";
|
||||
if (name === "summary") return closing ? "**\n\n" : "**";
|
||||
if (name === "details")
|
||||
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
|
||||
|
||||
@@ -39,6 +39,88 @@ it("resolves authored root-relative links only when the target is a known comman
|
||||
});
|
||||
|
||||
describe("shared Markdown source adaptation", () => {
|
||||
it("keeps HTML line breaks inside their Markdown table cells", async () => {
|
||||
const source =
|
||||
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
const rows: ASTNode[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "TableRow") rows.push(node);
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(rows).toHaveLength(4);
|
||||
const text = (node: ASTNode): string =>
|
||||
(node.content ?? "") + (node.children?.map(text).join("") ?? "");
|
||||
expect(rows.map((row) => row.children?.map(text))).toEqual([
|
||||
["A", "B"],
|
||||
["1", "first \u2028second"],
|
||||
["2", "first \u2028second"],
|
||||
["3", "first \u2028second"],
|
||||
]);
|
||||
});
|
||||
it("keeps table breaks inside a details wrapper without a blank line", async () => {
|
||||
const source =
|
||||
"<details><summary>Results</summary>\n| A | B |\n|---|---|\n| 1 | first<br>second |\n</details>";
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
const tables: ASTNode[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "Table") tables.push(node);
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(tables).toHaveLength(1);
|
||||
expect(tables[0].attributes?.bodyRowCount).toBe("1");
|
||||
expect(tables[0].children?.[1]?.children?.[0]?.children?.[1]?.children).toEqual([
|
||||
{ type: "Text", content: "first \u2028second" },
|
||||
]);
|
||||
});
|
||||
it.each([
|
||||
["src/first.ts", "src/second.ts"],
|
||||
["https://example.com/first", "src/second.ts"],
|
||||
["https://example.com/first", "https://example.com/second"],
|
||||
])("keeps %s and %s separate across a table break", async (first, second) => {
|
||||
const prepared = prepareMarkdownSource(
|
||||
`| A | B |\n|---|---|\n| 1 | ${first}<br>${second} |`,
|
||||
"/repo",
|
||||
);
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { hardSoftBreaks: true, latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const links: string[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "Link") links.push(String(node.attributes?.url));
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(links).toEqual([first, second]);
|
||||
});
|
||||
it.each([
|
||||
["| A<br>B | C |\n|---|---|\n| x | y |", "| A \u2028B | C |\n|---|---|\n| x | y |"],
|
||||
[
|
||||
"> | A | B |\n> |---|---|\n> | x | first<br>second |",
|
||||
"> | A | B |\n> |---|---|\n> | x | first \u2028second |",
|
||||
],
|
||||
[
|
||||
"| A | B |\n|---|---|\n| `<br>` | **first<br><BR />second** |",
|
||||
"| A | B |\n|---|---|\n| `<br>` | **first \u2028 \u2028second** |",
|
||||
],
|
||||
[
|
||||
"| A | B |\n|---|---|\n| x | \\<br> <br> |",
|
||||
"| A | B |\n|---|---|\n| x | \\<br> <br> |",
|
||||
],
|
||||
["Not a table | first<br>second", "Not a table | first \nsecond"],
|
||||
])("normalizes table breaks without changing literal tags: %s", (source, expected) => {
|
||||
expect(normalizeMarkdownHtml(source)).toBe(expected);
|
||||
});
|
||||
it("keeps paragraphs, fences and quotes together for the library", () => {
|
||||
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
expect(prepareMarkdownSource(source).render()).toBe(source);
|
||||
|
||||
@@ -10,9 +10,10 @@ import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
|
||||
|
||||
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
|
||||
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
|
||||
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\uFFFF_@.%+-]`;
|
||||
// U+2028 is an inline line break, including between table-cell file links.
|
||||
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%+-]`;
|
||||
const PATH_LETTER = String.raw`[a-zA-Z]`;
|
||||
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\uFFFF_@%+-]`;
|
||||
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+-]`;
|
||||
const LINE_SUFFIX = String.raw`(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?`;
|
||||
const FILE_EXTENSIONS = [...ASSISTANT_FILE_EXTENSIONS]
|
||||
.map((extension) => extension.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`))
|
||||
@@ -29,7 +30,7 @@ function createTextPattern(path: string, forgeReferencePattern?: string): RegExp
|
||||
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
|
||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||
return new RegExp(
|
||||
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
"g",
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user