mirror of
https://github.com/oven-sh/bun
synced 2026-03-05 06:51:08 +01:00
Compare commits
6 Commits
claude/fix
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3832c8570a | ||
|
|
663a4cc101 | ||
|
|
2dc1533291 | ||
|
|
9f9b681917 | ||
|
|
6c7e97231b | ||
|
|
32edef77e9 |
@@ -4,227 +4,87 @@ if(NOT BUILDKITE_CACHE OR NOT BUN_LINK_ONLY)
|
||||
return()
|
||||
endif()
|
||||
|
||||
optionx(BUILDKITE_ORGANIZATION_SLUG STRING "The organization slug to use on Buildkite" DEFAULT "bun")
|
||||
optionx(BUILDKITE_PIPELINE_SLUG STRING "The pipeline slug to use on Buildkite" DEFAULT "bun")
|
||||
optionx(BUILDKITE_BUILD_ID STRING "The build ID (UUID) to use on Buildkite")
|
||||
optionx(BUILDKITE_BUILD_NUMBER STRING "The build number to use on Buildkite")
|
||||
optionx(BUILDKITE_GROUP_ID STRING "The group ID to use on Buildkite")
|
||||
# This runs inside the ${targetKey}-build-bun step (see .buildkite/ci.mjs), which
|
||||
# links artifacts from ${targetKey}-build-cpp and ${targetKey}-build-zig in the
|
||||
# same build. Step keys follow a fixed ${targetKey}-build-{cpp,zig,bun} pattern
|
||||
# and build-bun's depends_on lists exactly those two steps, so we derive the
|
||||
# siblings by swapping the suffix on our own BUILDKITE_STEP_KEY.
|
||||
|
||||
if(ENABLE_BASELINE)
|
||||
set(DEFAULT_BUILDKITE_GROUP_KEY ${OS}-${ARCH}-baseline)
|
||||
else()
|
||||
set(DEFAULT_BUILDKITE_GROUP_KEY ${OS}-${ARCH})
|
||||
if(NOT DEFINED ENV{BUILDKITE_STEP_KEY})
|
||||
message(FATAL_ERROR "BUILDKITE_STEP_KEY is not set (expected inside a Buildkite job)")
|
||||
endif()
|
||||
set(BUILDKITE_STEP_KEY $ENV{BUILDKITE_STEP_KEY})
|
||||
|
||||
optionx(BUILDKITE_GROUP_KEY STRING "The group key to use on Buildkite" DEFAULT ${DEFAULT_BUILDKITE_GROUP_KEY})
|
||||
|
||||
if(BUILDKITE)
|
||||
optionx(BUILDKITE_BUILD_ID_OVERRIDE STRING "The build ID to use on Buildkite")
|
||||
if(BUILDKITE_BUILD_ID_OVERRIDE)
|
||||
setx(BUILDKITE_BUILD_ID ${BUILDKITE_BUILD_ID_OVERRIDE})
|
||||
endif()
|
||||
if(NOT BUILDKITE_STEP_KEY MATCHES "^(.+)-build-bun$")
|
||||
message(FATAL_ERROR "Unexpected BUILDKITE_STEP_KEY '${BUILDKITE_STEP_KEY}' (expected '<target>-build-bun')")
|
||||
endif()
|
||||
set(BUILDKITE_TARGET_KEY ${CMAKE_MATCH_1})
|
||||
|
||||
set(BUILDKITE_PATH ${BUILD_PATH}/buildkite)
|
||||
set(BUILDKITE_BUILDS_PATH ${BUILDKITE_PATH}/builds)
|
||||
# Download all artifacts from both sibling steps. The agent scopes to the
|
||||
# current build via $BUILDKITE_BUILD_ID; --step resolves by key within it.
|
||||
# git clean -ffxdq runs between builds (BUILDKITE_GIT_CLEAN_FLAGS), so
|
||||
# ${BUILD_PATH} starts clean.
|
||||
|
||||
if(NOT BUILDKITE_BUILD_ID)
|
||||
# TODO: find the latest build on the main branch that passed
|
||||
return()
|
||||
endif()
|
||||
file(MAKE_DIRECTORY ${BUILD_PATH})
|
||||
|
||||
# Use BUILDKITE_BUILD_NUMBER for the URL if available, as the UUID format causes a 302 redirect
|
||||
# that CMake's file(DOWNLOAD) doesn't follow, resulting in empty response.
|
||||
if(BUILDKITE_BUILD_NUMBER)
|
||||
setx(BUILDKITE_BUILD_URL https://buildkite.com/${BUILDKITE_ORGANIZATION_SLUG}/${BUILDKITE_PIPELINE_SLUG}/builds/${BUILDKITE_BUILD_NUMBER})
|
||||
else()
|
||||
setx(BUILDKITE_BUILD_URL https://buildkite.com/${BUILDKITE_ORGANIZATION_SLUG}/${BUILDKITE_PIPELINE_SLUG}/builds/${BUILDKITE_BUILD_ID})
|
||||
endif()
|
||||
setx(BUILDKITE_BUILD_PATH ${BUILDKITE_BUILDS_PATH}/builds/${BUILDKITE_BUILD_ID})
|
||||
|
||||
file(
|
||||
DOWNLOAD ${BUILDKITE_BUILD_URL}
|
||||
HTTPHEADER "Accept: application/json"
|
||||
TIMEOUT 15
|
||||
STATUS BUILDKITE_BUILD_STATUS
|
||||
${BUILDKITE_BUILD_PATH}/build.json
|
||||
)
|
||||
if(NOT BUILDKITE_BUILD_STATUS EQUAL 0)
|
||||
message(FATAL_ERROR "No build found: ${BUILDKITE_BUILD_STATUS} ${BUILDKITE_BUILD_URL}")
|
||||
return()
|
||||
endif()
|
||||
|
||||
file(READ ${BUILDKITE_BUILD_PATH}/build.json BUILDKITE_BUILD)
|
||||
# CMake's string(JSON ...) interprets escape sequences like \n, \r, \t.
|
||||
# We need to escape these specific sequences while preserving valid JSON escapes like \" and \\.
|
||||
# Strategy: Use a unique placeholder to protect \\ sequences, escape \n/\r/\t, then restore \\.
|
||||
# This prevents \\n (literal backslash + n) from being corrupted to \\\n.
|
||||
set(BKSLASH_PLACEHOLDER "___BKSLASH_PLACEHOLDER_7f3a9b2c___")
|
||||
string(REPLACE "\\\\" "${BKSLASH_PLACEHOLDER}" BUILDKITE_BUILD "${BUILDKITE_BUILD}")
|
||||
string(REPLACE "\\n" "\\\\n" BUILDKITE_BUILD "${BUILDKITE_BUILD}")
|
||||
string(REPLACE "\\r" "\\\\r" BUILDKITE_BUILD "${BUILDKITE_BUILD}")
|
||||
string(REPLACE "\\t" "\\\\t" BUILDKITE_BUILD "${BUILDKITE_BUILD}")
|
||||
string(REPLACE "${BKSLASH_PLACEHOLDER}" "\\\\" BUILDKITE_BUILD "${BUILDKITE_BUILD}")
|
||||
# CMake treats semicolons as list separators in unquoted variable expansions.
|
||||
# Commit messages and other JSON string fields can contain semicolons, which would
|
||||
# cause string(JSON) to receive garbled arguments. Escape them before parsing.
|
||||
string(REPLACE ";" "\\;" BUILDKITE_BUILD "${BUILDKITE_BUILD}")
|
||||
|
||||
string(JSON BUILDKITE_BUILD_UUID GET "${BUILDKITE_BUILD}" id)
|
||||
string(JSON BUILDKITE_JOBS GET "${BUILDKITE_BUILD}" jobs)
|
||||
string(JSON BUILDKITE_JOBS_COUNT LENGTH "${BUILDKITE_JOBS}")
|
||||
|
||||
if(NOT BUILDKITE_JOBS_COUNT GREATER 0)
|
||||
message(FATAL_ERROR "No jobs found: ${BUILDKITE_BUILD_URL}")
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(BUILDKITE_JOBS_FAILED)
|
||||
set(BUILDKITE_JOBS_NOT_FOUND)
|
||||
set(BUILDKITE_JOBS_NO_ARTIFACTS)
|
||||
set(BUILDKITE_JOBS_NO_MATCH)
|
||||
set(BUILDKITE_JOBS_MATCH)
|
||||
|
||||
math(EXPR BUILDKITE_JOBS_MAX_INDEX "${BUILDKITE_JOBS_COUNT} - 1")
|
||||
foreach(i RANGE ${BUILDKITE_JOBS_MAX_INDEX})
|
||||
string(JSON BUILDKITE_JOB GET "${BUILDKITE_JOBS}" ${i})
|
||||
string(JSON BUILDKITE_JOB_ID GET "${BUILDKITE_JOB}" id)
|
||||
string(JSON BUILDKITE_JOB_PASSED GET "${BUILDKITE_JOB}" passed)
|
||||
string(JSON BUILDKITE_JOB_GROUP_ID GET "${BUILDKITE_JOB}" group_uuid)
|
||||
string(JSON BUILDKITE_JOB_GROUP_KEY GET "${BUILDKITE_JOB}" group_identifier)
|
||||
string(JSON BUILDKITE_JOB_NAME GET "${BUILDKITE_JOB}" step_key)
|
||||
if(NOT BUILDKITE_JOB_NAME)
|
||||
string(JSON BUILDKITE_JOB_NAME GET "${BUILDKITE_JOB}" name)
|
||||
endif()
|
||||
|
||||
if(NOT BUILDKITE_JOB_PASSED)
|
||||
list(APPEND BUILDKITE_JOBS_FAILED ${BUILDKITE_JOB_NAME})
|
||||
continue()
|
||||
endif()
|
||||
|
||||
if(NOT (BUILDKITE_GROUP_ID AND BUILDKITE_GROUP_ID STREQUAL BUILDKITE_JOB_GROUP_ID) AND
|
||||
NOT (BUILDKITE_GROUP_KEY AND BUILDKITE_GROUP_KEY STREQUAL BUILDKITE_JOB_GROUP_KEY))
|
||||
list(APPEND BUILDKITE_JOBS_NO_MATCH ${BUILDKITE_JOB_NAME})
|
||||
continue()
|
||||
endif()
|
||||
|
||||
set(BUILDKITE_ARTIFACTS_URL https://buildkite.com/organizations/${BUILDKITE_ORGANIZATION_SLUG}/pipelines/${BUILDKITE_PIPELINE_SLUG}/builds/${BUILDKITE_BUILD_UUID}/jobs/${BUILDKITE_JOB_ID}/artifacts)
|
||||
set(BUILDKITE_ARTIFACTS_PATH ${BUILDKITE_BUILD_PATH}/artifacts/${BUILDKITE_JOB_ID}.json)
|
||||
|
||||
file(
|
||||
DOWNLOAD ${BUILDKITE_ARTIFACTS_URL}
|
||||
HTTPHEADER "Accept: application/json"
|
||||
TIMEOUT 15
|
||||
STATUS BUILDKITE_ARTIFACTS_STATUS
|
||||
${BUILDKITE_ARTIFACTS_PATH}
|
||||
foreach(SUFFIX cpp zig)
|
||||
set(STEP ${BUILDKITE_TARGET_KEY}-build-${SUFFIX})
|
||||
message(STATUS "Downloading artifacts from ${STEP}")
|
||||
execute_process(
|
||||
COMMAND buildkite-agent artifact download * . --step ${STEP}
|
||||
WORKING_DIRECTORY ${BUILD_PATH}
|
||||
RESULT_VARIABLE DOWNLOAD_RC
|
||||
ERROR_VARIABLE DOWNLOAD_ERR
|
||||
)
|
||||
|
||||
if(NOT BUILDKITE_ARTIFACTS_STATUS EQUAL 0)
|
||||
list(APPEND BUILDKITE_JOBS_NOT_FOUND ${BUILDKITE_JOB_NAME})
|
||||
continue()
|
||||
if(NOT DOWNLOAD_RC EQUAL 0)
|
||||
message(FATAL_ERROR "buildkite-agent artifact download from ${STEP} failed: ${DOWNLOAD_ERR}")
|
||||
endif()
|
||||
|
||||
file(READ ${BUILDKITE_ARTIFACTS_PATH} BUILDKITE_ARTIFACTS)
|
||||
string(REPLACE ";" "\\;" BUILDKITE_ARTIFACTS "${BUILDKITE_ARTIFACTS}")
|
||||
string(JSON BUILDKITE_ARTIFACTS_LENGTH LENGTH "${BUILDKITE_ARTIFACTS}")
|
||||
if(NOT BUILDKITE_ARTIFACTS_LENGTH GREATER 0)
|
||||
list(APPEND BUILDKITE_JOBS_NO_ARTIFACTS ${BUILDKITE_JOB_NAME})
|
||||
continue()
|
||||
endif()
|
||||
|
||||
math(EXPR BUILDKITE_ARTIFACTS_MAX_INDEX "${BUILDKITE_ARTIFACTS_LENGTH} - 1")
|
||||
foreach(i RANGE 0 ${BUILDKITE_ARTIFACTS_MAX_INDEX})
|
||||
string(JSON BUILDKITE_ARTIFACT GET "${BUILDKITE_ARTIFACTS}" ${i})
|
||||
string(JSON BUILDKITE_ARTIFACT_ID GET "${BUILDKITE_ARTIFACT}" id)
|
||||
string(JSON BUILDKITE_ARTIFACT_PATH GET "${BUILDKITE_ARTIFACT}" path)
|
||||
|
||||
if(NOT BUILDKITE_ARTIFACT_PATH MATCHES "\\.(o|a|lib|zip|tar|gz)")
|
||||
continue()
|
||||
endif()
|
||||
|
||||
if(BUILDKITE)
|
||||
if(BUILDKITE_ARTIFACT_PATH STREQUAL "libbun-profile.a")
|
||||
set(BUILDKITE_ARTIFACT_PATH libbun-profile.a.gz)
|
||||
elseif(BUILDKITE_ARTIFACT_PATH STREQUAL "libbun-asan.a")
|
||||
set(BUILDKITE_ARTIFACT_PATH libbun-asan.a.gz)
|
||||
endif()
|
||||
set(BUILDKITE_DOWNLOAD_COMMAND buildkite-agent artifact download ${BUILDKITE_ARTIFACT_PATH} . --build ${BUILDKITE_BUILD_UUID} --step ${BUILDKITE_JOB_ID})
|
||||
else()
|
||||
set(BUILDKITE_DOWNLOAD_COMMAND curl -L -o ${BUILDKITE_ARTIFACT_PATH} ${BUILDKITE_ARTIFACTS_URL}/${BUILDKITE_ARTIFACT_ID})
|
||||
endif()
|
||||
|
||||
add_custom_command(
|
||||
COMMENT
|
||||
"Downloading ${BUILDKITE_ARTIFACT_PATH}"
|
||||
VERBATIM COMMAND
|
||||
${BUILDKITE_DOWNLOAD_COMMAND}
|
||||
WORKING_DIRECTORY
|
||||
${BUILD_PATH}
|
||||
OUTPUT
|
||||
${BUILD_PATH}/${BUILDKITE_ARTIFACT_PATH}
|
||||
)
|
||||
if(BUILDKITE_ARTIFACT_PATH STREQUAL "libbun-profile.a.gz")
|
||||
add_custom_command(
|
||||
COMMENT
|
||||
"Unpacking libbun-profile.a.gz"
|
||||
VERBATIM COMMAND
|
||||
gunzip libbun-profile.a.gz
|
||||
WORKING_DIRECTORY
|
||||
${BUILD_PATH}
|
||||
OUTPUT
|
||||
${BUILD_PATH}/libbun-profile.a
|
||||
DEPENDS
|
||||
${BUILD_PATH}/libbun-profile.a.gz
|
||||
)
|
||||
elseif(BUILDKITE_ARTIFACT_PATH STREQUAL "libbun-asan.a.gz")
|
||||
add_custom_command(
|
||||
COMMENT
|
||||
"Unpacking libbun-asan.a.gz"
|
||||
VERBATIM COMMAND
|
||||
gunzip libbun-asan.a.gz
|
||||
WORKING_DIRECTORY
|
||||
${BUILD_PATH}
|
||||
OUTPUT
|
||||
${BUILD_PATH}/libbun-asan.a
|
||||
DEPENDS
|
||||
${BUILD_PATH}/libbun-asan.a.gz
|
||||
)
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
list(APPEND BUILDKITE_JOBS_MATCH ${BUILDKITE_JOB_NAME})
|
||||
endforeach()
|
||||
|
||||
if(BUILDKITE_JOBS_FAILED)
|
||||
list(SORT BUILDKITE_JOBS_FAILED COMPARE STRING)
|
||||
list(JOIN BUILDKITE_JOBS_FAILED " " BUILDKITE_JOBS_FAILED)
|
||||
message(WARNING "The following jobs were found, but failed: ${BUILDKITE_JOBS_FAILED}")
|
||||
endif()
|
||||
# libbun-profile.a and libbun-asan.a are gzipped before upload (see
|
||||
# register_command's ARTIFACTS handling in Globals.cmake). Windows .lib
|
||||
# files are not gzipped, so this glob is empty there.
|
||||
|
||||
if(BUILDKITE_JOBS_NOT_FOUND)
|
||||
list(SORT BUILDKITE_JOBS_NOT_FOUND COMPARE STRING)
|
||||
list(JOIN BUILDKITE_JOBS_NOT_FOUND " " BUILDKITE_JOBS_NOT_FOUND)
|
||||
message(WARNING "The following jobs were found, but could not fetch their data: ${BUILDKITE_JOBS_NOT_FOUND}")
|
||||
endif()
|
||||
file(GLOB BUILDKITE_GZ_ARTIFACTS "${BUILD_PATH}/*.gz")
|
||||
foreach(GZ ${BUILDKITE_GZ_ARTIFACTS})
|
||||
message(STATUS "Unpacking ${GZ}")
|
||||
execute_process(
|
||||
COMMAND gunzip -f ${GZ}
|
||||
RESULT_VARIABLE GUNZIP_RC
|
||||
ERROR_VARIABLE GUNZIP_ERR
|
||||
)
|
||||
if(NOT GUNZIP_RC EQUAL 0)
|
||||
message(FATAL_ERROR "gunzip ${GZ} failed: ${GUNZIP_ERR}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
if(BUILDKITE_JOBS_NO_MATCH)
|
||||
list(SORT BUILDKITE_JOBS_NO_MATCH COMPARE STRING)
|
||||
list(JOIN BUILDKITE_JOBS_NO_MATCH " " BUILDKITE_JOBS_NO_MATCH)
|
||||
message(WARNING "The following jobs were found, but did not match the group ID: ${BUILDKITE_JOBS_NO_MATCH}")
|
||||
endif()
|
||||
# Artifacts are uploaded with subdirectory paths (lolhtml/release/liblolhtml.a,
|
||||
# mimalloc/CMakeFiles/mimalloc-obj.dir/src/static.c.o, etc.) and the agent
|
||||
# recreates that structure on download. Recurse, but skip top-level CMakeFiles/
|
||||
# (our own compiler detection) and cache/ — nested CMakeFiles/ are real artifacts.
|
||||
|
||||
if(BUILDKITE_JOBS_NO_ARTIFACTS)
|
||||
list(SORT BUILDKITE_JOBS_NO_ARTIFACTS COMPARE STRING)
|
||||
list(JOIN BUILDKITE_JOBS_NO_ARTIFACTS " " BUILDKITE_JOBS_NO_ARTIFACTS)
|
||||
message(WARNING "The following jobs were found, but had no artifacts: ${BUILDKITE_JOBS_NO_ARTIFACTS}")
|
||||
endif()
|
||||
file(GLOB_RECURSE BUILDKITE_LINK_ARTIFACTS
|
||||
"${BUILD_PATH}/*.o"
|
||||
"${BUILD_PATH}/*.a"
|
||||
"${BUILD_PATH}/*.lib"
|
||||
)
|
||||
string(REGEX REPLACE "\\." "\\\\." BUILD_PATH_RE "${BUILD_PATH}")
|
||||
list(FILTER BUILDKITE_LINK_ARTIFACTS EXCLUDE REGEX "^${BUILD_PATH_RE}/(CMakeFiles|cache)/")
|
||||
|
||||
if(BUILDKITE_JOBS_MATCH)
|
||||
list(SORT BUILDKITE_JOBS_MATCH COMPARE STRING)
|
||||
list(JOIN BUILDKITE_JOBS_MATCH " " BUILDKITE_JOBS_MATCH)
|
||||
message(STATUS "The following jobs were found, and matched the group ID: ${BUILDKITE_JOBS_MATCH}")
|
||||
if(NOT BUILDKITE_LINK_ARTIFACTS)
|
||||
message(FATAL_ERROR "No linkable artifacts found in ${BUILD_PATH} after download")
|
||||
endif()
|
||||
list(LENGTH BUILDKITE_LINK_ARTIFACTS BUILDKITE_LINK_COUNT)
|
||||
message(STATUS "Registered ${BUILDKITE_LINK_COUNT} linkable artifacts from ${BUILDKITE_TARGET_KEY}-build-{cpp,zig}")
|
||||
|
||||
if(NOT BUILDKITE_JOBS_FAILED AND NOT BUILDKITE_JOBS_NOT_FOUND AND NOT BUILDKITE_JOBS_NO_MATCH AND NOT BUILDKITE_JOBS_NO_ARTIFACTS AND NOT BUILDKITE_JOBS_MATCH)
|
||||
message(FATAL_ERROR "Something went wrong with Buildkite?")
|
||||
endif()
|
||||
# Register a no-op custom command for each linkable artifact so register_command
|
||||
# (Globals.cmake) sees them as GENERATED and shims its own output to
|
||||
# ${output}.always_run_${target} instead of overwriting them with a rebuild.
|
||||
# With no DEPENDS, the no-op never fires — the file already exists.
|
||||
|
||||
foreach(ARTIFACT ${BUILDKITE_LINK_ARTIFACTS})
|
||||
add_custom_command(
|
||||
OUTPUT ${ARTIFACT}
|
||||
COMMAND ${CMAKE_COMMAND} -E true
|
||||
)
|
||||
endforeach()
|
||||
|
||||
@@ -124,30 +124,30 @@ Becomes something like this:
|
||||
To use React in your client-side code, import `react-dom/client` and render your app.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts title="src/backend.ts" icon="/icons/typescript.svg"
|
||||
import dashboard from "../public/dashboard.html";
|
||||
import { serve } from "bun";
|
||||
|
||||
serve({
|
||||
routes: {
|
||||
"/": dashboard,
|
||||
},
|
||||
async fetch(req) {
|
||||
// ...api requests
|
||||
return new Response("hello world");
|
||||
},
|
||||
routes: {
|
||||
"/": dashboard,
|
||||
},
|
||||
async fetch(req) {
|
||||
// ...api requests
|
||||
return new Response("hello world");
|
||||
},
|
||||
});
|
||||
|
||||
````
|
||||
```
|
||||
|
||||
```tsx title="src/frontend.tsx" icon="/icons/typescript.svg"
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './app';
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./app";
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container!);
|
||||
root.render(<App />);
|
||||
````
|
||||
```
|
||||
|
||||
```html title="public/dashboard.html" icon="file-code"
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -124,22 +124,32 @@ Return a string to replace the element's rendering. Return `null` or `undefined`
|
||||
|
||||
### Block callbacks
|
||||
|
||||
| Callback | Meta | Description |
|
||||
| ------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `heading` | `{ level: number, id?: string }` | Heading level 1–6. `id` is set when `headings: { ids: true }` is enabled |
|
||||
| `paragraph` | — | Paragraph block |
|
||||
| `blockquote` | — | Blockquote block |
|
||||
| `code` | `{ language?: string }` | Fenced or indented code block. `language` is the info-string when specified on the fence |
|
||||
| `list` | `{ ordered: boolean, start?: number }` | Ordered or unordered list. `start` is the start number for ordered lists |
|
||||
| `listItem` | `{ checked?: boolean }` | List item. `checked` is set for task list items (`- [x]` / `- [ ]`) |
|
||||
| `hr` | — | Horizontal rule |
|
||||
| `table` | — | Table block |
|
||||
| `thead` | — | Table head |
|
||||
| `tbody` | — | Table body |
|
||||
| `tr` | — | Table row |
|
||||
| `th` | `{ align?: "left" \| "center" \| "right" }` | Table header cell. `align` is set when alignment is specified |
|
||||
| `td` | `{ align?: "left" \| "center" \| "right" }` | Table data cell. `align` is set when alignment is specified |
|
||||
| `html` | — | Raw HTML content |
|
||||
| Callback | Meta | Description |
|
||||
| ------------ | --------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `heading` | `{ level, id? }` | Heading level 1–6. `id` is set when `headings: { ids: true }` is enabled |
|
||||
| `paragraph` | — | Paragraph block |
|
||||
| `blockquote` | — | Blockquote block |
|
||||
| `code` | `{ language? }` | Fenced or indented code block. `language` is the info-string when specified on the fence |
|
||||
| `list` | `{ ordered, start?, depth }` | `depth` is nesting level (0 = top-level). `start` is set for ordered lists |
|
||||
| `listItem` | `{ index, depth, ordered, start?, checked? }` | See [List item meta](#list-item-meta) below |
|
||||
| `hr` | — | Horizontal rule |
|
||||
| `table` | — | Table block |
|
||||
| `thead` | — | Table head |
|
||||
| `tbody` | — | Table body |
|
||||
| `tr` | — | Table row |
|
||||
| `th` | `{ align? }` | Table header cell. `align` is `"left"`, `"center"`, `"right"`, or absent |
|
||||
| `td` | `{ align? }` | Table data cell |
|
||||
| `html` | — | Raw HTML content |
|
||||
|
||||
#### List item meta
|
||||
|
||||
The `listItem` callback receives everything needed to render markers directly:
|
||||
|
||||
- `index` — 0-based position within the parent list
|
||||
- `depth` — the parent list's nesting level (0 = top-level)
|
||||
- `ordered` — whether the parent list is ordered
|
||||
- `start` — the parent list's start number (only when `ordered` is true)
|
||||
- `checked` — task list state (only for `- [x]` / `- [ ]` items)
|
||||
|
||||
### Inline callbacks
|
||||
|
||||
@@ -205,6 +215,33 @@ const ansi = Bun.markdown.render("# Hello\n\nThis is **bold** and *italic*", {
|
||||
});
|
||||
```
|
||||
|
||||
#### Nested list numbering
|
||||
|
||||
The `listItem` callback receives everything needed to render markers directly — no post-processing:
|
||||
|
||||
```ts
|
||||
const result = Bun.markdown.render("1. first\n 1. sub-a\n 2. sub-b\n2. second", {
|
||||
listItem: (children, { index, depth, ordered, start }) => {
|
||||
const n = (start ?? 1) + index;
|
||||
// 1. 2. 3. at depth 0, a. b. c. at depth 1, i. ii. iii. at depth 2
|
||||
const marker = !ordered
|
||||
? "-"
|
||||
: depth === 0
|
||||
? `${n}.`
|
||||
: depth === 1
|
||||
? `${String.fromCharCode(96 + n)}.`
|
||||
: `${toRoman(n)}.`;
|
||||
return " ".repeat(depth) + marker + " " + children.trimEnd() + "\n";
|
||||
},
|
||||
// Prepend a newline so nested lists are separated from their parent item's text
|
||||
list: children => "\n" + children,
|
||||
});
|
||||
// 1. first
|
||||
// a. sub-a
|
||||
// b. sub-b
|
||||
// 2. second
|
||||
```
|
||||
|
||||
#### Code block syntax highlighting
|
||||
|
||||
````ts
|
||||
|
||||
14
packages/bun-types/bun.d.ts
vendored
14
packages/bun-types/bun.d.ts
vendored
@@ -1193,10 +1193,20 @@ declare module "bun" {
|
||||
ordered: boolean;
|
||||
/** The start number for ordered lists. */
|
||||
start?: number;
|
||||
/** Nesting depth. `0` for a top-level list, `1` for a list inside a list item, etc. */
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/** Meta passed to the `listItem` callback. */
|
||||
interface ListItemMeta {
|
||||
/** 0-based index of this item within its parent list. */
|
||||
index: number;
|
||||
/** Nesting depth of the parent list. `0` for items in a top-level list. */
|
||||
depth: number;
|
||||
/** Whether the parent list is ordered. */
|
||||
ordered: boolean;
|
||||
/** The start number of the parent list (only set when `ordered` is true). */
|
||||
start?: number;
|
||||
/** Task list checked state. Set for `- [x]` / `- [ ]` items. */
|
||||
checked?: boolean;
|
||||
}
|
||||
@@ -1234,8 +1244,8 @@ declare module "bun" {
|
||||
code?: (children: string, meta?: CodeBlockMeta) => string | null | undefined;
|
||||
/** Ordered or unordered list. `start` is the first item number for ordered lists. */
|
||||
list?: (children: string, meta: ListMeta) => string | null | undefined;
|
||||
/** List item. `meta.checked` is set for task list items (`- [x]` / `- [ ]`). Only passed for task list items. */
|
||||
listItem?: (children: string, meta?: ListItemMeta) => string | null | undefined;
|
||||
/** List item. `meta` always includes `{index, depth, ordered}`. `meta.start` is set for ordered lists; `meta.checked` is set for task list items. */
|
||||
listItem?: (children: string, meta: ListItemMeta) => string | null | undefined;
|
||||
/** Horizontal rule. */
|
||||
hr?: (children: string) => string | null | undefined;
|
||||
/** Table. */
|
||||
|
||||
@@ -66,7 +66,12 @@ pub const JSBundler = struct {
|
||||
// Must use getKey to return the map's owned key, not the parameter
|
||||
if (comptime !bun.Environment.isWindows) {
|
||||
if (self.map.getKey(specifier)) |key| {
|
||||
return makeResult(key);
|
||||
return _resolver.Result{
|
||||
.path_pair = .{
|
||||
.primary = Fs.Path.initWithNamespace(key, "file"),
|
||||
},
|
||||
.module_type = .unknown,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const buf = bun.path_buffer_pool.get();
|
||||
@@ -74,55 +79,12 @@ pub const JSBundler = struct {
|
||||
const normalized_specifier = bun.path.pathToPosixBuf(u8, specifier, buf);
|
||||
|
||||
if (self.map.getKey(normalized_specifier)) |key| {
|
||||
return makeResult(key);
|
||||
}
|
||||
}
|
||||
|
||||
// For absolute specifiers, check if the specifier was produced by the HTML
|
||||
// scanner's top_level_dir join (e.g., "/virtual/foo.tsx" -> "<CWD>/virtual/foo.tsx").
|
||||
// If so, try stripping the top_level_dir prefix and looking up the original path.
|
||||
//
|
||||
// On Windows, both the specifier and top_level_dir may use backslashes, so we
|
||||
// normalize both to posix before prefix-matching and map lookup.
|
||||
if (specifier.len > 0 and isAbsolutePath(specifier)) {
|
||||
const top_level_dir = Fs.FileSystem.instance.top_level_dir;
|
||||
if (top_level_dir.len > 0) {
|
||||
// Normalize both specifier and top_level_dir to posix for consistent matching.
|
||||
const norm_spec_buf = bun.path_buffer_pool.get();
|
||||
defer bun.path_buffer_pool.put(norm_spec_buf);
|
||||
const norm_tld_buf = bun.path_buffer_pool.get();
|
||||
defer bun.path_buffer_pool.put(norm_tld_buf);
|
||||
const norm_specifier = bun.path.pathToPosixBuf(u8, specifier, norm_spec_buf);
|
||||
const norm_top_level_dir = bun.path.pathToPosixBuf(u8, top_level_dir, norm_tld_buf);
|
||||
|
||||
if (bun.strings.hasPrefix(norm_specifier, norm_top_level_dir)) {
|
||||
const after_prefix = norm_specifier[norm_top_level_dir.len..];
|
||||
if (after_prefix.len == 0) {
|
||||
// specifier == top_level_dir exactly, nothing to look up
|
||||
} else if (after_prefix[0] == '/') {
|
||||
// top_level_dir ended without separator, remainder starts with one.
|
||||
// e.g. top_level_dir="/workspace/bun", specifier="/workspace/bun/virtual/foo.tsx"
|
||||
// -> after_prefix="/virtual/foo.tsx" (already a valid absolute path)
|
||||
if (self.map.getKey(after_prefix)) |key| {
|
||||
return makeResult(key);
|
||||
}
|
||||
} else if (norm_top_level_dir[norm_top_level_dir.len - 1] == '/') {
|
||||
// top_level_dir ended with separator (e.g. "/workspace/bun/"),
|
||||
// so after_prefix is "virtual/foo.tsx" — prepend "/" to reconstruct
|
||||
// the original absolute path "/virtual/foo.tsx".
|
||||
const orig_buf = bun.path_buffer_pool.get();
|
||||
defer bun.path_buffer_pool.put(orig_buf);
|
||||
orig_buf[0] = '/';
|
||||
@memcpy(orig_buf[1..][0..after_prefix.len], after_prefix);
|
||||
const original_path = orig_buf[0 .. after_prefix.len + 1];
|
||||
|
||||
if (self.map.getKey(original_path)) |key| {
|
||||
return makeResult(key);
|
||||
}
|
||||
}
|
||||
// else: partial segment match (e.g. "/work" matched "/workspace/..."),
|
||||
// not a valid top_level_dir prefix — skip this fallback.
|
||||
}
|
||||
return _resolver.Result{
|
||||
.path_pair = .{
|
||||
.primary = Fs.Path.initWithNamespace(key, "file"),
|
||||
},
|
||||
.module_type = .unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +133,12 @@ pub const JSBundler = struct {
|
||||
const joined = buf[0..joined_len];
|
||||
// Must use getKey to return the map's owned key, not the temporary buffer
|
||||
if (self.map.getKey(joined)) |key| {
|
||||
return makeResult(key);
|
||||
return _resolver.Result{
|
||||
.path_pair = .{
|
||||
.primary = Fs.Path.initWithNamespace(key, "file"),
|
||||
},
|
||||
.module_type = .unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,15 +162,6 @@ pub const JSBundler = struct {
|
||||
return false;
|
||||
}
|
||||
|
||||
fn makeResult(key: []const u8) _resolver.Result {
|
||||
return _resolver.Result{
|
||||
.path_pair = .{
|
||||
.primary = Fs.Path.initWithNamespace(key, "file"),
|
||||
},
|
||||
.module_type = .unknown,
|
||||
};
|
||||
}
|
||||
|
||||
/// Parse the files option from JavaScript.
|
||||
/// Expected format: Record<string, string | Blob | File | TypedArray | ArrayBuffer>
|
||||
/// Uses async parsing for cross-thread safety since bundler runs on a separate thread.
|
||||
|
||||
@@ -759,8 +759,12 @@ const JsCallbackRenderer = struct {
|
||||
|
||||
const StackEntry = struct {
|
||||
buffer: std.ArrayListUnmanaged(u8) = .{},
|
||||
block_type: md.BlockType = .doc,
|
||||
data: u32 = 0,
|
||||
flags: u32 = 0,
|
||||
/// For ul/ol: number of li children seen so far (next li's index).
|
||||
/// For li: this item's 0-based index within its parent list.
|
||||
child_index: u32 = 0,
|
||||
detail: md.SpanDetail = .{},
|
||||
};
|
||||
|
||||
@@ -853,7 +857,22 @@ const JsCallbackRenderer = struct {
|
||||
if (block_type == .h) {
|
||||
self.#heading_tracker.enterHeading();
|
||||
}
|
||||
try self.#stack.append(self.#allocator, .{ .data = data, .flags = flags });
|
||||
|
||||
// For li: record its 0-based index within the parent list, then
|
||||
// increment the parent's counter so the next sibling gets index+1.
|
||||
var child_index: u32 = 0;
|
||||
if (block_type == .li and self.#stack.items.len > 0) {
|
||||
const parent = &self.#stack.items[self.#stack.items.len - 1];
|
||||
child_index = parent.child_index;
|
||||
parent.child_index += 1;
|
||||
}
|
||||
|
||||
try self.#stack.append(self.#allocator, .{
|
||||
.block_type = block_type,
|
||||
.data = data,
|
||||
.flags = flags,
|
||||
.child_index = child_index,
|
||||
});
|
||||
}
|
||||
|
||||
fn leaveBlockImpl(ptr: *anyopaque, block_type: md.BlockType, _: u32) bun.JSError!void {
|
||||
@@ -986,6 +1005,30 @@ const JsCallbackRenderer = struct {
|
||||
// Metadata object creation
|
||||
// ========================================
|
||||
|
||||
/// Walks the stack to count enclosing ul/ol blocks. Called during leave,
|
||||
/// so the top entry is the block itself (skip it for li, count it for ul/ol's
|
||||
/// own depth which excludes self).
|
||||
fn countListDepth(self: *JsCallbackRenderer) u32 {
|
||||
var depth: u32 = 0;
|
||||
// Skip the top entry (self) — we want enclosing lists only.
|
||||
const len = self.#stack.items.len;
|
||||
if (len < 2) return 0;
|
||||
for (self.#stack.items[0 .. len - 1]) |entry| {
|
||||
if (entry.block_type == .ul or entry.block_type == .ol) depth += 1;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/// Returns the parent ul/ol entry for the current li (top of stack).
|
||||
/// Returns null if the stack shape is unexpected.
|
||||
fn parentList(self: *JsCallbackRenderer) ?*const StackEntry {
|
||||
const len = self.#stack.items.len;
|
||||
if (len < 2) return null;
|
||||
const parent = &self.#stack.items[len - 2];
|
||||
if (parent.block_type == .ul or parent.block_type == .ol) return parent;
|
||||
return null;
|
||||
}
|
||||
|
||||
fn createBlockMeta(self: *JsCallbackRenderer, block_type: md.BlockType, data: u32, flags: u32) bun.JSError!?JSValue {
|
||||
const g = self.#globalObject;
|
||||
switch (block_type) {
|
||||
@@ -1000,15 +1043,10 @@ const JsCallbackRenderer = struct {
|
||||
return obj;
|
||||
},
|
||||
.ol => {
|
||||
const obj = JSValue.createEmptyObject(g, 2);
|
||||
obj.put(g, ZigString.static("ordered"), .true);
|
||||
obj.put(g, ZigString.static("start"), JSValue.jsNumber(data));
|
||||
return obj;
|
||||
return BunMarkdownMeta__createList(g, true, JSValue.jsNumber(data), self.countListDepth());
|
||||
},
|
||||
.ul => {
|
||||
const obj = JSValue.createEmptyObject(g, 1);
|
||||
obj.put(g, ZigString.static("ordered"), .false);
|
||||
return obj;
|
||||
return BunMarkdownMeta__createList(g, false, .js_undefined, self.countListDepth());
|
||||
},
|
||||
.code => {
|
||||
if (flags & md.BLOCK_FENCED_CODE != 0) {
|
||||
@@ -1023,21 +1061,31 @@ const JsCallbackRenderer = struct {
|
||||
},
|
||||
.th, .td => {
|
||||
const alignment = md.types.alignmentFromData(data);
|
||||
if (md.types.alignmentName(alignment)) |align_str| {
|
||||
const obj = JSValue.createEmptyObject(g, 1);
|
||||
obj.put(g, ZigString.static("align"), try bun.String.createUTF8ForJS(g, align_str));
|
||||
return obj;
|
||||
}
|
||||
return null;
|
||||
const align_js = if (md.types.alignmentName(alignment)) |align_str|
|
||||
try bun.String.createUTF8ForJS(g, align_str)
|
||||
else
|
||||
JSValue.js_undefined;
|
||||
return BunMarkdownMeta__createCell(g, align_js);
|
||||
},
|
||||
.li => {
|
||||
// The li entry is still on top of the stack; parent ul/ol is at len-2.
|
||||
const len = self.#stack.items.len;
|
||||
const item_index = if (len > 1) self.#stack.items[len - 1].child_index else 0;
|
||||
const parent = self.parentList();
|
||||
const is_ordered = parent != null and parent.?.block_type == .ol;
|
||||
// countListDepth() includes the immediate parent list; subtract it
|
||||
// so that items in a top-level list report depth 0.
|
||||
const enclosing = self.countListDepth();
|
||||
const depth: u32 = if (enclosing > 0) enclosing - 1 else 0;
|
||||
const task_mark = md.types.taskMarkFromData(data);
|
||||
if (task_mark != 0) {
|
||||
const obj = JSValue.createEmptyObject(g, 1);
|
||||
obj.put(g, ZigString.static("checked"), JSValue.jsBoolean(md.types.isTaskChecked(task_mark)));
|
||||
return obj;
|
||||
}
|
||||
return null;
|
||||
|
||||
const start_js = if (is_ordered) JSValue.jsNumber(parent.?.data) else JSValue.js_undefined;
|
||||
const checked_js = if (task_mark != 0)
|
||||
JSValue.jsBoolean(md.types.isTaskChecked(task_mark))
|
||||
else
|
||||
JSValue.js_undefined;
|
||||
|
||||
return BunMarkdownMeta__createListItem(g, item_index, depth, is_ordered, start_js, checked_js);
|
||||
},
|
||||
else => return null,
|
||||
}
|
||||
@@ -1047,14 +1095,18 @@ const JsCallbackRenderer = struct {
|
||||
const g = self.#globalObject;
|
||||
switch (span_type) {
|
||||
.a => {
|
||||
const obj = JSValue.createEmptyObject(g, 2);
|
||||
obj.put(g, ZigString.static("href"), try bun.String.createUTF8ForJS(g, detail.href));
|
||||
if (detail.title.len > 0) {
|
||||
obj.put(g, ZigString.static("title"), try bun.String.createUTF8ForJS(g, detail.title));
|
||||
}
|
||||
return obj;
|
||||
const href = try bun.String.createUTF8ForJS(g, detail.href);
|
||||
const title = if (detail.title.len > 0)
|
||||
try bun.String.createUTF8ForJS(g, detail.title)
|
||||
else
|
||||
JSValue.js_undefined;
|
||||
return BunMarkdownMeta__createLink(g, href, title);
|
||||
},
|
||||
.img => {
|
||||
// Image meta shares shape with link (src/href are both the first
|
||||
// field). We use a separate cached structure would require a
|
||||
// second slot, so just fall back to the generic path here —
|
||||
// images are rare enough that it doesn't matter.
|
||||
const obj = JSValue.createEmptyObject(g, 2);
|
||||
obj.put(g, ZigString.static("src"), try bun.String.createUTF8ForJS(g, detail.href));
|
||||
if (detail.title.len > 0) {
|
||||
@@ -1114,6 +1166,14 @@ const TagIndex = enum(u8) {
|
||||
|
||||
extern fn BunMarkdownTagStrings__getTagString(*jsc.JSGlobalObject, u8) JSValue;
|
||||
|
||||
// Fast-path meta-object constructors using cached Structures (see
|
||||
// BunMarkdownMeta.cpp). Each constructs via putDirectOffset so the
|
||||
// resulting objects share a single Structure and stay monomorphic.
|
||||
extern fn BunMarkdownMeta__createListItem(*jsc.JSGlobalObject, u32, u32, bool, JSValue, JSValue) JSValue;
|
||||
extern fn BunMarkdownMeta__createList(*jsc.JSGlobalObject, bool, JSValue, u32) JSValue;
|
||||
extern fn BunMarkdownMeta__createCell(*jsc.JSGlobalObject, JSValue) JSValue;
|
||||
extern fn BunMarkdownMeta__createLink(*jsc.JSGlobalObject, JSValue, JSValue) JSValue;
|
||||
|
||||
fn getCachedTagString(globalObject: *jsc.JSGlobalObject, tag: TagIndex) JSValue {
|
||||
return BunMarkdownTagStrings__getTagString(globalObject, @intFromEnum(tag));
|
||||
}
|
||||
|
||||
123
src/bun.js/bindings/BunMarkdownMeta.cpp
Normal file
123
src/bun.js/bindings/BunMarkdownMeta.cpp
Normal file
@@ -0,0 +1,123 @@
|
||||
#include "BunMarkdownMeta.h"
|
||||
|
||||
#include "JavaScriptCore/JSObjectInlines.h"
|
||||
#include "JavaScriptCore/ObjectConstructor.h"
|
||||
#include "JavaScriptCore/JSCast.h"
|
||||
|
||||
using namespace JSC;
|
||||
|
||||
namespace Bun {
|
||||
namespace MarkdownMeta {
|
||||
|
||||
// Builds a cached Structure with N fixed property offsets. Properties are
|
||||
// laid out in declaration order so the extern "C" create functions can use
|
||||
// putDirectOffset without name lookups.
|
||||
static Structure* buildStructure(VM& vm, JSGlobalObject* globalObject, std::initializer_list<ASCIILiteral> names)
|
||||
{
|
||||
Structure* structure = globalObject->structureCache().emptyObjectStructureForPrototype(
|
||||
globalObject,
|
||||
globalObject->objectPrototype(),
|
||||
names.size());
|
||||
|
||||
PropertyOffset offset;
|
||||
PropertyOffset expected = 0;
|
||||
for (auto name : names) {
|
||||
structure = structure->addPropertyTransition(vm, structure, Identifier::fromString(vm, name), 0, offset);
|
||||
ASSERT_UNUSED(expected, offset == expected);
|
||||
expected++;
|
||||
}
|
||||
return structure;
|
||||
}
|
||||
|
||||
Structure* createListItemMetaStructure(VM& vm, JSGlobalObject* globalObject)
|
||||
{
|
||||
return buildStructure(vm, globalObject, { "index"_s, "depth"_s, "ordered"_s, "start"_s, "checked"_s });
|
||||
}
|
||||
|
||||
Structure* createListMetaStructure(VM& vm, JSGlobalObject* globalObject)
|
||||
{
|
||||
return buildStructure(vm, globalObject, { "ordered"_s, "start"_s, "depth"_s });
|
||||
}
|
||||
|
||||
Structure* createCellMetaStructure(VM& vm, JSGlobalObject* globalObject)
|
||||
{
|
||||
return buildStructure(vm, globalObject, { "align"_s });
|
||||
}
|
||||
|
||||
Structure* createLinkMetaStructure(VM& vm, JSGlobalObject* globalObject)
|
||||
{
|
||||
return buildStructure(vm, globalObject, { "href"_s, "title"_s });
|
||||
}
|
||||
|
||||
} // namespace MarkdownMeta
|
||||
} // namespace Bun
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// extern "C" constructors — callable from MarkdownObject.zig
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
extern "C" JSC::EncodedJSValue BunMarkdownMeta__createListItem(
|
||||
JSGlobalObject* globalObject,
|
||||
uint32_t index,
|
||||
uint32_t depth,
|
||||
bool ordered,
|
||||
EncodedJSValue start,
|
||||
EncodedJSValue checked)
|
||||
{
|
||||
auto* global = jsCast<Zig::GlobalObject*>(globalObject);
|
||||
VM& vm = global->vm();
|
||||
|
||||
JSObject* obj = constructEmptyObject(vm, global->JSMarkdownListItemMetaStructure());
|
||||
obj->putDirectOffset(vm, 0, jsNumber(index));
|
||||
obj->putDirectOffset(vm, 1, jsNumber(depth));
|
||||
obj->putDirectOffset(vm, 2, jsBoolean(ordered));
|
||||
obj->putDirectOffset(vm, 3, JSValue::decode(start));
|
||||
obj->putDirectOffset(vm, 4, JSValue::decode(checked));
|
||||
|
||||
return JSValue::encode(obj);
|
||||
}
|
||||
|
||||
extern "C" JSC::EncodedJSValue BunMarkdownMeta__createList(
|
||||
JSGlobalObject* globalObject,
|
||||
bool ordered,
|
||||
EncodedJSValue start,
|
||||
uint32_t depth)
|
||||
{
|
||||
auto* global = jsCast<Zig::GlobalObject*>(globalObject);
|
||||
VM& vm = global->vm();
|
||||
|
||||
JSObject* obj = constructEmptyObject(vm, global->JSMarkdownListMetaStructure());
|
||||
obj->putDirectOffset(vm, 0, jsBoolean(ordered));
|
||||
obj->putDirectOffset(vm, 1, JSValue::decode(start));
|
||||
obj->putDirectOffset(vm, 2, jsNumber(depth));
|
||||
|
||||
return JSValue::encode(obj);
|
||||
}
|
||||
|
||||
extern "C" JSC::EncodedJSValue BunMarkdownMeta__createCell(
|
||||
JSGlobalObject* globalObject,
|
||||
EncodedJSValue align)
|
||||
{
|
||||
auto* global = jsCast<Zig::GlobalObject*>(globalObject);
|
||||
VM& vm = global->vm();
|
||||
|
||||
JSObject* obj = constructEmptyObject(vm, global->JSMarkdownCellMetaStructure());
|
||||
obj->putDirectOffset(vm, 0, JSValue::decode(align));
|
||||
|
||||
return JSValue::encode(obj);
|
||||
}
|
||||
|
||||
extern "C" JSC::EncodedJSValue BunMarkdownMeta__createLink(
|
||||
JSGlobalObject* globalObject,
|
||||
EncodedJSValue href,
|
||||
EncodedJSValue title)
|
||||
{
|
||||
auto* global = jsCast<Zig::GlobalObject*>(globalObject);
|
||||
VM& vm = global->vm();
|
||||
|
||||
JSObject* obj = constructEmptyObject(vm, global->JSMarkdownLinkMetaStructure());
|
||||
obj->putDirectOffset(vm, 0, JSValue::decode(href));
|
||||
obj->putDirectOffset(vm, 1, JSValue::decode(title));
|
||||
|
||||
return JSValue::encode(obj);
|
||||
}
|
||||
61
src/bun.js/bindings/BunMarkdownMeta.h
Normal file
61
src/bun.js/bindings/BunMarkdownMeta.h
Normal file
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
#include "root.h"
|
||||
#include "headers.h"
|
||||
#include "JavaScriptCore/JSObjectInlines.h"
|
||||
#include "ZigGlobalObject.h"
|
||||
|
||||
using namespace JSC;
|
||||
|
||||
namespace Bun {
|
||||
namespace MarkdownMeta {
|
||||
|
||||
// Cached Structures for the small metadata objects passed as the second
|
||||
// argument to Bun.markdown.render() callbacks. These have fixed shapes
|
||||
// so JSC's property access inline caches stay monomorphic and we avoid
|
||||
// the string-hash + property-transition cost of `put()`-style construction
|
||||
// on every callback (which matters a lot for list items and table cells).
|
||||
|
||||
Structure* createListItemMetaStructure(VM& vm, JSGlobalObject* globalObject);
|
||||
Structure* createListMetaStructure(VM& vm, JSGlobalObject* globalObject);
|
||||
Structure* createCellMetaStructure(VM& vm, JSGlobalObject* globalObject);
|
||||
Structure* createLinkMetaStructure(VM& vm, JSGlobalObject* globalObject);
|
||||
|
||||
} // namespace MarkdownMeta
|
||||
} // namespace Bun
|
||||
|
||||
// ListItemMeta: {index, depth, ordered, start, checked}
|
||||
// `start` and `checked` are always present (jsUndefined() when not applicable)
|
||||
// so the shape is fixed.
|
||||
extern "C" JSC::EncodedJSValue BunMarkdownMeta__createListItem(
|
||||
JSGlobalObject* globalObject,
|
||||
uint32_t index,
|
||||
uint32_t depth,
|
||||
bool ordered,
|
||||
EncodedJSValue start, // jsNumber or jsUndefined
|
||||
EncodedJSValue checked // jsBoolean or jsUndefined
|
||||
);
|
||||
|
||||
// ListMeta: {ordered, start, depth}
|
||||
// `start` is always present (jsUndefined for unordered).
|
||||
extern "C" JSC::EncodedJSValue BunMarkdownMeta__createList(
|
||||
JSGlobalObject* globalObject,
|
||||
bool ordered,
|
||||
EncodedJSValue start, // jsNumber or jsUndefined
|
||||
uint32_t depth);
|
||||
|
||||
// CellMeta: {align}
|
||||
// `align` is always present (jsUndefined when no alignment).
|
||||
extern "C" JSC::EncodedJSValue BunMarkdownMeta__createCell(
|
||||
JSGlobalObject* globalObject,
|
||||
EncodedJSValue align // jsString or jsUndefined
|
||||
);
|
||||
|
||||
// LinkMeta / ImageMeta: {href, title} or {src, title}
|
||||
// `title` is always present (jsUndefined when missing). `href` and `src`
|
||||
// share the structure slot (first property) — the property name differs
|
||||
// but the shape is the same; two separate structures are used.
|
||||
extern "C" JSC::EncodedJSValue BunMarkdownMeta__createLink(
|
||||
JSGlobalObject* globalObject,
|
||||
EncodedJSValue href,
|
||||
EncodedJSValue title // jsString or jsUndefined
|
||||
);
|
||||
@@ -124,6 +124,7 @@
|
||||
#include "JSSink.h"
|
||||
#include "JSSocketAddressDTO.h"
|
||||
#include "JSReactElement.h"
|
||||
#include "BunMarkdownMeta.h"
|
||||
#include "JSSQLStatement.h"
|
||||
#include "JSStringDecoder.h"
|
||||
#include "JSTextEncoder.h"
|
||||
@@ -1802,6 +1803,23 @@ void GlobalObject::finishCreation(VM& vm)
|
||||
init.set(Bun::JSReactElement::createStructure(init.vm, init.owner));
|
||||
});
|
||||
|
||||
m_JSMarkdownListItemMetaStructure.initLater(
|
||||
[](const Initializer<Structure>& init) {
|
||||
init.set(Bun::MarkdownMeta::createListItemMetaStructure(init.vm, init.owner));
|
||||
});
|
||||
m_JSMarkdownListMetaStructure.initLater(
|
||||
[](const Initializer<Structure>& init) {
|
||||
init.set(Bun::MarkdownMeta::createListMetaStructure(init.vm, init.owner));
|
||||
});
|
||||
m_JSMarkdownCellMetaStructure.initLater(
|
||||
[](const Initializer<Structure>& init) {
|
||||
init.set(Bun::MarkdownMeta::createCellMetaStructure(init.vm, init.owner));
|
||||
});
|
||||
m_JSMarkdownLinkMetaStructure.initLater(
|
||||
[](const Initializer<Structure>& init) {
|
||||
init.set(Bun::MarkdownMeta::createLinkMetaStructure(init.vm, init.owner));
|
||||
});
|
||||
|
||||
m_JSSQLStatementStructure.initLater(
|
||||
[](const Initializer<Structure>& init) {
|
||||
init.set(WebCore::createJSSQLStatementStructure(init.owner));
|
||||
|
||||
@@ -302,6 +302,10 @@ public:
|
||||
Structure* CommonJSModuleObjectStructure() const { return m_commonJSModuleObjectStructure.getInitializedOnMainThread(this); }
|
||||
Structure* JSSocketAddressDTOStructure() const { return m_JSSocketAddressDTOStructure.getInitializedOnMainThread(this); }
|
||||
Structure* JSReactElementStructure() const { return m_JSReactElementStructure.getInitializedOnMainThread(this); }
|
||||
Structure* JSMarkdownListItemMetaStructure() const { return m_JSMarkdownListItemMetaStructure.getInitializedOnMainThread(this); }
|
||||
Structure* JSMarkdownListMetaStructure() const { return m_JSMarkdownListMetaStructure.getInitializedOnMainThread(this); }
|
||||
Structure* JSMarkdownCellMetaStructure() const { return m_JSMarkdownCellMetaStructure.getInitializedOnMainThread(this); }
|
||||
Structure* JSMarkdownLinkMetaStructure() const { return m_JSMarkdownLinkMetaStructure.getInitializedOnMainThread(this); }
|
||||
Structure* ImportMetaObjectStructure() const { return m_importMetaObjectStructure.getInitializedOnMainThread(this); }
|
||||
Structure* ImportMetaBakeObjectStructure() const { return m_importMetaBakeObjectStructure.getInitializedOnMainThread(this); }
|
||||
Structure* AsyncContextFrameStructure() const { return m_asyncBoundFunctionStructure.getInitializedOnMainThread(this); }
|
||||
@@ -597,6 +601,10 @@ public:
|
||||
V(private, LazyPropertyOfGlobalObject<Structure>, m_commonJSModuleObjectStructure) \
|
||||
V(private, LazyPropertyOfGlobalObject<Structure>, m_JSSocketAddressDTOStructure) \
|
||||
V(private, LazyPropertyOfGlobalObject<Structure>, m_JSReactElementStructure) \
|
||||
V(private, LazyPropertyOfGlobalObject<Structure>, m_JSMarkdownListItemMetaStructure) \
|
||||
V(private, LazyPropertyOfGlobalObject<Structure>, m_JSMarkdownListMetaStructure) \
|
||||
V(private, LazyPropertyOfGlobalObject<Structure>, m_JSMarkdownCellMetaStructure) \
|
||||
V(private, LazyPropertyOfGlobalObject<Structure>, m_JSMarkdownLinkMetaStructure) \
|
||||
V(private, LazyPropertyOfGlobalObject<Structure>, m_memoryFootprintStructure) \
|
||||
V(private, LazyPropertyOfGlobalObject<JSObject>, m_requireFunctionUnbound) \
|
||||
V(private, LazyPropertyOfGlobalObject<JSObject>, m_requireResolveFunctionUnbound) \
|
||||
|
||||
@@ -186,15 +186,9 @@ pub const PathWatcher = struct {
|
||||
.manager = manager,
|
||||
});
|
||||
|
||||
errdefer {
|
||||
_ = manager.watchers.swapRemove(event_path);
|
||||
this.manager = null;
|
||||
this.deinit();
|
||||
}
|
||||
|
||||
if (uv.uv_fs_event_init(manager.vm.uvLoop(), &this.handle).toError(.watch)) |err| {
|
||||
return .{ .err = err };
|
||||
}
|
||||
// uv_fs_event_init on Windows unconditionally returns 0 (vendor/libuv/src/win/fs-event.c).
|
||||
// bun.assert evaluates its argument before the inline early-return, so this runs in release too.
|
||||
bun.assert(uv.uv_fs_event_init(manager.vm.uvLoop(), &this.handle) == .zero);
|
||||
this.handle.data = this;
|
||||
|
||||
// UV_FS_EVENT_RECURSIVE only works for Windows and OSX
|
||||
@@ -204,6 +198,12 @@ pub const PathWatcher = struct {
|
||||
event_path.ptr,
|
||||
if (recursive) uv.UV_FS_EVENT_RECURSIVE else 0,
|
||||
).toError(.watch)) |err| {
|
||||
// `errdefer` doesn't fire on `return .{ .err = ... }` (that's a successful return of a
|
||||
// Maybe(T), not an error-union return). Clean up the map entry and the half-initialized
|
||||
// watcher inline. See #26254.
|
||||
_ = manager.watchers.swapRemove(event_path);
|
||||
this.manager = null; // prevent deinit() from re-entering unregisterWatcher
|
||||
this.deinit();
|
||||
return .{ .err = err };
|
||||
}
|
||||
// we handle this in node_fs_watcher
|
||||
|
||||
@@ -301,10 +301,9 @@ pub fn scheduleBarrelDeferredImports(this: *BundleV2, result: *ParseTask.Result.
|
||||
// Handle import records without named bindings (not in named_imports).
|
||||
// - `import "x"` (bare statement): tree-shakeable with sideEffects: false — skip.
|
||||
// - `require("x")`: synchronous, needs full module — always mark as .all.
|
||||
// - `import("x")`: mark as .all ONLY if the barrel has no prior requests,
|
||||
// meaning this is the sole reference. If the barrel already has a .partial
|
||||
// entry from a static import, the dynamic import is likely a secondary
|
||||
// (possibly circular) reference and should not escalate requirements.
|
||||
// - `import("x")`: returns the full module namespace at runtime — consumer
|
||||
// can destructure or access any export. Must mark as .all. We cannot
|
||||
// safely assume which exports will be used.
|
||||
for (file_import_records.slice(), 0..) |ir, idx| {
|
||||
const target = if (ir.source_index.isValid())
|
||||
ir.source_index.get()
|
||||
@@ -319,10 +318,9 @@ pub fn scheduleBarrelDeferredImports(this: *BundleV2, result: *ParseTask.Result.
|
||||
const gop = try this.requested_exports.getOrPut(this.allocator(), target);
|
||||
gop.value_ptr.* = .all;
|
||||
} else if (ir.kind == .dynamic) {
|
||||
// Only escalate to .all if no prior requests exist for this target.
|
||||
if (!this.requested_exports.contains(target)) {
|
||||
try this.requested_exports.put(this.allocator(), target, .all);
|
||||
}
|
||||
// import() returns the full module namespace — must preserve all exports.
|
||||
const gop = try this.requested_exports.getOrPut(this.allocator(), target);
|
||||
gop.value_ptr.* = .all;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,8 +352,8 @@ pub fn scheduleBarrelDeferredImports(this: *BundleV2, result: *ParseTask.Result.
|
||||
}
|
||||
}
|
||||
|
||||
// Add bare require/dynamic-import targets to BFS as star imports (matching
|
||||
// the seeding logic above — require always, dynamic only when sole reference).
|
||||
// Add bare require/dynamic-import targets to BFS as star imports — both
|
||||
// always need the full namespace.
|
||||
for (file_import_records.slice(), 0..) |ir, idx| {
|
||||
const target = if (ir.source_index.isValid())
|
||||
ir.source_index.get()
|
||||
@@ -366,8 +364,7 @@ pub fn scheduleBarrelDeferredImports(this: *BundleV2, result: *ParseTask.Result.
|
||||
if (ir.flags.is_internal) continue;
|
||||
if (named_ir_indices.contains(@intCast(idx))) continue;
|
||||
if (ir.flags.was_originally_bare_import) continue;
|
||||
const is_all = if (this.requested_exports.get(target)) |re| re == .all else false;
|
||||
const should_add = ir.kind == .require or (ir.kind == .dynamic and is_all);
|
||||
const should_add = ir.kind == .require or ir.kind == .dynamic;
|
||||
if (should_add) {
|
||||
try queue.append(queue_alloc, .{ .barrel_source_index = target, .alias = "", .is_star = true });
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@ pub const DeclarationBlock = struct {
|
||||
.declarations = &declarations,
|
||||
.options = options,
|
||||
};
|
||||
errdefer decl_parser.deinit();
|
||||
|
||||
var parser = css.RuleBodyParser(PropertyDeclarationParser).new(input, &decl_parser);
|
||||
|
||||
while (parser.next()) |res| {
|
||||
@@ -80,6 +78,10 @@ pub const DeclarationBlock = struct {
|
||||
options.warn(e);
|
||||
continue;
|
||||
}
|
||||
// errdefer doesn't fire on `return .{ .err = ... }` — Result(T) is a tagged
|
||||
// union, not an error union. Free any declarations accumulated so far.
|
||||
css.deepDeinit(css.Property, input.allocator(), &declarations);
|
||||
css.deepDeinit(css.Property, input.allocator(), &important_declarations);
|
||||
return .{ .err = e };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,19 +951,23 @@ pub const UnresolvedColor = union(enum) {
|
||||
options: *const css.ParserOptions,
|
||||
parser: *ComponentParser,
|
||||
pub fn parsefn(this: *@This(), input2: *css.Parser) Result(UnresolvedColor) {
|
||||
const light = switch (input2.parseUntilBefore(css.Delimiters{ .comma = true }, TokenList, this, @This().parsefn2)) {
|
||||
// errdefer doesn't fire on `return .{ .err = ... }` — Result(T) is a
|
||||
// tagged union, not an error union. Clean up `light` inline.
|
||||
var light = switch (input2.parseUntilBefore(css.Delimiters{ .comma = true }, TokenList, this, @This().parsefn2)) {
|
||||
.result => |vv| vv,
|
||||
.err => |e| return .{ .err = e },
|
||||
};
|
||||
// TODO: fix this
|
||||
errdefer light.deinit();
|
||||
if (input2.expectComma().asErr()) |e| return .{ .err = e };
|
||||
if (input2.expectComma().asErr()) |e| {
|
||||
light.deinit(input2.allocator());
|
||||
return .{ .err = e };
|
||||
}
|
||||
const dark = switch (TokenListFns.parse(input2, this.options, 0)) {
|
||||
.result => |vv| vv,
|
||||
.err => |e| return .{ .err = e },
|
||||
.err => |e| {
|
||||
light.deinit(input2.allocator());
|
||||
return .{ .err = e };
|
||||
},
|
||||
};
|
||||
// TODO: fix this
|
||||
errdefer dark.deinit();
|
||||
return .{ .result = UnresolvedColor{
|
||||
.light_dark = .{
|
||||
.light = light,
|
||||
|
||||
@@ -541,7 +541,9 @@ describe("bundler", () => {
|
||||
});
|
||||
|
||||
// --- Ported from Rolldown: dynamic-import-entry ---
|
||||
// A submodule dynamically imports the barrel back
|
||||
// A submodule dynamically imports the barrel back. import() returns the full
|
||||
// module namespace — all barrel exports must be preserved, even if the
|
||||
// import() result is discarded (we can't statically prove it isn't used).
|
||||
|
||||
itBundled("barrel/DynamicImportInSubmodule", {
|
||||
files: {
|
||||
@@ -562,17 +564,103 @@ describe("bundler", () => {
|
||||
export const a = 'dyn-a';
|
||||
import('./index.js');
|
||||
`,
|
||||
// b.js has a syntax error — only a is imported, so b should be skipped
|
||||
"/node_modules/dynlib/b.js": /* js */ `
|
||||
export const b = <<<SYNTAX_ERROR>>>;
|
||||
export const b = 'dyn-b';
|
||||
`,
|
||||
},
|
||||
outdir: "/out",
|
||||
onAfterBundle(api) {
|
||||
api.expectFile("/out/entry.js").toContain("dyn-a");
|
||||
// b must be included — import() needs the full namespace
|
||||
api.expectFile("/out/entry.js").toContain("dyn-b");
|
||||
},
|
||||
});
|
||||
|
||||
// Dynamic import returns the full namespace at runtime — consumer can access any export.
|
||||
// When a file also has a static named import of the same barrel, the barrel
|
||||
// optimization must not drop exports the dynamic import might use.
|
||||
// Previously, the dynamic import was ignored if a static import already seeded
|
||||
// requested_exports, producing invalid JS (export clause referencing undeclared symbol).
|
||||
itBundled("barrel/DynamicImportWithStaticImportSameTarget", {
|
||||
files: {
|
||||
"/entry.js": /* js */ `
|
||||
import { a } from "barrel";
|
||||
console.log(a);
|
||||
const run = async () => {
|
||||
const { b } = await import("barrel");
|
||||
console.log(b);
|
||||
};
|
||||
run();
|
||||
`,
|
||||
"/node_modules/barrel/package.json": JSON.stringify({
|
||||
name: "barrel",
|
||||
main: "./index.js",
|
||||
sideEffects: false,
|
||||
}),
|
||||
"/node_modules/barrel/index.js": /* js */ `
|
||||
export { a } from "./a.js";
|
||||
export { b } from "./b.js";
|
||||
`,
|
||||
"/node_modules/barrel/a.js": /* js */ `
|
||||
export const a = "A";
|
||||
`,
|
||||
"/node_modules/barrel/b.js": /* js */ `
|
||||
export const b = "B";
|
||||
`,
|
||||
},
|
||||
splitting: true,
|
||||
format: "esm",
|
||||
target: "bun",
|
||||
outdir: "/out",
|
||||
run: {
|
||||
stdout: "A\nB",
|
||||
},
|
||||
});
|
||||
|
||||
// Same as above but static and dynamic importers are in separate files.
|
||||
// This was parse-order dependent — if the static importer's
|
||||
// scheduleBarrelDeferredImports ran first, it seeded .partial and the dynamic
|
||||
// importer's escalation was skipped. Now import() always escalates to .all.
|
||||
itBundled("barrel/DynamicImportWithStaticImportSeparateFiles", {
|
||||
files: {
|
||||
"/static-user.js": /* js */ `
|
||||
import { a } from "barrel2";
|
||||
console.log(a);
|
||||
`,
|
||||
"/dynamic-user.js": /* js */ `
|
||||
const run = async () => {
|
||||
const { b } = await import("barrel2");
|
||||
console.log(b);
|
||||
};
|
||||
run();
|
||||
`,
|
||||
"/node_modules/barrel2/package.json": JSON.stringify({
|
||||
name: "barrel2",
|
||||
main: "./index.js",
|
||||
sideEffects: false,
|
||||
}),
|
||||
"/node_modules/barrel2/index.js": /* js */ `
|
||||
export { a } from "./a.js";
|
||||
export { b } from "./b.js";
|
||||
`,
|
||||
"/node_modules/barrel2/a.js": /* js */ `
|
||||
export const a = "A";
|
||||
`,
|
||||
"/node_modules/barrel2/b.js": /* js */ `
|
||||
export const b = "B";
|
||||
`,
|
||||
},
|
||||
entryPoints: ["/static-user.js", "/dynamic-user.js"],
|
||||
splitting: true,
|
||||
format: "esm",
|
||||
target: "bun",
|
||||
outdir: "/out",
|
||||
run: [
|
||||
{ file: "/out/static-user.js", stdout: "A" },
|
||||
{ file: "/out/dynamic-user.js", stdout: "B" },
|
||||
],
|
||||
});
|
||||
|
||||
// --- Ported from Rolldown: multiple-entries ---
|
||||
// Multiple entry points that each import different things from barrels
|
||||
|
||||
|
||||
@@ -146,6 +146,130 @@ describe("Bun.markdown.render", () => {
|
||||
expect(result).toBe('<ol start="3"><li>first</li><li>second</li></ol>');
|
||||
});
|
||||
|
||||
test("listItem receives {index, depth, ordered, start, checked}", () => {
|
||||
const metas: any[] = [];
|
||||
Markdown.render("3. first\n4. second\n5. third\n", {
|
||||
listItem: (c: string, m: any) => {
|
||||
metas.push(m);
|
||||
return c;
|
||||
},
|
||||
list: (c: string) => c,
|
||||
});
|
||||
// Shape is fixed (5 properties) so JSC inline caches stay monomorphic;
|
||||
// `start` is undefined for unordered, `checked` is undefined for non-task items.
|
||||
expect(metas).toEqual([
|
||||
{ index: 0, depth: 0, ordered: true, start: 3, checked: undefined },
|
||||
{ index: 1, depth: 0, ordered: true, start: 3, checked: undefined },
|
||||
{ index: 2, depth: 0, ordered: true, start: 3, checked: undefined },
|
||||
]);
|
||||
// All items share the same hidden class.
|
||||
expect(Object.keys(metas[0])).toEqual(["index", "depth", "ordered", "start", "checked"]);
|
||||
});
|
||||
|
||||
test("listItem meta for unordered list (start is undefined)", () => {
|
||||
const metas: any[] = [];
|
||||
Markdown.render("- a\n- b\n", {
|
||||
listItem: (c: string, m: any) => {
|
||||
metas.push(m);
|
||||
return c;
|
||||
},
|
||||
list: (c: string) => c,
|
||||
});
|
||||
expect(metas).toEqual([
|
||||
{ index: 0, depth: 0, ordered: false, start: undefined, checked: undefined },
|
||||
{ index: 1, depth: 0, ordered: false, start: undefined, checked: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("listItem depth tracks nesting", () => {
|
||||
const metas: any[] = [];
|
||||
Markdown.render("1. outer\n 1. inner-a\n 2. inner-b\n2. outer2\n", {
|
||||
listItem: (_: string, m: any) => {
|
||||
metas.push(m);
|
||||
return "";
|
||||
},
|
||||
list: () => "",
|
||||
});
|
||||
// Callbacks fire bottom-up: inner items first, then outer.
|
||||
expect(metas).toEqual([
|
||||
{ index: 0, depth: 1, ordered: true, start: 1, checked: undefined },
|
||||
{ index: 1, depth: 1, ordered: true, start: 1, checked: undefined },
|
||||
{ index: 0, depth: 0, ordered: true, start: 1, checked: undefined },
|
||||
{ index: 1, depth: 0, ordered: true, start: 1, checked: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("list meta includes depth", () => {
|
||||
const metas: any[] = [];
|
||||
Markdown.render("- outer\n - inner\n", {
|
||||
list: (c: string, m: any) => {
|
||||
metas.push(m);
|
||||
return c;
|
||||
},
|
||||
listItem: (c: string) => c,
|
||||
});
|
||||
// Inner list fires first (bottom-up). Fixed shape: start is always present.
|
||||
expect(metas).toEqual([
|
||||
{ ordered: false, start: undefined, depth: 1 },
|
||||
{ ordered: false, start: undefined, depth: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("listItem meta includes checked alongside index/depth/ordered", () => {
|
||||
const metas: any[] = [];
|
||||
Markdown.render(
|
||||
"- [x] done\n- [ ] todo\n- plain\n",
|
||||
{
|
||||
listItem: (c: string, m: any) => {
|
||||
metas.push(m);
|
||||
return c;
|
||||
},
|
||||
list: (c: string) => c,
|
||||
},
|
||||
{ tasklists: true },
|
||||
);
|
||||
expect(metas).toEqual([
|
||||
{ index: 0, depth: 0, ordered: false, start: undefined, checked: true },
|
||||
{ index: 1, depth: 0, ordered: false, start: undefined, checked: false },
|
||||
{ index: 2, depth: 0, ordered: false, start: undefined, checked: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("listItem index resets across sibling lists", () => {
|
||||
const metas: any[] = [];
|
||||
Markdown.render("1. a\n2. b\n\npara\n\n1. c\n2. d\n", {
|
||||
listItem: (c: string, m: any) => {
|
||||
metas.push({ text: c, index: m.index });
|
||||
return c;
|
||||
},
|
||||
list: (c: string) => c,
|
||||
paragraph: (c: string) => c,
|
||||
});
|
||||
expect(metas).toEqual([
|
||||
{ text: "a", index: 0 },
|
||||
{ text: "b", index: 1 },
|
||||
{ text: "c", index: 0 },
|
||||
{ text: "d", index: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("listItem enables direct marker rendering (no post-processing)", () => {
|
||||
// The motivating use case: ANSI terminal renderer with depth-aware numbering.
|
||||
const toAlpha = (n: number) => String.fromCharCode(96 + n);
|
||||
const result = Markdown.render("1. first\n 1. sub-a\n 2. sub-b\n2. second\n", {
|
||||
listItem: (c: string, m: any) => {
|
||||
const n = (m.start ?? 1) + m.index;
|
||||
const marker = !m.ordered ? "-" : m.depth === 0 ? `${n}.` : `${toAlpha(n)}.`;
|
||||
const indent = " ".repeat(m.depth);
|
||||
return indent + marker + " " + c.trimEnd() + "\n";
|
||||
},
|
||||
// Nested lists are concatenated directly after the parent item's text;
|
||||
// prefix a newline so the outer listItem's trimEnd() works correctly.
|
||||
list: (c: string) => "\n" + c,
|
||||
});
|
||||
expect(result).toBe("\n1. first\n a. sub-a\n b. sub-b\n2. second\n");
|
||||
});
|
||||
|
||||
test("strikethrough callback", () => {
|
||||
const result = Markdown.render("~~deleted~~\n", {
|
||||
strikethrough: (children: string) => `<del>${children}</del>`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pathToFileURL } from "bun";
|
||||
import { bunRun, bunRunAsScript, isWindows, tempDirWithFiles } from "harness";
|
||||
import { bunEnv, bunExe, bunRun, bunRunAsScript, isWindows, tempDir, tempDirWithFiles } from "harness";
|
||||
import fs, { FSWatcher } from "node:fs";
|
||||
import path from "path";
|
||||
|
||||
@@ -725,3 +725,64 @@ describe("immediately closing", () => {
|
||||
for (let i = 0; i < 100; i++) fs.watch(testDir, { persistent: false, recursive: false }).close();
|
||||
});
|
||||
});
|
||||
|
||||
// On Windows, if fs.watch() fails after getOrPut() inserts into the internal path->watcher
|
||||
// map (e.g. uv_fs_event_start fails on a dangling junction, an ACL-protected dir, or a
|
||||
// directory deleted mid-watch), an errdefer that was silently broken by a !*T -> Maybe(*T)
|
||||
// refactor left the entry in place with a dangling key and an uninitialized value. The next
|
||||
// fs.watch() on the same path collided with the poisoned entry, returned the garbage value
|
||||
// as a *PathWatcher, and segfaulted at 0xFFFFFFFFFFFFFFFF calling .handlers.put() on it.
|
||||
//
|
||||
// https://github.com/oven-sh/bun/issues/26254
|
||||
// https://github.com/oven-sh/bun/issues/20203
|
||||
// https://github.com/oven-sh/bun/issues/19635
|
||||
//
|
||||
// Must run in a subprocess: on an unpatched build this segfaults the whole runtime.
|
||||
test.skipIf(!isWindows)("retrying a failed fs.watch does not crash (windows)", async () => {
|
||||
using dir = tempDir("fswatch-retry-failed", { "index.js": "" });
|
||||
const base = String(dir);
|
||||
|
||||
const fixture = /* js */ `
|
||||
const { mkdirSync, rmdirSync, symlinkSync, watch } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
const base = ${JSON.stringify(base)};
|
||||
const target = join(base, "target");
|
||||
const link = join(base, "link");
|
||||
|
||||
mkdirSync(target);
|
||||
symlinkSync(target, link, "junction"); // junctions need no admin rights on Windows
|
||||
rmdirSync(target); // junction now dangles
|
||||
|
||||
// Call 1: readlink(link) SUCCEEDS (returns the vanished target path into
|
||||
// a stack-local buffer), then uv_fs_event_start(target) fails ENOENT.
|
||||
// On unpatched builds: map entry left with dangling key + uninit value.
|
||||
try { watch(link); throw new Error("expected first watch to fail"); }
|
||||
catch (e) { if (e.code !== "ENOENT") throw e; }
|
||||
|
||||
// Call 2: identical stack frame layout -> identical outbuf address ->
|
||||
// identical key slice -> getOrPut returns found_existing=true ->
|
||||
// returns uninitialized value as a *PathWatcher -> segfault on unpatched builds.
|
||||
// Correct behaviour: throw ENOENT again.
|
||||
try { watch(link); throw new Error("expected second watch to fail"); }
|
||||
catch (e) { if (e.code !== "ENOENT") throw e; }
|
||||
|
||||
// Call 3: a valid watch must still work (map must not be corrupted).
|
||||
watch(base).close();
|
||||
|
||||
console.log("OK");
|
||||
`;
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "-e", fixture],
|
||||
cwd: base,
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout.trim()).toBe("OK");
|
||||
expect(exitCode).toBe(0); // unpatched: exitCode is 3 (Windows segfault)
|
||||
});
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
describe("issue #27687 - virtual HTML entrypoint with absolute script src", () => {
|
||||
test("resolves virtual script referenced via absolute path from virtual HTML", async () => {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["/virtual/index.html"],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
minify: false,
|
||||
files: {
|
||||
"/virtual/index.html": `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/virtual/_hydrate.tsx"></script>
|
||||
</body>
|
||||
</html>`,
|
||||
"/virtual/_hydrate.tsx": `console.log("Hydration entry loaded");`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.logs);
|
||||
}
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outputs.length).toBe(2);
|
||||
|
||||
const htmlOutput = result.outputs.find(o => o.type?.startsWith("text/html"));
|
||||
expect(htmlOutput).toBeDefined();
|
||||
|
||||
const jsOutput = result.outputs.find(o => o.type?.startsWith("text/javascript"));
|
||||
expect(jsOutput).toBeDefined();
|
||||
|
||||
const jsContent = await jsOutput!.text();
|
||||
expect(jsContent).toContain("Hydration entry loaded");
|
||||
});
|
||||
|
||||
test("resolves virtual script with absolute path from different virtual directory", async () => {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["/app/index.html"],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
minify: false,
|
||||
files: {
|
||||
"/app/index.html": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<script type="module" src="/shared/utils.js"></script>
|
||||
</body>
|
||||
</html>`,
|
||||
"/shared/utils.js": `export const msg = "cross-directory import works";
|
||||
console.log(msg);`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.logs);
|
||||
}
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const jsOutput = result.outputs.find(o => o.type?.startsWith("text/javascript"));
|
||||
expect(jsOutput).toBeDefined();
|
||||
|
||||
const jsContent = await jsOutput!.text();
|
||||
expect(jsContent).toContain("cross-directory import works");
|
||||
});
|
||||
|
||||
test("resolves virtual script with root-level absolute path from virtual HTML", async () => {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["/index.html"],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
minify: false,
|
||||
files: {
|
||||
"/index.html": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>`,
|
||||
"/app.js": `console.log("root level script");`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.logs);
|
||||
}
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const jsOutput = result.outputs.find(o => o.type?.startsWith("text/javascript"));
|
||||
expect(jsOutput).toBeDefined();
|
||||
|
||||
const jsContent = await jsOutput!.text();
|
||||
expect(jsContent).toContain("root level script");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user