diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/e2e/test1.spec.ts b/e2e/test1.spec.ts index e1de6faa..f9c75b08 100644 --- a/e2e/test1.spec.ts +++ b/e2e/test1.spec.ts @@ -60,6 +60,7 @@ test.describe("Upgrades & token transfer flow", () => { .getByPlaceholder("Repeat your seed phrase...") .fill(mkPwd("eve")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); const stalwartPrincipal = "v5znh-suak4-idmlq-uaq6k-iiygt-7d7de-jq7pf-dpzmt-zhmle-akfo2-mqe"; await expect(page.getByText(stalwartPrincipal)).toBeVisible(); @@ -104,6 +105,7 @@ test.describe("Upgrades & token transfer flow", () => { .getByPlaceholder("Repeat your seed phrase...") .fill(mkPwd("pete")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); await page.getByPlaceholder("alphanumeric").fill("pete"); await page.getByRole("button", { name: "SAVE" }).click(); await waitForUILoading(page); @@ -178,6 +180,7 @@ test.describe("Upgrades & token transfer flow", () => { .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("eve")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); await expect( page.getByRole("heading", { name: "RECOVERY" }), diff --git a/e2e/test2.spec.ts b/e2e/test2.spec.ts index 2e2e4f7c..f94d8978 100644 --- a/e2e/test2.spec.ts +++ b/e2e/test2.spec.ts @@ -28,6 +28,7 @@ test.describe("Regular users flow", () => { .getByPlaceholder("Repeat your seed phrase...") .fill(mkPwd("alice")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); const alicePrincipal = "xkqsg-2iln4-5zio6-xn4ja-s34n3-g63uk-kc6ex-wklca-7kfzz-67won-yqe"; await expect(page.getByText(alicePrincipal)).toBeVisible(); @@ -259,6 +260,7 @@ test.describe("Regular users flow", () => { .getByPlaceholder("Repeat your seed phrase...") .fill(mkPwd("bob")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); await page.getByPlaceholder("alphanumeric").fill("bob"); await page .getByPlaceholder("tell us what we should know about you") diff --git a/e2e/test3.spec.ts b/e2e/test3.spec.ts index 019b3f95..474ecf2e 100644 --- a/e2e/test3.spec.ts +++ b/e2e/test3.spec.ts @@ -25,6 +25,7 @@ test.describe("Regular users flow, part two", () => { .getByPlaceholder("Repeat your seed phrase...") .fill(mkPwd("john")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); await page .getByRole("button", { name: "MINT CREDITS WITH ICP" }) .click(); @@ -134,6 +135,7 @@ test.describe("Regular users flow, part two", () => { .getByPlaceholder("Repeat your seed phrase...") .fill(mkPwd("eye")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); await page .getByRole("button", { name: "MINT CREDITS WITH ICP" }) .click(); diff --git a/e2e/test4.spec.ts b/e2e/test4.spec.ts index 522e2304..1bb11414 100644 --- a/e2e/test4.spec.ts +++ b/e2e/test4.spec.ts @@ -32,6 +32,7 @@ test.describe("Report and transfer to user", () => { .getByPlaceholder("Repeat your seed phrase...") .fill(mkPwd("joe")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); transferICP( "e93e7f1cfa411dafa8debb4769c6cc1b7972434f1669083fd08d86d11c0c0722", 1, @@ -77,6 +78,7 @@ test.describe("Report and transfer to user", () => { .getByPlaceholder("Repeat your seed phrase...") .fill(mkPwd("jane")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); await page.getByPlaceholder("alphanumeric").fill("jane"); await page.getByRole("button", { name: "SAVE" }).click(); await waitForUILoading(page); @@ -101,6 +103,7 @@ test.describe("Report and transfer to user", () => { .getByPlaceholder("Repeat your seed phrase...") .fill(mkPwd("kyle")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); await page.getByPlaceholder("alphanumeric").fill("kyle"); await page.getByRole("button", { name: "SAVE" }).click(); await waitForUILoading(page); @@ -219,6 +222,7 @@ test.describe("Report and transfer to user", () => { .getByPlaceholder("Enter your seed phrase...") .fill(mkPwd("jane")); await page.getByRole("button", { name: "CONTINUE" }).click(); + await waitForUILoading(page); await page.goto("/#/user/kyle"); await page.reload(); await waitForUILoading(page); diff --git a/package.json b/package.json index 42423707..3907342c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "raw-loader": "4.0.2", "react": "19.0.0", "react-dom": "19.0.0", - "react-markdown": "10.1.0", "terser-webpack-plugin": "^5.3.10", "ts-loader": "9.5.0", "typescript": "5.8.3", @@ -49,7 +48,6 @@ "@dfinity/identity": "2.4.1", "@dfinity/ledger-icrc": "2.8.0", "@dfinity/principal": "2.4.1", - "diff-match-patch": "1.0.5", - "remark-gfm": "4.0.1" + "diff-match-patch": "1.0.5" } } diff --git a/src/backend/env/auction.rs b/src/backend/env/auction.rs index 1159e585..3e6f86c4 100644 --- a/src/backend/env/auction.rs +++ b/src/backend/env/auction.rs @@ -168,18 +168,23 @@ pub async fn cancel_bid(principal: Principal) -> Result { .checked_sub(DEFAULT_FEE.e8s()) .expect("nothing to refund"); - invoices::transfer( + if let Err(err) = invoices::transfer( user_account, Tokens::from_e8s(funds), Memo(727), Some(Subaccount(AUCTION_ICP_SUBACCOUNT)), ) .await - .map_err(|err| { + { let msg = format!("couldn't withdraw funds from bid {:?}: {}", bid, err); - mutate(|state| state.logger.error(&msg)); - msg - }) + mutate(|state| { + state.logger.error(&msg); + state.auction.bids.insert(bid); + }); + return Err(msg); + } + + Ok(funds) } fn remove_bid(state: &mut State, principal: Principal) -> Result { @@ -249,7 +254,7 @@ fn add_bid( assert!( !state.auction.bids.iter().any(|bid| bid.user == user_id), - "no bids exist for the user" + "bid already exists for the user" ); state.auction.bids.insert(Bid { diff --git a/src/backend/env/features.rs b/src/backend/env/features.rs index e69966c3..c7b975ad 100644 --- a/src/backend/env/features.rs +++ b/src/backend/env/features.rs @@ -112,7 +112,7 @@ pub fn create_feature(caller: Principal, post_id: PostId, now: Time) -> Result<( let _ = state.system_message( format!( - "A [new feature](#/post/{}) was created by `@{}`", + "A [new feature](#/post/{}) was created by @{}", post_id, user_name ), CONFIG.dao_realm.into(), diff --git a/src/backend/env/mod.rs b/src/backend/env/mod.rs index a2fb3b25..bb7769df 100644 --- a/src/backend/env/mod.rs +++ b/src/backend/env/mod.rs @@ -240,9 +240,11 @@ pub struct State { pub timers: Timers, - #[serde(default)] // Maps temporal session principals (delegates) created on custom domains to canonical user principals. delegations: HashMap, + + #[serde(default)] + pub cold_wallets: HashMap, } #[derive(Default, Deserialize, Serialize)] @@ -380,9 +382,10 @@ impl State { self.weekly_chores_delay_votes.insert(user.id); - if self.weekly_chores_delay_votes.len() * 100 - / self.users.values().filter(|user| user.stalwart).count() - >= CONFIG.report_confirmation_percentage as usize + let stalwart_count = self.users.values().filter(|user| user.stalwart).count(); + if stalwart_count > 0 + && self.weekly_chores_delay_votes.len() * 100 / stalwart_count + >= CONFIG.report_confirmation_percentage as usize { self.timers.last_weekly += WEEK; self.logger.info(format!( @@ -412,7 +415,7 @@ impl State { } pub fn link_cold_wallet(&mut self, caller: Principal, user_id: UserId) -> Result<(), String> { - if self.principal_to_user(caller).is_some() { + if self.principal_to_user(caller).is_some() || self.cold_wallets.contains_key(&caller) { return Err("this wallet is linked already".into()); } let user = self.users.get_mut(&user_id).ok_or("user not found")?; @@ -425,7 +428,7 @@ impl State { .get(&account(caller)) .copied() .unwrap_or_default(); - self.principals.insert(caller, user.id); + self.cold_wallets.insert(caller, user.id); Ok(()) } @@ -437,7 +440,7 @@ impl State { let principal = user.cold_wallet.take(); user.cold_balance = 0; if let Some(principal) = principal { - self.principals.remove(&principal); + self.cold_wallets.remove(&principal); } } Ok(()) @@ -1655,7 +1658,7 @@ impl State { let _ = state.system_message( format!( - "`@{}` is the lucky receiver of `{}` ${} as a weekly random reward! 🎲", + "@{} is the lucky receiver of `{}` ${} as a weekly random reward! 🎲", winner_name, CONFIG.random_reward_amount / base(), CONFIG.token_symbol, @@ -3062,8 +3065,9 @@ pub(crate) mod tests { assert_eq!(user.total_balance(), 80000); assert_eq!(state.principals.len(), 3); state.link_cold_wallet(pr(200), 1).unwrap(); - assert_eq!(state.principals.len(), 4); - assert_eq!(state.principal_to_user(pr(200)).unwrap().id, 1); + assert_eq!(state.principals.len(), 3); + assert_eq!(state.cold_wallets.get(&pr(200)), Some(&1)); + assert!(state.principal_to_user(pr(200)).is_none()); let user = state.users.get(&1).unwrap(); assert_eq!(user.total_balance(), 80000 + cold_balance); assert_eq!( @@ -3073,18 +3077,19 @@ pub(crate) mod tests { let voters = state.active_voters(0).collect::>(); assert_eq!(*voters.get(&1).unwrap(), (2 << 2) * 10000 + cold_balance); - state.emergency_votes.insert(pr(200), 1000); + state.emergency_votes.insert(pr(1), 1000); assert_eq!( - state.unlink_cold_wallet(pr(200)), + state.unlink_cold_wallet(pr(1)), Err("a vote on a pending proposal detected".into()) ); state.emergency_votes.clear(); - assert!(state.unlink_cold_wallet(pr(200)).is_ok(),); + assert!(state.unlink_cold_wallet(pr(1)).is_ok(),); let user = state.principal_to_user(pr(1)).unwrap(); assert_eq!(user.id, 1); assert!(user.cold_wallet.is_none()); assert_eq!(state.principals.len(), 3); + assert!(state.cold_wallets.is_empty()); let voters = state.active_voters(0).collect::>(); assert_eq!(*voters.get(&1).unwrap(), (2 << 2) * 10000); @@ -3755,7 +3760,7 @@ pub(crate) mod tests { assert_eq!(state.user("2").unwrap().id, u3); assert!(state.user("user22").is_none()); assert_eq!(state.user(&pr(2).to_text()).unwrap().id, u3); - assert_eq!(state.user(&cold_wallet.to_text()).unwrap().id, u2); + assert!(state.user(&cold_wallet.to_text()).is_none()); }); } diff --git a/src/backend/env/post.rs b/src/backend/env/post.rs index 3eb0c640..db9280a3 100644 --- a/src/backend/env/post.rs +++ b/src/backend/env/post.rs @@ -245,7 +245,7 @@ impl Post { } poll.voters.insert(user_id); poll.votes.entry(vote).or_default().insert(if anonymously { - MAX_USER_ID - 1 + MAX_USER_ID - poll.voters.len() as u64 } else { user_id }); diff --git a/src/backend/env/proposals.rs b/src/backend/env/proposals.rs index 571928a3..07abc76a 100644 --- a/src/backend/env/proposals.rs +++ b/src/backend/env/proposals.rs @@ -443,7 +443,7 @@ pub fn create_proposal( .expect("couldn't mutate post"); let _ = state.system_message( format!( - "A new [proposal](#/post/{}) was submitted by `@{}`", + "A new [proposal](#/post/{}) was submitted by @{}", post_id, &proposer_name ), CONFIG.dao_realm.into(), diff --git a/src/backend/env/token.rs b/src/backend/env/token.rs index c6219c94..40491a3e 100644 --- a/src/backend/env/token.rs +++ b/src/backend/env/token.rs @@ -557,15 +557,17 @@ pub fn append_to_ledger(state: &mut State, mut tx: Transaction) -> u128 { } fn update_user_balance(state: &mut State, principal: Principal, balance: Token) { - let Some(user) = state.principal_to_user_mut(principal) else { + if let Some(user) = state.principal_to_user_mut(principal) { + user.balance = balance; return; - }; - if user.principal == principal { - user.balance = balance - } else if user.cold_wallet == Some(principal) { - user.cold_balance = balance - } else { - unreachable!("unidentifiable principal") + } + if let Some(user) = state + .cold_wallets + .get(&principal) + .copied() + .and_then(|id| state.users.get_mut(&id)) + { + user.cold_balance = balance; } } diff --git a/src/backend/updates.rs b/src/backend/updates.rs index d6e3a55a..696d8e02 100644 --- a/src/backend/updates.rs +++ b/src/backend/updates.rs @@ -109,7 +109,17 @@ fn post_upgrade() { } #[allow(clippy::all)] -fn sync_post_upgrade_fixtures() {} +fn sync_post_upgrade_fixtures() { + mutate(|state| { + // Migrate cold wallet principals from `principals` to `cold_wallets` + for user in state.users.values() { + if let Some(cold_wallet) = user.cold_wallet { + state.cold_wallets.insert(cold_wallet, user.id); + state.principals.remove(&cold_wallet); + } + } + }); +} #[allow(clippy::all)] async fn async_post_upgrade_fixtures() {} @@ -654,8 +664,11 @@ async fn set_emergency_release(binary: ByteBuf) { if binary.is_empty() || state .principal_to_user(raw_caller(state).unwrap()) - .map(|user| user.account_age(WEEK) < CONFIG.min_stalwart_account_age_weeks) - .unwrap_or_default() + .map(|user| { + user.account_age(WEEK) < CONFIG.min_stalwart_account_age_weeks + || user.total_balance() < 2000 * token::base() + }) + .unwrap_or(true) { return; } diff --git a/src/frontend/src/common.tsx b/src/frontend/src/common.tsx index 4f560e67..94bb3329 100755 --- a/src/frontend/src/common.tsx +++ b/src/frontend/src/common.tsx @@ -600,7 +600,8 @@ export const Loading = ({ const [dot, setDot] = React.useState(0); const md = ■ ; React.useEffect(() => { - setTimeout(() => setDot(dot + 1), 200); + const id = setTimeout(() => setDot(dot + 1), 200); + return () => clearTimeout(id); }, [dot]); return (
); @@ -220,9 +218,8 @@ const markdownizer = ( ) => !value ? null : (
- { if (!blogTitle) return

{children}

; diff --git a/src/frontend/src/form.tsx b/src/frontend/src/form.tsx index 0605fa99..57b38e65 100644 --- a/src/frontend/src/form.tsx +++ b/src/frontend/src/form.tsx @@ -93,6 +93,10 @@ export const Form = ({ const [suggestedRealms, setSuggestedRealms] = React.useState([]); const [choresTimer, setChoresTimer] = React.useState(null); const [cursor, setCursor] = React.useState(0); + + React.useEffect(() => { + return () => Object.values(tmpUrls).forEach(URL.revokeObjectURL); + }, []); const [proposalValidationError, setProposalValidationError] = React.useState(""); const textarea = React.useRef(); @@ -816,9 +820,13 @@ export const loadFile = (file: any): Promise => { const loadImage = (blob: Uint8Array): Promise => { const image = new Image(); + const url = blobToUrl(blob); return new Promise((resolve) => { - image.onload = () => resolve(image); - image.src = blobToUrl(blob); + image.onload = () => { + URL.revokeObjectURL(url); + resolve(image); + }; + image.src = url; }); }; diff --git a/src/frontend/src/markdown.tsx b/src/frontend/src/markdown.tsx new file mode 100644 index 00000000..3b33705e --- /dev/null +++ b/src/frontend/src/markdown.tsx @@ -0,0 +1,816 @@ +import * as React from "react"; + +// --- Types --- + +type Alignment = "left" | "center" | "right"; + +type Block = + | { type: "heading"; level: number; content: string } + | { type: "paragraph"; content: string } + | { type: "code"; lang: string; content: string } + | { type: "blockquote"; blocks: Block[] } + | { + type: "list"; + ordered: boolean; + start: number; + items: Block[][]; + } + | { + type: "table"; + headers: string[]; + aligns: (Alignment | null)[]; + rows: string[][]; + } + | { type: "hr" } + | { type: "details"; summary: string; blocks: Block[] }; + +interface Components { + [key: string]: React.ComponentType; +} + +interface MarkdownProps { + children: string; + components?: Components; + remarkPlugins?: any[]; +} + +// --- Entity Decoding --- + +const NAMED_ENTITIES: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: "\u00A0", + copy: "\u00A9", + mdash: "\u2014", + ndash: "\u2013", + hellip: "\u2026", + middot: "\u00B7", + bull: "\u2022", + laquo: "\u00AB", + raquo: "\u00BB", +}; + +const decodeEntities = (text: string): string => + text.replace( + /&(?:#(\d+)|#x([0-9a-fA-F]+)|(\w+));/g, + (match, dec, hex, named) => { + if (dec) return String.fromCodePoint(parseInt(dec)); + if (hex) return String.fromCodePoint(parseInt(hex, 16)); + if (named) return NAMED_ENTITIES[named] || match; + return match; + }, + ); + +// --- Inline Parsing Helpers --- + +const findClosingBracket = (text: string, open: number): number => { + let depth = 0; + for (let i = open; i < text.length; i++) { + if (text[i] === "\\") { + i++; + continue; + } + if (text[i] === "[") depth++; + if (text[i] === "]") { + depth--; + if (depth === 0) return i; + } + } + return -1; +}; + +const findClosingParen = (text: string, open: number): number => { + let depth = 0; + for (let i = open; i < text.length; i++) { + if (text[i] === "\\") { + i++; + continue; + } + if (text[i] === "(") depth++; + if (text[i] === ")") { + depth--; + if (depth === 0) return i; + } + } + return -1; +}; + +const findDelimiter = (text: string, start: number, delim: string): number => { + for (let i = start; i < text.length; i++) { + if (text[i] === "\\") { + i++; + continue; + } + if (text[i] === "`") { + const end = text.indexOf("`", i + 1); + if (end !== -1) { + i = end; + continue; + } + } + if (text.startsWith(delim, i)) return i; + } + return -1; +}; + +const findSingleDelimiter = ( + text: string, + start: number, + ch: string, +): number => { + for (let i = start; i < text.length; i++) { + if (text[i] === "\\") { + i++; + continue; + } + if (text[i] === "`") { + const end = text.indexOf("`", i + 1); + if (end !== -1) { + i = end; + continue; + } + } + if ( + text[i] === ch && + (i === 0 || text[i - 1] !== ch) && + (i + 1 >= text.length || text[i + 1] !== ch) + ) + return i; + } + return -1; +}; + +// --- Inline Parser --- + +const parseInline = (text: string, comps: Components): React.ReactNode[] => { + if (!text) return []; + const result: React.ReactNode[] = []; + let buf = ""; + let i = 0; + let k = 0; + + const flush = () => { + if (buf) { + result.push(decodeEntities(buf)); + buf = ""; + } + }; + + const el = ( + tag: string, + props: Record, + ...children: React.ReactNode[] + ) => { + const key = k++; + const Comp = comps[tag]; + if (Comp) + return children.length > 0 + ? React.createElement( + Comp, + { key, node: undefined, ...props }, + ...children, + ) + : React.createElement(Comp, { + key, + node: undefined, + ...props, + }); + return children.length > 0 + ? React.createElement(tag, { key, ...props }, ...children) + : React.createElement(tag, { key, ...props }); + }; + + while (i < text.length) { + const ch = text[i]; + + // Escape + if ( + ch === "\\" && + i + 1 < text.length && + "\\`*_~[]!|".includes(text[i + 1]) + ) { + buf += text[i + 1]; + i += 2; + continue; + } + + // Hard line break: two+ spaces before newline + if (ch === " " && text[i + 1] === " ") { + let j = i + 2; + while (j < text.length && text[j] === " ") j++; + if (text[j] === "\n") { + flush(); + result.push(React.createElement("br", { key: k++ })); + i = j + 1; + continue; + } + } + + // HTML
+ if (ch === "<") { + const brMatch = text.slice(i).match(/^/i); + if (brMatch) { + flush(); + result.push(React.createElement("br", { key: k++ })); + i += brMatch[0].length; + continue; + } + } + + // Image: ![alt](src) + if (ch === "!" && text[i + 1] === "[") { + const altEnd = findClosingBracket(text, i + 1); + if (altEnd !== -1 && text[altEnd + 1] === "(") { + const srcEnd = findClosingParen(text, altEnd + 1); + if (srcEnd !== -1) { + flush(); + const alt = text.slice(i + 2, altEnd); + const src = text.slice(altEnd + 2, srcEnd); + result.push(el("img", { src, alt })); + i = srcEnd + 1; + continue; + } + } + } + + // Link: [text](url) + if (ch === "[") { + const textEnd = findClosingBracket(text, i); + if (textEnd !== -1 && text[textEnd + 1] === "(") { + const urlEnd = findClosingParen(text, textEnd + 1); + if (urlEnd !== -1) { + flush(); + const linkText = text.slice(i + 1, textEnd); + const href = text.slice(textEnd + 2, urlEnd); + const children = parseInline(linkText, comps); + result.push(el("a", { href }, ...children)); + i = urlEnd + 1; + continue; + } + } + } + + // Autolink: bare URLs + if ( + (text.startsWith("https://", i) || + text.startsWith("http://", i) || + text.startsWith("www.", i)) && + (i === 0 || " \t\n(".includes(text[i - 1])) + ) { + const urlMatch = text + .slice(i) + .match( + /^(https?:\/\/[^\s<>\[\]]*[^\s<>\[\].,;:!?)\]}'"']|www\.[^\s<>\[\]]*[^\s<>\[\].,;:!?)\]}'"'])/, + ); + if (urlMatch) { + flush(); + const url = urlMatch[1]; + const href = url.startsWith("www.") ? "https://" + url : url; + result.push(el("a", { href }, url)); + i += url.length; + continue; + } + } + + // Code span + if (ch === "`") { + let ticks = 0; + let j = i; + while (j < text.length && text[j] === "`") { + ticks++; + j++; + } + const closer = text.indexOf("`".repeat(ticks), j); + if (closer !== -1) { + flush(); + let code = text.slice(j, closer); + if ( + ticks > 1 && + code.length > 1 && + code[0] === " " && + code[code.length - 1] === " " + ) + code = code.slice(1, -1); + result.push(el("code", {}, code)); + i = closer + ticks; + continue; + } + } + + // Bold + Italic: ***text*** + if (text.startsWith("***", i)) { + const end = findDelimiter(text, i + 3, "***"); + if (end !== -1) { + flush(); + const inner = parseInline(text.slice(i + 3, end), comps); + result.push( + React.createElement( + "strong", + { key: k++ }, + React.createElement("em", { key: k++ }, ...inner), + ), + ); + i = end + 3; + continue; + } + } + + // Bold: **text** + if (text.startsWith("**", i)) { + const end = findDelimiter(text, i + 2, "**"); + if (end !== -1) { + flush(); + const inner = parseInline(text.slice(i + 2, end), comps); + result.push( + React.createElement("strong", { key: k++ }, ...inner), + ); + i = end + 2; + continue; + } + } + + // Strikethrough: ~~text~~ + if (text.startsWith("~~", i)) { + const end = findDelimiter(text, i + 2, "~~"); + if (end !== -1) { + flush(); + const inner = parseInline(text.slice(i + 2, end), comps); + result.push(React.createElement("del", { key: k++ }, ...inner)); + i = end + 2; + continue; + } + } + + // Italic: *text* + if (ch === "*" && !text.startsWith("**", i)) { + const end = findSingleDelimiter(text, i + 1, "*"); + if (end !== -1) { + flush(); + const inner = parseInline(text.slice(i + 1, end), comps); + result.push(React.createElement("em", { key: k++ }, ...inner)); + i = end + 1; + continue; + } + } + + // Italic: _text_ (only at word boundary) + if ( + ch === "_" && + !text.startsWith("__", i) && + (i === 0 || /\s/.test(text[i - 1])) + ) { + const end = findSingleDelimiter(text, i + 1, "_"); + if ( + end !== -1 && + (end + 1 >= text.length || /[\s.,;:!?)\]}]/.test(text[end + 1])) + ) { + flush(); + const inner = parseInline(text.slice(i + 1, end), comps); + result.push(React.createElement("em", { key: k++ }, ...inner)); + i = end + 1; + continue; + } + } + + buf += ch; + i++; + } + + flush(); + return result; +}; + +// --- Block Parsing Helpers --- + +const parseCells = (line: string): string[] => + line + .replace(/^\||\|$/g, "") + .split("|") + .map((cell) => cell.trim()); + +const parseAlignments = (line: string): (Alignment | null)[] => + line + .replace(/^\||\|$/g, "") + .split("|") + .map((cell) => { + const c = cell.trim(); + const left = c.startsWith(":"); + const right = c.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return null; + }); + +const isBlockStart = (line: string, nextLine?: string): boolean => { + const t = line.trim(); + if (/^#{1,6}\s/.test(t)) return true; + if (/^(?:[-*_]\s*){3,}$/.test(t)) return true; + if (t.startsWith("```") || t.startsWith("~~~")) return true; + if (t.startsWith("> ") || t === ">") return true; + if (/^[-*+] /.test(t)) return true; + if (/^\d+[.)] /.test(t)) return true; + if ( + t.startsWith("|") && + nextLine && + /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(nextLine.trim()) + ) + return true; + if (/^]/i.test(t)) return true; + return false; +}; + +// --- Block Parser --- + +const parseBlocks = (input: string): Block[] => { + const lines = input.split("\n"); + const blocks: Block[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + // Empty line + if (trimmed === "") { + i++; + continue; + } + + // Fenced code block + const fenceMatch = trimmed.match(/^(`{3,}|~{3,})(.*)/); + if (fenceMatch) { + const fence = fenceMatch[1][0].repeat(fenceMatch[1].length); + const lang = fenceMatch[2].trim(); + const codeLines: string[] = []; + i++; + while (i < lines.length) { + if ( + lines[i].trim().startsWith(fence) && + lines[i].trim().length <= fence.length + 2 + ) { + i++; + break; + } + codeLines.push(lines[i]); + i++; + } + blocks.push({ type: "code", lang, content: codeLines.join("\n") }); + continue; + } + + // ATX heading + const headingMatch = trimmed.match(/^(#{1,6})\s+(.*?)(?:\s+#+\s*)?$/); + if (headingMatch) { + blocks.push({ + type: "heading", + level: headingMatch[1].length, + content: headingMatch[2], + }); + i++; + continue; + } + + // Horizontal rule + if (/^(?:[-*_]\s*){3,}$/.test(trimmed) && !/^[-*+] \S/.test(trimmed)) { + blocks.push({ type: "hr" }); + i++; + continue; + } + + // Table + if (trimmed.includes("|") && i + 1 < lines.length) { + const nextTrimmed = lines[i + 1]?.trim() || ""; + if ( + /^\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test( + nextTrimmed, + ) + ) { + const headers = parseCells(line); + const aligns = parseAlignments(lines[i + 1]); + const rows: string[][] = []; + i += 2; + while ( + i < lines.length && + lines[i].trim() !== "" && + lines[i].includes("|") + ) { + rows.push(parseCells(lines[i])); + i++; + } + blocks.push({ type: "table", headers, aligns, rows }); + continue; + } + } + + // Blockquote + if (trimmed.startsWith(">")) { + const quoteLines: string[] = []; + while (i < lines.length) { + const t = lines[i].trim(); + if (t.startsWith("> ")) quoteLines.push(t.slice(2)); + else if (t === ">") quoteLines.push(""); + else break; + i++; + } + blocks.push({ + type: "blockquote", + blocks: parseBlocks(quoteLines.join("\n")), + }); + continue; + } + + // List + const listMatch = line.match(/^(\s*)([-*+]|\d+[.)]) /); + if (listMatch) { + const ordered = /\d/.test(listMatch[2]); + const startNum = ordered ? parseInt(listMatch[2]) : 1; + const baseIndent = listMatch[1].length; + const markerLen = listMatch[2].length + 1; + const items: string[][] = []; + let currentItem: string[] = []; + + while (i < lines.length) { + const li = lines[i]; + const itemMatch = li.match(/^(\s*)([-*+]|\d+[.)]) (.*)/); + if (itemMatch && itemMatch[1].length === baseIndent) { + if (currentItem.length) items.push([...currentItem]); + currentItem = [itemMatch[3]]; + i++; + } else if (li.trim() === "") { + if ( + i + 1 < lines.length && + lines[i + 1].match( + new RegExp(`^\\s{${baseIndent}}([-*+]|\\d+[.)]) `), + ) + ) { + currentItem.push(""); + i++; + continue; + } + break; + } else if ( + li.length > baseIndent + markerLen && + /^\s+/.test(li) && + li.search(/\S/) >= baseIndent + markerLen + ) { + currentItem.push(li.slice(baseIndent + markerLen)); + i++; + } else { + break; + } + } + if (currentItem.length) items.push(currentItem); + blocks.push({ + type: "list", + ordered, + start: startNum, + items: items.map((ls) => parseBlocks(ls.join("\n"))), + }); + continue; + } + + // Details/Summary + if (/^]/i.test(trimmed)) { + const contentLines: string[] = [trimmed]; + i++; + while (i < lines.length) { + contentLines.push(lines[i]); + if (/<\/details\s*>/i.test(lines[i])) { + i++; + break; + } + i++; + } + const raw = contentLines.join("\n"); + const summaryMatch = raw.match( + /]*>([\s\S]*?)<\/summary\s*>/i, + ); + const summary = summaryMatch ? summaryMatch[1].trim() : ""; + let inner = raw + .replace(/<\/?details[^>]*>/gi, "") + .replace(/]*>[\s\S]*?<\/summary\s*>/i, "") + .trim(); + blocks.push({ + type: "details", + summary, + blocks: parseBlocks(inner), + }); + continue; + } + + // Paragraph + const paraLines: string[] = []; + while ( + i < lines.length && + lines[i].trim() !== "" && + !isBlockStart(lines[i], lines[i + 1]) + ) { + paraLines.push(lines[i]); + i++; + } + if (paraLines.length) { + blocks.push({ + type: "paragraph", + content: paraLines.join("\n"), + }); + } else { + i++; + } + } + + return blocks; +}; + +// --- Block Renderer --- + +const renderBlock = ( + block: Block, + comps: Components, + key: number, +): React.ReactNode => { + const el = ( + tag: string, + props: Record, + ...children: React.ReactNode[] + ) => { + const Comp = comps[tag]; + if (Comp) + return children.length > 0 + ? React.createElement( + Comp, + { key, node: undefined, ...props }, + ...children, + ) + : React.createElement(Comp, { + key, + node: undefined, + ...props, + }); + return children.length > 0 + ? React.createElement(tag, { key, ...props }, ...children) + : React.createElement(tag, { key, ...props }); + }; + + switch (block.type) { + case "heading": { + const tag = `h${block.level}` as string; + const children = parseInline(block.content, comps); + return el(tag, {}, ...children); + } + case "paragraph": { + const children = parseInline(block.content, comps); + return el("p", {}, ...children); + } + case "code": + return React.createElement( + "pre", + { key }, + React.createElement( + "code", + block.lang + ? { className: `language-${block.lang}` } + : undefined, + block.content, + ), + ); + case "blockquote": + return el( + "blockquote", + {}, + ...block.blocks.map((b, i) => renderBlock(b, comps, i)), + ); + case "hr": + return el("hr", {}); + case "list": { + const tag = block.ordered ? "ol" : "ul"; + const listProps: any = {}; + if (block.ordered && block.start !== 1) + listProps.start = block.start; + return React.createElement( + tag, + { key, ...listProps }, + block.items.map((itemBlocks, i) => { + // Task list detection + const first = itemBlocks[0]; + let taskChecked: boolean | null = null; + let adjustedBlocks = itemBlocks; + if (first && first.type === "paragraph") { + const cbMatch = + first.content.match(/^\[([ xX])\]\s(.*)/s); + if (cbMatch) { + taskChecked = cbMatch[1] !== " "; + adjustedBlocks = [ + { + type: "paragraph" as const, + content: cbMatch[2], + }, + ...itemBlocks.slice(1), + ]; + } + } + const liChildren = adjustedBlocks.map((b, j) => { + if ( + adjustedBlocks.length === 1 && + b.type === "paragraph" + ) + return React.createElement( + React.Fragment, + { key: j }, + ...parseInline(b.content, comps), + ); + return renderBlock(b, comps, j); + }); + if (taskChecked !== null) { + liChildren.unshift( + React.createElement("input", { + key: "cb", + type: "checkbox", + checked: taskChecked, + disabled: true, + readOnly: true, + }), + " ", + ); + } + return React.createElement("li", { key: i }, ...liChildren); + }), + ); + } + case "details": + return React.createElement( + "details", + { key }, + React.createElement( + "summary", + null, + ...parseInline(block.summary, comps), + ), + ...block.blocks.map((b, i) => renderBlock(b, comps, i)), + ); + case "table": + return React.createElement( + "table", + { key }, + React.createElement( + "thead", + null, + React.createElement( + "tr", + null, + block.headers.map((h, i) => + React.createElement( + "th", + { + key: i, + style: block.aligns[i] + ? { textAlign: block.aligns[i]! } + : undefined, + }, + ...parseInline(h, comps), + ), + ), + ), + ), + React.createElement( + "tbody", + null, + block.rows.map((row, ri) => + React.createElement( + "tr", + { key: ri }, + row.map((cell, ci) => + React.createElement( + "td", + { + key: ci, + style: block.aligns[ci] + ? { textAlign: block.aligns[ci]! } + : undefined, + }, + ...parseInline(cell, comps), + ), + ), + ), + ), + ), + ); + } +}; + +// --- Component --- + +const Markdown = ({ children, components = {} }: MarkdownProps) => { + const blocks = React.useMemo(() => parseBlocks(children || ""), [children]); + return React.useMemo( + () => ( + <>{blocks.map((block, i) => renderBlock(block, components, i))} + ), + [blocks, components], + ); +}; + +export default Markdown;