diff --git a/.github/workflows/tbdocs-gh-pages.yml b/.github/workflows/tbdocs-gh-pages.yml index b0a951554..175ec2561 100644 --- a/.github/workflows/tbdocs-gh-pages.yml +++ b/.github/workflows/tbdocs-gh-pages.yml @@ -5,7 +5,7 @@ on: branches: ["staging"] # Allows you to run this workflow manually from the Actions tab. - # The manual path additionally cuts a GitHub release with the + # The manual path additionally publishes a GitHub release with the # offline-browsable site copy attached as a zip; pushes to # `staging` only deploy to Pages. workflow_dispatch: @@ -197,6 +197,39 @@ jobs: # Workflow-internal hand-off to the release job; the # release itself carries the long-lived copy of the zip. retention-days: 7 + # Every file the Downloads page offers from this site -- each + # same-site on it with the `download` attribute -- goes into + # the release beside the zip and the PDF, so a download added to + # that page reaches the release with no change here. + - name: Collect the Downloads page's files + if: github.event_name == 'workflow_dispatch' + env: + OUT: ${{ runner.temp }}/downloads + run: | + node -e ' + const fs = require("fs"), path = require("path"); + const html = fs.readFileSync("docs/_site/Downloads.html", "utf8"); + const out = process.env.OUT; + fs.mkdirSync(out, { recursive: true }); + let n = 0; + for (const [tag] of html.matchAll(/]*>/g)) { + if (!/\sdownload(?=[\s>=])/.test(tag)) continue; + const href = /\shref="([^"]+)"/.exec(tag)?.[1]; + if (!href || /^[a-z]+:/i.test(href)) continue; + const rel = decodeURIComponent(new URL(href, "https://site/Downloads").pathname).slice(1); + fs.copyFileSync(path.join("docs/_site", rel), path.join(out, path.basename(rel))); + console.log(rel); + n++; + } + if (n === 0) throw new Error("no downloads found on docs/_site/Downloads.html"); + ' + - name: Upload downloads workflow artifact + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v7 + with: + name: twinbasic-docs-downloads + path: ${{ runner.temp }}/downloads + retention-days: 7 - name: Upload book PDF workflow artifact uses: actions/upload-artifact@v7 with: @@ -216,7 +249,7 @@ jobs: uses: actions/deploy-pages@v5 # Release job -- runs only on manual dispatch, after a successful - # Pages deploy. Each manual deploy cuts a new GitHub release whose + # Pages deploy. Each manual deploy publishes a new GitHub release whose # tag tracks a publicly visible documentation snapshot at # https://docs.twinbasic.com, with the offline-browsable copy of # the site attached as twinbasic-docs-offline.zip. @@ -238,6 +271,11 @@ jobs: uses: actions/download-artifact@v8 with: name: twinbasic-docs-book-pdf + - name: Download downloads workflow artifact + uses: actions/download-artifact@v8 + with: + name: twinbasic-docs-downloads + path: downloads - name: Compute release tag and name id: tag env: @@ -265,9 +303,12 @@ jobs: **PDF book:** download `twinBASIC Book.pdf` for an offline-readable, printable copy of the full reference. + **Tools and test projects:** the other files are the ones the [Downloads](https://docs.twinbasic.com/Downloads) page offers, as they were at this snapshot: the help add-in's project, the three editions of impexp, and the contributed packages' test projects. + Source commit: ${{ github.sha }}. files: | twinbasic-docs-offline.zip twinBASIC Book.pdf + downloads/* fail_on_unmatched_files: true make_latest: 'true' diff --git a/BUGS-TO-REPORT.md b/BUGS-TO-REPORT.md index 0d74c2986..8afc0548c 100644 --- a/BUGS-TO-REPORT.md +++ b/BUGS-TO-REPORT.md @@ -4737,4 +4737,300 @@ Severity: high for a project that has the line. The compiler crashes on every at +--- + +## Setting a Slider's `BackColor` at run time does not change what the control shows, even after the window is invalidated + +**Describe the bug** +The Slider of the WinNativeCommonCtls package keeps drawing its old background after `BackColor` is set at run time. The new colour appears only after the control is hidden and shown again. Invalidating the window (`InvalidateRect` and `UpdateWindow`) does not make it appear. Observed by reading a pixel of the control's window with `GetPixel`, and by eye. +The Slider hands out its `BackColor` only in its handler of the `WM_CTLCOLORSTATIC` message (`HandleGetColors`, in `CONTROLS/Slider.twin`), and has no handler for the property being set. The ProgressBar and the ListView of the same package have one (`BackColorChanged`, `Handles BackColor.OnPropertyLet`), which validates the colour and repaints. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `slider-backcolor-no-repaint.twinproj` (attached as `slider-backcolor-no-repaint.zip`). `Form1` holds one Slider, `Slider1`, with a design-time `BackColor` of `&H808080`. +2. Run the project (F5). `Sub Main` shows `Form1`, lets it paint, and then does: + ``` + Form1.Slider1.BackColor = vbRed + Settle ' DoEvents and Sleep 25, twenty times + Debug.Print "BackColor set to &H" & Hex$(Form1.Slider1.BackColor) & ", pixel = &H" & Hex$(Corner()) + ``` + where `Corner` is `GetPixel` on `GetDC(Form1.Slider1.hWnd)` at (2, 2), a point outside the channel and the thumb. It then calls `InvalidateRect` and `UpdateWindow` on the Slider's window and reads the pixel again, and last sets `Visible` to `False` and back to `True` and reads it a third time. +3. See in the DEBUG CONSOLE: + ``` + painted, BackColor = &H808080, pixel = &H808080 + BackColor set to &HFF, pixel = &H808080 + after InvalidateRect and UpdateWindow, pixel = &H808080 + after Visible = False, True, pixel = &HFF + ``` + +**Expected behavior** +The Slider shows `BackColor` as soon as it is set: the second line ends `pixel = &HFF`. A control that is invalidated draws its current `BackColor`, so the third line does too. The ProgressBar and ListView of the same package repaint when their `BackColor` is set. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 1005 + +**Additional context** +`GetPixel` reads what is on screen. `PrintWindow` is not used because it makes the window paint itself, which would show the new colour and hide the defect. +Tried in a scratch variant, not kept: `RedrawWindow` with `RDW_INVALIDATE Or RDW_ERASE Or RDW_UPDATENOW Or RDW_ALLCHILDREN`, and a change of `Slider1.Value`, left the pixel at `&H808080` too. Minimising and restoring the form, and resizing it, were tried only after the hide and show, so they show nothing about this. +Severity: low. The colour is wrong until the control is hidden and shown, and nothing is lost. +VB6 was not tried. + + + +--- + +## `ListItem.BackColor` raises error 380 when it is assigned its own default, -1, so a row that has a colour cannot return to the list's colour + +**Describe the bug** +The `BackColor` of a ListItem (WinNativeCommonCtls) defaults to `-1`, which means "use the ListView's `BackColor`" (`SUPPORT/ListItem.twin`: `Public BackColor As OLE_COLOR = -1`, with the comment `CLR_NONE`). Assigning `-1` to a row that has been given a colour raises error 380, *Invalid property value*. The row keeps its colour, so once a row has a colour of its own there is no way back to the list's colour. +The cause is `BackColorChanged`, which calls `VB.CommonValidateColorOrRevert`. In BETA 1005 that function refuses `-1` (the change that made every colour property of the VB controls refuse a value that is not a colour); `-1` is the one value this property uses on purpose. Assigning `-1` to a new item, which already holds `-1`, raises nothing, because nothing changes. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `listitem-backcolor-default-refused.twinproj` (attached as `listitem-backcolor-default-refused.zip`). `Form1` holds one ListView, `ListView1`, and the module `ListItemBackColorTests` holds the test cases. +2. Run, with `On Error Resume Next`: + ``` + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + item.BackColor = vbYellow + item.BackColor = -1 + Debug.Print Err.Number, item.BackColor + ``` +3. See `380` and `65535`. Expected `0` and `-1`. + +The module below is the reproducer's, and is meant to be adapted as a regression test. On BETA 1005 the first four cases pass and the last fails with `Expected: 0, Actual: 380`. +```vb +[TestFixture] +Module ListItemBackColorTests + + Private Const UseListColour As Long = -1 + + [TestCase] + Public Sub DefaultIsUseListColour() + Dim item As ListItem, value As Long + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + value = item.BackColor + Unload Form1 + Assert.Strict.AreEqual UseListColour, value, "the default" + End Sub + + [TestCase] + Public Sub ColourIsAccepted() + Dim item As ListItem, number As Long, value As Long + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + On Error Resume Next: item.BackColor = vbYellow: number = Err.Number: On Error GoTo 0 + value = item.BackColor + Unload Form1 + Assert.Strict.AreEqual 0, number, "the error" + Assert.Strict.AreEqual CLng(vbYellow), value, "the colour" + End Sub + + [TestCase] + Public Sub DefaultAssignedToNewItem() + Dim item As ListItem, number As Long, value As Long + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + On Error Resume Next: item.BackColor = UseListColour: number = Err.Number: On Error GoTo 0 + value = item.BackColor + Unload Form1 + Assert.Strict.AreEqual 0, number, "the error" + Assert.Strict.AreEqual UseListColour, value, "the colour" + End Sub + + [TestCase] + Public Sub ColourIsKeptWhenDefaultIsAssigned() + Dim item As ListItem, value As Long + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + item.BackColor = vbYellow + On Error Resume Next: item.BackColor = UseListColour: On Error GoTo 0 + value = item.BackColor + Unload Form1 + Assert.Strict.AreEqual True, (value = CLng(vbYellow) Or value = UseListColour), "yellow or the list's colour" + End Sub + + [TestCase] + Public Sub DefaultAssignedAfterColour() + Dim item As ListItem, number As Long, value As Long + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + item.BackColor = vbYellow + On Error Resume Next: item.BackColor = UseListColour: number = Err.Number: On Error GoTo 0 + value = item.BackColor + Unload Form1 + Assert.Strict.AreEqual 0, number, "the error" + Assert.Strict.AreEqual UseListColour, value, "the colour" + End Sub + +End Module +``` + +**Expected behavior** +Assigning `-1` to `ListItem.BackColor` is accepted and the row is drawn in the ListView's `BackColor` again, as for a new row. `-1` is the property's default and its documented meaning, so the colour check has to let it through for this property (the controls of the VB package have no such value, and refusing it there is right). MSCOMCTL's ListItem has no `BackColor`, so VB6 has nothing to compare. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 1005 + +**Additional context** +A ListItem's `ForeColor` defaults to a system colour, not to -1, so the same check does no harm there. Severity: low. A program can only give a row a colour that looks like the list's, and the row is not changed by the refused assignment. + + + +--- + +## A call to a `Declare Function` marked `[ConstantFoldable]` crashes the build when Constant Function Folding is on + +**Describe the bug** +With **Constant Function Folding** set to **Yes** in Project Settings (`"optimizer.constantFunctionFolding": true`), a `Declare Function` that carries `[ConstantFoldable]` (or `[ConstantFoldableNumericsOnly]`) and is called with constant arguments crashes the compiler during the build. The project compiles clean (0 errors, 0 warnings); Build then prints `NATIVE EXCEPTION: ACCESS_VIOLATION` in the DEBUG CONSOLE, the IDE restarts the compiler on the project, the same exception follows, and no exe is written. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `constantfoldable-declare-crash.twinproj` (attached as `constantfoldable-declare-crash.zip`). Its project has Constant Function Folding set to Yes, and its one source file, `Startup.twin`, holds the whole bug: + ``` + Module Startup + + Public Sub Main() + GetCurrentProcessId + End Sub + + [ConstantFoldable] + Declare Function GetCurrentProcessId Lib "kernel32" () As Long + + End Module + ``` +2. Let the IDE compile it: no diagnostic. +3. Build the project (**Build**, or `node scripts/tbbuild.mjs --build`). See the DEBUG CONSOLE: `NATIVE EXCEPTION: ACCESS_VIOLATION {unknown} [$00000000]`, then `restarting from MEMORY [...]` and the same exception again. The build produces no exe. + +What it depends on, each tried in a project of its own: +- it needs the setting on, the attribute and a call: with Constant Function Folding off the project builds; without `[ConstantFoldable]` it builds; with the attribute and no call (`Sub Main` empty) it builds; +- every argument of the call must be a constant, and a call with none counts: `r = DSleepFn(5, 0)` for `Declare Function DSleepFn Lib "kernel32" Alias "SleepEx" (ByVal ms As Long, ByVal a As Long) As Long` crashes, and so does the zero-argument call above. With a variable for either argument (`v = 5: r = DSleepFn(v, 0)`, or `DSleepFn(5, v)`) it builds; +- `[ConstantFoldableNumericsOnly]` crashes as `[ConstantFoldable]` does; `Declare`, `Declare PtrSafe`, `Private Declare` and `DeclareWide` all crash; the call may be a statement or the right side of an assignment; +- a `[ConstantFoldable]` ordinary `Function` in a Module, called with a constant, builds (it is folded, which is what the attribute is for), and so does a `Declare Sub`. + +**Expected behavior** +A built exe. A call to a Declare is not a pure function of its arguments (it calls into a DLL), so it is either not folded, or the attribute is refused on a `Declare Function` with a diagnostic, as it is refused on a method of a class. The compiler does not crash. The attribute does not exist in VB6, and neither does Constant Function Folding. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 1005 + +**Additional context** +The same in a 32-bit and a 64-bit build. The IDE's compile (what runs when a project is opened or edited) is clean, so the editor shows no sign of the problem; only Build crashes, so a project can hold the line for a long time before it is noticed. +Severity: medium. The build crashes every time, and the compiler restarts into the same crash, but the project compiles and runs from the IDE, and removing the attribute or the setting fixes it. No wrong code is produced. + + + +--- + +## `[RunBeforeStartupObject]` on a generic procedure crashes the build + +**Describe the bug** +`[RunBeforeStartupObject]` on a generic procedure of a Module, `Public Sub Pre(Of T)()`, compiles without a diagnostic and then crashes the compiler during the build. The DEBUG CONSOLE shows `NATIVE EXCEPTION: ACCESS_VIOLATION`, the IDE restarts the compiler on the project, the same exception follows, and no exe is written. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `runbeforestartupobject-generic-crash.twinproj` (attached as `runbeforestartupobject-generic-crash.zip`). Its one source file, `Startup.twin`, holds the whole bug: + ``` + Module Startup + + Public Sub Main() + End Sub + + [RunBeforeStartupObject] + Public Sub Pre(Of T)() + End Sub + + End Module + ``` +2. Let the IDE compile it: no diagnostic. +3. Build the project. See the DEBUG CONSOLE: `NATIVE EXCEPTION: ACCESS_VIOLATION {no-basic-code} [$1EA2AFE8:twinBASIC_win32.dll+0002AFE8]`, then `restarting from MEMORY [...]` and the same exception again. No exe is written. + +What it depends on, each tried in a project of its own: +- the generic procedure with the attribute alone is the cause: `Pre(Of T)` without the attribute builds, and `Pre()` with it, not generic, builds; +- a generic `Function` crashes too (`Public Function Pre(Of T)() As Boolean`); the body, an empty Sub or Function, does not matter, and nothing calls `Pre`. + +**Expected behavior** +Either a diagnostic that the attribute is not allowed on a generic procedure, or a built exe in which `Pre` runs before `Main` (what it does on a procedure that is not generic). The compiler does not crash. The attribute does not exist in VB6. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 1005 + +**Additional context** +The same in a 32-bit and a 64-bit build. Only Build crashes: the IDE's compile and the sweep of the attribute's sites find no problem, so the line stays unnoticed until the first build. +Severity: medium. The build crashes every time, and the compiler restarts into the same crash; removing the attribute from the generic procedure fixes it. + + + +--- + +## `[RunAfterBuild]` on a generic Sub is accepted and the Sub never runs + +**Describe the bug** +A Sub marked `[RunAfterBuild]` runs in the IDE once the build has finished. When the Sub is generic (`Public Sub Built(Of T)()`), the attribute is accepted with no diagnostic, the project builds, and the DEBUG CONSOLE says `[BUILD] Executing '...Built'...` as it does for any other such Sub, but the body of the Sub is never run: its `Debug.Print` prints nothing and a file it opens is not written. The compiler does count the Sub as the project's `[RunAfterBuild]`, since a second one is TB5114, *encountered too many [RunAfterBuild] attributes. Only allowed one per-project*. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `runafterbuild-generic-sub-never-runs.twinproj` (attached as `runafterbuild-generic-sub-never-runs.zip`). Its one source file, `Startup.twin`, holds the whole bug: + ``` + Module Startup + + Public Sub Main() + End Sub + + [RunAfterBuild] + Public Sub Built(Of T)() + Debug.Cls + Debug.Print "built" + End Sub + + End Module + ``` +2. Build the project. +3. See the DEBUG CONSOLE: the build log, ending `[BUILD] Executing 'RunafterbuildGenericSubNeverRuns.Startup.Built'...`, and no line `built` after it. Remove `(Of T)` and build again: `built` is printed (the console is cleared first, so it is the only line). + +Tried in a project of its own: a body that opens a file under `Environ("TEMP")` and writes a line to it, in place of the `Debug.Print`; the file is not created. The same in a 32-bit and a 64-bit build. + +**Expected behavior** +The Sub runs after the build, as it does without `(Of T)`, or the attribute is refused on a generic Sub with a diagnostic (as `[RunAfterBuild]` is refused on a second procedure, TB5114). A Sub that the compiler accepts, counts and announces as executing runs. The attribute does not exist in VB6. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 1005 + +**Additional context** +`[RunAfterBuild]` on a `Function` and on a `Property Get` in a Module runs as it does on a Sub, in the same project layout. +Severity: low. Nothing crashes and nothing is lost; the code the author expected to run does not, with no message. + + + +--- + +## Some VB6 attribute lines have no effect in a built type library + +**Describe the bug** +twinBASIC reads the `Attribute` lines of a `.cls` file, and the IDE's VB6 import keeps them as VB6 wrote them, but some of them do not reach the type library of the built DLL. `VB_HelpID` and `VB_VarHelpID` leave the class and its members with help context 0. `VB_VarMemberFlags = "40"` and `"400"` leave a public field neither hidden nor nonbrowsable. `VB_MemberFlags = "200"` (user-interface default) and `"14"` (bindable and displaybind) leave the member with no flags. No diagnostic says so. The other attribute lines do take effect in the same build: `VB_Description`, `VB_VarDescription`, `VB_UserMemId`, `VB_VarUserMemId`, `VB_MemberFlags = "40"`, `"400"` and `"440"`, `VB_Creatable`, `VB_GlobalNameSpace`, `VB_PredeclaredId` and `VB_Exposed`. Seen by reading the built DLL's type library with `LoadTypeLibEx`. + +**To Reproduce** +Steps to reproduce the behavior: +1. Unzip `vb6-attribute-lines-no-effect.zip` and open `vb6-attribute-lines-no-effect.twinproj`: an ActiveX DLL with **Register DLL after build** off, so building it registers nothing. `Attrs.cls` holds the lines; `TwinAttrs.twin` is the control. +2. Build it. +3. Open the DLL's type library in OLE/COM Object Viewer (**File → View TypeLib...**). +4. See the coclass `Attrs` with no `helpcontext`, and in `_Attrs`: `HiddenField` and `UnbrowsedField` with neither `hidden` nor `nonbrowsable`, `HelpField` and `WithHelp` with no `helpcontext`, `UiDefault` without `uidefault`, and `Bound` without `bindable` or `displaybind`. +5. See `HiddenMethod`, whose line is `VB_MemberFlags = "40"`, marked `hidden`, and `TwinAttrs.HiddenField`, written with `[Hidden]`, marked `hidden` too. + +**Expected behavior** +Each line gives what it asks for, as its neighbours do: a help context of 2000, 2001 and 2002, a hidden field as `[Hidden]` gives on a field and as `VB_MemberFlags = "40"` gives on a method, a nonbrowsable field, and `FUNCFLAG_FUIDEFAULT`, `FUNCFLAG_FBINDABLE` and `FUNCFLAG_FDISPLAYBIND`. VB6 writes these lines from its Procedure Attributes dialog, so a VB6 component imported into twinBASIC and built again loses its help context IDs, its hidden fields and its data-binding flags without a word. twinBASIC has no attribute of its own for a help context ID, a user-interface default or data binding, so these lines are the only way to ask for them. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 1005 + +**Additional context** +The import is not the cause: it keeps every `Attribute` line of a `.bas` and a `.cls` byte for byte, and those of a `.frm`, `.ctl` and `.pag` inside the class it writes. The type information a class has at run time in the IDE carries no flags and no descriptions for any member, whichever way they are written, so the built DLL is where to look. `[NonBrowsable]` on a public field leaves it without `nonbrowsable` as well. A VB6 build of the same class was not made: VB6 registers an ActiveX DLL as it builds it. Severity: medium for anyone moving a VB6 component to twinBASIC under binary compatibility; nothing fails, and the loss shows only to the component's users, in their object browsers and data-binding dialogs. + diff --git a/WIP.A11y.md b/WIP.A11y.md index 0e8241028..cfe75bd35 100644 --- a/WIP.A11y.md +++ b/WIP.A11y.md @@ -10,7 +10,7 @@ which refuses a tree older than the sources that produced it, then [scripts/pick_a11y_sample.mjs --check](scripts/pick_a11y_sample.mjs) --- see [Choosing the sample](#choosing-the-sample) --- and then [scripts/check_a11y.mjs](scripts/check_a11y.mjs), which runs puppeteer + -axe-core over fourteen sample pages against WCAG 2.0/2.1/2.2 at Level A + AA +axe-core over sixteen sample pages against WCAG 2.0/2.1/2.2 at Level A + AA (plus the `heading-order` best-practice rule) and exits non-zero on any violation. It requires `build.bat` to have produced an up-to-date `_site/`. @@ -55,7 +55,7 @@ axe excludes `aria-hidden` subtrees from rule evaluation wholesale, so the icon **A closed `
` is invisible to the scan.** Its subtree is `notRendered`, so axe never walks it -- the same property that makes the section-links disclosure cheap is what hides it. A defect inside it (for example `target-size` on every link) would pass `check.bat`, because the state a reader sees after one click is never audited. -`STATE_AUDITS` in [scripts/lib/axe-scan.mjs](scripts/lib/axe-scan.mjs) closes that: entries layered onto the page x theme x viewport matrix that apply a DOM mutation from `PAGE_STATES` after navigation and before the audit. Two entries, each run across both themes and both viewports --- eight extra audits on top of the 56 the fourteen sample pages produce, for the 64 the scan reports. +`STATE_AUDITS` in [scripts/lib/axe-scan.mjs](scripts/lib/axe-scan.mjs) closes that: entries layered onto the page x theme x viewport matrix that apply a DOM mutation from `PAGE_STATES` after navigation and before the audit. Two entries, each run across both themes and both viewports --- eight extra audits on top of the 64 the sixteen sample pages produce, for the 72 the scan reports. Three things about it are essential: @@ -69,7 +69,7 @@ Syntax-highlight token colours are kept above 4.5:1 automatically: [builder/high ## Choosing the sample -The scan audits fourteen pages out of ~1,160, so its page list decides what it can report at all -- and a page list that stops being representative fails *silently*. A hand-picked list goes stale as the site grows around it: a sample with no table, image, `
` widget or video card leaves `image-alt`, the table rules and `scrollable-region-focusable` in the run options with nothing to run on, and the gate reports a clean pass. +The scan audits sixteen pages out of ~1,160, so its page list decides what it can report at all -- and a page list that stops being representative fails *silently*. A hand-picked list goes stale as the site grows around it: a sample with no table, image, `
` widget or video card leaves `image-alt`, the table rules and `scrollable-region-focusable` in the run options with nothing to run on, and the gate reports a clean pass. The full-site sweep ([scripts/sweep_a11y.mjs](scripts/sweep_a11y.mjs) — every page, both themes, both viewports, 3,476 audits, ~20 min) is the check on the sample. It shows which constructs the sample has to contain: table wrappers (`scrollable-region-focusable`), footnote back-links (`link-in-text-block`), the FAQ's `` elements at the phone viewport (`target-size`), heading structure (`heading-order`), diagrams (`role-img-alt`) and code-block colours (`color-contrast`). diff --git a/WIP.Build.md b/WIP.Build.md index f5fc3236a..541fbcee8 100644 --- a/WIP.Build.md +++ b/WIP.Build.md @@ -784,8 +784,8 @@ The decisions, each with what it rules out: commit behind the add-in being edited. `Resources/HELP/` is also refused by name, in case it is ever added. A file git does not track is named in the build's output. - **The archive in the project is the one this build wrote, handed over in memory.** - The `helpArchive` task returns the zip it wrote, and `addinProject` waits for it and - packs those bytes as `Resources/HELP/site.zip`. Reading the file back from + The `helpArchive` task (a worker task) returns the zip it wrote, as bytes, and + `addinProject` waits for it and packs those bytes as `Resources/HELP/site.zip`. Reading the file back from `add-in/Resources/HELP/` could pick up the archive of an earlier build whenever this one wrote none (`--no-help-archive`, `--serve`, another `--dest`, a fixture): that case packs no archive, and the summary line says `without the offline archive`. @@ -831,7 +831,10 @@ The decisions, each with what it rules out: `deriveTreeRels` takes (`onlineOnlyRels`), so the link check resolves the page's link to it and the publish sweep sees it. If the task stops writing, `--check-audit-index` reports the file as indexed but not on disk. -- **The pack is `impexp.mjs`'s `importProject`, in process.** `builder/` may not import +- **The pack is `impexp.mjs`'s `packFiles`, in process and in memory.** It packs the + files as `importProject` packs them from a folder (`test/addin-project.test.mjs` pins + the two to the same bytes), with no copy through `%TEMP%`: staging the 53 MB there + took 200 ms to 1.9 s on a busy disk, against about 100 ms in memory. `builder/` may not import `scripts/` (`biome.jsonc`), because `check_tree_fresh.mjs` would not see a change to what the build imports. `impexp.mjs` is the one exception, by name in the rule: it imports nothing, the build already publishes it through `bundle_extra`, and diff --git a/WIP.Harness.md b/WIP.Harness.md index 6a782d35c..595c5efd7 100644 --- a/WIP.Harness.md +++ b/WIP.Harness.md @@ -190,9 +190,10 @@ about a hundred builds by itself. has one 2,514-character run of 263 pipe-separated identifiers, from `On|Off|Explicit` to `UserDefinedTypeIsAnAlias`, with `DllExport|ComExport` in the middle of it. It mixes keywords, attributes and object members (`Debug`, `Circle`, `PSet`), so a name in it is only a -candidate. Swept, exactly one name the page does not document is accepted anywhere: -`[PropertyPage]`, at eighteen sites, all of them members of a Class or an Interface. The -other 195 are refused at every site. +candidate. Swept, exactly one name the page did not document was accepted anywhere: +`[PropertyPage]`, at eighteen sites, all of them members of a Class or an Interface, with a +String argument only (any other is TB5163). It now has an entry. The other 195 are refused at +every site. **Six things the sweep has to get right, each of which gives a wrong report when missed:** @@ -233,6 +234,65 @@ they apply nearly everywhere or the compiler tolerates what it does not check. T cannot say which, and neither can a clean build, which is also why *accepted but not documented* is a list to read and not a list to copy into the page. +### The accepted sites already judged + +Every *accepted but not documented* site of the BETA 1005 sweep was judged by asking what +the attribute does there against a control without it: the type library of an ActiveX DLL +built with `project.dllRegisterAfterBuild: false` and dumped with TLI +(`C:\Windows\SysWOW64\cscript.exe` and `TLI.TypeLibInfoFromFile`, since `tlbinf32.dll` is +32-bit), diagnostics from `tbbuild`, and output from `tbrun` and `tbrun --exe`. Where it did +something, the page now names the site. **The sites below were accepted and did nothing, or +nothing anyone could see, so the page leaves them out; a later sweep that lists them again +is not news.** Read a new sweep's list against this one and judge only what is not here. + +- **Nothing reaches a type library from** a Module or its members, a CoClass (even with + `[CoClassId]` or `[COMCreatable]`), a generic or Private class, a Friend or Private member, + `Class_Initialize`, a WithEvents variable, an Enum, a Type, a Type field or a parameter. So a + type-library attribute there is accepted to no effect: `Restricted`, `Hidden` and + `Description` at those sites (the Module-member `Hidden` sites stay on the page, from the + packages' use, for IntelliSense), `DispId` on a Friend Sub, `COMExtensible`, `ComImport` + and `InterfaceId` on a generic or Private class. +- **On an Interface:** `ClassId`, `COMCreatable`, `CustomControl`, `EventInterfaceId`, + `EventsUseDispInterface`, `WindowsControl`, `SetDllDirectory`, `Debuggable`, and the three + run-time checks (they do not reach an implementing class). `AppObject` and `PredeclaredId` + there make the interface's bare name resolve, with no object behind it, and `PredeclaredId` + on a CoClass does the same given a `[Default]` interface line (TB5044 without): left off as + meaningless. +- **On a Class:** `ComImport`, `OleAutomation` (a class's interfaces are dual), and + `SetDllDirectory`, which shows nothing even at its documented sites. +- **On an Event:** `AllowUnpopulatedVtableEntry`, `BindOnlyIfNoArguments`, + `BindOnlyIfStringSuffix`, `CompilerOptions`, `Debuggable`, `DebugOnly`, `Enumerator`, + `IgnoreWarnings`, `EnforceErrors`, `EnforceWarnings`, the run-time checks, `TypeHint`, + `Unimplemented`. `DefaultMember` on an Event gives it DISPID 0 in the event interface, + which no client uses: left off. +- **On a Declare:** the run-time checks, `IgnoreWarnings`, `EnforceErrors`, + `EnforceWarnings`, `Debuggable`, `BindOnlyIfStringSuffix`, `SimplerByVals`, `IdeButton`, + `TestCase`, `ConstantFoldable` on a Declare Sub. `ConstantFoldable` on a Declare Function + crashes the build (the page's WARNING). +- **On a Const:** `BindOnlyIfNoArguments`, `Unimplemented`. **On a module, Private or + WithEvents variable:** `Unimplemented`. **On a public class variable:** `NonBrowsable` + sets no type library flag, though it stays on the page from the packages' use. +- **On a procedure:** `DLLStackCheck` (the exe is the same size), `ConstantFoldable` on a Sub + or a generic Function, `RunAfterBuild` on a generic Sub (never runs: the page's WARNING), + `RunBeforeStartupObject` on a generic Sub (crashes the build: WARNING), `TypeHint` on a Sub + (its argument is not even checked; on a Function or a Property Get it is, and its effect + there is IntelliSense, which no tool here reads), `TestCase` anywhere but a Sub of a + `[TestFixture]` Module (`tbrun --tests` never asks the compiler about it). +- **On a Delegate:** `SimplerByVals`. +- `BindOnlyIfStringSuffix` showed no effect at its documented site either, and + `ConstantFoldableNumericsOnly` folded a call with a String argument: both are on the page + without a claim the probes could make. + +**An `Applicable to:` line is read by `parseTargets`** (`scripts/lib/attributes-doc.mjs`), +one target per phrase between commas and `and`s, from a short list of phrasings. Before +committing a changed line, read it back: `targetsOf` from `scripts/lib/attribute-sweep.mjs` +over `parseAttributes` of the page. The phrasings that misread: a leading article before +a word the rules anchor (`a Const` reads as nothing, `Const` as a module Const), a place +named in a later phrase (`procedure, variable in a Class` turns the bare `procedure` into +one in a Module), and `variables and procedures in a Class` anywhere in a line, which +returns those two targets and drops the rest. Pin each new line in `check_attribute_sweep.mjs`'s +`LINES`. + ## Compiling a twinBASIC project without the IDE in front of you Exported sources say what the compiler *accepts today*; they cannot answer a question no diff --git a/WIP.HelpAddin.md b/WIP.HelpAddin.md index e7f3c0796..f14e18116 100644 --- a/WIP.HelpAddin.md +++ b/WIP.HelpAddin.md @@ -797,7 +797,10 @@ has the complete list): its own rule. Giving that page a `/tB/` permalink, with the old one in `redirect_from:`, would. Deferred. - **The offline tree has no copy of the index.** The add-in embeds its own, and the help - archive (increment 5) is the offline tree as built. + archive (increment 5) is the offline tree as built, less `HELP_ARCHIVE_SKIP` in + `builder/help-archive-step.mjs` (the Documentation Development section, Videos, + Challenges, every `downloads` folder). `HelpServer` redirects any file the archive + lacks to `SiteUrl()`, because every page's navigation links to those sections. ### Stage 4: the add-in, in increments diff --git a/WIP.IDE.md b/WIP.IDE.md index ed7be4b79..7c405690e 100644 --- a/WIP.IDE.md +++ b/WIP.IDE.md @@ -133,7 +133,7 @@ for contributors. - **`shoot_docs.mjs` and WIP.Screenshots.md** hold the states the screenshots already set up, which are often the quickest way to reach a dialog. A new picture is a new shot there, taken in both themes; never make a picture by hand. -- The kit `.claude/tooling-review-scratch/beta995-probes/ide-driving-survey.md` (gitignored) +- The kit `.claude/tooling-review-scratch/ide-docs/ide-driving-survey.md` (gitignored) maps the debugger, Settings and Export internals. ## Writing a chapter diff --git a/WIP.Screenshots.md b/WIP.Screenshots.md index 2c568766e..07976bf0f 100644 --- a/WIP.Screenshots.md +++ b/WIP.Screenshots.md @@ -23,7 +23,10 @@ Ten images under `docs/`, each for a stated reason: - **`packPublishPackage1`**: `packagePublisherPublish` opens the sign-in form when no publisher is signed in, before the confirmation box, and a package is never actually published, so the old file stays. **`packPublishComplete1`** is the result of a real publish, so it stays too. -- **`Tutorials/WebView2/Images/tbWebView2InAForm.gif`** is skipped (owner). +- **`Tutorials/WebView2/Images/tbWebView2InAForm.webp`** is skipped (owner). It is the + former GIF as an animated WebP (Pillow, `quality=80, method=4, allow_mixed=True, + minimize_size=True`: 1.08 MB against the GIF's 3.74 MB, the same 12,260 ms); a lossless + WebP of it is larger than the GIF, because the GIF's dithering changes every frame. - **`favicon.png`** is never changed by this work (owner). ## Decisions (owner) diff --git a/WIP.Typography.md b/WIP.Typography.md index bb0edd421..fea61c7a1 100644 --- a/WIP.Typography.md +++ b/WIP.Typography.md @@ -35,7 +35,7 @@ centre far enough to intersect a neighbour. The link is sized outright clearance. Two rules follow. **Run the full sweep after any change that moves type -metrics**: the fourteen-page sample stays clean through such a change and would +metrics**: the sixteen-page sample stays clean through such a change and would ship the regression. And **treat a target that passes only on the spacing exception as unfixed**: the result depends on the font, the surrounding layout and the viewport at once, and any of the three can move. diff --git a/WIP.md b/WIP.md index 8614cc609..7142ba3d3 100644 --- a/WIP.md +++ b/WIP.md @@ -81,6 +81,7 @@ The rest of this file is the maintenance guide for updating existing pages or ad > Windows add a BOM unasked. `lib/frontmatter.mjs` strips a leading BOM before parsing. - `docs/Reference/Built-In/tbIDE/` — IDE Extensibility package (this is the **addin SDK**). The package is type-only — it ships **public interfaces + CoClasses** that an addin DLL binds to; every implementation behind them lives in the twinBASIC IDE itself. The user-facing surface is one entry-point factory (`tbCreateCompilerAddin`) plus 23 CoClasses grouped by role: the addin contract (`AddIn`), the root API (`Host`), the loaded `Project`, the editors collection (`Editor` / `CodeEditor` / `Editors`), the virtual file system (`FileSystem` / `FileSystemItem` / `Folder` / `File`), the in-IDE UI surface (`Toolbar` / `Toolbars` / `Button` / `ToolWindow` / `ToolWindows`), the HTML DOM inside a tool window (`HtmlElement` / `HtmlElements` / `HtmlElementProperty` / `HtmlElementProperties` / `HtmlEventProperty` / `HtmlEventProperties`), the `DebugConsole`, `KeyboardShortcuts`, `Themes`, and the single concrete user-instantiable helper class `AddinTimer`. Flat layout — one page per CoClass / Class plus the index landing. - `docs/Reference/Contributed/` — the contributed packages, one folder each, landing page `Contributed.md` beside it. **A package's landing page opens, under its H1, with `**Author:**`** (owner): the publisher TWINSERV lists (`publisher` in `indexer/.packages/manifests/contributed.json`), or the author the package's own sources name when that is someone else (VBA_JSON: Tim Hall, published by WaynePhillipsEA). Next to the author, on the same line, `**Version:**`: the version the section documents, which is its `indexed_from` and changes with it (owner). Then `**On GitHub:**` with a link, when a repository holds the package, or the library the package says it is made from; search GitHub for the package's name and the author's account, and check that the repository holds the package's code (its history too: `TBMANLIB_*` were never in woeoio's `tbman`). A landing page with one or two sections has no `* TOC` list. A class with member pages is a folder, `/index.md` at `/tB/Packages/Contributed///` with its members beside it, so a member links to its class as `(.)`; a class documented on one page is `.md`. Each package is a chapter of the book's Packages part in `_book.yml`. A section's `indexed_from` is the four-part TWINSERV version, and its snapshot is under `indexer/snapshots/contributed/`. **Every NOTE and WARNING of a section is backed by test cases** in the package's test project, `test/package-tests//` (owner; [its README](test/package-tests/README.md)), and names them in a `` marker on the line above it, which `check_package_tests.mjs` in `test.bat` enforces. **A NOTE or WARNING is only for a real difference or limit** (owner): a statement that describes how the package works stays prose, tied to its cases by a region, the marker on a line of its own above it and `` after it (owner). A difference from the object the package replaces that gives a different result silently (tbRegExp's `\1(a)`, `{,m}`, `$~` against VBScript) is a NOTE, not a WARNING (owner). A case checks what is correct, not what the package does (owner): a defect is a failing case, recorded in the project's `expect.json`, so the project tells the package's author what to fix. A section whose project is not written yet is in that gate's `PENDING` set. `package-tests.bat` runs the cases; a case it reports *fixed* means the callout goes. **The landing page ends, after See Also, with `## Test cases`**: one or two sentences linking to `-tests.twinproj` (owner), which the build publishes beside the section without the package's code in it (owner): it references the package as a linked package, which the reader puts in `%APPDATA%\twinBASIC\packages` ([WIP.PackageTests.md](WIP.PackageTests.md)). Its samples compile in the `contributed-` sample template, whose `Settings` names the package's version: **change that version with `indexed_from`** ([WIP.ExamplesBuild.md](WIP.ExamplesBuild.md#template-projects)). **A contributed package is documented and its samples compiled against the newest twinBASIC BETA installed, never an older one** (owner): the version that matters is the package's, and a statement about the compiler (such as "does not compile on BETA 1005") names that newest build. **A contributed package that does not compile on that build, or has defects that a program would meet, is listed under Experimental Packages** (owner); a minor defect that the section documents does not make it Experimental (owner: VBComDlg, whose Replace All raises no event, stays Contributed; owner: nor does a fixed limit that the section states at the top of its landing page). **A package moves from Contributed to Experimental for its defects only after a separate agent has reproduced each defect independently** and ruled out misuse, a wrong expectation, a twinBASIC defect and the harness (owner): `docs/Reference/Experimental.md`, at `/tB/Packages/Experimental/`, after Contributed Packages. Its landing page's `parent:` is `Experimental Packages`, its line is in `Experimental.md` and not in `Contributed.md`, and its book chapter follows the Experimental Packages chapter. Its folder and URLs stay under `Contributed/`, so a package that is fixed moves back by changing only those three, and its pages keep their addresses. +- `docs/Downloads/index.md` — the Downloads page, `/Downloads`, a top-level page at `nav_order: 4` and in `_book.yml`'s `left_out:`. **Every file the documentation publishes for readers has a card or a row there**, and the page that documents it keeps its own link (owner). Cards are `
` inside `
` (styles at the end of `custom.scss`), with the theme's `.btn` for each file. The release job publishes every same-site link on the built page that has the `download` attribute, beside the zip and the PDF, so a new download needs no change to the workflow. `{{tbdocs:size:}}` gives a published file's size and `{{tbdocs:packageTestRows}}` the test projects' rows, both expanded before render; the help add-in's size is prose ("about 51 MB" in October 2026; it is also on the Help Add-In page), because its project embeds the offline tree, this page included, so it cannot be known before render. The PDF's release URL spells it `twinBASIC.Book.pdf`: GitHub turns the space into a dot, and the spaced name 404s. The footer's **Read Offline** link (`read_offline_link` in `_config.yml`) goes to the page's `#read-offline`. - `docs/Reference/Statements.md` — alphabetical index of language statements. - `docs/Reference/Procedures and Functions.md` — alphabetical index of procedures/functions. - `docs/LLVM/` — the LLVM section: compiling with the LLVM back end. A top-level section between Features and Reference Section in the nav, at `nav_order: 6` (Features is 5). `index.md` is the landing page and `Getting-Started.md` the only page so far, with its screenshots under `Images/`. Everything it describes arrived in **BETA 984**. Its language claims are checked on BETA 1005 with `tbrun --llvm` and `--exe`: the comma, space and comma-space spellings of `[CompilerOptions]` all turn LLVM on (`+optimize` alone does not, and an attribute replaces the project's options, so `[CompilerOptions("+optimize")]` is non-LLVM under project-wide LLVM), `[CompilerOptions("")]` turns it off under project-wide LLVM, the long CPU-flag sample runs, an error is lost between procedures when either the callee or the caller is LLVM-compiled, `Err.Raise` is unreadable in an LLVM handler (`-353703423`), a `Double` `*`, `+`, `-` or `^` overflow raises no error (`^` gives 0, the others infinity; `FloatingPointErrorChecks(True)` changes nothing), the conversions round half away from zero, and `Debug.Assert` is skipped and its condition not evaluated. Also on 1005: with LLVM on under Compiler Options (DEBUG) for the whole project, an IDE run of a `[CompilerOptions("")]` procedure that calls an LLVM-compiled one ends in an access violation at the call (a built exe is fine; the page's WARNING). Its samples are marked `check_build`, which compiles them; `examples.bat --llvm` also builds them with LLVM. @@ -248,6 +249,7 @@ The vocabulary tables further down cover word choice. The rules in this subsecti | `broker` (as verb) | manages, handles | | `carry` / `carries` (figurative) | has, contains, includes | | `catches up` | resumes, processes the queue | +| `cut` / `cuts` / `cutting` (a release, a tag, a version) | publish, make (owner: never "cut a release", anywhere, comments included) | | `comes up` (a connection) | is established, becomes ready | | `drive` / `driven` (figurative) | controlled by, determined by, powered by | | `footgun` / `footguns` | easy mistake to make, hazard, pitfall | @@ -378,7 +380,7 @@ is a build artifact. - **A diagram whose boxes link** (a `URL` on a node or cluster, relative to the page's rendered URL, which the browser resolves it against: a markdown link on the same page is first looked up against the source file's folder, so the two can be written differently, as on `Documentation/index.md`) declares its nodes in reading order and sets `outputorder=nodesfirst`: that order is the Tab order. Each linked box has a `tooltip`, a sentence or two on what it links to; without one the hover text is the raw label. `builder/inline-diagram.mjs` makes it a group of links when it is inlined; `Tools-Groups.dot` is the example. - **Scale a diagram whole, or not at all.** Stretching an SVG up inflates its labels; shrinking the text alone unmoors the labels from their boxes. - **A `@font-face` added to [docs/_sass/custom/_fonts.scss](docs/_sass/custom/_fonts.scss) needs its stack added to [docs/_sass/modules-dark.scss](docs/_sass/modules-dark.scss) as well**, and must stay inside the `emit-font-faces` mixin. The dark compilation re-emits its whole payload under two selectors at raised specificity: a face declared there would be invalid, and a stack left out there applies in light mode and silently does not in dark. -- **Run the full a11y sweep after any change that moves type metrics.** An inline element's measured height is its font's content area, so `target-size` results move with the face. The fourteen-page sample can stay clean while the full sweep regresses. +- **Run the full a11y sweep after any change that moves type metrics.** An inline element's measured height is its font's content area, so `target-size` results move with the face. The sixteen-page sample can stay clean while the full sweep regresses. Why Graphviz needs Inter's widths patched into its Times table and what breaks that patch, why the web rule is `width: auto` and the PDF's is `zoom: 0.875`, @@ -541,8 +543,8 @@ Why the report separates the wedged task from the merely blocked ones, and why - `ide-test.bat` --- the same runner for scenarios that operate the IDE itself rather than an add-in: every lane in `test/ide/lanes.mjs`, base port 9660. Same exit codes, same standing outside every gate and outside CI, same rules. - `try-help-addin.bat` --- opens one IDE on the user's desktop with the help add-in built and loaded, to try it by hand, and puts the registry back once it is closed (see [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler)). Needs `build.bat` first. Exit 0 closed and put back, 1 the add-in or the project did not compile, 2 the tool could not run, 3 not put back. - `node scripts/shoot_docs.mjs` --- writes the documentation's IDE screenshots (plan and owner's decisions: [WIP.Screenshots.md](WIP.Screenshots.md)). Each picture is a shot in a table, `out` its path under `docs/` (`--only` matches it), in a setup (the main three below; the full list is in WIP.Screenshots.md), each one IDE on a **private** desktop started only when a shot in it is selected: `help` (builds the add-in as `try-help-addin.bat` does, opens `test/addin/helpdemo`, `TB_DOCS_HELP_REAL_TITLEBAR=0`: the eight `docs/IDE/AddIns/Images/Help_.png`), `no-project` (`Lane.openNoProject` with `includeProjects`: the menus, cut out with alpha, the dialogs that need no project, and the empty panels, each floated on its own, after the dialogs) and `project` (the demo without the add-in: About, which needs the compiler's licence answer, and IDE Options, whose font lists need a project). One setup is not an IDE: `web` starts puppeteer's Chrome through `launchOnDesktop` on a private desktop of its own (never headless, never on the user's desktop; native-window occlusion and occluded-window backgrounding switched off, or a capture waits forever or comes back blank) for the two public pages the FAQ shows, taken once; it needs the network, as do `webpage` (the Webpage pane on `https://docs.twinbasic.com/`, its page captured from the pane's own DevTools target and pasted into the pane's place) and the package-server setups, which never publish. Every IDE gets `--force-device-scale-factor=1` (`Lane`'s `browserArgs`), else the page follows the display's scaling; the page is overridden to 1280x880 at 2x, and `PAGE_DEFAULTS` puts the IDE's options, panel layouts and keyboard groups to their defaults and the recent lists to the tool's own **in the page only**, its save calls replaced first so nothing is written. Each picture is a `Page.captureScreenshot` with a `clip`; a page showing it gives it `{:width height}` of half its pixel size. **Every picture is taken in two themes by one IDE**: dark as `X.png`, then, after `backToPrepared` and `tbTheme_SwitchToLightMode`, Light (not Classic) as `X.light.png` beside it, which the build pairs by name ([WIP.Build.md](WIP.Build.md#pictures-in-two-themes)); a light picture with the dark one's pixels is not kept, and one more than 32 device pixels off the dark one's size fails (a wrong state, not the theme). A label's pill follows the theme unless the shot names a `tone`. A shot's `annotate` list (arrows, rings, badges...) is drawn by `scripts/lib/shot-annotate.mjs` as one SVG layer over the page before the capture and removed after it, the clip growing to hold it. A picture is rewritten only when its bytes differ, and a second run reports every one `unchanged`; a capture is not byte-stable, so `capture` takes up to eight and keeps one equal to the file on disk, and a picture that differs from the file only by a capture's noise (16 grey levels, 0.5% of its pixels) is left as it is. `--jobs N` (default 6) runs the setups at once on their own ports and desktops, a full run with `--svg` in about 17 minutes on BETA 1005 (`--jobs 1`: one at a time); `--diffs ` writes the old picture, the new one and an amplified difference map for each that changed, named with its theme. Settle by a condition, never a fixed sleep, and a picture that shows some state sets it itself rather than relying on the shots before it. The page's animations and carets are switched off through a style sheet put into the document and every shadow root; a picture that holds DEBUG CONSOLE lines has their times (and a CodeLens run's time taken) set in the page once the real ones show. **A running program's window** (setup `programs`, fixture `test/shots/programs`) is taken **once** (`once: true`, the dark pass only) with `captureWindow`, cropped to its visible frame, at the display's DPI, shown at its size at 96 DPI. **The Windows user name must never be in a published picture**: the visible text of the page, text boxes included, is searched for it before the first capture and again when a capture is chosen, with the picture's SVG under `--svg` (a page can draw again in between: Project Settings puts a real path back over the one the shot set), and for a program's window the texts of the window and its children, and the run fails when it is there. The tool finds each name it presses F1 on by searching the demo's source, not by line. The add-in is built on a port of its own (`Lane.addAddin`'s `port`): the building IDE's WebView2 process can hold its port after the IDE ends for longer than `launchIde`'s ten seconds. **`--svg`** also draws each picture as SVG from the page as it stands at the capture (`scripts/svgshot/`: a DOM snapshot converted, its text set in per-picture subsets of the fonts the IDE drew it with, cut by `scripts/subset_font.py`, which needs Python with fontTools), compares it with the PNG, and writes `X.svg` or `X.light.svg` beside it only when no more than 0.25% of its pixels are far off; otherwise an older SVG is removed and the page shows the PNG ([WIP.Build.md](WIP.Build.md#pictures-as-svg)). With `--svg` each picture's *bundle*, what its SVG is drawn from, is saved in the gitignored `.svgshot-bundles/` (`--bundles`; never under `docs/`, a bundle holds the user name), and **after a change to the converter, `node scripts/svgshot/replay.mjs` draws every SVG again from the bundles, with no IDE**, in about 20 s for the 370 (an SVG the same as its file is not compared again, `--recheck` compares all; the fonts' cuts are kept in `.svgshot-bundles/subsets/`; one browser per process, GPU off); it lists the pictures whose bundle cannot answer what the converter now asks, with the `--only` command that retakes them ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). `node scripts/svgshot/bench.mjs` tests the converter's replicas of what Chromium draws outside the DOM (scrollbars, drop-down lists) in Edge, with no IDE ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). No wrapper; outside every gate and outside CI, as `try-help-addin.bat` is. Exit 0 every picture written or unchanged, 1 a picture failed, 2 the tool could not run, 3 not put back. Run `build.bat` first. -- **The help add-in's project file is written by every build, CI's included.** The `addinProject` task (`builder/addin-project.mjs`) packs `add-in/` into `tB/IDE/AddIns/downloads/tbDocsHelp.twinproj` in the **online tree only**, which the Help Add-In page offers as a download; `_config.yml`'s `addin_project` declares both ends, so a fixture config gets none. It waits for the `helpArchive` task. It packs the files `git ls-files` lists there, read from the working folder, never a `Resources/HELP/` file from the disk, with CRLF made LF outside `Resources/` (so a Windows and a Linux build write the same bytes), the `tB/symbols.json` the same build wrote in place of the committed copy, and **the zip that build's archive task wrote** as `Resources/HELP/site.zip`, passed in memory (a build that wrote none --- `--no-help-archive`, `--serve`, another `--dest`, a fixture --- packs without it, and the summary line says `without the offline archive`). The download is about 28 MB with the archive, 1 MB without. The offline tree holds no copy (the project carries that tree's archive, so a copy there would be circular), and its links to the file are rewritten to the website by the general rule in `offline-rewrite.mjs`: a URL the offline tree lacks but the online tree has becomes `site.url` + path, and is no miss (`computeWebsiteUrl`; the offline link check's `forbidExempt` excuses exactly those links, `isWebsiteOnlyLink`). The pack is `importProject` from `scripts/impexp.mjs`, the one module under `scripts/` that `builder/` may import (`biome.jsonc`); `check_tree_fresh.mjs` watches it and `add-in/` for that reason. The publish allowlist exempts the one path in the trees only (`publishPolicyFor`'s `generated`); a `.twinproj` anywhere else, or in `docs/`, is refused. `--serve` writes it into its one tree; `--dry-run` writes nothing. A failure (no git) fails the build with exit 1 and the index audit names the missing file. **Don't build the DLLs in CI from it**: that needs a twinBASIC install; the download is the project, and the reader builds it. Why each of these: [WIP.Build.md](WIP.Build.md#the-help-add-ins-project-file); `test/addin-project.test.mjs` is the gate. -- **The help archive is the `helpArchive` task of the build's graph.** `builder/help-archive-step.mjs` writes `add-in/Resources/HELP/site.zip` from `docs/_site-offline` through `lib/help-archive.mjs`, once every task that writes into the offline tree has finished (`flushJoin`, `scss`, `writeOffline`; the archive task lists exactly those) and **before** the Gantt injection, so the zip holds `BuildInfo.html` without the chart and `assets/images/gantt.svg` as the 105-byte placeholder the sources hold (it is a tracked static file, so the file list already has it). It reads the file back to verify it. A mismatch fails the build (exit 1). After the Gantt injection `recheckHelpArchive` checks that the offline tree on disk lists the same files as the archive and differs from it only in `BuildInfo.html` and `gantt.svg`; a task that wrote into the offline tree late fails the build there. The help add-in embeds the zip as a resource and serves the pages from it with its own server; a build of the add-in without it uses the live site. The file is gitignored (about 26 MB), so a build of the add-in from a clean clone is the online one until `build.bat` has run. The download carries the same zip (previous bullet). **`--no-help-archive` turns the step off, and neither CI workflow may pass it** (`check_ci_workflows.mjs` refuses it, in `ALLOWED.forbiddenBuildFlags`), because CI's published download would then hold no archive. It runs only for the guarded source tree (`docs`) built into the default `docs/_site-offline` (not another `--dest`, so `compare_trees` and `check_links_diff` leave it alone), never under `--serve` or `--dry-run`, never without an offline tree, and not keyed on `$CI`. There is no command-line tool for the archive: the `help-offline` lane makes its own with `writeHelpArchive`. The zip's format is fixed by the add-in's reader, which does no inflating ([WIP.HelpAddin.md](WIP.HelpAddin.md), Stage 4, increment 5). +- **The help add-in's project file is written by every build, CI's included.** The `addinProject` task (`builder/addin-project.mjs`) packs `add-in/` into `tB/IDE/AddIns/downloads/tbDocsHelp.twinproj` in the **online tree only**, which the Help Add-In page offers as a download; `_config.yml`'s `addin_project` declares both ends, so a fixture config gets none. It waits for the `helpArchive` task. It packs the files `git ls-files` lists there, read from the working folder, never a `Resources/HELP/` file from the disk, with CRLF made LF outside `Resources/` (so a Windows and a Linux build write the same bytes), the `tB/symbols.json` the same build wrote in place of the committed copy, and **the zip that build's archive task wrote** as `Resources/HELP/site.zip`, passed in memory (a build that wrote none --- `--no-help-archive`, `--serve`, another `--dest`, a fixture --- packs without it, and the summary line says `without the offline archive`). The download is about 51 MB with the archive, 1 MB without. The offline tree holds no copy (the project carries that tree's archive, so a copy there would be circular), and its links to the file are rewritten to the website by the general rule in `offline-rewrite.mjs`: a URL the offline tree lacks but the online tree has becomes `site.url` + path, and is no miss (`computeWebsiteUrl`; the offline link check's `forbidExempt` excuses exactly those links, `isWebsiteOnlyLink`). The pack is `packFiles` from `scripts/impexp.mjs`, in memory (never staged through `%TEMP%`), the one module under `scripts/` that `builder/` may import (`biome.jsonc`); `check_tree_fresh.mjs` watches it and `add-in/` for that reason. The publish allowlist exempts the one path in the trees only (`publishPolicyFor`'s `generated`); a `.twinproj` anywhere else, or in `docs/`, is refused. `--serve` writes it into its one tree; `--dry-run` writes nothing. A failure (no git) fails the build with exit 1 and the index audit names the missing file. **Don't build the DLLs in CI from it**: that needs a twinBASIC install; the download is the project, and the reader builds it. Why each of these: [WIP.Build.md](WIP.Build.md#the-help-add-ins-project-file); `test/addin-project.test.mjs` is the gate. +- **The help archive is the `helpArchive` task of the build's graph.** `builder/help-archive-step.mjs` writes `add-in/Resources/HELP/site.zip` from `docs/_site-offline` through `lib/help-archive.mjs`, once every task that writes into the offline tree has finished (`flushJoin`, `scss`, `writeOffline`; the archive task lists exactly those) and **before** the Gantt injection. **The archive leaves out `HELP_ARCHIVE_SKIP`** (`builder/help-archive-step.mjs`): the Documentation Development section (`BuildInfo.html` and `assets/images/gantt.svg` with it, so the chart never reaches the zip), Videos and their poster frames, Challenges, and **every download, which stays online** (owner): a file readers download goes in a `downloads` folder beside its page, and the step fails the build when the Downloads page offers a file the archive would hold. The add-in's server redirects any file the archive lacks to the website, because pages link to all of these; a new entry needs nothing else. It reads the file back to verify it. A mismatch fails the build (exit 1). After the Gantt injection `recheckHelpArchive` checks that the offline tree on disk lists the same files as the archive and differs from it only in `BuildInfo.html` and `gantt.svg`; a task that wrote into the offline tree late fails the build there. The help add-in embeds the zip as a resource and serves the pages from it with its own server; a build of the add-in without it uses the live site. The file is gitignored (about 26 MB), so a build of the add-in from a clean clone is the online one until `build.bat` has run. The download carries the same zip (previous bullet). **`--no-help-archive` turns the step off, and neither CI workflow may pass it** (`check_ci_workflows.mjs` refuses it, in `ALLOWED.forbiddenBuildFlags`), because CI's published download would then hold no archive. It runs only for the guarded source tree (`docs`) built into the default `docs/_site-offline` (not another `--dest`, so `compare_trees` and `check_links_diff` leave it alone), never under `--serve` or `--dry-run`, never without an offline tree, and not keyed on `$CI`. There is no command-line tool for the archive: the `help-offline` lane makes its own with `writeHelpArchive`. The zip's format is fixed by the add-in's reader, which does no inflating ([WIP.HelpAddin.md](WIP.HelpAddin.md), Stage 4, increment 5). - `node scripts/bug_repro.mjs` --- the reproducer projects under `bugs//` for the entries of [BUGS-TO-REPORT.md](BUGS-TO-REPORT.md): `new`, `pack` (impexp, then the zip, written in Node), `compile`, `build` and `run` through `tbbuild` and `tbrun`, `vb6` (builds the optional `vb6/` VB6 project beside the twinBASIC one in a temp copy, runs its exe on a private desktop, and prints its `out.txt`; a project with a form or a user control is built without Unattended Execution, which VB6 refuses for it; `pack` zips its sources into `-vb6.zip`; VB6 is started only by `scripts/lib/vb6.mjs`, never from a shell; a graphical defect's `images` key makes `run` and `vb6` keep what the `PngDump` modules of `test/repro-templates/png/` draw as `images/-tb.png` and `-vb6.png`, with a `-compare.png` of both, tested by `test/png.test.mjs`), `file` (moves a filed entry and its reproducer to `bugs/filed//`; `file --marked` does every marked entry), and `verify`, which reads each `bugs/*/repro.json` and `bugs/filed/*/repro.json` and says whether the bug still reproduces on the newest beta; a `lane` reproducer names the `ide-test` or `addin-test` lane and tests that assert its bug, which `verify` runs last through that runner, a `probe` one a script under `scripts/`, and a `test` one its own `[TestCase]` Subs and compile cases through `tbrun --tests`, judged by the `PASS` lines of what is fixed and the `FAIL` lines of the bug (`new --template tests` starts one). **No reproducer should need a person at the IDE** (owner): a bug the IDE has to be operated for gets a lane test, and `manual` is left for what cannot be automated at all. It has no wrapper. **`verify` is run by a person only**, never by a gate or CI: it needs a twinBASIC install, like `examples.bat`. Default port 9440, and `--jobs N` takes N ports from there. Its command-line cases are in `scripts/lib/cli-cases.mjs`, and need no IDE. - `node scripts/vb6run.mjs ` and `node scripts/vb6run.mjs --docs [--only ]` --- build and run VB6 code, so that what a sample prints in twinBASIC can be compared with VB6: a stand-alone file of statements or a `.bas` module, or `--docs`, which builds the documentation's `check_run` fences in VB6 and says for each whether it prints what its page says (`same`, `differs`, `not VB6`, `error`, `refused`; a `projname=` group is built as a project of its own, its `slot=file` fences translated into VB6 `.cls` and `.bas` components). It has no wrapper and is outside every gate and outside CI: it needs VB6 (`--vb6`, `VB6_EXE`, or the standard install folder). **Never start `VB6.EXE` from a shell**: in Git Bash `/make` is rewritten as a path and VB6 answers with a modal box on the desktop; the tool spawns it from Node with an argument array, and a sample that calls `MsgBox` or `InputBox` or contains `End` is refused. Its `Debug.Print` rewrite and its file translation have probes in `test/example-batches.test.mjs`, and its command-line cases are in `scripts/lib/cli-cases.mjs`; both need no VB6. What it relies on is in [WIP.ExamplesBuild.md](WIP.ExamplesBuild.md). diff --git a/add-in/Resources/SYMBOLS/symbols.json b/add-in/Resources/SYMBOLS/symbols.json index dbb0eaf2b..24153ab6c 100644 --- a/add-in/Resources/SYMBOLS/symbols.json +++ b/add-in/Resources/SYMBOLS/symbols.json @@ -62,6 +62,7 @@ {"name":"PopulateFrom","package":null,"container":null,"kind":"attribute","url":"/tB/Core/Attributes#populatefrom"}, {"name":"PredeclaredID","package":null,"container":null,"kind":"attribute","url":"/tB/Core/Attributes#predeclaredid"}, {"name":"PreserveSig","package":null,"container":null,"kind":"attribute","url":"/tB/Core/Attributes#preservesig"}, + {"name":"PropertyPage","package":null,"container":null,"kind":"attribute","url":"/tB/Core/Attributes#propertypage"}, {"name":"RedirectToStaticImplementation","package":null,"container":null,"kind":"attribute","url":"/tB/Core/Attributes#redirecttostaticimplementation"}, {"name":"Restricted","package":null,"container":null,"kind":"attribute","url":"/tB/Core/Attributes#restricted"}, {"name":"RunAfterBuild","package":null,"container":null,"kind":"attribute","url":"/tB/Core/Attributes#runafterbuild"}, diff --git a/add-in/Sources/HelpAddIn.twin b/add-in/Sources/HelpAddIn.twin index 0e99e14e3..2da2e39f1 100644 --- a/add-in/Sources/HelpAddIn.twin +++ b/add-in/Sources/HelpAddIn.twin @@ -193,9 +193,10 @@ Private Class HelpAddIn ' Serves the pages from the archive, when the add-in was built with one ' (HelpServer). The line it prints names the origin, which the scenarios - ' read. + ' read. A file the archive lacks is on the site. Private Sub StartServer() Set Server = New HelpServer + Server.Fallback = SiteUrl() Dim serving As String = Server.Start() If Len(serving) > 0 Then Host.DebugConsole.PrintText TAG & serving If Len(Server.Origin) = 0 Then Set Server = Nothing diff --git a/add-in/Sources/HelpServer.twin b/add-in/Sources/HelpServer.twin index 6e4c78451..7ac5759c3 100644 --- a/add-in/Sources/HelpServer.twin +++ b/add-in/Sources/HelpServer.twin @@ -4,7 +4,9 @@ ' a local build of the documentation. A path is found as GitHub Pages finds it: ' /a/b is a/b.html, or a redirect to /a/b/ when a/b/index.html exists, and the ' query string is ignored. An entry the zip deflated is sent as it is, in a -' gzip wrapper (Content-Encoding: gzip), so nothing is inflated here. +' gzip wrapper (Content-Encoding: gzip), so nothing is inflated here. A file +' the archive lacks is a redirect to the same path on the site (Fallback): the +' archive leaves some sections and every download out, and pages link to them. ' ' It listens on 127.0.0.1 and names itself http://localhost:: a frame ' on 127.0.0.1 is missing from the IDE page's frame tree, where the help lane @@ -91,6 +93,9 @@ Private Class HelpServer ' "http://127.0.0.1:" while serving, else "". Public Origin As String Public FileCount As Long + ' The site a file the archive lacks is redirected to, with no trailing + ' slash; "" answers it with the archive's 404 page. + Public Fallback As String ' Starts serving and returns what it serves, for the DEBUG CONSOLE, or "" ' when the add-in has no archive. Origin stays "" unless it is serving. @@ -350,8 +355,11 @@ Private Class HelpServer Return Reply("301 Moved Permanently", -1, headOnly, target & "/" & query) End If End If - If i < 0 Then Return Reply("404 Not Found", Lookup("404.html"), headOnly) - Return Reply("200 OK", i, headOnly) + If i >= 0 Then Return Reply("200 OK", i, headOnly) + ' A page or a download the archive leaves out (the build's + ' HELP_ARCHIVE_SKIP), or a file newer than it, is on the site. + If Len(Fallback) > 0 Then Return Reply("302 Found", -1, headOnly, Fallback & target & query) + Return Reply("404 Not Found", Lookup("404.html"), headOnly) BadRequest: Return Reply("400 Bad Request", -1, False) End Function diff --git a/bugs/constantfoldable-declare-crash/constantfoldable-declare-crash.twinproj b/bugs/constantfoldable-declare-crash/constantfoldable-declare-crash.twinproj new file mode 100644 index 000000000..2a2199453 Binary files /dev/null and b/bugs/constantfoldable-declare-crash/constantfoldable-declare-crash.twinproj differ diff --git a/bugs/constantfoldable-declare-crash/repro.json b/bugs/constantfoldable-declare-crash/repro.json new file mode 100644 index 000000000..f75e34f1c --- /dev/null +++ b/bugs/constantfoldable-declare-crash/repro.json @@ -0,0 +1,7 @@ +{ + "mode": "build", + "expect": { + "exit": 5, + "message": "ACCESS_VIOLATION" + } +} diff --git a/bugs/constantfoldable-declare-crash/src/Settings b/bugs/constantfoldable-declare-crash/src/Settings new file mode 100644 index 000000000..65817af8e --- /dev/null +++ b/bugs/constantfoldable-declare-crash/src/Settings @@ -0,0 +1,62 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "ConstantfoldableDeclareCrash", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "Reproduces: t", + "project.exportPathIsV2": true, + "project.id": "{348EE617-B3BC-4AC1-80D5-8FBAC6F3CE28}", + "project.name": "ConstantfoldableDeclareCrash", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "optimizer.constantFunctionFolding": true, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/bugs/constantfoldable-declare-crash/src/Sources/Startup.twin b/bugs/constantfoldable-declare-crash/src/Sources/Startup.twin new file mode 100644 index 000000000..3b9c0d324 --- /dev/null +++ b/bugs/constantfoldable-declare-crash/src/Sources/Startup.twin @@ -0,0 +1,11 @@ +' Project Settings > Constant Function Folding is on. The project compiles clean; the build crashes the compiler. +Module Startup + + Public Sub Main() + GetCurrentProcessId + End Sub + + [ConstantFoldable] + Declare Function GetCurrentProcessId Lib "kernel32" () As Long + +End Module diff --git a/bugs/listitem-backcolor-default-refused/listitem-backcolor-default-refused.twinproj b/bugs/listitem-backcolor-default-refused/listitem-backcolor-default-refused.twinproj new file mode 100644 index 000000000..9f8749b0d Binary files /dev/null and b/bugs/listitem-backcolor-default-refused/listitem-backcolor-default-refused.twinproj differ diff --git a/bugs/listitem-backcolor-default-refused/repro.json b/bugs/listitem-backcolor-default-refused/repro.json new file mode 100644 index 000000000..2839fb87a --- /dev/null +++ b/bugs/listitem-backcolor-default-refused/repro.json @@ -0,0 +1,13 @@ +{ + "mode": "test", + "expect": { + "exit": 7, + "output": [ + "^PASS ListItemBackColorTests\\.DefaultIsUseListColour$", + "^PASS ListItemBackColorTests\\.ColourIsAccepted$", + "^PASS ListItemBackColorTests\\.DefaultAssignedToNewItem$", + "^PASS ListItemBackColorTests\\.ColourIsKeptWhenDefaultIsAssigned$", + "^FAIL ListItemBackColorTests\\.DefaultAssignedAfterColour: .*Expected: 0 .*Actual: 380" + ] + } +} diff --git a/bugs/listitem-backcolor-default-refused/src/Settings b/bugs/listitem-backcolor-default-refused/src/Settings new file mode 100644 index 000000000..8bb126a6e --- /dev/null +++ b/bugs/listitem-backcolor-default-refused/src/Settings @@ -0,0 +1,89 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "ListitemBackcolorDefaultRefused", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "Reproduces: ListItem.BackColor raises error 380 when it is assigned its own default, -1", + "project.exportPathIsV2": true, + "project.id": "{5230BD73-7C4C-4F6C-A081-98C6810C0F2E}", + "project.name": "ListitemBackcolorDefaultRefused", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + }, + { + "id": "{D63CB78D-9F5B-42FE-B94C-A545F2B2BFC3}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - Common Controls Compatibility Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WinNativeCommonCtls", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 1 + }, + { + "id": "{9847A10D-8987-44B2-8B87-EA77DEF7F1D9}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] Assert", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "Assert", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 24 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/bugs/listitem-backcolor-default-refused/src/Sources/Form1.tbform b/bugs/listitem-backcolor-default-refused/src/Sources/Form1.tbform new file mode 100644 index 000000000..e4ea069cb --- /dev/null +++ b/bugs/listitem-backcolor-default-refused/src/Sources/Form1.tbform @@ -0,0 +1,94 @@ +[ + { + "AlwaysShowKeyboardCues": false, + "Appearance": "vbAppear3d", + "AutoRedraw": false, + "BackColor": 13223074, + "BorderStyle": "vbSizable", + "Caption": "ListItem BackColor", + "ClipControls": true, + "ControlBox": true, + "DrawMode": "vbCopyPen", + "DrawStyle": "vbSolid", + "DrawWidth": 1, + "Enabled": true, + "FillColor": 0, + "FillStyle": "vbFSTransparent", + "FontBold": false, + "FontItalic": false, + "FontName": "Segoe UI", + "FontSize": 8, + "FontStrikethru": false, + "FontTransparent": true, + "FontUnderline": false, + "ForeColor": -2147483630, + "FormDesignerId": "{7B247400-0000-4000-9000-7B2474000F01}", + "HasDC": true, + "Height": 480, + "HelpContextID": 0, + "Icon": "", + "KeyPreview": false, + "Left": 0, + "LinkMode": "vbLinkNone", + "LinkTopic": null, + "MDIChild": false, + "MaxButton": true, + "MaxHeight": 0, + "MaxWidth": 0, + "MinButton": true, + "MinHeight": 0, + "MinWidth": 0, + "MouseIcon": "", + "MousePointer": "vbDefault", + "Moveable": true, + "Name": "Form1", + "NegotiateMenus": false, + "OLEDropMode": "vbOLEDropNone", + "Opacity": 100, + "Palette": "", + "PaletteMode": "vbPaletteModeHalftone", + "Picture": "", + "PictureDpiScaling": false, + "RightToLeft": false, + "ScaleHeight": 8560, + "ScaleLeft": 0, + "ScaleMode": "vbTwips", + "ScaleTop": 0, + "ScaleWidth": 11760, + "ShowInTaskbar": true, + "StartUpPosition": "vbStartUpScreen", + "TabFocusAutoSelect": false, + "Tag": null, + "Top": 0, + "TopMost": false, + "TransparencyKey": -1, + "Visible": true, + "WhatsThisButton": false, + "WhatsThisHelp": false, + "Width": 640, + "WindowState": "vbNormal", + "__IDEOptions": { + "alignToGrid": false, + "gridHeight": 10, + "gridWidth": 10, + "lockedControls": [], + "multiColoredGrabbers": true, + "showGrid": true, + "showOutlines": false + }, + "__lastUpdateMarker": 3573907440, + "_children": [ + { + "Height": 200, + "Left": 20, + "Name": "ListView1", + "Top": 20, + "Width": 300, + "_className": "ListView", + "_clsid": "{ED6464B0-4324-4E2E-8A17-83A81F9D48DD}" + } + ], + "_className": "Form", + "_clsid": "{33AD4F38-6699-11CF-B70C-00AA0060D393}" + } +] diff --git a/bugs/listitem-backcolor-default-refused/src/Sources/Form1.twin b/bugs/listitem-backcolor-default-refused/src/Sources/Form1.twin new file mode 100644 index 000000000..aab107d00 --- /dev/null +++ b/bugs/listitem-backcolor-default-refused/src/Sources/Form1.twin @@ -0,0 +1,6 @@ +' A form holding one ListView, ListView1. The test cases load it and never show it. +[FormDesignerId("7B247400-0000-4000-9000-7B2474000F01")] +[PredeclaredId] +Class Form1 + +End Class diff --git a/bugs/listitem-backcolor-default-refused/src/Sources/ListItemBackColorTests.twin b/bugs/listitem-backcolor-default-refused/src/Sources/ListItemBackColorTests.twin new file mode 100644 index 000000000..146473196 --- /dev/null +++ b/bugs/listitem-backcolor-default-refused/src/Sources/ListItemBackColorTests.twin @@ -0,0 +1,74 @@ +' Regression tests for ListItem.BackColor. Its default is -1, which means "use the +' ListView's BackColor", so assigning -1 is how a row that has been given a colour +' returns to the list's colour. Form1 holds one ListView; it is loaded, never shown. +' Each case adds an item and works with its BackColor under On Error Resume Next. +[TestFixture] +Module ListItemBackColorTests + + Private Const UseListColour As Long = -1 + + ' The default is the one the property documents. + [TestCase] + Public Sub DefaultIsUseListColour() + Dim item As ListItem, value As Long + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + value = item.BackColor + Unload Form1 + Assert.Strict.AreEqual UseListColour, value, "the default" + End Sub + + ' A colour that is a colour is accepted and read back. + [TestCase] + Public Sub ColourIsAccepted() + Dim item As ListItem, number As Long, value As Long + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + On Error Resume Next: item.BackColor = vbYellow: number = Err.Number: On Error GoTo 0 + value = item.BackColor + Unload Form1 + Assert.Strict.AreEqual 0, number, "the error" + Assert.Strict.AreEqual CLng(vbYellow), value, "the colour" + End Sub + + ' The default assigned to a new item changes nothing and raises nothing. + [TestCase] + Public Sub DefaultAssignedToNewItem() + Dim item As ListItem, number As Long, value As Long + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + On Error Resume Next: item.BackColor = UseListColour: number = Err.Number: On Error GoTo 0 + value = item.BackColor + Unload Form1 + Assert.Strict.AreEqual 0, number, "the error" + Assert.Strict.AreEqual UseListColour, value, "the colour" + End Sub + + ' Whatever happens to -1, the colour the row had is not lost. + [TestCase] + Public Sub ColourIsKeptWhenDefaultIsAssigned() + Dim item As ListItem, value As Long + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + item.BackColor = vbYellow + On Error Resume Next: item.BackColor = UseListColour: On Error GoTo 0 + value = item.BackColor + Unload Form1 + Assert.Strict.AreEqual True, (value = CLng(vbYellow) Or value = UseListColour), "yellow or the list's colour" + End Sub + + ' A row that has been given a colour returns to the list's colour with -1. + [TestCase] + Public Sub DefaultAssignedAfterColour() + Dim item As ListItem, number As Long, value As Long + Load Form1 + Set item = Form1.ListView1.ListItems.Add(, , "row") + item.BackColor = vbYellow + On Error Resume Next: item.BackColor = UseListColour: number = Err.Number: On Error GoTo 0 + value = item.BackColor + Unload Form1 + Assert.Strict.AreEqual 0, number, "the error" + Assert.Strict.AreEqual UseListColour, value, "the colour" + End Sub + +End Module diff --git a/bugs/listitem-backcolor-default-refused/src/Sources/Startup.twin b/bugs/listitem-backcolor-default-refused/src/Sources/Startup.twin new file mode 100644 index 000000000..be8f5c8bc --- /dev/null +++ b/bugs/listitem-backcolor-default-refused/src/Sources/Startup.twin @@ -0,0 +1,6 @@ +Module Startup + + Public Sub Main() + End Sub + +End Module diff --git a/bugs/runafterbuild-generic-sub-never-runs/repro.json b/bugs/runafterbuild-generic-sub-never-runs/repro.json new file mode 100644 index 000000000..1ed40230a --- /dev/null +++ b/bugs/runafterbuild-generic-sub-never-runs/repro.json @@ -0,0 +1,12 @@ +{ + "mode": "run", + "exe": true, + "expect": { + "exit": 0, + "output": [ + "^\\[BUILD\\] Executing 'RunafterbuildGenericSubNeverRuns\\.Startup\\.Built'\\.\\.\\.$", + "^--- exe: exit 0$" + ], + "absent": ["^built$"] + } +} diff --git a/bugs/runafterbuild-generic-sub-never-runs/runafterbuild-generic-sub-never-runs.twinproj b/bugs/runafterbuild-generic-sub-never-runs/runafterbuild-generic-sub-never-runs.twinproj new file mode 100644 index 000000000..fefd19d82 Binary files /dev/null and b/bugs/runafterbuild-generic-sub-never-runs/runafterbuild-generic-sub-never-runs.twinproj differ diff --git a/bugs/runafterbuild-generic-sub-never-runs/src/Settings b/bugs/runafterbuild-generic-sub-never-runs/src/Settings new file mode 100644 index 000000000..fb08c1b68 --- /dev/null +++ b/bugs/runafterbuild-generic-sub-never-runs/src/Settings @@ -0,0 +1,61 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "RunafterbuildGenericSubNeverRuns", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "Reproduces: t", + "project.exportPathIsV2": true, + "project.id": "{59DF2A5E-4BA1-4174-8270-99EF28698F7B}", + "project.name": "RunafterbuildGenericSubNeverRuns", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/bugs/runafterbuild-generic-sub-never-runs/src/Sources/Startup.twin b/bugs/runafterbuild-generic-sub-never-runs/src/Sources/Startup.twin new file mode 100644 index 000000000..e48d669f7 --- /dev/null +++ b/bugs/runafterbuild-generic-sub-never-runs/src/Sources/Startup.twin @@ -0,0 +1,15 @@ +' [RunAfterBuild] on a generic Sub is accepted and builds with no diagnostic. The DEBUG CONSOLE says +' it is executing the Sub, but its body never runs: "built" is not printed. (The compiler does count +' the Sub: a second [RunAfterBuild] in the project is TB5114.) Without (Of T), "built" is printed. +Module Startup + + Public Sub Main() + End Sub + + [RunAfterBuild] + Public Sub Built(Of T)() + Debug.Cls + Debug.Print "built" + End Sub + +End Module diff --git a/bugs/runbeforestartupobject-generic-crash/repro.json b/bugs/runbeforestartupobject-generic-crash/repro.json new file mode 100644 index 000000000..f75e34f1c --- /dev/null +++ b/bugs/runbeforestartupobject-generic-crash/repro.json @@ -0,0 +1,7 @@ +{ + "mode": "build", + "expect": { + "exit": 5, + "message": "ACCESS_VIOLATION" + } +} diff --git a/bugs/runbeforestartupobject-generic-crash/runbeforestartupobject-generic-crash.twinproj b/bugs/runbeforestartupobject-generic-crash/runbeforestartupobject-generic-crash.twinproj new file mode 100644 index 000000000..ed68a49df Binary files /dev/null and b/bugs/runbeforestartupobject-generic-crash/runbeforestartupobject-generic-crash.twinproj differ diff --git a/bugs/runbeforestartupobject-generic-crash/src/Settings b/bugs/runbeforestartupobject-generic-crash/src/Settings new file mode 100644 index 000000000..1da0ca57a --- /dev/null +++ b/bugs/runbeforestartupobject-generic-crash/src/Settings @@ -0,0 +1,61 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "RunbeforestartupobjectGenericCrash", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "Reproduces: t", + "project.exportPathIsV2": true, + "project.id": "{EDC01513-51DF-482C-A08B-96ED1A5ABB15}", + "project.name": "RunbeforestartupobjectGenericCrash", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/bugs/runbeforestartupobject-generic-crash/src/Sources/Startup.twin b/bugs/runbeforestartupobject-generic-crash/src/Sources/Startup.twin new file mode 100644 index 000000000..a1fd14521 --- /dev/null +++ b/bugs/runbeforestartupobject-generic-crash/src/Sources/Startup.twin @@ -0,0 +1,11 @@ +' The project compiles clean; the build crashes the compiler. +Module Startup + + Public Sub Main() + End Sub + + [RunBeforeStartupObject] + Public Sub Pre(Of T)() + End Sub + +End Module diff --git a/bugs/slider-backcolor-no-repaint/repro.json b/bugs/slider-backcolor-no-repaint/repro.json new file mode 100644 index 000000000..afd94f3fc --- /dev/null +++ b/bugs/slider-backcolor-no-repaint/repro.json @@ -0,0 +1,12 @@ +{ + "mode": "run", + "expect": { + "exit": 0, + "output": [ + "^painted, BackColor = &H808080, pixel = &H808080$", + "^BackColor set to &HFF, pixel = &H808080$", + "^after InvalidateRect and UpdateWindow, pixel = &H808080$", + "^after Visible = False, True, pixel = &HFF$" + ] + } +} diff --git a/bugs/slider-backcolor-no-repaint/slider-backcolor-no-repaint.twinproj b/bugs/slider-backcolor-no-repaint/slider-backcolor-no-repaint.twinproj new file mode 100644 index 000000000..1bb804d5f Binary files /dev/null and b/bugs/slider-backcolor-no-repaint/slider-backcolor-no-repaint.twinproj differ diff --git a/bugs/slider-backcolor-no-repaint/src/Settings b/bugs/slider-backcolor-no-repaint/src/Settings new file mode 100644 index 000000000..9e10c7a80 --- /dev/null +++ b/bugs/slider-backcolor-no-repaint/src/Settings @@ -0,0 +1,75 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "SliderBackcolorNoRepaint", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "Reproduces: Setting a Slider's BackColor at run time does not repaint the control", + "project.exportPathIsV2": true, + "project.id": "{12EDBDD7-B29B-47E6-82B7-71265E62977A}", + "project.name": "SliderBackcolorNoRepaint", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + }, + { + "id": "{D63CB78D-9F5B-42FE-B94C-A545F2B2BFC3}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - Common Controls Compatibility Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WinNativeCommonCtls", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 1 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/bugs/slider-backcolor-no-repaint/src/Sources/Form1.tbform b/bugs/slider-backcolor-no-repaint/src/Sources/Form1.tbform new file mode 100644 index 000000000..b3846a466 --- /dev/null +++ b/bugs/slider-backcolor-no-repaint/src/Sources/Form1.tbform @@ -0,0 +1,95 @@ +[ + { + "AlwaysShowKeyboardCues": false, + "Appearance": "vbAppear3d", + "AutoRedraw": false, + "BackColor": 13223074, + "BorderStyle": "vbSizable", + "Caption": "Slider BackColor", + "ClipControls": true, + "ControlBox": true, + "DrawMode": "vbCopyPen", + "DrawStyle": "vbSolid", + "DrawWidth": 1, + "Enabled": true, + "FillColor": 0, + "FillStyle": "vbFSTransparent", + "FontBold": false, + "FontItalic": false, + "FontName": "Segoe UI", + "FontSize": 8, + "FontStrikethru": false, + "FontTransparent": true, + "FontUnderline": false, + "ForeColor": -2147483630, + "FormDesignerId": "{7B247400-0000-4000-9000-7B2474000F01}", + "HasDC": true, + "Height": 480, + "HelpContextID": 0, + "Icon": "", + "KeyPreview": false, + "Left": 0, + "LinkMode": "vbLinkNone", + "LinkTopic": null, + "MDIChild": false, + "MaxButton": true, + "MaxHeight": 0, + "MaxWidth": 0, + "MinButton": true, + "MinHeight": 0, + "MinWidth": 0, + "MouseIcon": "", + "MousePointer": "vbDefault", + "Moveable": true, + "Name": "Form1", + "NegotiateMenus": false, + "OLEDropMode": "vbOLEDropNone", + "Opacity": 100, + "Palette": "", + "PaletteMode": "vbPaletteModeHalftone", + "Picture": "", + "PictureDpiScaling": false, + "RightToLeft": false, + "ScaleHeight": 8560, + "ScaleLeft": 0, + "ScaleMode": "vbTwips", + "ScaleTop": 0, + "ScaleWidth": 11760, + "ShowInTaskbar": true, + "StartUpPosition": "vbStartUpScreen", + "TabFocusAutoSelect": false, + "Tag": null, + "Top": 0, + "TopMost": false, + "TransparencyKey": -1, + "Visible": true, + "WhatsThisButton": false, + "WhatsThisHelp": false, + "Width": 640, + "WindowState": "vbNormal", + "__IDEOptions": { + "alignToGrid": false, + "gridHeight": 10, + "gridWidth": 10, + "lockedControls": [], + "multiColoredGrabbers": true, + "showGrid": true, + "showOutlines": false + }, + "__lastUpdateMarker": 3573907440, + "_children": [ + { + "BackColor": 8421504, + "Height": 60, + "Left": 20, + "Name": "Slider1", + "Top": 20, + "Width": 300, + "_className": "Slider", + "_clsid": "{FE755F8E-4EF9-42A4-A88B-B876EF5B4450}" + } + ], + "_className": "Form", + "_clsid": "{33AD4F38-6699-11CF-B70C-00AA0060D393}" + } +] diff --git a/bugs/slider-backcolor-no-repaint/src/Sources/Form1.twin b/bugs/slider-backcolor-no-repaint/src/Sources/Form1.twin new file mode 100644 index 000000000..10adb7fab --- /dev/null +++ b/bugs/slider-backcolor-no-repaint/src/Sources/Form1.twin @@ -0,0 +1,6 @@ +' A form holding one Slider, Slider1, with a design-time BackColor of &H808080. +[FormDesignerId("7B247400-0000-4000-9000-7B2474000F01")] +[PredeclaredId] +Class Form1 + +End Class diff --git a/bugs/slider-backcolor-no-repaint/src/Sources/Startup.twin b/bugs/slider-backcolor-no-repaint/src/Sources/Startup.twin new file mode 100644 index 000000000..53792009b --- /dev/null +++ b/bugs/slider-backcolor-no-repaint/src/Sources/Startup.twin @@ -0,0 +1,59 @@ +' Setting a Slider's BackColor at run time does not change what the control shows: it keeps +' drawing the old colour, even when the window is invalidated, until it is hidden and shown +' again. Sub Main shows Form1, which holds one Slider (design-time BackColor &H808080), lets +' it paint, sets BackColor to red, lets the messages run with nothing else touching the +' control, and reads a pixel of the Slider's window outside its channel and thumb. It then +' invalidates the window (InvalidateRect and UpdateWindow) and reads the pixel again, and +' last hides and shows the Slider and reads it a third time. GetPixel on the window's DC +' reads what is on screen; PrintWindow would make the window paint itself and hide the defect. +' Run it (F5 in the IDE, and read the DEBUG CONSOLE) or with: node scripts/bug_repro.mjs run slider-backcolor-no-repaint +Module Startup + + Private Declare PtrSafe Function GetDC Lib "user32" (ByVal hWnd As LongPtr) As LongPtr + Private Declare PtrSafe Function ReleaseDC Lib "user32" (ByVal hWnd As LongPtr, ByVal hDC As LongPtr) As Long + Private Declare PtrSafe Function GetPixel Lib "gdi32" (ByVal hDC As LongPtr, ByVal x As Long, ByVal y As Long) As Long + Private Declare PtrSafe Function InvalidateRect Lib "user32" (ByVal hWnd As LongPtr, ByVal lpRect As LongPtr, ByVal bErase As Long) As Long + Private Declare PtrSafe Function UpdateWindow Lib "user32" (ByVal hWnd As LongPtr) As Long + Private Declare PtrSafe Sub Sleep Lib "kernel32" (ByVal dwMilliseconds As Long) + + ' Lets the messages in the queue run, and the control paint, with nothing else happening. + Private Sub Settle() + Dim i As Long + For i = 1 To 20 + DoEvents + Sleep 25 + Next + End Sub + + ' The colour (a Long, &HBBGGRR) of the pixel at (2, 2) of the Slider's window. + Private Function Corner() As Long + Dim dc As LongPtr + dc = GetDC(Form1.Slider1.hWnd) + Corner = GetPixel(dc, 2, 2) + ReleaseDC Form1.Slider1.hWnd, dc + End Function + + Public Sub Main() + Form1.Show vbModeless + Settle + Debug.Print "painted, BackColor = &H" & Hex$(Form1.Slider1.BackColor) & ", pixel = &H" & Hex$(Corner()) + + Form1.Slider1.BackColor = vbRed + Settle + Debug.Print "BackColor set to &H" & Hex$(Form1.Slider1.BackColor) & ", pixel = &H" & Hex$(Corner()) + + InvalidateRect Form1.Slider1.hWnd, 0, 1 + UpdateWindow Form1.Slider1.hWnd + Settle + Debug.Print "after InvalidateRect and UpdateWindow, pixel = &H" & Hex$(Corner()) + + Form1.Slider1.Visible = False + Settle + Form1.Slider1.Visible = True + Settle + Debug.Print "after Visible = False, True, pixel = &H" & Hex$(Corner()) + + Unload Form1 + End Sub + +End Module diff --git a/bugs/vb6-attribute-lines-no-effect/repro.json b/bugs/vb6-attribute-lines-no-effect/repro.json new file mode 100644 index 000000000..d730679cd --- /dev/null +++ b/bugs/vb6-attribute-lines-no-effect/repro.json @@ -0,0 +1,8 @@ +{ + "mode": "lane", + "lane": "ide:vb6-import", + "tests": [ + "VB_HelpID, VB_VarHelpID, VB_VarMemberFlags and VB_MemberFlags other than hidden and nonbrowsable have no effect" + ], + "steps": "1. Pack the project (node scripts/bug_repro.mjs pack vb6-attribute-lines-no-effect) or unzip vb6-attribute-lines-no-effect.zip. 2. Open it in the IDE: an ActiveX DLL with Register DLL after build off, so building it registers nothing. 3. Build it. 4. Open the DLL's type library in OLE/COM Object Viewer (File > View TypeLib...): the class Attrs has no helpcontext, its members HiddenField and UnbrowsedField are neither hidden nor nonbrowsable, HelpField and WithHelp have no helpcontext, UiDefault is not uidefault and Bound is neither bindable nor displaybind. HiddenMethod is hidden, and so is TwinAttrs.HiddenField, the control written with [Hidden]." +} diff --git a/bugs/vb6-attribute-lines-no-effect/src/Settings b/bugs/vb6-attribute-lines-no-effect/src/Settings new file mode 100644 index 000000000..5c6e4bbc5 --- /dev/null +++ b/bugs/vb6-attribute-lines-no-effect/src/Settings @@ -0,0 +1,63 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "Vb6AttributeLinesNoEffect", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "ActiveX DLL", + "project.description": "Reproduces: Some VB6 attribute lines have no effect in a built type library", + "project.dllRegisterAfterBuild": false, + "project.dllRegisterLocalMachine": false, + "project.exportPathIsV2": true, + "project.id": "{569D9682-1A18-4F79-8B75-8F8B4AB654D9}", + "project.name": "Vb6AttributeLinesNoEffect", + "project.optionExplicit": true, + "project.references": [ + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "symbolId": "stdole", + "versionMajor": 2, + "versionMinor": 0 + }, + { + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "VB", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "(None)", + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} \ No newline at end of file diff --git a/bugs/vb6-attribute-lines-no-effect/src/Sources/Attrs.cls b/bugs/vb6-attribute-lines-no-effect/src/Sources/Attrs.cls new file mode 100644 index 000000000..06d0f0614 --- /dev/null +++ b/bugs/vb6-attribute-lines-no-effect/src/Sources/Attrs.cls @@ -0,0 +1,44 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True + Persistable = 0 'NotPersistable + DataBindingBehavior = 0 'vbNone + DataSourceBehavior = 0 'vbNone + MTSTransactionMode = 0 'NotAnMTSObject +END +Attribute VB_Name = "Attrs" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = True +Attribute VB_PredeclaredId = False +Attribute VB_Exposed = True +Attribute VB_HelpID = 2000 +Option Explicit + +' Each line below has no effect in the built type library (BETA 1005). + +Public HiddenField As Long +Attribute HiddenField.VB_VarMemberFlags = "40" + +Public UnbrowsedField As Long +Attribute UnbrowsedField.VB_VarMemberFlags = "400" + +Public HelpField As Long +Attribute HelpField.VB_VarHelpID = 2002 + +Public Function WithHelp() As Long +Attribute WithHelp.VB_HelpID = 2001 +End Function + +Public Property Get UiDefault() As Long +Attribute UiDefault.VB_MemberFlags = "200" +End Property + +Public Property Get Bound() As Long +Attribute Bound.VB_MemberFlags = "14" +End Property + +' The control: this line does take effect, FUNCFLAG_FHIDDEN. + +Public Function HiddenMethod() As Long +Attribute HiddenMethod.VB_MemberFlags = "40" +End Function diff --git a/bugs/vb6-attribute-lines-no-effect/src/Sources/TwinAttrs.twin b/bugs/vb6-attribute-lines-no-effect/src/Sources/TwinAttrs.twin new file mode 100644 index 000000000..de7029784 --- /dev/null +++ b/bugs/vb6-attribute-lines-no-effect/src/Sources/TwinAttrs.twin @@ -0,0 +1,6 @@ +' The control for VB_VarMemberFlags = "40": the same field marked [Hidden] is +' hidden in the built type library, FUNCFLAG_FHIDDEN on its property get and let. +Class TwinAttrs + [Hidden] + Public HiddenField As Long +End Class diff --git a/bugs/vb6-attribute-lines-no-effect/vb6-attribute-lines-no-effect.twinproj b/bugs/vb6-attribute-lines-no-effect/vb6-attribute-lines-no-effect.twinproj new file mode 100644 index 000000000..ee3a4429e Binary files /dev/null and b/bugs/vb6-attribute-lines-no-effect/vb6-attribute-lines-no-effect.twinproj differ diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index b4412fa83..11c512d1a 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -203,7 +203,7 @@ compared before and after: | harness | The `examples.bat` summary unchanged (1,129 samples as of 2026-09-25) and `addin-test.bat` green, against the local BETA 983. Never two harness runs at once. | | book | Page count, outline and extracted text unchanged; the PDF's bytes include timestamps. From C66, `check_pdf_shims_equiv.mjs`, which compares the shimmed pdf-lib with stock, is the oracle for the shims. | | command lines | From C47, `check_cli.mjs`'s recorded cases: each migrated tool's exit code, stream and message for the invocations that stop during argument parsing. | -| CI | For a commit that changes a workflow or the composite action: a dispatch of `checks.yml` on `origin`, and the deploy workflow's own run on the next push of `staging`. A dispatch of the deploy workflow cuts a GitHub release, so it is not a test. Pushing is the owner's call, so these commits wait for it. | +| CI | For a commit that changes a workflow or the composite action: a dispatch of `checks.yml` on `origin`, and the deploy workflow's own run on the next push of `staging`. A dispatch of the deploy workflow publishes a GitHub release, so it is not a test. Pushing is the owner's call, so these commits wait for it. | ## Execution @@ -303,7 +303,7 @@ is implemented. include the case a review-worded pin would miss: exact also where a new version would change a gate's verdict on unchanged code, which is why Biome is pinned exact though it patches nothing. -6. **A dispatch of the deploy workflow is not a test.** It cuts a GitHub release; folded into +6. **A dispatch of the deploy workflow is not a test.** It publishes a GitHub release; folded into the Oracles table's CI row. 7. **The command-line defects are fixed before `cli.mjs` exists (L1-2, L1-3, A7-5).** The review's fix for each is the shared module, but decision (e) makes Phase 2 change no diff --git a/builder/PLAN-checks.md b/builder/PLAN-checks.md index 756f60afc..51da58533 100644 --- a/builder/PLAN-checks.md +++ b/builder/PLAN-checks.md @@ -486,7 +486,8 @@ one per tree index. No headroom concern. [builder/check.mjs](check.mjs) (the build-side plumbing) and [builder/check-tree.mjs](check-tree.mjs) (the index derivation), with the graph shape as designed: extraction folded into `flush()`, then `linkJoin`, -`checkBook` and `checkReport` on main. Five deviations, the first three +`checkBook` and `checkReport` on main (`checkBook` has since moved to +the lane that wrote the book; see PLAN-scheduler.md). Five deviations, the first three forced and the last two found by measurement. **1. `treeIndex` folded into `dispatch` rather than becoming its own task.** diff --git a/builder/PLAN-counts.md b/builder/PLAN-counts.md index 4716a1b52..e7314ac00 100644 --- a/builder/PLAN-counts.md +++ b/builder/PLAN-counts.md @@ -374,6 +374,13 @@ noted it is missing for checks too. - **Scope beyond counts.** Nothing in the mechanism is specific to integers; `{{tbdocs:defaultBranch}}` would work identically. Resist for now — a value that is not a count has no obvious derivation and reopens the constants trap. + The one exception is the Downloads page's `{{tbdocs:size:}}` and + `{{tbdocs:packageTestRows}}`: text derived from the files the build publishes, + and needed in a raw HTML block and on a table line, which the inline rule + cannot reach. They are expanded in the page source instead, between + `maskCode` and its `restore` (see the header of counts.mjs), so code is + still untouched, and `checkPlaceholders` and `findSurvivingPlaceholder` + guard them as they guard a count. - **Whether the a11y sample size belongs here.** `check_a11y.mjs`'s thirteen pages are named in prose in several places, but that list lives in `scripts/lib/axe-scan.mjs`, outside the build. It would need the same diff --git a/builder/PLAN-sab-pull-scheduler.md b/builder/PLAN-sab-pull-scheduler.md index 6437875be..f06b4997b 100644 --- a/builder/PLAN-sab-pull-scheduler.md +++ b/builder/PLAN-sab-pull-scheduler.md @@ -2116,7 +2116,48 @@ thread sets the task to DONE and notifies the slot. Before waiting, the worker checks if other non-dependent work is available (one scan); if so, it does that work instead of sleeping. -### Phase 14: Move `writePdf` to a worker --- DEFERRED +### Phase 14: Move `writePdf` to a worker --- DONE + +**Outcome.** Done by the "full worker migration" below, with the two +blockers removed differently from the "paths forward" listed. The text +under Motivation, Investigation and Consequence is the original +investigation and is kept as written. + +1. **Index-based references at the boundary, not in + `resolveBookChapters`.** A new main task, `bookPayload` + (`builder/book-payload.mjs`), packs a projection of the pages + (`permalink`, `canonicalPermalink`, `navPath`, `srcRel`, `destPath`, + `frontmatter`, `renderedContent`), `bookData` with every page + reference replaced by `{$page: i}` (the page's index in the page + list), the static files, the config, the build information, the + highlight CSS and the check flag, into one SAB. The worker resolves + each index back to the same object, so `bookCoverage`'s identity + comparisons hold. `resolveBookChapters` is unchanged. +2. **Pre-rendered part text, at `bookPayload`.** `prerenderPartText` + (`book.mjs`) renders each part's subtitle and intro into + `part._rendered`; `renderPartDivider` reads it when present and + renders as before otherwise. +3. **`writePdf` and `checkBook` are worker tasks.** `writePdf` waits for + the new `"book"` task data (`WorkerPool.broadcastTaskData`, posted in + `bookPayload`'s `submit` before the task is marked done, so it is + queued on every lane before any lane can claim `writePdf`), keeps + the book HTML on its lane, and returns the counters only. + `checkBook` is pinned to that lane, so the 6.5--16 MB string never + crosses back. `helpArchive` moved to a worker in the same change + (reading `skipOffline` through the memoised `sharedData()`), which + is where most of the gain is. +4. **Handler ids** `helpArchive` 8, `writePdf` 9, `checkBook` 10. + +The tail (the end of `flushJoin` to the end of the last task) took +2,095--2,292 ms (median 2,235) before and 1,339--1,513 ms (median +1,478) after, five interleaved builds of each on a busy 16-lane machine: +about 0.75 s off each build. `bookPayload` costs ~95 ms on the main +thread. The three trees are byte-identical to the previous code's +(`scripts/compare_trees.mjs`). The remaining critical paths are +`writeOffline` → `helpArchive` → `addinProject` (main) and +`bookPayload` → `writePdf` → `checkBook`. The reasoning and the +experiment that showed overlapping main tasks gains nothing are in +PLAN-scheduler.md, §Tail of the graph. **Motivation.** `writePdf` depends on `flushJoin` + `mermaid` + `resolveBookChapters` --- no data dependency on the offline pipeline @@ -2171,9 +2212,8 @@ that: `renderedContent`, `frontmatter` subset). Packing cost ~30--50 ms; net main-thread savings ~100--120 ms. -Deferred: the refactoring cost is significant for a ~120 ms saving on -a 4 s build. Revisit if the build wall-clock shrinks enough that the -PDF task becomes a larger fraction. +Deferred at the time: the refactoring cost looked significant for a +~120 ms saving on a 4 s build. (Done later; see Outcome above.) ### Phase 15: Generic dynamic tasks and per-chunk flush --- DONE diff --git a/builder/PLAN-scheduler.md b/builder/PLAN-scheduler.md index e044287f0..bddbfa8ae 100644 --- a/builder/PLAN-scheduler.md +++ b/builder/PLAN-scheduler.md @@ -97,7 +97,10 @@ Visible idle time on the main thread: `markdown-init` → `seo` → `book` is the only true dependency edge. - `writeOffline` and `writePdf` are independent of each other; both read `_site/` (already written by `writePhase`) and write into - independent output trees. They run sequentially today. + independent output trees. They run sequentially today. (They still + do not overlap on the main thread, which awaits each main task before + it claims the next; the overlap comes from moving `writePdf` to a + worker. See §Phase 14 of PLAN-sab-pull-scheduler.md.) - `renderPhase` and `templatePhase` are CPU-bound and block the main thread completely. `Promise.all(pages.map(...))` is cooperative concurrency only -- it interleaves on a single thread. @@ -212,7 +215,7 @@ The split: | `searchData` | M | ~40 ms; owns pages read | | `writeAux` | M | Owns pages + bookData reads | | `writeOffline` | M | I/O dominated (~1.1 s); see §Post-write tasks for the workerize-or-not call | -| `writePdf` | M | I/O dominated (~240 ms); ditto | +| `writePdf` | M | I/O dominated (~240 ms); ditto. Now **W**, behind a main `bookPayload` task (see §Tail of the graph) | | `buildInfo` | **W** | Free overlap with the main spine | | `scss` | **W** | ~700 ms -- the biggest seed-task parallelism win | | `mermaid` | **W** | ~2 ms idle, ~150 ms when SVGs regen; runs concurrently with discover | @@ -310,11 +313,12 @@ Three structural wins over the serial baseline: Across N workers this compresses to ~`3500 / N + dispatch overhead`. On a 4-core box, ~875 ms wall-clock (saving ~2.6 s). On an 8-core box, ~440 ms (saving ~3 s). Dispatch overhead is ~50 ms. -3. **`writeOffline` and `writePdf` overlap on async I/O.** Both stay - `runOnMain` initially -- they share the main thread for their CPU - sections but `await fs.writeFile`-style I/O windows interleave. The - gain is the shorter of their two CPU sections (~240 ms). See - §Post-write tasks for the case to workerize one of them later. +3. **`writeOffline` and `writePdf` overlap.** The first design kept + both `runOnMain` and expected their I/O windows to interleave. They + do not: `Scheduler._mainScan` awaits each main task before it claims + the next, so main tasks run one at a time, in declaration order. + The overlap exists only since `writePdf` moved to a worker. See + §Tail of the graph. **Mermaid → staticFiles ordering.** Under the scheduler `mermaid` and `discover` run in parallel, so freshly-emitted SVGs aren't in @@ -1254,7 +1258,8 @@ writeOffline: { }, // writePdf depends on renderJoin + mermaid (CSS sourced directly). -// It runs in parallel with write → searchData → writeAux → writeOffline. +// It was meant to run in parallel with write → searchData → writeAux → +// writeOffline; as a main task it could not (see §Tail of the graph). ``` **Restoring the derive-time exports.** `redirects.mjs` / @@ -1270,16 +1275,15 @@ to `p.frontmatter.layout !== "book-combined"` (the property that determines whether `html` will be set; known after discover) so the derive can run at any point after discover. -**Workerizing writeOffline or writePdf (measured, declined).** Both -phases have non-trivial CPU sections: writeOffline rewrites URLs -across all 856 HTML files; writePdf assembles `book.html` via -`assembleBook`. `writePdf` now depends only on `renderJoin` + `mermaid` -(CSS is sourced directly, not from `_site/`), so it runs in parallel -with the entire `write → searchData → writeAux → writeOffline` chain. -Cooperative async concurrency on the main thread interleaves their I/O -gaps. The structured-clone cost of shipping `pages[]` across the worker -boundary (~37–65 ms) would be pure overhead. See §Phase 3-follow-up -for the full measurement. +**Workerizing writeOffline or writePdf (declined at the time; writePdf +since done).** Both phases have non-trivial CPU sections: writeOffline +rewrites URLs across all 856 HTML files; writePdf assembles `book.html` +via `assembleBook`. `writePdf` now depends only on `renderJoin` + +`mermaid` (CSS is sourced directly, not from `_site/`). The decision +below rested on the belief that cooperative async concurrency on the +main thread interleaves the two tasks' I/O gaps; it does not, because +main tasks never overlap. `writePdf` is a worker task now; see +§Tail of the graph. ## Timing / profiling @@ -1363,7 +1367,7 @@ data lifetime across the worker boundary, or low-level serialisation. | 1. Seeds + main-thread spine | Sonnet | Each task body is a thin wrapper around an existing phase function. The scheduler core is copy-from-plan. All mutation is on the main thread (no cross-worker identity yet). | | 2. Render fan-out | Opus | Cross-thread structured-clone semantics, module-scope initialisation order, dynamic task registration, per-page delta-merge identity. Debugging concurrency bugs needs depth. | | 3. Post-write tasks | Sonnet | All `runOnMain`; thin wrappers around existing write functions. No new concurrency surface beyond the already-built scheduler. | -| 3-follow-up. Workerize writeOffline | Opus (decided) | Profiled: zero CPU contention; cooperative async overlap is already optimal. Declined — no implementation needed. | +| 3-follow-up. Workerize writeOffline | Opus (decided) | Declined at the time (see the section); `writePdf` was workerized later, see §Tail of the graph. | | 4. SAB broadcast | Opus | JSON + SAB approach; measured ~55% fan-out reduction (~8 ms saving). | Escalate to Opus mid-phase if a Sonnet session hits a debugging block @@ -1501,29 +1505,35 @@ expected savings above within ±20%. **Suggested model:** Sonnet. Port `write`, `searchData`, `writeAux`, `writeOffline`, `writePdf` as -`runOnMain` tasks. `writeOffline` and `writePdf` run in parallel on -the main thread; the gain is the shorter of their two CPU sections -(~240 ms) plus interleaved I/O. +`runOnMain` tasks. The plan expected `writeOffline` and `writePdf` to +run in parallel on the main thread, for a gain of the shorter of their +two CPU sections (~240 ms) plus interleaved I/O. They run one after +the other (see §Tail of the graph). `runBuild()` shrinks to: pool construction + `scheduler.start(ctx)` + summary output + `pool.destroy()`. **Expected savings:** ~240 ms (4.0 s → ~3.75 s on 4 cores). -**Verification.** `build.bat && check.bat` clean. `writeOffline` and -`writePdf` should overlap in the timing summary -- their `start` -timestamps should be within a few ms of each other. +**Verification.** `build.bat && check.bat` clean. (The plan said +`writeOffline` and `writePdf` should overlap in the timing summary, +with `start` timestamps within a few ms of each other; as main tasks +they cannot. See §Tail of the graph.) ### Phase 3-follow-up: workerize writeOffline (optional) — DECLINED -**Decision:** do not workerize. Profiling shows zero CPU contention. +**Decision at the time:** do not workerize. The record below is kept +as written. It no longer holds for the current scheduler, whatever the +scheduler of the time did: `Scheduler._mainScan` awaits each main task +before it claims the next, so two main tasks never run at the same +time. `writePdf` was workerized afterwards; see §Tail of the graph. -Two independent measurement runs confirmed that `writeOffline` and -`writePdf` already achieve perfect overlap via cooperative async -concurrency on the main thread. In both runs the combined wall-clock -equalled `max(writeOffline, writePdf)` — the "wasted (CPU contention)" -metric was 0 ms. `writePdf`'s `assembleBook` synchronous section -(~150 ms) runs entirely inside `writeOffline`'s I/O await gaps. +Two independent measurement runs found the combined wall-clock of +`writeOffline` and `writePdf` equal to `max(writeOffline, writePdf)` +and the "wasted (CPU contention)" metric 0 ms, and read that as +perfect overlap via cooperative async concurrency on the main thread. +`writePdf`'s `assembleBook` synchronous section (~150 ms) was thought +to run entirely inside `writeOffline`'s I/O await gaps. Structured-clone cost for shipping `pages[]` across the worker boundary was measured at ~37–65 ms (depending on per-page HTML size), @@ -1532,8 +1542,58 @@ Adding the `offline` handler to `cpu-worker.mjs` would also increase worker spawn time (acorn import), complicate the worker's module surface, and add a result-merge path — all for no measurable gain. -The overlap already saves ~260 ms vs. sequential execution (the full -duration of `writePdf`). No further action needed. +The current scheduler has no such overlap; see §Tail of the graph. + +### Tail of the graph: what the main thread does after `flushJoin` + +`Scheduler._mainScan` awaits each main task before it claims the next, +so main tasks never overlap: they run one at a time, lowest declaration +index first. After `flushJoin` the main thread ran, in milliseconds: + +``` +writeAux 30 → writeOffline 200 → helpArchive 600 → addinProject 220 + → writePdf 600 → linkJoin 110 → checkBook 300 → checkReport 80 +``` + +An experiment that let main tasks overlap (not awaiting them) gained +nothing: the tail took 2.17–2.23 s against 2.03–2.41 s serial. The +tail is CPU-bound on the main thread (`writePdf`'s ~600 ms is mostly +the synchronous `assembleBook`), so overlapping only stretched the +others: `writeOffline` 200 → 530 ms, `helpArchive` 600 → 1,700 ms. + +The gain had to come from moving work to workers, which are idle by +then: + +- `helpArchive` is a worker task (`handler: "helpArchive"`). It reads + the offline tree from disk and `skipOffline` from `dispatch`'s + broadcast, through `sharedData()` in `cpu-worker.mjs`. Its zip + crosses the thread boundary as a `Uint8Array`; its `submit` wraps it + as a `Buffer` again without copying, for `addinProject`. +- `writePdf` is a worker task behind a new main task, `bookPayload` + (`expected` is the old `writePdf` list). `markdown-it` cannot cross + to a worker, so `bookPayload` first renders each part's subtitle + and intro (`prerenderPartText`). It then packs (`book-payload.mjs`) a + projection of the pages, `bookData` with every page reference + replaced by `{$page: i}`, the static files, the config, the build + information, the highlight CSS and the check flag, and its `submit` + broadcasts the buffer with `WorkerPool.broadcastTaskData`, before the + task is marked done, so the message is queued on every lane before + any lane can claim `writePdf`. The index references are unpacked + into the same objects, so `bookCoverage`'s identity comparisons hold. + `bookPayload` is declared before `writeAux`, so the main thread + claims it first. It costs ~95 ms. +- `checkBook` is a worker task pinned to `writePdf`'s lane, so the + 6.5–16 MB book string never crosses back. + +Tail (the end of `flushJoin` to the end of the last task), alternating +builds of the old and the new code, five of each, on a busy 16-lane +machine: 2,095–2,292 ms (median 2,235) before, 1,339–1,513 ms (median +1,478) after, about 0.75 s off each build. The online, offline and PDF +trees are byte-identical (`scripts/compare_trees.mjs`). The critical +paths that remain are `writeOffline` → `helpArchive` (worker) → +`addinProject` (main; 200–1,900 ms, because it round-trips 53 MB +through `%TEMP%`, which varies with a busy disk), and `bookPayload` → +`writePdf` → `checkBook`, both on workers. ### Phase 4: SharedArrayBuffer broadcast diff --git a/builder/addin-project.mjs b/builder/addin-project.mjs index d56b49f6f..8d951e270 100644 --- a/builder/addin-project.mjs +++ b/builder/addin-project.mjs @@ -34,18 +34,18 @@ // same bytes, and impexp writes no time and sorts every folder, so two builds // of one tree do too. // -// The pack is impexp's own importProject, in process, as the harness packs -// (scripts/lib/tb-project.mjs): never the compiler executable, never Python. +// The pack is impexp's own packFiles, in memory: the files never touch the +// disk, as importProject would need them to (the harness packs with it, +// scripts/lib/tb-project.mjs). Never the compiler executable, never Python. // impexp.mjs is the one module under scripts/ the builder may import // (biome.jsonc): it depends on nothing, it is a build input already, published // through bundle_extra, and check_tree_fresh.mjs watches it. import { execFile } from "node:child_process"; import { promises as fs } from "node:fs"; -import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { importProject } from "../scripts/impexp.mjs"; +import { packFiles } from "../scripts/impexp.mjs"; import { posix } from "./paths.mjs"; const exec = promisify(execFile); @@ -123,24 +123,11 @@ export async function packAddinProject({ dir, symbols, archive = null }) { } if (archive) files.push({ rel: ARCHIVE_IN_PROJECT, bytes: archive }); - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "addin-project-")); - try { - const stage = path.join(tmp, path.basename(dir)); - for (const f of files) { - const p = path.join(stage, ...f.rel.split("/")); - await fs.mkdir(path.dirname(p), { recursive: true }); - await fs.writeFile(p, f.bytes); - } - const out = path.join(tmp, "project.twinproj"); - importProject(out, stage); - return { - bytes: await fs.readFile(out), - files: files.map((f) => f.rel), - untracked: untracked.filter((rel) => !isHelp(rel)), - }; - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } + return { + bytes: packFiles(files, path.basename(dir)).bytes, + files: files.map((f) => f.rel), + untracked: untracked.filter((rel) => !isHelp(rel)), + }; } /** diff --git a/builder/book-payload.mjs b/builder/book-payload.mjs new file mode 100644 index 000000000..2f09f2049 --- /dev/null +++ b/builder/book-payload.mjs @@ -0,0 +1,77 @@ +// What a worker needs to write the PDF tree (pdf.mjs's writePdf), packed on +// the main thread into one SharedArrayBuffer that every lane can read. +// +// The book is assembled from the pages as the main thread holds them after +// render:i.submit merged each one's renderedContent, and from site.bookData, +// whose _chapters, _foreword and _landing entries resolveBookChapters filled +// with those same page objects. bookCoverage compares them by identity, so a +// page reference inside bookData is packed as {$page: i}, its index in the +// pages list, and unpacked as the same object the list holds. +// +// markdown-it cannot cross the thread boundary, so the parts' subtitles and +// intros are rendered beforehand (book.mjs's prerenderPartText). + +import { packShared, unpackShared } from "./sab-broadcast.mjs"; + +// What book.mjs and pdf.mjs read of a page. A page holds more -- its +// HTML, its source -- that the book never reads and the packing would +// only copy. +function pageProjection(p) { + return { + permalink: p.permalink, + canonicalPermalink: p.canonicalPermalink, + navPath: p.navPath, + srcRel: p.srcRel, + destPath: p.destPath, + frontmatter: p.frontmatter, + renderedContent: p.renderedContent, + }; +} + +/** + * @param {object} o + * @param {object[]} o.pages the build's pages, renderedContent merged + * @param {object[]} o.staticFiles the static-file descriptors + * @param {object} o.site config, buildInfo and bookData are read + * @param {string | null} o.highlightCss + * @param {boolean} o.check hand the book to the link check + * @param {boolean} o.skip write no PDF tree + * @returns {SharedArrayBuffer} + */ +export function packBookPayload({ pages, staticFiles, site, highlightCss, check, skip }) { + if (skip) return packShared({ skip: true }); + const index = new Map(pages.map((p, i) => [p, i])); + const bookData = JSON.parse( + JSON.stringify(site.bookData, (_key, value) => (index.has(value) ? { $page: index.get(value) } : value)), + ); + return packShared({ + pages: pages.map(pageProjection), + bookData, + staticFiles, + config: site.config, + buildInfo: site.buildInfo, + highlightCss, + check, + }); +} + +/** The arguments writePdf takes, or null when the build writes no PDF tree. */ +export function unpackBookPayload(sab) { + const data = unpackShared(sab); + if (data.skip) return null; + const { pages } = data; + const resolve = (value) => { + if (Array.isArray(value)) return value.map(resolve); + if (value === null || typeof value !== "object") return value; + if (typeof value.$page === "number") return pages[value.$page]; + for (const key of Object.keys(value)) value[key] = resolve(value[key]); + return value; + }; + return { + pages, + staticFiles: data.staticFiles, + site: { config: data.config, buildInfo: data.buildInfo, bookData: resolve(data.bookData) }, + highlightCss: data.highlightCss, + check: data.check, + }; +} diff --git a/builder/book.mjs b/builder/book.mjs index 837f2cdb6..6f28eb049 100644 --- a/builder/book.mjs +++ b/builder/book.mjs @@ -756,28 +756,36 @@ function renderPartDivider(part, partNum, site) { const closedAttr = part.outline_closed ? ` data-pdf-bookmark-closed` : ``; out += `

${part.title}

`; } - if (part.subtitle) { - // book.html line 111-112: subtitle | markdownify | remove '

' - // | remove '

' | strip. Inline render via the markdown-it - // instance Phase 3 stashed on site.markdown. - const md = site.markdown; - if (!md) { - throw new Error("Phase 8: site.markdown is unset; Phase 3 didn't run before assembleBook."); - } - const renderedSubtitle = md.render(String(part.subtitle)).replaceAll("

", "").replaceAll("

", "").trim(); - out += `\n

${renderedSubtitle}

`; + const { subtitleHtml, introHtml } = part._rendered ?? renderPartText(part, site.markdown); + if (subtitleHtml !== undefined) out += `\n

${subtitleHtml}

`; + if (introHtml !== undefined) out += `\n
${introHtml}
`; + out += `\n`; + return out; +} + +// A part's subtitle and intro as HTML. book.html line 111-112: subtitle | +// markdownify | remove '

' | remove '

' | strip; the intro is rendered +// whole. Through the markdown-it instance Phase 3 stashed on site.markdown. +function renderPartText(part, md) { + const out = {}; + if (!part.subtitle && !part.intro) return out; + if (!md) { + throw new Error("Phase 8: site.markdown is unset; Phase 3 didn't run before assembleBook."); } - if (part.intro) { - const md = site.markdown; - if (!md) { - throw new Error("Phase 8: site.markdown is unset; Phase 3 didn't run before assembleBook."); - } - out += `\n
${md.render(String(part.intro))}
`; + if (part.subtitle) { + out.subtitleHtml = md.render(String(part.subtitle)).replaceAll("

", "").replaceAll("

", "").trim(); } - out += `\n`; + if (part.intro) out.introHtml = md.render(String(part.intro)); return out; } +// Renders every part's subtitle and intro into `part._rendered`, for an +// assembly that has no markdown-it instance: the book is assembled on a +// worker, and markdown-it cannot cross the thread boundary. +export function prerenderPartText(bookData, md) { + for (const part of bookData?.parts ?? []) part._rendered = renderPartText(part, md); +} + // PLAN-8 §6.9: chapter divider matches book.html lines 218-227. function renderChapterDivider(chEntry) { const dividerId = chapterDividerId(chEntry); diff --git a/builder/counts.mjs b/builder/counts.mjs index 44863915e..68a1d11f9 100644 --- a/builder/counts.mjs +++ b/builder/counts.mjs @@ -62,11 +62,39 @@ // // A name with no uses is fine and is not reported. Names are cheap; the // registry is allowed to offer more than the prose currently asks for. +// +// ------------------------------------------------------ the download pages +// +// Two placeholders are not numbers from `deriveCounts` but text the Downloads +// page needs, and both sit where the inline rule cannot reach: in a raw HTML +// block, and as a line of a markdown table. +// +// {{tbdocs:size:}} the size of a file the online tree +// publishes, as `43 KB` or `1.2 MB` +// {{tbdocs:packageTestRows}} one table row for each contributed package's +// test project: name, download, size +// +// So `expandDownloadPlaceholders` expands them in the page's source, between +// `maskCode` and its `restore` (render.mjs, applyPreRenderRewrites), where code +// is already hidden. Their values are settled before any page renders, because +// the test projects are packed first (tbdocs.mjs, packTests) and the packageTests +// task writes those same bytes. `checkPlaceholders` is `validateCountNames` for +// them: it rejects, naming the file and the line, a path the build does not +// publish or whose size cannot be known before render. The help add-in's +// project is the deliberate case: it embeds the offline tree, which holds every +// page that states a size, so its size would depend on itself. +import path from "node:path"; import { blockRegions, maskCode } from "../lib/markdown.mjs"; import { CODE_OR_PRE } from "./code-guard.mjs"; -export const PLACEHOLDER_RE = /\{\{tbdocs:([A-Za-z][A-Za-z0-9]*)\}\}/g; +// The optional `:` is a site path for `size`; a count name takes none. +export const PLACEHOLDER_RE = /\{\{tbdocs:([A-Za-z][A-Za-z0-9]*)(?::([^\s{}]+))?\}\}/g; + +const SIZE_NAME = "size"; +const ROWS_NAME = "packageTestRows"; +// What a placeholder may name besides a count, for the "available" line of the error. +const DOWNLOAD_NAMES = [`${SIZE_NAME}:`, ROWS_NAME]; // ---------------------------------------------------------------- registry // @@ -233,7 +261,9 @@ export function countPlugin(md, ctx) { } function substitute(text, counts) { - return text.replace(PLACEHOLDER_RE, (whole, name) => (Object.hasOwn(counts, name) ? String(counts[name]) : whole)); + return text.replace(PLACEHOLDER_RE, (whole, name, arg) => + arg === undefined && Object.hasOwn(counts, name) ? String(counts[name]) : whole, + ); } // -------------------------------------------------------------- validation @@ -249,13 +279,16 @@ function substitute(text, counts) { * `line` is 1-based in `rawContent`. It is counted with the code put back, * because a masked fence is one line however many it holds. * - * @returns {{name: string, line: number}[]} + * `arg` is the text after the name's colon, or undefined. + * + * @returns {{name: string, arg: string|undefined, line: number}[]} */ export function findCountRefs(rawContent, md) { + if (!rawContent.includes("{{tbdocs:")) return []; const { masked, restore } = maskCode(rawContent.replace(/\r\n?/g, "\n"), { md }); const out = []; for (const m of masked.matchAll(PLACEHOLDER_RE)) { - out.push({ name: m[1], line: restore(masked.slice(0, m.index)).split("\n").length }); + out.push({ name: m[1], arg: m[2], line: restore(masked.slice(0, m.index)).split("\n").length }); } return out; } @@ -297,31 +330,167 @@ function editDistance(a, b) { * A message names the line of the file, not of `rawContent`: the page's * `contentLine`, from `discover`, says where its content starts. * - * @returns {string[]} one message per bad reference; empty means clean + * It also checks the two download placeholders against `downloads` (from + * `deriveDownloads`; null when the caller has none, which makes them errors) and + * returns the size of each file a `size` placeholder names, for the render + * workers. + * + * @returns {{problems: string[], sizes: Record}} one message per + * bad reference, empty meaning clean; and the bytes of each file named */ -export function validateCountNames(pages, counts, md) { - const names = Object.keys(counts).sort(); +export function checkPlaceholders(pages, counts, downloads, md) { + const names = [...Object.keys(counts), ...DOWNLOAD_NAMES].sort(); const problems = []; + const sizes = {}; for (const p of pages) { - for (const { name, line } of findCountRefs(p.rawContent ?? "", md)) { - if (Object.hasOwn(counts, name)) continue; - const guess = nearest(name, names); - problems.push( - `${p.srcRel}:${line + (p.contentLine ?? 1) - 1}\n` + - ` unknown count name {{tbdocs:${name}}}\n` + - (guess ? ` did you mean: ${guess}?\n` : "") + - ` available: ${names.join(", ")}`, - ); + for (const { name, arg, line } of findCountRefs(p.rawContent ?? "", md)) { + const where = `${p.srcRel}:${line + (p.contentLine ?? 1) - 1}`; + const refuse = (reason) => problems.push(`${where}\n {{tbdocs:${name}${arg ? `:${arg}` : ""}}}: ${reason}`); + if (name === SIZE_NAME) { + if (!arg) refuse("a size needs the site path of a file: {{tbdocs:size:}}"); + else if (!downloads) refuse("this build has no downloads to state sizes of"); + else { + const rel = arg.replace(/^\/+/, ""); + const bytes = downloads.files.get(rel); + if (bytes !== undefined) sizes[arg] = bytes; + else refuse(downloads.refused.get(rel) ?? notPublished(rel, downloads)); + } + } else if (name === ROWS_NAME) { + if (arg) refuse("takes no argument"); + else if (!downloads) refuse("this build has no downloads to list"); + } else if (Object.hasOwn(counts, name)) { + if (arg) refuse("a count name takes no argument"); + } else { + const guess = nearest(name, names); + problems.push( + `${where}\n` + + ` unknown count name {{tbdocs:${name}}}\n` + + (guess ? ` did you mean: ${guess}?\n` : "") + + ` available: ${names.join(", ")}`, + ); + } } } - return problems; + return { problems, sizes }; +} + +/** `checkPlaceholders` for a caller with no downloads: the problems alone. */ +export function validateCountNames(pages, counts, md) { + return checkPlaceholders(pages, counts, null, md).problems; +} + +// A path no file answers to: say what the build does publish, and name a file of that name +// elsewhere, the usual slip. +function notPublished(rel, downloads) { + const base = path.posix.basename(rel); + const same = [...downloads.files.keys()].filter((k) => path.posix.basename(k) === base); + return ( + `the build does not publish ${rel}; a size is known for the static files under docs/, the ` + + "bundle_extra files and the package test projects" + + (same.length ? `\n did you mean: ${same.slice(0, 3).join(", ")}?` : "") + ); +} + +// -------------------------------------------------------------- downloads + +const HELP_ADDIN_REFUSAL = + "the help add-in's project embeds the offline tree, which holds this page, so its size depends " + + "on the size stated and cannot be known before render"; +const REGENERATED_REFUSAL = + "the build rewrites this file when it copies it or generates it after the pages are rendered, " + + "so its size cannot be known before render"; + +/** + * What the download placeholders know, from build state. + * + * `files` holds the bytes of every file whose size is settled before any page renders: the + * static files discover found (the `bundle_extra` files among them), and the package test + * projects. `refused` holds the paths that are published but cannot be sized, with the reason. + * + * @param {object} state scheduler state: needs `staticFiles` + * @param {object} extra + * @param {{dest: string, packed: {pkg: string, rel: string, bytes: Buffer}[]}|null} extra.tests + * the packed test projects, and where the online tree has them + * @param {string|null} extra.addinDest the help add-in's project file, which is refused + * @returns {{files: Map, refused: Map, + * tests: {dest: string, packages: {pkg: string, rel: string, bytes: number}[]}}} + */ +export function deriveDownloads(state, { tests = null, addinDest = null }) { + const files = new Map(); + const refused = new Map(); + for (const f of state.staticFiles ?? []) { + const rel = f.destRel.replaceAll("\\", "/"); + // A stylesheet has its url() paths rewritten for a base path as it is copied; an SVG may be + // one the dot task regenerates, which runs beside this. + if (/\.(css|svg)$/i.test(rel)) refused.set(rel, REGENERATED_REFUSAL); + else files.set(rel, f.size); + } + if (addinDest) refused.set(addinDest, HELP_ADDIN_REFUSAL); + const packages = (tests?.packed ?? []).map(({ pkg, rel, bytes }) => ({ pkg, rel, bytes: bytes.length })); + for (const t of packages) files.set(t.rel, t.bytes); + return { files, refused, tests: { dest: tests?.dest ?? "", packages } }; +} + +/** `43 KB` below a mebibyte (never less than 1 KB), `1.2 MB` from there. */ +export function formatSize(bytes) { + const kb = Math.max(1, Math.round(bytes / 1024)); + return kb < 1024 ? `${kb} KB` : `${(bytes / 1048576).toFixed(1)} MB`; +} + +// `target`, a path under the site root, relative to the folder the page `permalink` is served +// from -- which is how a relative link in the page resolves (WIP.Authoring.md, cross-section +// linking). A trailing slash on `target` is kept. +function linkFrom(permalink, target) { + const dir = String(permalink ?? "/").replace(/[^/]*$/, ""); + const from = dir.split("/").filter(Boolean); + const to = target.split("/").filter(Boolean); + let common = 0; + while (common < from.length && common < to.length && from[common] === to[common]) common++; + const rel = [...from.slice(common).map(() => ".."), ...to.slice(common)].join("/"); + return target.endsWith("/") ? `${rel}/` : rel; +} + +/** + * Expand `{{tbdocs:size:}}` and `{{tbdocs:packageTestRows}}` in a page's source, which + * the caller has already masked of code. + * + * `downloads` is the object the build hands the render workers: `sizes`, the bytes of each + * path the pages name, and `tests`, as `deriveDownloads` returns it. `checkPlaceholders` has + * rejected what cannot be expanded, so a miss here is a bug in the build and throws. + * + * @param {string} src + * @param {{sizes: Record, tests: {dest: string, packages: object[]}}} downloads + * @param {string} permalink the page's URL; the table rows' links are relative to it + */ +export function expandDownloadPlaceholders(src, downloads, permalink) { + if (!src.includes("{{tbdocs:")) return src; + return src.replace(PLACEHOLDER_RE, (whole, name, arg) => { + if (name === SIZE_NAME && arg !== undefined) { + const bytes = downloads.sizes[arg]; + if (bytes === undefined) + throw new Error(`{{tbdocs:size:${arg}}} has no size; checkPlaceholders should have refused it`); + return formatSize(bytes); + } + if (name === ROWS_NAME && arg === undefined) { + const { dest, packages } = downloads.tests; + return packages + .map(({ pkg, rel, bytes }) => { + const file = path.posix.basename(rel); + const section = linkFrom(permalink, `${dest}/${pkg}/`); + const download = linkFrom(permalink, rel); + return `| [${pkg}](${section}) |
${file} | ${formatSize(bytes)} |`; + }) + .join("\n"); + } + return whole; + }); } // Consumes ... and
...
atomically in the leading // alternation (code-guard.mjs), so a placeholder the documentation is // deliberately SHOWING does not trip the assertion. const SURVIVING_PLACEHOLDER_RE = new RegExp( - String.raw`${CODE_OR_PRE.source}|(\{\{tbdocs:[A-Za-z][A-Za-z0-9]*\}\})`, + String.raw`${CODE_OR_PRE.source}|(\{\{tbdocs:[A-Za-z][A-Za-z0-9]*(?::[^\s{}]+)?\}\})`, "g", ); diff --git a/builder/cpu-worker.mjs b/builder/cpu-worker.mjs index 4e794489d..47c623ea2 100644 --- a/builder/cpu-worker.mjs +++ b/builder/cpu-worker.mjs @@ -9,6 +9,10 @@ import { parentPort, workerData } from "node:worker_threads"; import { compileLightScss, compileDarkScss } from "./scss.mjs"; import { regenerateDot } from "./dot.mjs"; import { captureBuildInfo } from "./build-info.mjs"; +import { helpArchiveStep } from "./help-archive-step.mjs"; +import { writePdf } from "./pdf.mjs"; +import { unpackBookPayload } from "./book-payload.mjs"; +import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { createMarkdownIt, renderPhase } from "./render.mjs"; import { renderFullNav, templatePhase, withPartialNav } from "./template.mjs"; @@ -47,6 +51,16 @@ let ctx = null; // { srcRoot, destRoot, opts, workerCount } let _payloadSAB = null; // SharedArrayBuffer with packed per-task payloads let _sharedSAB = null; // SharedArrayBuffer with packed shared payload +let _shared = null; // _sharedSAB decoded, once per lane + +// What dispatch broadcast. A task that reads it runs after dispatch, so the +// SAB is here by then; renderEnvInit, which can be claimed earlier, waits +// for it first. +function sharedData() { + if (!_sharedSAB) throw new Error("the shared payload was read before dispatch broadcast it"); + _shared ??= unpackShared(_sharedSAB); + return _shared; +} // ── Handler table ─────────────────────────────────────────────────────────── @@ -78,7 +92,8 @@ const handlers = { vendoredImagesObj, pictureSizes, counts, - } = unpackShared(_sharedSAB); + downloads, + } = sharedData(); const { initHighlighter } = await import("./highlight.mjs"); const highlighter = await initHighlighter(); @@ -97,6 +112,7 @@ const handlers = { vendoredVideos, vendoredImages, counts, + downloads, }); const site = { ...siteData, markdown, buildInfo }; @@ -199,6 +215,55 @@ const handlers = { return { buildInfo }; }, + // On a worker because it is the main thread's longest task after the + // pages are written, and it needs nothing from the main thread that + // dispatch has not already broadcast: it reads the offline tree from disk. + async helpArchive() { + const { skipOffline } = sharedData(); + return helpArchiveStep({ + src: path.relative(REPO_ROOT, ctx.srcRoot).replaceAll(path.sep, "/"), + offlineRoot: skipOffline ? null : `${ctx.destRoot}-offline`, + serve: !!ctx.opts.serve, + dryRun: !!ctx.opts.dryRun, + disabled: !!ctx.opts.skipHelpArchive, + }); + }, + + // The PDF tree, from what bookPayload packed on the main thread. The book + // itself stays on this lane for checkBook rather than crossing back. + async writePdf() { + const book = unpackBookPayload(await taskData("book")); + _bookCheck = null; + if (!book) return null; + const { checkBook, ...counters } = await writePdf(book.pages, book.staticFiles, book.site, ctx.destRoot, { + tolerateMissingImages: ctx.opts.tolerateMissingImages, + highlightCss: book.highlightCss, + check: book.check, + }); + _bookCheck = checkBook ?? null; + return counters; + }, + + // The book is one 6.5 MB document with almost entirely internal + // fragments, written by a different task from a different string. + // Keeping it its own pass is cheaper than special-casing the chunk + // path for a single file. + async checkBook() { + if (!_bookCheck) return null; + const { html, rels } = _bookCheck; + _bookCheck = null; + const { checkChunk, joinChunks, treeIndexFor, TREES } = await import("./check.mjs"); + const root = ctx.destRoot + TREES.pdf.suffix; + const env = { root, tree: TREES.pdf, basePath: "", index: treeIndexFor(root, rels) }; + let chunk; + try { + chunk = checkChunk([{ destPath: "book.html", html }], env); + } catch (err) { + chunk = { error: `book check failed: ${err.message}` }; + } + return joinChunks([chunk], { root, tree: TREES.pdf, relFiles: ["book.html"] }); + }, + async render(taskIdx) { const offset = Atomics.load(views.payloadOffset, taskIdx); const length = Atomics.load(views.payloadLength, taskIdx); @@ -278,6 +343,16 @@ for (const [name, id] of Object.entries(HANDLERS)) handlerById[id] = handlers[na let _renderEnv = null; let _pendingFlush = []; +let _taskData = new Map(); // key → SAB a main-thread task broadcast (broadcastTaskData) +let _bookCheck = null; // writePdf's book for checkBook, which is pinned to the same lane + +// The data a main-thread predecessor broadcast for this task. The message +// is queued before the predecessor is marked done, but this lane may still +// be inside the pull loop that claimed the task, so yield until it lands. +async function taskData(key) { + while (!_taskData.has(key)) await new Promise((resolve) => setImmediate(resolve)); + return _taskData.get(key); +} let _checkEnv = null; // set by renderEnvInit when --check is on let _checkChunk = null; // builder/check.mjs's checkChunk, imported with it @@ -315,7 +390,7 @@ async function runChunkCheck(items) { return out; } -// ── Message handler (init + dynamicData only) ─────────────────────────────── +// ── Message handler (init, dynamicData, taskData) ─────────────────────────── parentPort.on("message", (msg) => { if (msg.init) { @@ -323,15 +398,23 @@ parentPort.on("message", (msg) => { ctx = msg.ctx; _payloadSAB = null; _sharedSAB = null; + _shared = null; _renderEnv = null; _checkEnv = null; _pendingFlush = []; + _taskData = new Map(); + _bookCheck = null; pullLoop(); return; } if (msg.dynamicData) { _payloadSAB = msg.payloadSAB; _sharedSAB = msg.sharedSAB; + _shared = null; + return; + } + if (msg.taskData) { + _taskData.set(msg.taskData, msg.sab); return; } }); diff --git a/builder/gantt.mjs b/builder/gantt.mjs index 190071b84..31360fcfb 100644 --- a/builder/gantt.mjs +++ b/builder/gantt.mjs @@ -43,11 +43,11 @@ export const GANTT_SECTION = { highlighterInit: "Seeds", loadData: "Seeds", discover: "Spine", vendorAssets: "Spine", nav: "Spine", markdownInit: "Spine", buildInit: "Spine", resolveBookChapters: "Spine", - deriveRedirects: "Spine", deriveSitemap: "Spine", + deriveRedirects: "Spine", deriveSitemap: "Spine", packTests: "Spine", dispatch: "Render", prepDest: "Render", prepPageDirs: "Render", renderJoin: "Render", flushJoin: "Write", writeAssets: "Write", searchData: "Write", symbolIndex: "Write", helpArchive: "Write", addinProject: "Write", packageTests: "Write", writeAux: "Write", - writeOffline: "Write", writePdf: "Write", + writeOffline: "Write", bookPayload: "Write", writePdf: "Write", linkJoin: "Check", checkBook: "Check", checkReport: "Check", }; const GANTT_SECTION_ORDER = ["Seeds", "Spine", "Render", "Write", "Check"]; diff --git a/builder/help-archive-step.mjs b/builder/help-archive-step.mjs index a6bcc88f1..882b73281 100644 --- a/builder/help-archive-step.mjs +++ b/builder/help-archive-step.mjs @@ -4,13 +4,20 @@ // gitignored, so a local build is what produces it. The add-in's project file, // the download, carries the same bytes (addin-project.mjs). // +// The archive is the offline tree less HELP_ARCHIVE_SKIP: what nobody looks up +// from the IDE, and the files readers download, which stay on the website. The +// pages left out are still linked from every page's navigation, from the +// search and from prose; the add-in's server answers a request for a file the +// archive lacks with a redirect to the website. +// // `helpArchiveStep` is the build's `helpArchive` task. It waits for every task // that writes into the offline tree, and runs before the Gantt injection, which // rewrites BuildInfo.html in both trees after the task graph is done and -// writes assets/images/gantt.svg. So the archive holds BuildInfo.html without -// the chart, and gantt.svg as the placeholder the offline tree copied from the -// sources. Two builds of one tree write the same archive, and the same project -// file with it, which the chart (it holds the build's timings) would prevent. +// writes assets/images/gantt.svg. Both are left out of the archive; were they +// not, the archive would hold BuildInfo.html without the chart, and gantt.svg +// as the placeholder the offline tree copied from the sources. Two builds of +// one tree write the same archive, and the same project file with it, which +// the chart (it holds the build's timings) would prevent. // // `recheckHelpArchive` runs at the end of runBuild, after the Gantt injection, // and checks that the offline tree on disk still lists the archive's files and @@ -26,13 +33,75 @@ // download published from CI's build holds the archive. The step does not look // at $CI. +import { readFile } from "node:fs/promises"; import path from "node:path"; import { checkHelpArchive, writeHelpArchive } from "../lib/help-archive.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { GUARDED_SRC } from "./baseline.mjs"; +import { offlineExcluded } from "./offline-rewrite.mjs"; export const HELP_ARCHIVE_REL = "add-in/Resources/HELP/site.zip"; +// What the archive leaves out of the offline tree: fnmatch patterns with +// PATHNAME semantics, as offline_exclude in _config.yml (`*` stays within one +// folder, `**` crosses them), against each file's path in the tree. +export const HELP_ARCHIVE_SKIP = [ + // The documentation's own development: its tools, build and authoring, and + // BuildInfo. Its pictures are in Documentation/Images. + "Documentation/**", + // BuildInfo's chart. + "assets/images/gantt.svg", + // Links to videos, which YouTube does not let the help pane show in its + // frame, and their poster frames, which only these pages and the + // documentation's own show. + "Videos.html", + "Videos/**", + "assets/thumbnails/**", + // Contests that are over. + "Challenges.html", + "Challenges/**", + // The files readers download: they stay on the website. Each is in a + // `downloads` folder beside the page that documents it, and the step + // refuses an archive that holds a file the Downloads page offers. + "**/downloads/**", +]; + +/** True for a file of the offline tree (relative, forward slashes) that the archive leaves out. */ +export const helpArchiveSkips = (name) => offlineExcluded(name, HELP_ARCHIVE_SKIP); + +// The page that offers every file the documentation publishes for readers. +const DOWNLOADS_PAGE = "Downloads.html"; + +/** + * The files of the offline tree that the Downloads page offers (a same-site + * link with the `download` attribute) and `skip` keeps in the archive. + * + * @param {string} offlineRoot the offline tree + * @param {(name: string) => boolean} skip + * @returns {Promise} relative, forward slashes; empty when the tree has no Downloads page + */ +export async function downloadsKept(offlineRoot, skip) { + let html; + try { + html = await readFile(path.join(offlineRoot, DOWNLOADS_PAGE), "utf8"); + } catch (err) { + if (err.code === "ENOENT") return []; + throw err; + } + const base = new URL(`file:///tree/${DOWNLOADS_PAGE}`); + const kept = new Set(); + for (const [tag] of html.matchAll(/]*>/g)) { + if (!/\sdownload(?:[\s>=]|$)/.test(tag)) continue; + const href = /\shref="([^"]*)"/.exec(tag)?.[1]; + if (!href) continue; + const url = new URL(href.replaceAll("&", "&"), base); + if (url.protocol !== "file:" || !url.pathname.startsWith("/tree/")) continue; + const name = decodeURIComponent(url.pathname.slice("/tree/".length)); + if (!skip(name)) kept.add(name); + } + return [...kept].sort(); +} + const mb = (n) => (n / 1048576).toFixed(1); // The offline tree the archive is made from, and the only one: Windows paths @@ -66,7 +135,18 @@ export async function helpArchiveStep({ if (src !== GUARDED_SRC || serve || dryRun || disabled || !offlineRoot) return NOT_WRITTEN; if (!sameDir(path.resolve(offlineRoot), OFFLINE_TREE)) return NOT_WRITTEN; try { - const s = await writeHelpArchive({ src: path.resolve(offlineRoot), out }); + const kept = await downloadsKept(path.resolve(offlineRoot), helpArchiveSkips); + if (kept.length > 0) { + return { + ...NOT_WRITTEN, + text: + `help archive: not written: it would hold ${kept.length} file(s) the Downloads page offers, ` + + `which stay on the website; put each in a downloads folder, or add it to HELP_ARCHIVE_SKIP:\n` + + kept.map((k) => ` ${k}\n`).join(""), + failed: true, + }; + } + const s = await writeHelpArchive({ src: path.resolve(offlineRoot), out, skip: helpArchiveSkips }); return { text: `help archive: ${HELP_ARCHIVE_REL}, ${s.entries} entries, ${mb(s.zipBytes)} MB (${(s.ms / 1000).toFixed(1)} s)\n`, failed: false, @@ -102,7 +182,12 @@ export async function recheckHelpArchive({ archive, offlineRoot, mayDiffer }) { if (!archive?.zip || !archive.out) return { text: "", failed: false }; let problems; try { - problems = await checkHelpArchive({ src: path.resolve(offlineRoot), zip: archive.out, mayDiffer }); + problems = await checkHelpArchive({ + src: path.resolve(offlineRoot), + zip: archive.out, + mayDiffer, + skip: helpArchiveSkips, + }); } catch (err) { return { text: `help archive: the offline tree could not be checked against it: ${err.message}\n`, failed: true }; } diff --git a/builder/package-tests.mjs b/builder/package-tests.mjs index c8ac0842d..f1e5e7e4e 100644 --- a/builder/package-tests.mjs +++ b/builder/package-tests.mjs @@ -12,15 +12,14 @@ // landing page say how). So the download holds only the documentation's own code, the shared // Expect.twin and the package's cases and RunAll module, under the repository's licence. // -// The pack is impexp's own importProject, in process, as for the add-in's project -// (addin-project.mjs). Line ends are LF in the staged files, which impexp packs with CRLF, so a +// The pack is impexp's own packFiles, in memory, as for the add-in's project +// (addin-project.mjs). Line ends are LF in the files, which impexp packs with CRLF, so a // Windows and a Linux checkout write the same bytes. import { promises as fs } from "node:fs"; -import os from "node:os"; import path from "node:path"; import { downloadName, packageTestNames, templateSettings, testSettings, testSources } from "../lib/package-tests.mjs"; -import { importProject } from "../scripts/impexp.mjs"; +import { packFiles } from "../scripts/impexp.mjs"; import { posix } from "./paths.mjs"; /** @@ -47,42 +46,45 @@ export function packageTestRels(cfg) { /** One package's project file, in memory. */ export async function packPackageTests(cfg, pkg) { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "package-tests-")); - try { - const stage = path.join(tmp, `${pkg}Tests`); - await fs.mkdir(path.join(stage, "Sources"), { recursive: true }); - const settings = testSettings(templateSettings(cfg.templates, pkg), pkg); - await fs.writeFile(path.join(stage, "Settings"), `${JSON.stringify(settings, null, "\t")}\n`, "utf8"); - for (const f of testSources(cfg.src, pkg)) { - const out = path.join(stage, "Sources", ...f.name.split("/")); - await fs.mkdir(path.dirname(out), { recursive: true }); - await fs.writeFile(out, f.text, "utf8"); - } - const out = path.join(tmp, "project.twinproj"); - importProject(out, stage); - return await fs.readFile(out); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); + const settings = testSettings(templateSettings(cfg.templates, pkg), pkg); + const files = [{ rel: "Settings", bytes: Buffer.from(`${JSON.stringify(settings, null, "\t")}\n`, "utf8") }]; + for (const f of testSources(cfg.src, pkg)) + files.push({ rel: `Sources/${f.name}`, bytes: Buffer.from(f.text, "utf8") }); + return packFiles(files, `${pkg}Tests`).bytes; +} + +/** + * Pack every package's test project, in memory. The build does this before any page renders, so + * that a page can state a download's size ({{tbdocs:size:...}}, counts.mjs), and the + * packageTests task writes these same bytes. + * + * @returns {Promise<{packed: {pkg: string, rel: string, bytes: Buffer}[], ms: number}>} + */ +export async function packAllPackageTests(cfg) { + const t0 = Date.now(); + const packed = []; + for (const pkg of packageTestNames(cfg.src)) { + const rel = `${cfg.dest}/${pkg}/${downloadName(pkg)}`; + packed.push({ pkg, rel, bytes: await packPackageTests(cfg, pkg) }); } + return { packed, ms: Date.now() - t0 }; } /** - * Pack every package's test project and write it into the online tree. + * Write what packAllPackageTests packed into the online tree. * * @returns {Promise<{written: {rel: string, bytes: number}[], ms: number}>} */ -export async function writePackageTests(cfg, root) { +export async function writePackageTests({ packed, ms: packMs }, root) { const t0 = Date.now(); const written = []; - for (const pkg of packageTestNames(cfg.src)) { - const rel = `${cfg.dest}/${pkg}/${downloadName(pkg)}`; - const bytes = await packPackageTests(cfg, pkg); + for (const { rel, bytes } of packed) { const out = path.join(root, ...rel.split("/")); await fs.mkdir(path.dirname(out), { recursive: true }); await fs.writeFile(out, bytes); written.push({ rel, bytes: bytes.length }); } - return { written, ms: Date.now() - t0 }; + return { written, ms: packMs + Date.now() - t0 }; } /** The build's line for the task's result. */ diff --git a/builder/page-baseline.json b/builder/page-baseline.json index 18891a62c..a923a7d69 100644 --- a/builder/page-baseline.json +++ b/builder/page-baseline.json @@ -1,5 +1,5 @@ { "src": "docs", - "pages": 1285, + "pages": 1286, "staticFiles": 1011 } diff --git a/builder/publish-policy.mjs b/builder/publish-policy.mjs index 4057a5b60..c248d6357 100644 --- a/builder/publish-policy.mjs +++ b/builder/publish-policy.mjs @@ -51,6 +51,7 @@ export const SOURCE_EXTENSIONS = new Set([ ".png", ".svg", ".txt", // the three font licences under assets/fonts/ + ".webp", // animations: a fraction of a GIF's size ".woff2", ]); diff --git a/builder/render.mjs b/builder/render.mjs index 782c946e0..a9c8ee19d 100644 --- a/builder/render.mjs +++ b/builder/render.mjs @@ -17,7 +17,7 @@ import footnote from "markdown-it-footnote"; import { blockRegions, maskCode } from "../lib/markdown.mjs"; import { initHighlighter } from "./highlight.mjs"; -import { countPlugin, findSurvivingPlaceholder } from "./counts.mjs"; +import { countPlugin, expandDownloadPlaceholders, findSurvivingPlaceholder } from "./counts.mjs"; import { replaceOutsideCode } from "./code-guard.mjs"; import { splitFragment } from "./url.mjs"; import { escapeMarkup, escapeMarkupAndQuotes, escapeRegExp } from "./escape.mjs"; @@ -77,7 +77,10 @@ function renderPage(page, md) { } let source; try { - source = applyPreRenderRewrites(page.rawContent, md, { packageVersion: page.packageVersion ?? "" }); + source = applyPreRenderRewrites(page.rawContent, md, { + packageVersion: page.packageVersion ?? "", + permalink: page.permalink, + }); } catch (e) { throw new Error(`${page.srcRel}: ${e.message}`); } @@ -118,7 +121,8 @@ function padEmptyCells(html) { // the build's, so a call without one throws. // // `options.packageVersion` is the version the page's package section -// documents, which a callout label's package version must be. +// documents, which a callout label's package version must be. `options.permalink` +// is the page's URL, which the links in {{tbdocs:packageTestRows}} are relative to. export function applyPreRenderRewrites(rawContent, md, options = {}) { if (!md) throw new TypeError("applyPreRenderRewrites: pass the site's markdown-it instance"); // CommonMark normalises CRLF/CR to LF before block parsing. The rewrites @@ -141,6 +145,10 @@ export function applyPreRenderRewrites(rawContent, md, options = {}) { // starts with one, never joins a comment to a fence. const code = maskCode(source, { md }); let work = code.masked; + // The download placeholders expand first, so the rewrites below see the page as it will be + // read. They are in prose or in a raw HTML block, where the inline substitution (counts.mjs) + // cannot reach them; a placeholder in code is masked and stays as written. + if (md.downloads) work = expandDownloadPlaceholders(work, md.downloads, options.permalink); work = rewriteTripleAsteriskEmphasis(work); work = encodeSpacesInMediaUrls(work); work = rewriteListItemSetextHeadings(work); @@ -476,6 +484,9 @@ export function createMarkdownIt(ctx) { // `replacements` has done the dash and quote transforms -- see counts.mjs // for why the layer matters and what it deliberately cannot reach. md.use(countPlugin, ctx); + // What the pre-render rewrites expand {{tbdocs:size:...}} and {{tbdocs:packageTestRows}} + // from (counts.mjs); none in a caller that has no downloads, which leaves them unexpanded. + md.downloads = ctx.downloads ?? null; return md; } diff --git a/builder/sab-scheduler.mjs b/builder/sab-scheduler.mjs index 9196871a5..44b867cf0 100644 --- a/builder/sab-scheduler.mjs +++ b/builder/sab-scheduler.mjs @@ -34,6 +34,9 @@ export const HANDLERS = { dot: 5, buildInfo: 6, render: 7, + helpArchive: 8, + writePdf: 9, + checkBook: 10, }; // ── SAB layout ─────────────────────────────────────────────────────────────── diff --git a/builder/symbol-baseline.json b/builder/symbol-baseline.json index 5cb0db53a..4806624b6 100644 --- a/builder/symbol-baseline.json +++ b/builder/symbol-baseline.json @@ -59,6 +59,7 @@ "/tB/Core/Attributes#populatefrom", "/tB/Core/Attributes#predeclaredid", "/tB/Core/Attributes#preservesig", + "/tB/Core/Attributes#propertypage", "/tB/Core/Attributes#redirecttostaticimplementation", "/tB/Core/Attributes#restricted", "/tB/Core/Attributes#runafterbuild", diff --git a/builder/tbdocs.mjs b/builder/tbdocs.mjs index 8848543b5..581c28ab5 100644 --- a/builder/tbdocs.mjs +++ b/builder/tbdocs.mjs @@ -64,11 +64,12 @@ import { Scheduler } from "./scheduler.mjs"; import { GANTT_SECTION, groupGanttTimings, renderGantt } from "./gantt.mjs"; import { discover } from "./discover.mjs"; -import { deriveCounts, validateCountNames } from "./counts.mjs"; +import { checkPlaceholders, deriveCounts, deriveDownloads } from "./counts.mjs"; import { computeNav } from "./nav.mjs"; import { vendorAssets } from "./vendor-assets.mjs"; import { computeSiteSeo } from "./seo.mjs"; -import { resolveBookChapters } from "./book.mjs"; +import { prerenderPartText, resolveBookChapters } from "./book.mjs"; +import { packBookPayload } from "./book-payload.mjs"; import { loadData } from "./data.mjs"; import { createMarkdownIt, buildLinkTables, diagramIsTall, serializeLinkTables } from "./render.mjs"; import { assignPackageVersions } from "../lib/callout-labels.mjs"; @@ -89,7 +90,6 @@ import { writeSearchDataFromChunks } from "./search.mjs"; import { writeOffline, enumerateVendoredThemeAssets } from "./offline.mjs"; import { buildSitePathsSync, deriveOfflineCss } from "./offline-rewrite.mjs"; import { normalizeBaseurl } from "./url.mjs"; -import { writePdf } from "./pdf.mjs"; // Only the index derivation is a static import: it runs inside dispatch, // on the render fan-out's critical path, and check-tree.mjs pulls // nothing heavier than node:path. The rest of the check -- and with it @@ -98,8 +98,14 @@ import { writePdf } from "./pdf.mjs"; import { deriveTreeRels } from "./check-tree.mjs"; import { syncAddinIndex } from "./addin-index.mjs"; import { addinProjectOf, formatAddinProject, writeAddinProject } from "./addin-project.mjs"; -import { formatPackageTests, packageTestRels, packageTestsOf, writePackageTests } from "./package-tests.mjs"; -import { helpArchiveStep, recheckHelpArchive } from "./help-archive-step.mjs"; +import { + formatPackageTests, + packageTestRels, + packageTestsOf, + packAllPackageTests, + writePackageTests, +} from "./package-tests.mjs"; +import { recheckHelpArchive } from "./help-archive-step.mjs"; import { baselineWrites } from "./baseline.mjs"; import { checkPageBaseline } from "./page-baseline.mjs"; import { checkSymbolBaseline } from "./symbol-baseline.mjs"; @@ -544,9 +550,11 @@ const TASKS = { markdownInit: { // deriveRedirects is here for the counts registry alone: {{tbdocs:redirectStubs}} // is derived from the stub set, and nothing else on this task needs it. - expected: ["discover", "vendorAssets", "deriveRedirects"], + // packTests is here for {{tbdocs:size:...}} and {{tbdocs:packageTestRows}}, which state the + // test projects' sizes before any page renders. + expected: ["discover", "vendorAssets", "deriveRedirects", "packTests"], runOnMain: true, - execute({ deriveRedirects: { stubs } }, _ctx, state) { + execute({ deriveRedirects: { stubs }, packTests }, ctx, state) { const linkTables = buildLinkTables(state.pages); const baseurl = String(state.site.config.baseurl || ""); const staticFileSet = new Set(state.staticFiles.map((s) => s.srcRel)); @@ -568,12 +576,27 @@ const TASKS = { vendoredImages: state.site.vendoredImages, counts: state.site.counts, }); - const badNames = validateCountNames(state.pages, state.site.counts, state.site.markdown); + // The downloads' sizes, for the placeholders the pre-render rewrites expand + // ({{tbdocs:size:...}}, {{tbdocs:packageTestRows}}). The help add-in's project is named + // only to refuse it: it embeds the offline tree, which holds the pages that state sizes. + if (packTests?.failed) throw new Error(`package tests: not packed: ${packTests.error}`); + const testsCfg = packageTestsOf(state.site.config, ctx.srcRoot); + const downloads = deriveDownloads(state, { + tests: testsCfg && packTests?.packed ? { dest: testsCfg.dest, packed: packTests.packed } : null, + addinDest: addinProjectOf(state.site.config)?.dest ?? null, + }); + const { problems: badNames, sizes } = checkPlaceholders( + state.pages, + state.site.counts, + downloads, + state.site.markdown, + ); if (badNames.length) { - throw new Error( - `unknown {{tbdocs:...}} count name in ${badNames.length} place(s):\n\n` + badNames.join("\n\n"), - ); + throw new Error(`bad {{tbdocs:...}} placeholder in ${badNames.length} place(s):\n\n` + badNames.join("\n\n")); } + // Only what the pages ask for goes to the workers. + state.site.downloads = { sizes, tests: downloads.tests }; + state.site.markdown.downloads = state.site.downloads; state.site.linkTablesSerialized = serializeLinkTables(linkTables); const { seoSiteTitle, seoLogoUrl } = computeSiteSeo(state.site.config, state.site.markdown); @@ -793,6 +816,7 @@ const TASKS = { vendoredImagesObj: Object.fromEntries(state.site.vendoredImages ?? []), pictureSizes: state.site.pictureSizes ?? {}, counts: state.site.counts, + downloads: state.site.downloads, }; const sharedSAB = packShared(shared); return { chunks, sharedSAB }; @@ -1023,21 +1047,18 @@ const TASKS = { // (the rest: statics, theme assets, redirect stubs, nav.js, search-data.js). // Nothing else writes there before the Gantt injection, which runBuild does // after the graph; recheckHelpArchive checks that at the end. The Gantt chart - // is injected after this task, so the archive holds BuildInfo.html without it. + // is injected after this task; the archive leaves out both files it writes + // (HELP_ARCHIVE_SKIP), and would otherwise hold BuildInfo.html without it. + // It runs on a worker (cpu-worker.mjs), so the main thread writes the book + // meanwhile; it reads skipOffline from dispatch's broadcast. helpArchive: { expected: ["flushJoin", "scss", "writeOffline"], - runOnMain: true, - async execute(_, ctx, state) { - const skipOffline = skipsOffline(ctx, state); - return helpArchiveStep({ - src: path.relative(REPO_ROOT, ctx.srcRoot).replaceAll(path.sep, "/"), - offlineRoot: skipOffline ? null : `${ctx.destRoot}-offline`, - serve: !!ctx.opts.serve, - dryRun: !!ctx.opts.dryRun, - disabled: !!ctx.opts.skipHelpArchive, - }); + handler: "helpArchive", + // The zip crosses the thread boundary as a plain Uint8Array; make it a + // Buffer again, without copying, for addinProject. + submit(out) { + if (out.zip) out.zip = Buffer.from(out.zip.buffer, out.zip.byteOffset, out.zip.byteLength); }, - submit() {}, }, // Write the help add-in's project file, packed from add-in/, into the online @@ -1076,13 +1097,13 @@ const TASKS = { // writes nothing, and a tree whose config declares no package_tests (the test fixtures) // packs nothing. A failure is reported and fails the build. packageTests: { - expected: ["prepDest"], + expected: ["prepDest", "packTests"], runOnMain: true, - async execute(_, ctx, state) { - const cfg = packageTestsOf(state.site.config, ctx.srcRoot); - if (!cfg || ctx.opts.dryRun) return null; + async execute({ packTests }, ctx) { + if (packTests?.failed) return packTests; + if (!packTests?.packed || ctx.opts.dryRun) return null; try { - return await writePackageTests(cfg, ctx.destRoot); + return await writePackageTests(packTests, ctx.destRoot); } catch (err) { return { failed: true, error: err.message }; } @@ -1090,6 +1111,59 @@ const TASKS = { submit() {}, }, + // Pack the contributed packages' test projects in memory, before any page renders: a page can + // state a download's size ({{tbdocs:size:...}}), and the packageTests task writes these bytes. + // A tree whose config declares no package_tests (the test fixtures) packs nothing; a failure + // is reported by packageTests, which fails the build. + packTests: { + expected: ["config"], + runOnMain: true, + async execute({ config: { config } }, ctx) { + const cfg = packageTestsOf(config, ctx.srcRoot); + if (!cfg) return {}; + try { + return await packAllPackageTests(cfg); + } catch (err) { + return { failed: true, error: err.message }; + } + }, + submit() {}, + }, + + // Pack what writePdf needs for its worker (book-payload.mjs) and broadcast + // it. Depends on flushJoin (pages have renderedContent), + // resolveBookChapters (bookData._chapters refs into state.pages), and + // dot (SVG descriptors in staticFiles). The parts' subtitles and intros + // are rendered here, because markdown-it stays on the main thread. + bookPayload: { + // renderJoin is listed although execute() ignores it. The payload holds + // page.renderedContent, which render:i.submit fills -- and a dep + // count reaching zero is not a promise that those submits have run. + // It holds transitively today via flush:i being lane-pinned to + // render:i, which is not something to rely on in a list whose whole + // job is to say what must have merged. renderJoin is DONE by this + // point, so it costs nothing. + expected: ["flushJoin", "renderJoin", "dot", "resolveBookChapters"], + runOnMain: true, + execute(_, ctx, state) { + const skip = !!ctx.opts.dryRun || skipsPdf(ctx, state); + if (!skip) prerenderPartText(state.site.bookData, state.site.markdown); + return packBookPayload({ + pages: state.pages, + staticFiles: state.staticFiles, + site: state.site, + highlightCss: state.site.highlightCss, + check: !!state.checkTrees, + skip, + }); + }, + // Before the task is marked done, so the data is queued on every lane + // before one can claim writePdf. + submit(sab, _state, scheduler) { + scheduler.pool.broadcastTaskData("book", sab); + }, + }, + // Write redirect stubs + sitemap/robots. Waits for writeAssets (theme on // disk), searchData, deriveRedirects, and deriveSitemap. // Passes searchStats through to writeOffline (for search-data.js). @@ -1130,33 +1204,13 @@ const TASKS = { }, }, - // Produce _site-pdf/. Depends on flushJoin (pages have renderedContent), - // resolveBookChapters (bookData._chapters refs into state.pages), and - // dot (SVG descriptors in staticFiles). Sources CSS directly: + // Produce _site-pdf/ on a worker (cpu-worker.mjs), while the main thread + // writes the offline tree and checks the links. Sources CSS directly: // tb-highlight.css from state.site.highlighter, print.css from staticFiles. - // Runs in parallel with writeAssets → searchData → writeAux → writeOffline. writePdf: { - // renderJoin is listed although execute() ignores it. writePdf reads - // page.renderedContent, which render:i.submit fills -- and a dep - // count reaching zero is not a promise that those submits have run. - // It holds transitively today via flush:i being lane-pinned to - // render:i, which is not something to rely on in a list whose whole - // job is to say what must have merged. renderJoin is DONE by this - // point, so it costs nothing. - expected: ["flushJoin", "renderJoin", "dot", "resolveBookChapters"], - runOnMain: true, - async execute(_, ctx, state) { - const skipPdf = skipsPdf(ctx, state); - if (ctx.opts.dryRun || skipPdf) return null; - return writePdf(state.pages, state.staticFiles, state.site, ctx.destRoot, { - tolerateMissingImages: ctx.opts.tolerateMissingImages, - highlightCss: state.site.highlightCss, - check: !!state.checkTrees, - }); - }, - submit() { - /* terminal */ - }, + expected: ["bookPayload"], + handler: "writePdf", + submit() {}, }, // ── Checks ──────────────────────────────────────────────────────── @@ -1273,26 +1327,13 @@ const TASKS = { submit() {}, }, - // The book is one 6.5 MB document with almost entirely internal - // fragments, written by a different task from a different string. - // Keeping it its own pass is cheaper than special-casing the chunk - // path for a single file. + // Checks the book on the lane that wrote it (cpu-worker.mjs), so the + // 6.5 MB string never crosses back to the main thread. Without --check + // writePdf keeps no book and this returns null. checkBook: { expected: ["writePdf"], - runOnMain: true, - async execute({ writePdf: pdfResult }, ctx, state) { - if (!state.checkTrees || !pdfResult?.checkBook) return null; - const { checkChunk, joinChunks, treeIndexFor, TREES } = await import("./check.mjs"); - const root = ctx.destRoot + TREES.pdf.suffix; - const env = { root, tree: TREES.pdf, basePath: "", index: treeIndexFor(root, pdfResult.checkBook.rels) }; - let chunk; - try { - chunk = checkChunk([{ destPath: "book.html", html: pdfResult.checkBook.html }], env); - } catch (err) { - chunk = { error: `book check failed: ${err.message}` }; - } - return joinChunks([chunk], { root, tree: TREES.pdf, relFiles: ["book.html"] }); - }, + handler: "checkBook", + pin_to_predecessor: "writePdf", submit() {}, }, @@ -1741,7 +1782,8 @@ export async function runBuild(opts) { // The help add-in's archive was written before the Gantt injection above, so // the offline tree on disk has to differ from it in the two files the - // injection writes and in nothing else -- see help-archive-step.mjs. + // injection writes and in nothing else -- see help-archive-step.mjs. The + // archive leaves both out today, so mayDiffer names files it does not hold. const injectedOffline = injected.filter((i) => i.which === "offline").map((i) => i.destPath); const stale = await recheckHelpArchive({ archive, diff --git a/builder/template.mjs b/builder/template.mjs index 2500b33a2..0a24ff3b6 100644 --- a/builder/template.mjs +++ b/builder/template.mjs @@ -976,9 +976,9 @@ function renderFooterLegal(page, config) { // as one item, and that lone odd element out is what let the row's baselines // drift: the min-height that makes the links 24px targets applied to the // anchors and not to it. The framing sentence was the only reason for the -// span, so it is gone and the two downloads are ordinary links that say what -// they do. Their old text ("Offline Copy", "PDF") was ambiguous on its own in -// a screen reader's links list anyway. +// span, so it is gone. The two downloads have since become one link to the +// Downloads page's section on reading offline, which offers both and says what +// each is. // // The last-modified stamp is not an action, and moved to the legal row where // the prose items live. @@ -997,7 +997,7 @@ function renderFooterActions(page, config) { config.gh_edit_repository && config.gh_edit_branch && config.gh_edit_view_mode; - const showOffline = config.gh_offline_link && config.gh_offline_link_url; + const readOffline = config.read_offline_link ? String(config.read_offline_link) : ""; let inner = ""; if (showBackToTop) { @@ -1007,11 +1007,9 @@ function renderFooterActions(page, config) { const href = ghEditHref(page, config); inner += ` ${escapeMarkupAndQuotes(String(config.gh_edit_link_text))}\n`; } - if (showOffline) { - inner += ` Download offline copy\n`; - if (config.gh_pdf_link_url) { - inner += ` Download PDF\n`; - } + if (readOffline) { + const href = relativeUrl(readOffline, String(config.baseurl ?? "")); + inner += ` Read Offline\n`; } if (inner === "") return ""; diff --git a/builder/worker-pool.mjs b/builder/worker-pool.mjs index 2967326c7..ece8d28ae 100644 --- a/builder/worker-pool.mjs +++ b/builder/worker-pool.mjs @@ -68,6 +68,16 @@ export class WorkerPool { } } + // One main-thread task's data for a worker task that follows it. Posted + // before the main task is marked done, so it is queued on every lane + // before any lane can claim the successor; the handler waits for it + // (cpu-worker.mjs's taskData). + broadcastTaskData(key, sab) { + for (const w of this._workers) { + w.postMessage({ taskData: key, sab }); + } + } + destroy() { return Promise.all(this._workers.map((w) => w.terminate())); } diff --git a/docs/Documentation/Authoring.md b/docs/Documentation/Authoring.md index e884d5815..440a5d1ab 100644 --- a/docs/Documentation/Authoring.md +++ b/docs/Documentation/Authoring.md @@ -24,6 +24,7 @@ The documentation is grouped by area, each a folder under `docs/`: - **Features** --- articles on individual twinBASIC language features. - **LLVM** --- compiling with the LLVM back end: turning it on, its options, and its current limitations. - **IDE** --- the IDE user guide. +- **Downloads** --- one page that lists every file this documentation publishes for readers, with a link to each one's documentation. - **Documentation** --- this section: the toolchain reference and this authoring guide. A page's `permalink` fixes its URL independently of the folder the file lives in. With the right permalink (next section) the file can move without breaking a link. @@ -45,7 +46,8 @@ the file, and stops before writing: ``` The types a source file may use: `.html`, `.png`, `.jpg`, `.jpeg`, `.gif`, -`.svg`, `.woff2`, `.css`, `.js`, `.txt`, and `CNAME` at the site root. Pages +`.webp`, `.svg`, `.woff2`, `.css`, `.js`, `.txt`, and `CNAME` at the site root. +An animation is an animated `.webp`, a fraction of the size of the same `.gif`. Pages (`.md` with frontmatter) are rendered rather than copied. **A file you do not want published does not belong under `docs/`,** unless a @@ -236,7 +238,7 @@ The repair re-levels the whole page. The plugin raises **every** heading of leve **The plugin runs only on a page that uses `#` and `###` and no `##` anywhere.** More than four hundred pages are in that state. Adding one `##` section to such a page disarms the normalizer: every `###` already there stops being repaired and becomes a heading-order defect, in the same edit that added a correctly-levelled section. -Nothing warns you. The build succeeds, because it does not check heading order, and `check.bat`'s accessibility scan audits fourteen sample pages out of roughly 1,160. +Nothing warns you. The build succeeds, because it does not check heading order, and `check.bat`'s accessibility scan audits sixteen sample pages out of roughly 1,160. When you add a section to a page whose headings start at `###`, pick one of these and finish it: @@ -356,6 +358,16 @@ Two consequences: alone. To *show* the syntax, put it in backticks. A placeholder inside a raw HTML block fails the build. +Two more placeholders give the [Downloads](../../Downloads) page text rather than a +number, and work in a raw HTML block and on a table line as well: +`{{tbdocs:size:}}` is the size of a file the online tree publishes, such as +`43 KB` or `1.2 MB`, and `{{tbdocs:packageTestRows}}` is a table row for each +contributed package's test project. The path is relative to the site root, and may +name a static file under `docs/`, a `bundle_extra` file or a package test project. +The build fails, naming the file and the line, on any other path, and on one whose size +cannot be known before the pages render: a stylesheet or an SVG, and the help add-in's +project, which contains the page that states its size. + Numbers already written as words stay as words, because a substitution always yields digits. [Callouts](#callouts) below opens *Three severities, used distinctly* and keeps the word: it counts the three bullets beneath it, a fact @@ -754,7 +766,7 @@ Images live in an `Images/` folder beside the page that uses them, and are refer **The text in the square brackets is the image's alt text, and every image needs one.** Write what a reader who cannot see the picture would otherwise miss --- what the screenshot shows and which part of it the surrounding prose is pointing at. A caption-length phrase is usually right; the file name, the word *screenshot*, and the name of the button you happened to be looking for are not. -No gate checks the alt text. The accessibility scan's `image-alt` rule asks only whether an accessible name *exists*. `![image](…)` passes it. So does `![img](…)`. So does an empty `![](…)`, because an empty alt marks an image as decorative, which is right for a spacer and wrong for a screenshot. The scan also samples only fourteen pages. Whether the text describes the picture is for you and your reviewer to judge. +No gate checks the alt text. The accessibility scan's `image-alt` rule asks only whether an accessible name *exists*. `![image](…)` passes it. So does `![img](…)`. So does an empty `![](…)`, because an empty alt marks an image as decorative, which is right for a spacer and wrong for a screenshot. The scan also samples only sixteen pages. Whether the text describes the picture is for you and your reviewer to judge. Copy the *shape* of a neighbouring page's image markdown, never its alt text. The example above used to read `![Create Package]`, and that string spread onto ten further images --- a references dialog, a toolbox, a properties window, an animated GIF --- describing none of them. @@ -845,6 +857,7 @@ Common substitutions: | leverage, utilize | use | | in order to | to | | spin up, kick off | start | +| cut (a release) | publish, make | | surface (as a verb) | expose, raise | | wire up | connect | | under the hood | internally | diff --git a/docs/Documentation/Builder.md b/docs/Documentation/Builder.md index 5d89f18be..04148dc95 100644 --- a/docs/Documentation/Builder.md +++ b/docs/Documentation/Builder.md @@ -127,11 +127,11 @@ Modules grouped by role. [Pipeline Stages](Pipeline-Stages) covers each in depth | [`link-check.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/link-check.mjs) | The pure core: HTML in, findings out, with no filesystem traversal and no CLI. The build's check and the standalone [`scripts/check_links.mjs`](Tools-Site-Checks#check-links) share it. | | [`check.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/check.mjs) | The check over rendered pages, which `scripts/check_links.mjs` runs too: per-chunk `checkChunk`, the `joinChunks` merge and `findingsFor`. Also the build's `TREES` table, report formatting, and the `--check-audit-index` tree-index audit. | | [`check-tree.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/check-tree.mjs) | `deriveTreeRels` --- every relative path the build believes it emitted into a tree. The check's existence oracle comes from this, not from a `readdir`; the audit verifies it against the disk. | -| [`counts.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/counts.mjs) | The registry behind `{{tbdocs:}}` in prose. `deriveCounts(state)` computes every name from build state; `countPlugin` substitutes them as a core rule over the inline token stream, so code is never touched. `validateCountNames` rejects an unknown name on main before any worker renders, and `findSurvivingPlaceholder` rejects a placeholder that reached the output. See [Authoring Pages](Authoring#counts). | +| [`counts.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/counts.mjs) | The registry behind `{{tbdocs:}}` in prose. `deriveCounts(state)` computes every name from build state; `countPlugin` substitutes them as a core rule over the inline token stream, so code is never touched. `checkPlaceholders` rejects an unknown name on main before any worker renders, and `findSurvivingPlaceholder` rejects a placeholder that reached the output. `{{tbdocs:size:}}` and `{{tbdocs:packageTestRows}}` are not numbers but text for the Downloads page: `deriveDownloads` settles the sizes of the static files and the packed package test projects, and `expandDownloadPlaceholders` expands them in the page source, where the inline rule cannot reach. See [Authoring Pages](Authoring#counts). | | [`page-baseline.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/page-baseline.mjs) | The page-count drift guard, and the committed `page-baseline.json` it compares against. A rise rewrites the file, a fall fails the build, and neither CI nor `--serve` may write. See [Building and Deployment](Building#the-page-count-drift-guard). | | [`symbol-baseline.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/symbol-baseline.mjs) | The symbol index's drift guard, and the committed `symbol-baseline.json`, every URL `tB/symbols.json` has published. A new URL rewrites the file, a lost one fails the build. | | [`addin-index.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/addin-index.mjs) | The help add-in's committed copy of the index, `add-in/Resources/SYMBOLS/symbols.json`: a local build rewrites it when it differs from `tB/symbols.json`, and CI and `--serve` only say so. | -| [`addin-project.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/addin-project.mjs) | The help add-in's project file, a published download: packs the files git tracks in `add-in/`, with this build's symbol index, through `importProject` from `scripts/impexp.mjs` (the one `scripts/` module the builder may import). See [Build and Generation Tools](Tools-Build#the-help-add-ins-project-file). | +| [`addin-project.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/addin-project.mjs) | The help add-in's project file, a published download: packs the files git tracks in `add-in/`, with this build's symbol index, in memory through `packFiles` from `scripts/impexp.mjs` (the one `scripts/` module the builder may import). See [Build and Generation Tools](Tools-Build#the-help-add-ins-project-file). | | [`baseline.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/baseline.mjs) | The read, the write and the comparison the two drift guards share; `GUARDED_SRC`, the one source tree they have figures for; and `baselineWrites`, which lets only a build into `docs/_site` rewrite a committed file. | | [`publish-policy.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/publish-policy.mjs) | The allowlist of file types that may reach a published tree, enforced in `discover` (static-file inventory) and in `dispatch` (each tree's `deriveTreeRels` inventory). A finding aborts the build. See [Drift guards](#drift-guards-and-failure-modes). | @@ -210,12 +210,12 @@ The full layout, the allocation helper and the `readTaskMeta` / `writeTaskMeta` ## Task DAG by section -The pipeline has 34 named static tasks plus 2N dynamic ones (N render chunks + N flush tasks). The Gantt chart groups them into five sections, and so does the rest of this page: +The pipeline has 37 named static tasks plus 2N dynamic ones (N render chunks + N flush tasks). The Gantt chart groups them into five sections, and so does the rest of this page: - **Seeds**: `config`, `buildInfo`, `scssLight`, `scssDark`, `highlighterInit`, `loadData` -- **Spine**: `discover`, `vendorAssets`, `nav`, `dot`, `buildInit`, `markdownInit`, `deriveRedirects`, `deriveSitemap`, `resolveBookChapters` +- **Spine**: `discover`, `vendorAssets`, `nav`, `dot`, `buildInit`, `markdownInit`, `deriveRedirects`, `deriveSitemap`, `resolveBookChapters`, `packTests` - **Render**: `dispatch`, `prepDest`, `prepPageDirs`, `render:i`, `renderJoin` -- **Write**: `scss`, `flush:i`, `flushJoin`, `writeAssets`, `searchData`, `symbolIndex`, `helpArchive`, `addinProject`, `writeAux`, `writeOffline`, `writePdf` +- **Write**: `scss`, `flush:i`, `flushJoin`, `writeAssets`, `searchData`, `symbolIndex`, `helpArchive`, `addinProject`, `packageTests`, `writeAux`, `writeOffline`, `bookPayload`, `writePdf` - **Check**: `linkJoin`, `checkBook`, `checkReport` --- present on every ordinary build, because `build.bat` always passes `--check-audit-index` `warmInit` and `renderEnvInit` belong to none of the five; the chart draws them as start-up bars in the worker rows, and they are described under Render. The chart does not draw the three `Join` barriers. @@ -258,7 +258,7 @@ config ┬→ highlighterInit → loadData - `markdownInit` --- builds the link tables, instantiates the shared markdown-it and computes site-level SEO. The link tables and SEO constants reach the render workers in the shared SAB payload. - `deriveRedirects` --- derives the redirect stubs. - `deriveSitemap` --- the absolute-URL list for `sitemap.xml`. Deferred to `dispatch` so it runs while the main thread would otherwise wait on the render workers. -- `resolveBookChapters` --- resolves the `_book.yml` chapter selectors to `Page` references. `writePdf` must see the same `Page` objects after the render fan-out has filled in `renderedContent`. +- `resolveBookChapters` --- resolves the `_book.yml` chapter selectors to `Page` references. `bookPayload` must see the same `Page` objects after the render fan-out has filled in `renderedContent`. ### Render @@ -282,7 +282,7 @@ dispatch ┬→ render:0 ┬→ flush:0 ┐ - `warmInit` (worker, `on_demand` + `unique_per_worker` + `run_when_idle` + `survives_reset`) --- per-lane Shiki bootstrap. A worker with nothing else claimable runs it during the main-thread spine, every render worker needs it on its own lane, and in serve mode the per-lane done flag survives rebuilds, so later builds skip it. - `renderEnvInit` (worker, `on_demand` + `unique_per_worker`) --- per-lane render environment: unpacks the shared SAB, rebuilds the link-table Maps and instantiates the worker's own markdown-it. Every `render:i` lists it in `perWorkerDeps`, so the first render claim on a lane pulls it in. - `render:i` (worker, dynamic) --- the per-chunk compute, in the five sub-stages listed under "Render fan-out in detail". Returns a delta with `renderedContent` per page and the chunk's search entries. -- `renderJoin` (main, `on_demand`) --- barrier that unblocks `searchData`, `symbolIndex` and `writePdf`. `dispatch.submit()` sets its dep count to N *and* rewrites its `expected` list with every chunk name; the dep count alone is not a barrier over the submits. See [Pipeline Stages](Pipeline-Stages#renderjoin-main-on_demand). +- `renderJoin` (main, `on_demand`) --- barrier that unblocks `searchData`, `symbolIndex` and `bookPayload`. `dispatch.submit()` sets its dep count to N *and* rewrites its `expected` list with every chunk name; the dep count alone is not a barrier over the submits. See [Pipeline Stages](Pipeline-Stages#renderjoin-main-on_demand). ### Write @@ -290,15 +290,16 @@ dispatch ┬→ render:0 ┬→ flush:0 ┐ - `scss` (main) --- joins both halves and writes the combined CSS to `_site/` and `_site-offline/`. Waits for `prepDest`. - `flush:i` (worker, dynamic, `pin_to_predecessor`) --- writes the chunk's page HTML on the worker that rendered it: the online tree always, the offline tree unless `skipOffline`. The pinning matters because the worker keeps the batch on its own `_pendingFlush` FIFO at the end of `render`, and only the matching `flush:i` drains it. **With `--check`, the link and integrity check runs here too**, over the chunk's just-written HTML, which is already decoded and in worker memory, so the check never writes ~270 MB out to read it back. -- `flushJoin` (main, `on_demand`) --- barrier that aggregates per-chunk write stats and gates `writeAux` + `writePdf`. +- `flushJoin` (main, `on_demand`) --- barrier that aggregates per-chunk write stats and gates `writeAux` + `bookPayload`. - `writeAssets` (main) --- writes generated CSS, copies vendored theme JS and the project's static files. It does not write page HTML; `flush:i` did. Depends on `prepPageDirs`. - `searchData` (main) --- concatenates `state.searchChunks` (filled by each `render:i`'s `submit()`), joins `symbolIndex`'s symbols onto the matching entries by URL (adding the `names` / `qualified` / `primary` fields; see [Site search](Pipeline-Stages#searchdata-main) and `WIP.Search.md`'s "Design" §2), renumbers the global `i` index and writes `search-data.json`. The workers already did the heavy work (heading split, content sanitisation, URL encoding). Depends on `renderJoin`, `prepDest` and `symbolIndex`. - `symbolIndex` (main) --- writes `tB/symbols.json`, the [symbol index](Building#the-symbol-index) the IDE help add-in reads, and passes its `symbols` array to `searchData`'s join. It reads the heading ids from every `/tB/` page's `renderedContent`, joins them with the committed `builder/package-api.json`, and returns the index's URLs for the drift guard that `runBuild` runs at the end. Depends on `renderJoin` and `prepDest`. `checkReport`, `searchData` and `addinProject` wait for it. -- `helpArchive` (main) --- writes `add-in/Resources/HELP/site.zip`, the archive of the offline tree that the IDE help add-in embeds (see [Build and Generation Tools](Tools-Build#the-help-archive)). Depends on `flushJoin`, `scss` and `writeOffline`, which between them write the whole offline tree. It runs before the Gantt chart is injected, so the archive holds `BuildInfo.html` without the chart. It runs only for a `docs` build into the default `--dest`, `docs/_site`, with an offline tree, and not under `--serve`, `--dry-run` or `--no-help-archive`. After the chart is injected, `runBuild` checks that the offline tree still lists the archive's files and differs from it in `BuildInfo.html` and `assets/images/gantt.svg` alone. -- `addinProject` (main) --- packs the help add-in's source folder, `add-in/`, into `tbDocsHelp.twinproj` and writes it into the online tree at the path `_config.yml`'s `addin_project` names (see [Build and Generation Tools](Tools-Build#the-help-add-ins-project-file)). The files are those git tracks there, the symbol index is the one `symbolIndex` wrote, and `Resources/HELP/site.zip` is the archive `helpArchive` wrote, if any. Depends on `symbolIndex`, `prepDest` and `helpArchive`; `checkReport` waits for it. Does nothing under `--dry-run` or for a config that declares no download. +- `helpArchive` (worker) --- writes `add-in/Resources/HELP/site.zip`, the archive of the offline tree that the IDE help add-in embeds (see [Build and Generation Tools](Tools-Build#the-help-archive)). Depends on `flushJoin`, `scss` and `writeOffline`, which between them write the whole offline tree. It leaves out the sections `HELP_ARCHIVE_SKIP` lists, `BuildInfo.html` and its chart among them, and runs before the Gantt chart is injected. It runs only for a `docs` build into the default `--dest`, `docs/_site`, with an offline tree, and not under `--serve`, `--dry-run` or `--no-help-archive`. After the chart is injected, `runBuild` checks that the offline tree still lists the archive's files and differs from it in `BuildInfo.html` and `assets/images/gantt.svg` alone. +- `addinProject` (main) --- packs the help add-in's source folder, `add-in/`, into `tbDocsHelp.twinproj` and writes it into the online tree at the path `_config.yml`'s `addin_project` names (see [Build and Generation Tools](Tools-Build#the-help-add-ins-project-file)). The files are those git tracks there, the symbol index is the one `symbolIndex` wrote, and `Resources/HELP/site.zip` is the archive `helpArchive` wrote, if any. The zip comes back from the worker as a plain byte array, which `helpArchive`'s `submit` wraps again as a `Buffer` without copying it. Depends on `symbolIndex`, `prepDest` and `helpArchive`; `checkReport` waits for it. Does nothing under `--dry-run` or for a config that declares no download. - `writeAux` (main) --- writes redirect stubs, the sitemap and robots.txt. Depends on `writeAssets`, `searchData`, `flushJoin`, `deriveRedirects`, `deriveSitemap`. - `writeOffline` (main) --- produces `_site-offline/`. `render:i` and `flush:i` already computed and wrote the per-page HTML, so this task does the cross-cutting work: CSS url() rewriting, the just-the-docs.js AST patch, the `search-data.js` wrapper, the offline `nav.js`, theme assets and redirect stubs. -- `writePdf` (main) --- assembles `_site-pdf/book.html` and copies the images it references. Depends on `flushJoin` (`renderedContent` is filled), `resolveBookChapters` (`bookData._chapters` is set) and `dot` (diagram SVGs are in `staticFiles`). +- `bookPayload` (main) --- packs what the book needs into one shared buffer and broadcasts it to every worker. It renders each part's subtitle and intro first, because markdown-it cannot cross to a worker, and replaces every page reference in `bookData` with the page's index in the page list, so the worker resolves it back to the same object. Depends on `flushJoin` (`renderedContent` is filled), `renderJoin`, `resolveBookChapters` (`bookData._chapters` is set) and `dot` (diagram SVGs are in `staticFiles`). The broadcast is posted before the task is marked done, so it is queued on every lane before any lane can claim `writePdf`. Under `--dry-run`, or when the build writes no PDF tree, it packs a marker that tells `writePdf` there is nothing to write. +- `writePdf` (worker) --- unpacks that payload, assembles `_site-pdf/book.html` and copies the images it references. It waits on the lane for the broadcast to arrive. With `--check` it keeps the book on its lane for `checkBook` and returns only the counters. ## What runs where @@ -321,8 +322,10 @@ Every task and where it runs: | Write | `scss` | main | Joins light + dark; writes online + offline CSS. | | Write | `flush:i` | worker (pinned) | Page HTML write, online + offline. | | Write | `flushJoin` | main | Barrier. | -| Write | `writeAssets`, `searchData`, `symbolIndex`, `helpArchive`, `addinProject`, `writeAux`, `writeOffline`, `writePdf` | main | I/O bound; cooperative async concurrency. | -| Check | `linkJoin`, `checkBook`, `checkReport` | main | No-ops without `--check`. | +| Write | `writeAssets`, `searchData`, `symbolIndex`, `addinProject`, `writeAux`, `writeOffline`, `bookPayload` | main | One at a time, in declaration order: the main thread finishes a task before it claims the next, and the tail is CPU-bound there. | +| Write | `helpArchive`, `writePdf` | worker | Run beside the main-thread writers. `writePdf` follows `bookPayload`. | +| Check | `linkJoin`, `checkReport` | main | No-ops without `--check`. | +| Check | `checkBook` | worker (pinned) | Runs on the lane that wrote the book, so the book never crosses back. Returns null without `--check`. | Per-page SEO (`computeChunkSeo`), per-page offline HTML (`deriveOfflinePage`) and per-chunk search entries (`deriveSearchEntries`) all run inside `render:i`, not on the main thread. The SEO values return in the render delta. `offlineHtml` is stored on the page for the matching `flush:i` to write to `_site-offline/`. The render `submit()` stores each chunk's search entries at `state.searchChunks[i]`. @@ -345,7 +348,7 @@ The scheduler owns a `SharedState` instance. Five fields are declared on the cla Worker output flow: a worker posts `{ done: taskIdx, output, timing, lane }` to the main thread. The pool callback passes it to `Scheduler._onWorkerDone()`, and the task's `submit()` merges the delta into the master `pages[]` through `pageByDest`. Only then does the worker run `onTaskDone()` to set the SAB status to `DONE` and wake siblings. This order matters: otherwise a downstream main-thread task could be claimed before its predecessor's output arrived. -`render:i.submit()` mutates the master `Page` objects in place (`renderedContent`, `offlineMisses`) and writes the chunk's search entries into `state.searchChunks[i]`. The objects stay the same: `resolveBookChapters` stores `Page` references in `bookData._chapters`, and `writePdf` reads `renderedContent` from those objects after the fan-out. +`render:i.submit()` mutates the master `Page` objects in place (`renderedContent`, `offlineMisses`) and writes the chunk's search entries into `state.searchChunks[i]`. The objects stay the same: `resolveBookChapters` stores `Page` references in `bookData._chapters`, and `bookPayload` reads `renderedContent` from those objects after the fan-out, packing it for `writePdf`'s worker. ## Render fan-out in detail diff --git a/docs/Documentation/Building.md b/docs/Documentation/Building.md index c4d1cb93a..8e25d8715 100644 --- a/docs/Documentation/Building.md +++ b/docs/Documentation/Building.md @@ -316,7 +316,7 @@ A page in a package folder that gives no entry at all is reported by name after check.bat -[`scripts/check_a11y.mjs`](Tools-Site-Checks#check-a11y) drives `axe-core` inside headless Chromium (via `puppeteer`) over fourteen sample pages against WCAG 2.0/2.1/2.2 at Level A + AA (plus the `heading-order` best-practice rule), and exits non-zero on any violation. It runs last, after the cheaper gates: a freshness check that refuses a stale tree, the [DOT diagram fit check](#diagram-fonts-and-why-checkbat-measures-them), and the sample-coverage check that says whether the fourteen pages still cover every markup construct the site uses. [Tools and Scripts](Tools#checkbat) has the list in order. +[`scripts/check_a11y.mjs`](Tools-Site-Checks#check-a11y) drives `axe-core` inside headless Chromium (via `puppeteer`) over sixteen sample pages against WCAG 2.0/2.1/2.2 at Level A + AA (plus the `heading-order` best-practice rule), and exits non-zero on any violation. It runs last, after the cheaper gates: a freshness check that refuses a stale tree, the [DOT diagram fit check](#diagram-fonts-and-why-checkbat-measures-them), and the sample-coverage check that says whether the sixteen pages still cover every markup construct the site uses. [Tools and Scripts](Tools#checkbat) has the list in order. Each page is scanned in **both the light and dark themes**, because dark mode is a separate palette. The scan runs against `_site-offline/`, because the online tree's root-absolute asset URLs do not resolve under `file://` and every page would be unstyled. It needs the Chromium install from the [requirements](#requirements). @@ -482,15 +482,15 @@ Self-hosting the body face fixes the *text* at 17px everywhere but does not reti ![The repository's Actions tab, and the workflow list in the left sidebar where the deploy workflow is chosen](Images/choose-workflow.png){:width="552" height="191"} -8. Manually run the build and deployment workflow if a release snapshot is needed. Pushes to `staging` deploy to Pages automatically; only the manual run also cuts a GitHub release. See [Cutting a release](#cutting-a-release). +8. Manually run the build and deployment workflow if a release snapshot is needed. Pushes to `staging` deploy to Pages automatically; only the manual run also publishes a GitHub release. See [Publishing a release](#publishing-a-release). ![The Run workflow dropdown open on its branch selector, with the green Run workflow button that starts the manual deployment](Images/run-workflow.png){:width="368" height="231"} -### Cutting a release -{: #cutting-a-release } +### Publishing a release +{: #publishing-a-release } -A release is a downloadable snapshot of the documentation. **Only a manual dispatch cuts one**, so the newest release can lag the live site by any amount; the welcome page tells readers to check the date. +A release is a downloadable snapshot of the documentation. **Only a manual dispatch publishes one**, together with the deploy of the same commit. Go to Actions → **Build & deploy docs** → **Run workflow**. It takes one input: @@ -498,18 +498,19 @@ Go to Actions → **Build & deploy docs** → **Run workflow**. It takes one inp |---|---| | `release_tag` | Used verbatim as both the tag and the release name. Leave it blank for `docs-YYYY-MM-DD-HHMM` in UTC. | -The release job runs only after a green deploy, on the dispatched commit. It attaches two assets, and a missing one fails the job rather than publishing half a release: +The release job runs only after a green deploy, on the dispatched commit. It attaches the assets below, and a missing one fails the job rather than publishing half a release: | asset | what it is | |---|---| | `twinbasic-docs-offline.zip` | `_site-offline/` zipped from the inside, so `index.html` sits at the archive root. Extract anywhere and open it --- no server, and search, navigation and dark mode all work. | -| `twinBASIC Book.pdf` | The PDF book, A4, about 2,250 pages, bookmarked to `h1`--`h4`. | +| `twinBASIC Book.pdf` | The PDF book, A4, about 2,400 pages, bookmarked to `h1`--`h4`. | +| the Downloads page's files | Every file the [Downloads](../../Downloads) page offers from this site: each same-site link on it with the `download` attribute, read from the built page. A download added to that page is in the next release with no change to the workflow. | > [!IMPORTANT] -> The release is marked *latest*, and the site's two download buttons are -> `releases/latest/download/` URLs. A custom `release_tag` that is not the latest release -> therefore breaks both buttons silently. GitHub also replaces the space in the PDF's -> filename with a dot in those URLs, which is why `_config.yml` spells it +> The release is marked *latest*, and the Downloads page links to the zip and +> the PDF with `releases/latest/download/` URLs. A custom `release_tag` that is not the latest +> release therefore breaks both links silently. GitHub also replaces the space in the PDF's +> filename with a dot in those URLs, which is why the Downloads page spells it > `twinBASIC.Book.pdf` --- that is correct and must not be "fixed". Afterwards, check the live site: diff --git a/docs/Documentation/Extending.md b/docs/Documentation/Extending.md index 38466c0a5..f6ccb7b03 100644 --- a/docs/Documentation/Extending.md +++ b/docs/Documentation/Extending.md @@ -141,8 +141,8 @@ Find the row for what changed. The middle column is the step that explains the m |---|---|---| | A task's `expected` list | [Pick the right flags](#2-pick-the-right-flags) | Nothing. The scheduler derives successor edges from `expected`. The exception is the dynamic edges: `render:i` and `flush:i` are wired in `dispatch.submit`, not in `TASKS`. | | A task's `execute()` or handler return shape | [Define the task in `TASKS`](#4-define-the-task-in-tasks) | The task's own `submit()`, and every downstream task that reads the field back off `state`. | -| Which thread a task runs on | [Decide where the work runs](#1-decide-where-the-work-runs) | Swap `runOnMain: true` + `execute` for `handler:` plus a `cpu-worker.mjs` entry, and add the name to `HANDLERS` in `sab-scheduler.mjs`. A task moved onto a worker loses direct access to `state`: everything it reads must arrive through the payload SAB, and everything it produces must come back through its return value. | -| Which Gantt section a task charts under | --- | **`GANTT_SECTION` in `gantt.mjs`**, not the task definition. A `ganttSection` on the definition wins where it is set, which is why the walkthroughs below set one, but only the `render:i` and `flush:i` tasks that `dispatch` registers carry one. Of the 33 static tasks, 31 are in the map; the other two, `warmInit` and `renderEnvInit`, are `unique_per_worker` tasks, whose per-lane timings chart as `Boot`. Any other task with no section, or with a section the chart does not draw, fails the build. | +| Which thread a task runs on | [Decide where the work runs](#1-decide-where-the-work-runs) | Swap `runOnMain: true` + `execute` for `handler:` plus a `cpu-worker.mjs` entry, and add the name to `HANDLERS` in `sab-scheduler.mjs`. A task moved onto a worker loses direct access to `state`: everything it reads must arrive through the payload SAB or, for the output of a main-thread predecessor, through a broadcast (see [Write the worker handler](#3-write-the-worker-handler-if-worker-resident)), and everything it produces must come back through its return value. | +| Which Gantt section a task charts under | --- | **`GANTT_SECTION` in `gantt.mjs`**, not the task definition. A `ganttSection` on the definition wins where it is set, which is why the walkthroughs below set one, but only the `render:i` and `flush:i` tasks that `dispatch` registers carry one. Of the 37 static tasks, 35 are in the map; the other two, `warmInit` and `renderEnvInit`, are `unique_per_worker` tasks, whose per-lane timings chart as `Boot`. Any other task with no section, or with a section the chart does not draw, fails the build. | | A field on the per-page render delta | [Worked example B](#6-worked-example-b-distributed-compute) | The `pages.map(…)` projection at the end of the render handler **and** the merge in `dispatch.submit`'s `render:i` callback. Without both, the field never reaches main. | | What a markdown-it rule emits | [Write the plugin](#1-write-the-plugin) | Nothing in the chain, if the rule is self-contained. But changing the emitted HTML changes what the accessibility scan sees --- read the construct-family note at the end of [Verify](#3-verify). | @@ -219,7 +219,8 @@ export const HANDLERS = { warmInit: 0, renderEnvInit: 1, flush: 2, scssLight: 3, scssDark: 4, dot: 5, buildInfo: 6, render: 7, - myHandler: 8, // ← new + helpArchive: 8, writePdf: 9, checkBook: 10, + myHandler: 11, // ← new }; ``` @@ -247,6 +248,8 @@ const handlers = { The handler's return value comes back to main as `{ done: taskIdx, output, timing, lane }`. The scheduler stores `output` in `results` and passes it to your task's `submit()`. +A worker cannot read `state`, so it cannot see what a main-thread task produced. Work that follows one takes one of two forms. A worker that needs only what `dispatch` broadcast reads it with `sharedData()`, as `helpArchive` does. A worker that needs a main-thread task's output gets a main task in front of it that packs the output into a `SharedArrayBuffer` and broadcasts it from its `submit`, with `scheduler.pool.broadcastTaskData("", sab)`; the handler reads it with `await taskData("")`. The `bookPayload` task and the `writePdf` handler are the model. Broadcast in `submit`, which runs before the task is marked done, so that the message is queued on every lane before any lane can claim the worker task. Whatever the output holds must survive structured cloning: a markdown-it instance does not, so `bookPayload` renders the parts' subtitles and intros before it packs, and replaces each page reference with the page's index in the list. + ### 4. Define the task in `TASKS` Edit the `TASKS` object in `tbdocs.mjs`: @@ -545,7 +548,7 @@ Then run `test.bat`, which judges a `render.mjs` edit. Two of its gates read thi **A pattern assembled from string constants is still checked by the regex gate,** when the source decides what the constants are. If it does not (a function parameter, a `let` built in a loop), `node scripts/check_regex_safety.mjs --census` names the pattern and you must reason about it yourself. See [`check_regex_safety.mjs`](Tools-Toolchain-Tests#check-regex-safety) for the shapes it resolves. -**If the plugin emits markup the site has not carried before --- a new wrapper element, a widget, a figure, a control --- register a construct family for it in [`scripts/pick_a11y_sample.mjs`](Tools-Site-Checks#pick-a11y-sample) in the same change.** The accessibility scan audits fourteen sample pages out of ~1,160, and no axe rule keyed on a construct runs unless a sample page carries it. Without a family the gate keeps reporting a clean pass while covering less. `node scripts/pick_a11y_sample.mjs --census` shows the existing families and their pages; `--check` names the gaps and the cheapest page that closes each. A new task or sub-stage that changes the emitted HTML, or a template change, carries the same obligation. +**If the plugin emits markup the site has not carried before --- a new wrapper element, a widget, a figure, a control --- register a construct family for it in [`scripts/pick_a11y_sample.mjs`](Tools-Site-Checks#pick-a11y-sample) in the same change.** The accessibility scan audits sixteen sample pages out of ~1,160, and no axe rule keyed on a construct runs unless a sample page carries it. Without a family the gate keeps reporting a clean pass while covering less. `node scripts/pick_a11y_sample.mjs --census` shows the existing families and their pages; `--check` names the gaps and the cheapest page that closes each. A new task or sub-stage that changes the emitted HTML, or a template change, carries the same obligation. --- @@ -719,6 +722,8 @@ Two shapes qualify. Most names count things `discover` already found: pages unde Then read the built page and check the number against the thing it counts. Nothing compares a derivation against reality: a name that returns the wrong number is as green as one that returns the right one. +A value that is text, or that has to appear in a raw HTML block or on a table line, is not a count: `countPlugin` cannot reach it. `{{tbdocs:size:}}` and `{{tbdocs:packageTestRows}}` are the model for one. `expandDownloadPlaceholders` expands them in the page source, between the code mask and its restore in `applyPreRenderRewrites`; `checkPlaceholders` refuses what cannot be expanded, naming the file and the line; and the values reach the render workers in the shared payload, as `counts` does. A value that depends on a file written after the pages render cannot be known in time, and is refused. + --- ## What a change obliges in the documentation @@ -733,7 +738,7 @@ Three surfaces, and the second is the one that gets missed. **The task's own section.** One `###` heading per task, under the numbered section matching its Gantt section. It opens with a fenced block giving its `expected` array --- and its `execute()` return shape where that matters --- followed by prose for what `submit()` merges into `SharedState`. A per-lane start-up task, which the chart draws in the worker rows rather than in a section, goes under Render, as `warmInit` and `renderEnvInit` do. A new predecessor, a new field on the returned delta, a new key written to `state`: each is an edit here. -**The reverse edges.** A task's position in the graph is stated in its own section and again in the `expected` line of every task that depends on it. Add `myTask` to `writeAux.expected` in the code and `writeAux`'s section goes on printing the old list. `Pipeline-Stages.md` names `renderJoin` on many lines; three of them are the `expected` declarations of `searchData`, `symbolIndex` and `writePdf`. Grep the task name across the whole file rather than editing only its own section. +**The reverse edges.** A task's position in the graph is stated in its own section and again in the `expected` line of every task that depends on it. Add `myTask` to `writeAux.expected` in the code and `writeAux`'s section goes on printing the old list. `Pipeline-Stages.md` names `renderJoin` on many lines; three of them are the `expected` declarations of `searchData`, `symbolIndex` and `bookPayload`. Grep the task name across the whole file rather than editing only its own section. **The module export table.** [Module export tables](Pipeline-Stages#module-export-tables) gives the signature of the function each task calls, return shape included. A return shape is thus documented twice on one page, and the two copies go stale independently. diff --git a/docs/Documentation/Pipeline-Stages.md b/docs/Documentation/Pipeline-Stages.md index d5abb9d74..41d972d97 100644 --- a/docs/Documentation/Pipeline-Stages.md +++ b/docs/Documentation/Pipeline-Stages.md @@ -122,6 +122,7 @@ export const HANDLERS = { warmInit: 0, renderEnvInit: 1, flush: 2, scssLight: 3, scssDark: 4, dot: 5, buildInfo: 6, render: 7, + helpArchive: 8, writePdf: 9, checkBook: 10, }; ``` @@ -224,11 +225,13 @@ Calls `buildInitConfig(state.site)` from `template.mjs` to pre-render the config ### `markdownInit` (main) ```js -markdownInit.expected = ["discover", "vendorAssets", "deriveRedirects"] +markdownInit.expected = ["discover", "vendorAssets", "deriveRedirects", "packTests"] ``` Builds the link tables (`buildLinkTables(state.pages)`) and derives the `{{tbdocs:}}` counts registry (`deriveCounts(state, { redirectStubs: stubs.length })` from `counts.mjs`), throwing if a page names a count that does not exist. The redirect-stub count is why `deriveRedirects` is a predecessor. +It also settles the downloads that `{{tbdocs:size:}}` and `{{tbdocs:packageTestRows}}` state (`deriveDownloads`, then `checkPlaceholders`, which throws on a path the build does not publish or cannot size). `packTests`, a task of its own that follows `config`, packs the contributed packages' test projects in memory so that their sizes are known; `packageTests` writes those same bytes. Only the sizes the pages ask for travel to the workers, as `state.site.downloads`. + It then instantiates the shared markdown-it (`createMarkdownIt({ highlighter: null, linkTables, baseurl, staticFiles, vendoredVideos, vendoredImages, counts })`), serializes the link tables (`serializeLinkTables(linkTables)`) for the workers, and computes site-level SEO (`computeSiteSeo(state.site.config, state.site.markdown)`). It writes `counts`, `markdown`, `linkTablesSerialized`, `seoSiteTitle` and `seoLogoUrl` to `state.site`. Per-page SEO is **not** computed here; each render worker does it via `computeChunkSeo`. ### `deriveRedirects` (main) @@ -255,7 +258,7 @@ deriveSitemap.execute() → { urls } resolveBookChapters.expected = ["deriveSitemap"] ``` -Calls `resolveBookChapters(state.site.bookData, state.pages)` from `book.mjs`, which sets `bookData._chapters` to `Page[]` references. These must be the same `Page` objects `writePdf` sees after the render fan-out fills in `renderedContent`. +Calls `resolveBookChapters(state.site.bookData, state.pages)` from `book.mjs`, which sets `bookData._chapters` to `Page[]` references. These must be the same `Page` objects `bookPayload` sees after the render fan-out fills in `renderedContent`: it packs each reference as the page's index in `state.pages`, and the worker resolves the index back to the same object. --- @@ -348,7 +351,7 @@ renderJoin.expected = ["render:0", …, "render:N-1"] // and depCount set to N renderJoin.execute() → {} ``` -Barrier over every `render:i`. Unblocks `searchData`, `symbolIndex` and `writePdf`. +Barrier over every `render:i`. Unblocks `searchData`, `symbolIndex` and `bookPayload`. > [!IMPORTANT] > **A dependency count of zero does not mean the submits have run**, so the `expected` @@ -370,7 +373,7 @@ Barrier over every `render:i`. Unblocks `searchData`, `symbolIndex` and `writePd ## Section 4 --- Write and post-write -The tasks that write the output. `scss` joins the two stylesheets after `prepDest`; each `flush:i` writes its chunk's pages after its `render:i` and `prepPageDirs`; the main-thread writers produce the rest, all but `writeAssets` behind `renderJoin` or `flushJoin`. +The tasks that write the output. `scss` joins the two stylesheets after `prepDest`; each `flush:i` writes its chunk's pages after its `render:i` and `prepPageDirs`; the rest are produced by the main-thread writers, which run one at a time in the order `TASKS` declares them, and by two worker tasks, `helpArchive` and `writePdf`. All but `writeAssets` wait behind `renderJoin` or `flushJoin`. ### `scss` (main) @@ -402,7 +405,7 @@ flushJoin.expected = [] // populated by dispatch.submit with ["flush:0", ..., flushJoin.execute(inputs) → { written, offlineWritten, offlineMisses } ``` -Aggregates per-chunk write stats. Unblocks `writeAux`, `writePdf` and `linkJoin`. +Aggregates per-chunk write stats. Unblocks `writeAux`, `helpArchive`, `bookPayload` and `linkJoin`. ### `writeAssets` (main) @@ -468,21 +471,46 @@ writeOffline.expected = ["writeAux", "writeAssets"] Calls `writeOffline(state.staticFiles, state.site, destRoot, { auxStats, sitePaths, profileOffline })` from `offline.mjs`. `render:i` / `flush:i` have already rewritten and written the per-page HTML. This task does the rest: CSS `url()` rewriting, the `just-the-docs.js` AST patch (`deriveOfflineJtdJs`), the `search-data.js` wrapper (`deriveOfflineSearchDataJs`), the offline `nav.js` (the whole nav tree with its links relative to the site's root), theme assets and redirect stubs. `sitePaths` comes from `state.sitePaths`. -### `writePdf` (main) +### `helpArchive` (worker) + +```js +helpArchive.expected = ["flushJoin", "scss", "writeOffline"] +helpArchive.handler = "helpArchive" +``` + +Writes `add-in/Resources/HELP/site.zip`, the archive of the offline tree, less the sections in `HELP_ARCHIVE_SKIP`, that the IDE help add-in embeds (see [Build and Generation Tools](Tools-Build#the-help-archive)). The three predecessors between them write the whole offline tree. The handler calls `helpArchiveStep` from `help-archive-step.mjs`, with the `skipOffline` flag it reads from `dispatch`'s shared broadcast (`sharedData()` in `cpu-worker.mjs`), and reads the offline tree from disk. It runs on a worker because it needs nothing that `dispatch` has not broadcast, and the main thread meanwhile does the work that follows `writeOffline`. + +The zip comes back as a plain byte array. `submit` wraps it again as a `Buffer` over the same memory, which `addinProject` packs into the add-in's project file and `runBuild` checks the offline tree against once the Gantt chart is injected (`recheckHelpArchive`). The step writes nothing for a source root other than `docs`, a `--dest` other than `docs/_site`, and under `--serve`, `--dry-run` or `--no-help-archive`. + +### `bookPayload` (main) + +```js +bookPayload.expected = ["flushJoin", "renderJoin", "dot", "resolveBookChapters"] +bookPayload.execute() → SharedArrayBuffer +``` + +Packs what `writePdf` needs and broadcasts it to every worker. `execute` first calls `prerenderPartText(bookData, site.markdown)` from `book.mjs`, which renders each part's subtitle and intro into `part._rendered`, because the markdown-it instance cannot cross to a worker. It then calls `packBookPayload` from `book-payload.mjs`. The payload holds a projection of each page (`permalink`, `canonicalPermalink`, `navPath`, `srcRel`, `destPath`, `frontmatter` and `renderedContent`), `bookData` with every page reference replaced by `{ $page: i }`, the static-file inventory, the config, the build information, `highlightCss` and the `--check` flag. The index `i` is the page's place in `state.pages`, so the worker resolves each reference back to the same object and `bookCoverage`'s identity comparisons still hold. Under `--dry-run`, or when the build writes no PDF tree, the payload is `{ skip: true }`. Takes ~95 ms. + +`submit` calls `pool.broadcastTaskData("book", sab)`. It runs before the task is marked done, so the message is queued on every lane before any lane can claim `writePdf`. `bookPayload` is declared before `writeAux` in `TASKS`: the main thread claims its ready tasks in declaration order, so it packs the book first and `writePdf` starts on a worker while the main thread writes the rest. + +`renderJoin` is listed although `execute()` ignores it: `expected` says what must have *merged*, and the payload holds `page.renderedContent`. + +### `writePdf` (worker) ```js -writePdf.expected = ["flushJoin", "renderJoin", "dot", "resolveBookChapters"] +writePdf.expected = ["bookPayload"] +writePdf.handler = "writePdf" ``` -Calls `writePdf(state.pages, state.staticFiles, state.site, destRoot, { tolerateMissingImages, highlightCss })` from `pdf.mjs`. It calls `assembleBook(site, pages)` from `book.mjs` for the `book.html` string, writes `tb-highlight.css`, copies `print.css` via the `staticFiles` inventory, and copies every image referenced in `book.html`. Missing images throw; `--tolerate-missing-images` downgrades that to a warning. It also runs `bookCoverage` and returns its `formatBookCoverage` lines as `coverage`, which the build prints under a `book:` heading --- warnings about pages `_book.yml` does not mention, never a failure. +Waits on its lane for the `"book"` broadcast (`taskData("book")` in `cpu-worker.mjs`), unpacks it with `unpackBookPayload`, and returns `null` when the payload says to skip. Otherwise it calls `writePdf(pages, staticFiles, site, destRoot, { tolerateMissingImages, highlightCss, check })` from `pdf.mjs`. That calls `assembleBook(site, pages)` from `book.mjs` for the `book.html` string, writes `tb-highlight.css`, copies `print.css` via the `staticFiles` inventory, and copies every image referenced in `book.html`. Missing images throw; `--tolerate-missing-images` downgrades that to a warning. It also runs `bookCoverage` and returns its `formatBookCoverage` lines as `coverage`, which the build prints under a `book:` heading --- warnings about pages `_book.yml` does not mention, never a failure. -`renderJoin` is listed although `execute()` ignores it: `expected` says what must have *merged*, and the book is assembled from `page.renderedContent`. +With `--check`, the book HTML and the list of files in the PDF tree stay on the lane, for `checkBook`; the result returned to main holds only the counters. --- ## Section 5 --- Check and report -Three main-thread tasks that run only when `--check` is on. The per-chunk findings came back with each `flush:i`, so these tasks join, audit and report without parsing anything again. +Three tasks that run only when `--check` is on: `linkJoin` and `checkReport` on the main thread, `checkBook` on the worker that wrote the book. The per-chunk findings came back with each `flush:i`, so these tasks join, audit and report without parsing anything again. Two invariants hold for all three. **A failing check never aborts the build**: failures are returned as data and become an exit code at the end. And **a check that examined less than the whole site is reported as a failure**: a short chunk list, a missing per-tree result or an errored chunk is never skipped silently. @@ -502,14 +530,16 @@ Four things happen here that a worker cannot do: - **Chunk completeness is asserted.** A count mismatch against `state.checkChunkCount` becomes an error on every tree. A chunk with no entry for a tree is named, since every lane builds the same tree keys. - **`book-combined` pages are excluded** from the page list; `writePdf` owns them and `checkBook` covers them. -### `checkBook` (main) +### `checkBook` (worker, `pin_to_predecessor`) ```js checkBook.expected = ["writePdf"] -checkBook.execute({ writePdf }) → joinedResult | null +checkBook.handler = "checkBook" +checkBook.pinnedTo = writePdf // F_PIN_TO_PRED +→ joinedResult | null ``` -Checks `_site-pdf/book.html` as a one-document chunk against a tree index built from the sparse PDF tree. **Informational**: the book's cross-page links legitimately do not resolve within that tree, so its findings are printed but do not set the exit code. The `_site` pass enforces every page the book contains. +Runs on the lane that wrote the book, so the 6.5--16 MB string never crosses back to the main thread. Returns `null` without `--check`. Otherwise it checks `_site-pdf/book.html` as a one-document chunk against a tree index built from the sparse PDF tree. **Informational**: the book's cross-page links legitimately do not resolve within that tree, so its findings are printed but do not set the exit code. The `_site` pass enforces every page the book contains. ### `checkReport` (main, terminal) @@ -534,7 +564,7 @@ The same modules as above, with the exports of each file. ### `check.mjs` -The link and integrity check over rendered pages, which the standalone [`scripts/check_links.mjs`](Tools-Site-Checks#check-links) also runs, over a tree it reads from disk. The build imports it dynamically --- on the workers in `renderEnvInit`, on main in `linkJoin` / `checkBook` / `checkReport` --- because `htmlparser2` costs ~23 ms to import and a build without `--check` should not pay it on sixteen lanes. +The link and integrity check over rendered pages, which the standalone [`scripts/check_links.mjs`](Tools-Site-Checks#check-links) also runs, over a tree it reads from disk. The build imports it dynamically --- on the workers in `renderEnvInit`, on main in `linkJoin` / `checkReport`, on the book's lane in `checkBook` --- because `htmlparser2` costs ~23 ms to import and a build without `--check` should not pay it on sixteen lanes. | Symbol | Signature | Description | |---|---|---| @@ -668,6 +698,14 @@ Both HTML escapers convert a value that is not a string with `String` first. | `rewriteBookHrefs` | `(html, site, pages) → string` | Rewrites a link to a page in the book to that page's `href="#ch-X"` anchor, and a link to any other site page to its absolute URL under `site.config.url` (a site path is dead in a PDF). | | `bookCoverage` | `(bookData, pages) → { unlisted, both, emptyEntries, emptyLeftOut, missingUrls }` | Runs after `resolveBookChapters`. Reports pages in no book entry and no `left_out:` entry, pages in both, entries that match no page, and landing or foreword URLs that name no page. Every list is empty on a consistent manifest. | | `formatBookCoverage` | `(coverage) → string[]` | The `book:` warning lines for a `bookCoverage` result; `[]` when there is nothing to report. | +| `prerenderPartText` | `(bookData, md) → void` | Renders each part's subtitle and intro into `part._rendered`, because markdown-it cannot cross to a worker. `assembleBook` uses `_rendered` when it is present and renders through `site.markdown` otherwise. Called by `bookPayload`. | + +### `book-payload.mjs` + +| Symbol | Signature | Description | +|---|---|---| +| `packBookPayload` | `({ pages, staticFiles, site, highlightCss, check, skip }) → SharedArrayBuffer` | Packs what `writePdf` reads into one buffer: a projection of each page, `bookData` with every page reference replaced by `{ $page: i }` (the page's index in `pages`), the static-file inventory, the config, the build information, `highlightCss` and `check`. With `skip` it packs `{ skip: true }`. | +| `unpackBookPayload` | `(sab) → { pages, staticFiles, site, highlightCss, check } \| null` | The arguments `writePdf` takes, with each `{ $page: i }` resolved to the object `pages[i]`, or `null` when the payload says to write no PDF tree. Runs on the worker. | ### `build-info.mjs` @@ -767,10 +805,14 @@ For **renderer rules**, order inverts. Both image plugins capture the current `m | Symbol | Signature | Description | |---|---|---| -| `PLACEHOLDER_RE` | `RegExp` | Matches a `{{tbdocs:}}` placeholder and captures the name. Used by the `substitute` helper that `countPlugin` calls, and by `findCountRefs` to locate placeholders in a page's raw markdown. | +| `PLACEHOLDER_RE` | `RegExp` | Matches a `{{tbdocs:}}` or `{{tbdocs::}}` placeholder and captures the name and the argument. Used by the `substitute` helper that `countPlugin` calls, and by `findCountRefs` to locate placeholders in a page's raw markdown. | | `deriveCounts` | `(state, extra) → Record` | Derives every named count from build state: page, static-file, reference-page and documentation-page totals, folder-style reference indexes and how many of them have a permalink ending in a slash, packages (total, default, built-in), attribute anchors, enumerations, and `extra.redirectStubs`. Called by `markdownInit` to build `state.site.counts`, whose keys are the names a page may use. | | `countPlugin` | `(md, ctx) → void` | A markdown-it plugin, the last one `createMarkdownIt` applies. Substitutes each `{{tbdocs:}}` placeholder in prose and image alt text with the value `ctx.counts` holds under that name, and leaves an unknown name as written; code spans and fences are untouched. Its core rule is pushed last, so it runs after `replacements`. Does nothing when `ctx.counts` is absent. | -| `findCountRefs` | `(rawContent, md) → { name, line }[]` | Finds every `{{tbdocs:...}}` reference outside code in a page's raw source, masking code with `maskCode` from `lib/markdown.mjs` and the site's markdown-it instance `md`, as the pre-render rewrites do. `line` is 1-based in `rawContent`, counted with the code put back (a masked fence is one line). Called by `validateCountNames` for each page. | +| `findCountRefs` | `(rawContent, md) → { name, arg, line }[]` | Finds every `{{tbdocs:...}}` reference outside code in a page's raw source, masking code with `maskCode` from `lib/markdown.mjs` and the site's markdown-it instance `md`, as the pre-render rewrites do. `line` is 1-based in `rawContent`, counted with the code put back (a masked fence is one line). Called by `validateCountNames` for each page. | +| `checkPlaceholders` | `(pages, counts, downloads, md) → { problems, sizes }` | `validateCountNames` for all placeholders: also refuses a `size` whose path the build does not publish or cannot size before render (`downloads.refused`, which names the help add-in's project, stylesheets and SVGs), a `size` without a path, an argument on a name that takes none, and either download placeholder when `downloads` is `null`. `sizes` holds the bytes of each file named, which is all the workers are sent. | +| `deriveDownloads` | `(state, { tests, addinDest }) → { files, refused, tests }` | The sizes known before render: the static files discover found (the `bundle_extra` files among them) and the packed package test projects (`tests`, from `packTests`), and the paths that are published but cannot be sized, each with its reason. | +| `expandDownloadPlaceholders` | `(src, downloads, permalink) → string` | Expands `{{tbdocs:size:}}` as `43 KB` or `1.2 MB`, and `{{tbdocs:packageTestRows}}` as one table row for each package test project, with links relative to `permalink`. `applyPreRenderRewrites` calls it on the source with code masked, using the `downloads` the page's `md` carries. | +| `formatSize` | `(bytes) → string` | Whole KB (1024-based, at least 1) below a mebibyte, MB with one decimal from there. | | `validateCountNames` | `(pages, counts, md) → string[]` | Checks every page's count references against the known `counts` names via `findCountRefs`. Returns one message per unknown reference, giving the file and line (the reference's line in `rawContent`, moved down by the page's `contentLine`), the nearest known name when one is within an edit distance of three, and every known name. Empty when every reference is known. `markdownInit` throws when the result is non-empty. | | `findSurvivingPlaceholder` | `(html) → string\|null` | Scans rendered HTML, outside `` and `
`, for a `{{tbdocs:...}}` placeholder that survived rendering; returns the first or `null`. `renderPhase` in `render.mjs` calls it after each page renders and throws on a survivor. |
 
@@ -895,7 +937,7 @@ For **renderer rules**, order inverts. Both image plugins capture the current `m
 | Symbol | Signature | Description |
 |---|---|---|
 | `addinProjectOf` | `(config) → { src, dest } \| null` | The download `_config.yml`'s `addin_project` declares, `dest` without a leading slash; `null` unless both ends are given. |
-| `packAddinProject` | `({ dir, symbols }) → Promise<{ bytes, files, untracked }>` | Packs the files `git ls-files` lists in `dir`, as the working folder holds them, less `Resources/HELP/`, with CRLF made LF outside `Resources/`, and `symbols` in place of `Resources/SYMBOLS/symbols.json` when given. Stages them in a temporary folder and packs it with `importProject` from `scripts/impexp.mjs`. `untracked` lists the files git does not track that were left out. Throws when git fails or tracks no `Settings`. |
+| `packAddinProject` | `({ dir, symbols }) → Promise<{ bytes, files, untracked }>` | Packs the files `git ls-files` lists in `dir`, as the working folder holds them, less `Resources/HELP/`, with CRLF made LF outside `Resources/`, and `symbols` in place of `Resources/SYMBOLS/symbols.json` when given. Packs them in memory with `packFiles` from `scripts/impexp.mjs`, which gives the bytes `importProject` would give for the same files in a folder. `untracked` lists the files git does not track that were left out. Throws when git fails or tracks no `Settings`. |
 | `writeAddinProject` | `({ dir, symbols, roots, rel }) → Promise<{ rel, bytes, files, untracked, roots, ms }>` | Packs once and writes the file at `rel` in each of `roots`. |
 | `formatAddinProject` | `(result, dirLabel, indent) → string` | The line `runBuild` prints, and a second naming any untracked file left out. |
 
@@ -937,7 +979,7 @@ Pure-compute rewrite helpers, split from `offline.mjs` so `cpu-worker.mjs` can i
 
 | Symbol | Signature | Description |
 |---|---|---|
-| `writePdf` | `(pages, staticFiles, site, destRoot, { tolerateMissingImages?, highlightCss }) → Promise` | Writes the `_site-pdf/` source tree: `book.html` + `tb-highlight.css` + `print.css` + referenced images. |
+| `writePdf` | `(pages, staticFiles, site, destRoot, { tolerateMissingImages?, highlightCss, check? }) → Promise` | Writes the `_site-pdf/` source tree: `book.html` + `tb-highlight.css` + `print.css` + referenced images. Runs on a worker, on the arguments `unpackBookPayload` returns. With `check` the stats also hold the book HTML and the tree's file list, which the worker keeps for `checkBook`. |
 
 ### `scheduler.mjs`
 
@@ -950,7 +992,7 @@ Pure-compute rewrite helpers, split from `offline.mjs` so `cpu-worker.mjs` can i
 
 | Symbol | Signature | Description |
 |---|---|---|
-| `WorkerPool` | class | Constructor: `(size, workerUrl)`. Methods: `sendInit(sab, ctx)` (broadcasts the init message; increments `_buildCount`), `broadcastDynamicData(payloadSAB, sharedSAB)`, `destroy()`. Public fields: `bootTimings[]`, `_buildCount` (read by `runBuild` to detect serve-mode rebuilds). The caller sets the callbacks (`onWorkerDone`, `onWorkerError`, `onPerWorkerTiming`, `onMainTaskReady`). |
+| `WorkerPool` | class | Constructor: `(size, workerUrl)`. Methods: `sendInit(sab, ctx)` (broadcasts the init message; increments `_buildCount`), `broadcastDynamicData(payloadSAB, sharedSAB)`, `broadcastTaskData(key, sab)` (posts one main-thread task's output to every lane, for the worker task that follows it; the handler reads it with `taskData(key)`), `destroy()`. Public fields: `bootTimings[]`, `_buildCount` (read by `runBuild` to detect serve-mode rebuilds). The caller sets the callbacks (`onWorkerDone`, `onWorkerError`, `onPerWorkerTiming`, `onMainTaskReady`). |
 
 ### `cpu-worker.mjs`
 
@@ -967,6 +1009,9 @@ The handler table is built from the imported `HANDLERS` constant:
 | `dot` | `dot.mjs` | `regenerateDot` over `srcRoot`. |
 | `buildInfo` | `build-info.mjs` | `captureBuildInfo`. |
 | `render` | inline | Five-stage chunk render: `renderPhase` → `computeChunkSeo` → `templatePhase` → offline → `deriveSearchEntries`. |
+| `helpArchive` | `help-archive-step.mjs` | `helpArchiveStep` over the offline tree; reads `skipOffline` from the shared payload through `sharedData()`. |
+| `writePdf` | `pdf.mjs` | Waits for the `"book"` task data, unpacks it with `unpackBookPayload`, writes the PDF tree and keeps the book for `checkBook`. |
+| `checkBook` | inline | `checkChunk` over the book `writePdf` kept on this lane; `null` without `--check`. |
 
 ### `sab-scheduler.mjs`
 
diff --git a/docs/Documentation/Tools-Build.md b/docs/Documentation/Tools-Build.md
index 157b45bd9..2e8849c6f 100644
--- a/docs/Documentation/Tools-Build.md
+++ b/docs/Documentation/Tools-Build.md
@@ -106,15 +106,17 @@ A `.twinproj` is not a publishable type. The [publish allowlist](Tools-Toolchain
 ### The help archive
 {: #the-help-archive }
 
-Every build writes the zip the IDE help add-in serves the documentation from. The add-in embeds `add-in/Resources/HELP/site.zip` as a resource, and the build writes that file from the offline tree, before the build-time chart is added to `BuildInfo.html`. The archive therefore holds `BuildInfo.html` without the chart, and `assets/images/gantt.svg` as the placeholder the sources hold. The step prints one line:
+Every build writes the zip the IDE help add-in serves the documentation from. The add-in embeds `add-in/Resources/HELP/site.zip` as a resource, and the build writes that file from the offline tree, before the build-time chart is added to `BuildInfo.html`. The step prints one line:
 
-    help archive: add-in/Resources/HELP/site.zip, 1475 entries, 26.0 MB (0.3 s)
+    help archive: add-in/Resources/HELP/site.zip, 2098 entries, 47.6 MB (0.7 s)
+
+The archive leaves out what nobody looks up from the IDE: this Documentation Development section (`BuildInfo.html` and its chart, `assets/images/gantt.svg`, among it), the Videos pages and their poster frames, and the Challenges. It also leaves out every file readers download, which stays on the website: each is in a `downloads` folder beside the page that documents it. `HELP_ARCHIVE_SKIP` in `builder/help-archive-step.mjs` lists them, as patterns in the form `offline_exclude` takes. Every page still links to them, from the navigation, the search and the text; the add-in's server answers a request for a file the archive lacks with a redirect to the same file on the website. A file the Downloads page offers that the archive would hold fails the build, which names it.
 
 The zip is too large to commit; `add-in/Resources/HELP/` is in `.gitignore`.
 
 Only a build of the documentation tree into `docs/_site-offline` writes the archive. Builds of another source tree or `--dest`, `--serve`, `--dry-run` and builds without an offline tree skip it, and so does `--no-help-archive`. CI must not pass `--no-help-archive`, or the project file it publishes would hold no archive; `check_ci_workflows.mjs` fails when one does.
 
-A file the build cannot write, or an archive that does not match the tree, fails the build with exit code 1. The build ends by checking the offline tree on disk against the archive again, now that the chart is in: the same files must be listed, and only `BuildInfo.html` and `assets/images/gantt.svg` may differ in content.
+A file the build cannot write, or an archive that does not match the tree, fails the build with exit code 1. The build ends by checking the offline tree on disk against the archive again, now that the chart is in: the same files must be listed, less the ones the archive leaves out, and only `BuildInfo.html` and `assets/images/gantt.svg` may differ in content.
 
 The reader on the twinBASIC side does no inflating of its own, so the zip format is fixed:
 
diff --git a/docs/Documentation/Tools-Compiler.md b/docs/Documentation/Tools-Compiler.md
index 1558c5e85..fd0a32e05 100644
--- a/docs/Documentation/Tools-Compiler.md
+++ b/docs/Documentation/Tools-Compiler.md
@@ -300,6 +300,6 @@ No gate can build it, so `check_impexp_parity.mjs` compares it only when `IMPEXP
 
 **None of them is run as a command by the site build.** They are published downloads: `_config.yml`'s `bundle_extra` copies the Node and Python scripts and the twinBASIC project file into `Features/Packages/downloads/` for [Import/Export Tool](../../Features/Packages/Import-Export-Tool). The `bundle_extra` exemption is by exact path, so moving any of the three files breaks the download; see [`check_publish_policy.mjs`](Tools-Toolchain-Tests#check-publish-policy).
 
-The Node edition is also a library. `scripts/lib/tb-project.mjs` imports its `exportProject` and `importProject`, which every tool that unpacks or packs a project calls, among them [`tbbuild.mjs`](#tbbuild), [`tbrun.mjs`](#tbrun), [`check_examples.mjs`](Tools-Samples#check-examples), [`census_attributes.mjs`](Tools-Samples#census-attributes) and [`bug_repro.mjs`](Tools-Defects#bug-repro). The site build imports `importProject` too, to pack the [help add-in's project file](Tools-Build#the-help-add-ins-project-file). `impexp.mjs` is the one module under `scripts/` that `builder/` may import (`biome.jsonc` refuses any other), so [`check_tree_fresh.mjs`](Tools-Site-Checks#check-tree-fresh) watches it.
+The Node edition is also a library. `scripts/lib/tb-project.mjs` imports its `exportProject` and `importProject`, which every tool that unpacks or packs a project calls, among them [`tbbuild.mjs`](#tbbuild), [`tbrun.mjs`](#tbrun), [`check_examples.mjs`](Tools-Samples#check-examples), [`census_attributes.mjs`](Tools-Samples#census-attributes) and [`bug_repro.mjs`](Tools-Defects#bug-repro). The site build imports its `packFiles`, which packs a list of files held in memory as `importProject` packs the same files in a folder, to pack the [help add-in's project file](Tools-Build#the-help-add-ins-project-file) and the contributed packages' test projects. `impexp.mjs` is the one module under `scripts/` that `builder/` may import (`biome.jsonc` refuses any other), so [`check_tree_fresh.mjs`](Tools-Site-Checks#check-tree-fresh) watches it.
 
 Exit codes: the table in [Import/Export Tool](../../Features/Packages/Import-Export-Tool#checking-the-result) gives every code. The three editions share these codes, which differ from those of the other tools here.
diff --git a/docs/Documentation/Tools-Site-Checks.md b/docs/Documentation/Tools-Site-Checks.md
index e5498f641..31aafbdbd 100644
--- a/docs/Documentation/Tools-Site-Checks.md
+++ b/docs/Documentation/Tools-Site-Checks.md
@@ -65,7 +65,7 @@ Exit codes: **0** every link is reachable and every anchor exists, **1** a link
     node scripts/check_a11y.mjs [--root-dir ] [--theme light|dark|both] [--viewport desktop|mobile|both]
                                 [--stock-axe] [--minified]
 
-Automated accessibility scan of the built site, and the second of `check.bat`'s four steps (right after [`check_tree_fresh.mjs`](#check-tree-fresh)). It loads `axe-core` into headless Chromium (via `puppeteer`) and runs it against fourteen sample pages in both themes at two viewports, plus two state audits that open a disclosure first --- 64 audits in all. The page list is derived, and [`scripts/pick_a11y_sample.mjs`](#pick-a11y-sample) keeps it representative.
+Automated accessibility scan of the built site, and the second of `check.bat`'s four steps (right after [`check_tree_fresh.mjs`](#check-tree-fresh)). It loads `axe-core` into headless Chromium (via `puppeteer`) and runs it against sixteen sample pages in both themes at two viewports, plus two state audits that open a disclosure first --- 72 audits in all. The page list is derived, and [`scripts/pick_a11y_sample.mjs`](#pick-a11y-sample) keeps it representative.
 
 This script only reports. The scan itself --- page list, themes and viewports, blocked requests, axe run options, source patches and state audits --- lives in [`scripts/lib/axe-scan.mjs`](#axe-scan), shared with `sweep_a11y.mjs` and `check_a11y_fingerprint.mjs`. Change the scan there, not here.
 
@@ -94,7 +94,7 @@ Exit codes: **0** no page has a violation (incomplete checks are reported but do
 
     node scripts/pick_a11y_sample.mjs [--check|--propose|--census] [--fresh]
 
-Derives the accessibility scan's page list, and checks that it still covers every construct the site uses. The scan reads fourteen pages out of ~1,160. If the list stops being representative, nothing fails: the rule for a construct no sample page carries never runs.
+Derives the accessibility scan's page list, and checks that it still covers every construct the site uses. The scan reads sixteen pages out of ~1,160. If the list stops being representative, nothing fails: the rule for a construct no sample page carries never runs.
 
 The script holds a list of **construct families**: markup shapes some axe rule keys on, each recording that rule. The modes:
 
@@ -248,7 +248,7 @@ Each substitution inside a patch also asserts its target was found, so a bump th
                                 [--limit N] [--resume] [--report] [--out FILE]
                                 [--root-dir DIR] [--stock-axe] [--recycle-every N]
 
-The full-site accessibility sweep: every page, both themes, both viewports --- 3,476 audits, roughly 20 minutes. That is too slow for a commit gate, so the fourteen-page sample runs instead; but the sample reports only on constructs it carries.
+The full-site accessibility sweep: every page, both themes, both viewports --- 3,476 audits, roughly 20 minutes. That is too slow for a commit gate, so the sixteen-page sample runs instead; but the sample reports only on constructs it carries.
 
 Run the sweep after any change that moves type metrics or page structure, and when adding a construct family to [`pick_a11y_sample.mjs`](#pick-a11y-sample). It audits disclosures **closed** only; open-state coverage is the sample scan's `STATE_AUDITS`.
 
diff --git a/docs/Documentation/Tools-Toolchain-Tests.md b/docs/Documentation/Tools-Toolchain-Tests.md
index 0d2ca17cd..befb63ba6 100644
--- a/docs/Documentation/Tools-Toolchain-Tests.md
+++ b/docs/Documentation/Tools-Toolchain-Tests.md
@@ -81,6 +81,7 @@ The gate also covers `lib/markdown.mjs` and `lib/frontmatter.mjs`, the modules t
 
 - the rewrite chain leaves alone a fence that only the site's parser finds, and an admonition written inside one;
 - the build's check of `{{tbdocs:}}` count names skips names in code and gives an unknown one's line in its file;
+- the `{{tbdocs:size:}}` and `{{tbdocs:packageTestRows}}` placeholders are expanded outside code, in a raw HTML block and on a table line, and refused, at their line, for a path the build does not publish or cannot size;
 - `discover` names the page and line of `title: Input #`, and says nothing about a quoted value;
 - [`convert_em_dash_separators.mjs`](Tools-Build#convert-em-dash-separators), which rewrites page source by hand, converts prose and nothing else.
 
diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md
index 8c90eddcf..b7c208d2c 100644
--- a/docs/Documentation/Tools.md
+++ b/docs/Documentation/Tools.md
@@ -308,7 +308,7 @@ The build also reads these declarative files.
 
 | File | Effect |
 |---|---|
-| `docs/_config.yml` | Site config. `tbdocs` reads `url`, `baseurl`, `title`, `logo`, `also_build_offline`, `also_build_pdf`, `offline_exclude`, `exclude` (which filters the source walk but is **not** the publish safety net --- see [`check_publish_policy.mjs`](Tools-Toolchain-Tests#check-publish-policy)), the footer / aux-link knobs, the GitHub edit-link knobs, and the download-link knobs (`gh_offline_link`, `gh_offline_link_url`, `gh_pdf_link_url`). Jekyll-only keys (`markdown`, `kramdown`, `theme`, `highlighter`, the `defaults` block, the `compress_html` block) are ignored. |
+| `docs/_config.yml` | Site config. `tbdocs` reads `url`, `baseurl`, `title`, `logo`, `also_build_offline`, `also_build_pdf`, `offline_exclude`, `exclude` (which filters the source walk but is **not** the publish safety net --- see [`check_publish_policy.mjs`](Tools-Toolchain-Tests#check-publish-policy)), the footer / aux-link knobs, the GitHub edit-link knobs, and the footer's **Read Offline** link (`read_offline_link`). Jekyll-only keys (`markdown`, `kramdown`, `theme`, `highlighter`, the `defaults` block, the `compress_html` block) are ignored. |
 | `docs/_book.yml` | The PDF book's chapter manifest. Entries are resolved to pages via the selector schema (`page` / `pages` / `nav_page` / `nav_pages` / `no_descent`) and control PDF outline behaviour via `landing_page:`, `landing_is_target:`, `no_outline_entry:`, `no_heading_shift:`, and `outline_closed:`. Its `left_out:` list names the pages not in the book, each with a `reason:`; the build warns about a page that is in neither. The file header documents the full schema. |
 | `builder/themes/Light.theme`, `Dark.theme`, `Classic.theme` | twinBASIC IDE theme files, vendored from the BETA installer. `builder/highlight-theme.mjs` parses them into the palette behind the renderer's scope-to-class mapping and the generated `tb-highlight.css`. Refresh them from the installer when the IDE adds palette entries. |
 | `builder/twinbasic.tmLanguage.json` | TextMate grammar for the twinBASIC language. Shiki uses it to tokenise every ` ```tb ` code block. |
diff --git a/docs/Downloads/index.md b/docs/Downloads/index.md
new file mode 100644
index 000000000..952050810
--- /dev/null
+++ b/docs/Downloads/index.md
@@ -0,0 +1,73 @@
+---
+title: Downloads
+nav_order: 4
+permalink: /Downloads
+---
+
+# Downloads
+
+Lists the tools, offline copies and test projects this documentation publishes, each with a link to its documentation.
+
+## Tools
+
+
+
+ +### Help add-in for the IDE + +Press F1 on a name in the code editor, and its reference page opens in a tool window beside the code, with or without an internet connection. + +

twinBASIC project, about 51 MB: open it in the IDE and build it into the add-in's two DLLs.

+ +

tbDocsHelp.twinproj Documentation for the help add-in

+ +
+
+ +### impexp + +Unpacks a `.twinproj` or `.twinpack` file into a folder of source files, and packs a folder back into a project file. The three editions take the same commands. + +

Node.js 18+, Python 3.6+, or a twinBASIC project that builds into a Windows program.

+ +

impexp.mjs {{tbdocs:size:Features/Packages/downloads/impexp.mjs}} impexp.py {{tbdocs:size:Features/Packages/downloads/impexp.py}} impexp.twinproj {{tbdocs:size:Features/Packages/downloads/impexp.twinproj}} Documentation for impexp

+ +
+
+ +## Read offline + +Both are attached to the [documentation releases](https://github.com/twinbasic/documentation/releases) on GitHub. + +
+
+ +### The twinBASIC Book + +The whole site as one A4 PDF of about 2,400 pages, with bookmarks for every section. It suits a tablet or a printer better than a small e-reader. + +

PDF, from the newest release.

+ +

twinBASIC Book.pdf

+ +
+
+ +### Offline copy of the site + +Every page of this site, to read from disk in any browser with no network. Navigation, dark mode and search all work. + +

ZIP, from the newest release: extract it and open index.html.

+ +

twinbasic-docs-offline.zip

+ +
+
+ +## Package test projects + +Each contributed package that has test cases publishes them as a twinBASIC project. The project holds only the tests: it references the package, which goes in `%APPDATA%\twinBASIC\packages`. The package's landing page says what the cases check. + +| Package | Test project | Size | +|---------|--------------|------| +{{tbdocs:packageTestRows}} diff --git a/docs/IDE/AddIns/Help.md b/docs/IDE/AddIns/Help.md index d1062d3ef..df2576d43 100644 --- a/docs/IDE/AddIns/Help.md +++ b/docs/IDE/AddIns/Help.md @@ -113,7 +113,7 @@ The window is open again the next time the IDE starts, if it was open when the I The add-in is a twinBASIC project, which the IDE builds into two DLLs, one for each bitness. -1. Download the project file, tbDocsHelp.twinproj, about 28 MB. It is made from the add-in's source code each time this documentation is published. It holds the index of the symbols these pages document and a copy of the whole documentation. +1. Download the project file, tbDocsHelp.twinproj, about 51 MB. It is made from the add-in's source code each time this documentation is published. It holds the index of the symbols these pages document and a copy of the whole documentation. 2. Open `tbDocsHelp.twinproj` in the twinBASIC IDE and build it. Then set the build target to **win64** and build it again. The two DLLs, `tbDocsHelp_win32.dll` and `tbDocsHelp_win64.dll`, are written to a `Build` folder next to the project file. 3. Copy each DLL to the matching `win32` or `win64` add-ins folder, as [Add Ins](../AddIns/) describes, and restart the IDE. @@ -129,7 +129,7 @@ python scripts/impexp.py import tbDocsHelp.twinproj add-in Then build `tbDocsHelp.twinproj` and copy the DLLs as in steps 2 and 3 above. -**For help that works offline**, build the documentation before packing the folder, as [Building and Deployment](../../../Documentation/Development/Building) describes. The build writes a copy of the whole site into the add-in's source folder, `add-in\Resources\HELP\site.zip`, about 27 MB. A DLL built with that file in place serves the pages itself, without the internet. Without the file, the add-in shows the pages from the website. +**For help that works offline**, build the documentation before packing the folder, as [Building and Deployment](../../../Documentation/Development/Building) describes. The build writes a copy of the site into the add-in's source folder, `add-in\Resources\HELP\site.zip`, about 50 MB. A DLL built with that file in place serves the pages itself, without the internet. The copy leaves out the pages about developing this documentation, the videos, the challenges and the files to download, which open from the website. Without the file, the add-in shows all the pages from the website. ## See Also diff --git a/docs/IDE/Projects/Saving-and-Exporting.md b/docs/IDE/Projects/Saving-and-Exporting.md index fa76fe65b..74b93d45c 100644 --- a/docs/IDE/Projects/Saving-and-Exporting.md +++ b/docs/IDE/Projects/Saving-and-Exporting.md @@ -86,7 +86,7 @@ The state is kept in the registry, in a value for each project path, under `Proj ## Open a project - **File → New Project...** (Ctrl+N) and **File → Open Recent...** open the **New / Open Project** dialog, which has the tabs **New**, **Samples** and **Recent**. **Open Recent...** starts on the **Recent** tab. A **Recent (VB6)** tab shows too when the machine has a VB6 history. [New Project](../Project/New) describes the dialog. -- **File → Open Project...** (Ctrl+O) opens the Windows **Open** dialog. A `.vbp` file is imported as a VB6 project; the result is unsaved. A project opened with **Import from folder...** is unsaved as well. +- **File → Open Project...** (Ctrl+O) opens the Windows **Open** dialog. A `.vbp` file is imported as a VB6 project; the result is unsaved. The import keeps the `Attribute` lines of the VB6 files as they are, and the [FAQ](../../../FAQ#vb6-import) says what becomes of each file and each line. A project opened with **Import from folder...** is unsaved as well. The project that is open is closed before the new one loads, and the question above comes at that point. In the **New / Open Project** dialog, it comes when you press **Open**, not before. diff --git a/docs/Miscellaneous/FAQs.md b/docs/Miscellaneous/FAQs.md index 105e52039..423692e97 100644 --- a/docs/Miscellaneous/FAQs.md +++ b/docs/Miscellaneous/FAQs.md @@ -248,6 +248,32 @@ You can import individual files, from VB projects or any type, through the Impor ![The Import from twinproj dialog: a tick-box tree of everything found in ImportDemo.vbp, with the Resources branch expanded over its ICON and MANIFEST folders and a Sources branch below, where a module from a folder beside the project's sits in a PARENT-FOLDER branch. Form1.frm.tbform and Form1.frm.twin are both ticked.](Images/16833fae-4bd7-418f-bb16-691a611a5b01.png){:width="468" height="397"} Importing a .vbp also carries over its [startup object](tB/IDE/Project-Settings#startup-object) and its [icon form](tB/IDE/Project-Settings#icon-form). A VB6 project that was linked as a console program --- its `LinkSwitches` line contains `/SUBSYSTEM:CONSOLE`, written in capitals --- becomes a [console application](tB/IDE/Project-Settings#is-console-application). No other linker switch is carried over. + +**What happens to the `Attribute` lines.** The import keeps every `Attribute` line as VB6 wrote it, and turns none of them into a twinBASIC attribute such as `[Description]`. twinBASIC reads the lines itself when it compiles. **Import from VBP...** and **File → Open Project...** both import a project this way: + +- A `.bas` or `.cls` file is imported unchanged, because twinBASIC compiles these files as they are. +- A `.frm`, `.ctl` or `.pag` file becomes two files. The designer file (`.tbform`, `.tbcontrol` or `.tbppage`) holds the properties from the file's `Begin` block. The `.twin` file holds a **Class** with the module's code, its `Attribute` lines included. The import puts the attributes `[FormDesignerId]`, `[ClassId]`, `[InterfaceId]` and `[EventInterfaceId]` in front of the class, with new GUIDs, and `[COMControl]` in front of a UserControl. None of them comes from an `Attribute` line: a form keeps its `Attribute VB_PredeclaredId = True` line and gets no `[PredeclaredId]`. +- A UserControl's **PropertyPages** list, which VB6 keeps in the `.ctx` file, goes into the `PropertyPages` entry of the `.tbcontrol` file. The `VB_ProcData` lines that tie a property to a property page or a category stay in the class as they were. + +In a built ActiveX DLL, these lines have the same effect as the twinBASIC attribute beside them: + +| VB6 line | Effect in the type library | twinBASIC attribute | +|---|---|---| +| `VB_Description`, `VB_VarDescription` | The help string of the class, member, public field or event. | [`[Description]`](tB/Core/Attributes#description) | +| `VB_UserMemId = 0` | The default member. | [`[DefaultMember]`](tB/Core/Attributes#defaultmember) | +| `VB_UserMemId = -4` | The enumerator that **For Each** uses. | [`[Enumerator]`](tB/Core/Attributes#enumerator) | +| `VB_UserMemId` with another value | The member's dispatch ID. | [`[DispId]`](tB/Core/Attributes#dispid) | +| `VB_VarUserMemId` | The public field's dispatch ID. | None: `[DispId]` is not accepted on a field. | +| `VB_MemberFlags = "40"` | The member is hidden. | [`[Hidden]`](tB/Core/Attributes#hidden) | +| `VB_MemberFlags = "400"` | The member is nonbrowsable. | [`[NonBrowsable]`](tB/Core/Attributes#nonbrowsable) | +| `VB_PredeclaredId = True` | The class has a predeclared instance. | [`[PredeclaredId]`](tB/Core/Attributes#predeclaredid) | + +`VB_Name` is the name of the module or class. A class with `VB_Exposed = False` is left out of the type library. A class with `VB_Creatable = False` is not marked creatable there, and a class with `VB_GlobalNameSpace = True` is marked as an application object (`appobject`). + + + +> [!WARNING] BETA 1005 +> Some `Attribute` lines have no effect, and nothing says so. `VB_HelpID` and `VB_VarHelpID` give no help context ID. `VB_VarMemberFlags` leaves a public field neither hidden nor nonbrowsable. `VB_MemberFlags = "200"` (the user-interface default) and `"14"` (the data-binding flags `bindable` and `displaybind`) set no flag. A component moved from VB6 loses these when it is built again. In a `.twin` file, `[Hidden]` on a public field does hide it.
diff --git a/docs/Reference/Attributes.md b/docs/Reference/Attributes.md index 4d7f0cf1a..ef0541491 100644 --- a/docs/Reference/Attributes.md +++ b/docs/Reference/Attributes.md @@ -33,27 +33,46 @@ The available attributes are listed below in alphabetic order. Not every attribu Syntax: **[AllowUnpopulatedVtableEntry]** -Applicable to: [procedure](../Gloss#procedure) prototype in an [**Interface**](Interface) +Applicable to: [procedure](../Gloss#procedure) prototype in an [**Interface**](Interface), [procedure](../Gloss#procedure) in a [**Class**](Class) Marks a prototype that a class implementing the interface is not obliged to supply. The twinBASIC packages use it to add members to an interface without breaking code written against an earlier revision of it. `ItbHostEventsV2` extends `ItbHostEventsV1` and adds one such prototype; `ItbHostEventsV3` extends that and adds another. An addin that implements only the **V1** members still satisfies **V3**. +On a **Public** procedure of a **Class**, it does the same for a class that [**Implements**](Implements) that class: the member may be left out, where otherwise the build fails with TB65535, *Missing implementation of member ...*. On a property it covers only the accessor it is written on, so a **Property Get** and its **Property Let** each need it. + + from that usage, not from a specification. + On a Class member (BETA 1005, tbbuild): a Class B that Implements Class A and + supplies only A's unmarked Sub compiles when A's other Sub carries the + attribute, and fails with TB65535 "Missing implementation of member Sub + OptionalOne()" without it; the same for a Function, an object-returning + Function and each property accessor separately (marking the Get alone still + reports the Let). A Friend Sub and an Event are never required, with or + without it. No trace in the type library. --> ## AppObject (optional Bool) {: #appobject } Syntax: **[AppObject** [ **( True** \| **False )** ] **]** -Applicable to: [**CoClass**](CoClass) +Applicable to: [**CoClass**](CoClass), [**Class**](Class) Legacy VB attribute: *VB_GlobalNameSpace* Indicates the class is part of the global namespace. You should not include this attribute without a full understanding of the meaning. The **Global** class is an AppObject. +On a **Class**, the members of the class can be called by their bare names anywhere in the project. Those calls all reach one instance of the class, which is not the same as an instance created with [**New**](New). In an ActiveX build the class is marked `appobject` in the type library, so a client sees its members as global names too. + + + For more details, see [this VBA documentation page](https://learn.microsoft.com/en-us/openspecs/microsoft_general_purpose_programming_languages/ms-vbal/189fb41b-cc3a-4999-a6d2-ba89f72d2870). ## ArrayBoundsChecks (optional Bool) @@ -70,12 +89,20 @@ Disables or enables array element access bounds checking within the scope of a c Syntax: **[BindOnlyIfNoArguments** [ **( True** \| **False )** ] **]** -Applicable to: [procedure](../Gloss#procedure) +Applicable to: [**Declare** (API declaration)](Declare), variable in a [**Module**](Module), variable in a [**Class**](Class), [procedure](../Gloss#procedure) Only binds this name to a callsite when no arguments are present. Normally false, but see below for an exception. This attribute resolves the cases where compiler's special treatment of certain procedure names conflicts with a procedure of the same name that shouldn't be treated specially. This currently affects procedures named `Left`. Such procedures get an implicit `[BindOnlyIfNoArguments(True)]` assigned by the compiler. If the user wants to have a procedure of this name, it should include `[BindOnlyIfNoArguments(False)]`. +A **Declare** takes the attribute as a procedure does, and a **Declare** named `Left` gets the implicit one too. On a variable named like a built-in procedure, a call with arguments binds to the built-in procedure, where it would otherwise be error TB5030, *Unexpected call arguments*. + + + ## BindOnlyIfStringSuffix (optional Bool) {: #bindonlyifstringsuffix } @@ -151,7 +178,17 @@ For an overview of coclasses in tB, see [Defining coclasses](../../Features/Lang Syntax: **[COMControl** [ **( True** \| **False )** ] **]** -Applicable to: [**Interface**](Interface) +Applicable to: [**Interface**](Interface), [**Class**](Class) + +On a **Class**, it marks the class as an ActiveX control (the `control` flag of its coclass in the type library). The compiler then warns, with TB0013, about a class that has no [InterfaceId](#interfaceid) or no [EventInterfaceId](#eventinterfaceid) attribute: a host may cache those identifiers, so they should be fixed rather than generated anew by each build. + + ## COMCreatable (optional Bool) {: #comcreatable } @@ -197,9 +234,13 @@ See also [DllExport](#dllexport). Syntax: **[COMExtensible** [ **( True** \| **False )** ] **]** -Applicable to: [**Interface**](Interface) +Applicable to: [**Interface**](Interface), [**Class**](Class) + +Specifies whether new members added at runtime can be called by name through an interface implementing **IDispatch**. This attribute is set to **False** by default. On a **Class**, it applies to the interface the class exposes through COM. -Specifies whether new members added at runtime can be called by name through an interface implementing **IDispatch**. This attribute is set to **False** by default. + ## ComImport (optional Bool) {: #comimport } @@ -215,10 +256,17 @@ Specifies that an interface is an import from an external COM library, for insta Syntax: **[CompileIf(** condition **)]** -Applicable to: [procedure definitions](../Gloss#procedure) +Applicable to: [**Declare** (API declaration)](Declare), [**Const**](Const), variable in a [**Module**](Module), variable in a [**Class**](Class), [**Event**](Event) declaration in a [**Class**](Class), [procedure definitions](../Gloss#procedure) Controls the conditional compilation of a procedure definition. Has no default value. +It controls a **Declare**, a **Const**, a variable or an **Event** in the same way: with a **False** condition the declaration is left out, so a use of its name is an error, such as TB5079, *Unrecognized symbol*. + + + ## CompilerOptions (String) {: #compileroptions } @@ -238,20 +286,40 @@ Sets the LLVM compiler options for one procedure, in place of the **Compiler Opt Syntax: **[ConstantFoldable** [ **( True** \| **False )** ] **]** -Applicable to: [**Function**](Function) in a [**Module**](Module). The compiler rejects it on a method in a [**Class**](Class). +Applicable to: [**Function**](Function) in a [**Module**](Module), [**Property Get**](Property) in a [**Module**](Module). The compiler rejects it on a method in a [**Class**](Class). Specify this attribute for functions that, when called with non-variable input, can be computed at compile time rather than at runtime. For example, a function that converts a string literal to ANSI. The result never changes, so the resulting ANSI string is stored rather than recomputed on every run. Such functions are also called *pure functions*, because their output depends only on the arguments and not on the state of the program. The restriction to modules is not an oversight. Folding a call to a method would require constant propagation through object state, and a notion of a constant object for the propagation to terminate on. twinBASIC's object model is dynamic enough to make both hard, so the compiler rejects the attribute there rather than folding a subset of cases that would be difficult to describe. +Calls are folded only when **Constant Function Folding** is set to **Yes** in [Project Settings](../IDE/Project-Settings#constant-function-folding). A **Property Get** in a module folds as a function does. On a **Sub** the attribute is accepted and has no effect. + +> [!WARNING] BETA 1005 +> The compiler also accepts the attribute on a **Declare Function**. With **Constant Function Folding** on, a call to such a function whose arguments are all constants crashes the compiler during the build, though the project compiles without an error. + + + + ## ConstantFoldableNumericsOnly (optional Bool) {: #constantfoldablenumericsonly } Syntax: **[ConstantFoldableNumericsOnly** [ **( True** \| **False )** ] **]** -Applicable to: [**Function**](Function) in a [**Module**](Module). The compiler rejects it on a method in a [**Class**](Class). +Applicable to: [**Function**](Function) in a [**Module**](Module), [**Property Get**](Property) in a [**Module**](Module). The compiler rejects it on a method in a [**Class**](Class). -A limited case of [constant foldable attribute](#constantfoldable), which applies only if the function was called with a numeric parameter. The restriction to modules is the same one [ConstantFoldable](#constantfoldable) carries, and for the same reason. +A limited case of [constant foldable attribute](#constantfoldable), which applies only if the function was called with a numeric parameter. The restriction to modules is the same one [ConstantFoldable](#constantfoldable) carries, and for the same reason, and so are the Project Settings option it needs and the crash on a **Declare Function**. + + ## CustomControl (String) {: #customcontrol } @@ -297,18 +365,26 @@ The editor names the twinBASIC packages use, most-used first. The list is not ex Syntax: **[Debuggable** [ **( True** \| **False )** ] **]** -Applicable to: [**Module**](Module), [procedure in a **Class** or **Module**](../Gloss#procedure) +Applicable to: [**Module**](Module), [**Class**](Class), [procedure in a **Class** or **Module**](../Gloss#procedure) + +When false, turns off breakpoints and stepping for the method, class or module. The default value is **True**. In a class or module marked `[Debuggable(False)]`, a [**Stop**](Stop) statement does not enter break mode. -When false, turns of breakpoints and stepping for the method or module. The default value is **True**. + ## DebugOnly (optional Bool) {: #debugonly } Syntax: **[DebugOnly** [ **( True** \| **False )** ] **]** -Applicable to: [**Sub**](Sub) +Applicable to: [**Sub**](Sub), [**Declare Sub**](Declare) -Excludes calls to this procedure from the Build. They are only available when running from the IDE, i.e. debugging. +Excludes calls to this procedure from the Build. They are only available when running from the IDE, i.e. debugging. On a **Declare Sub** the same holds for calls to the API procedure. + + The attribute is accepted on a **Sub** in a **Module**, a **Class** or an **Interface**, and on a **Declare Sub**. On a **Function**, a **Property** or a **Declare Function** it is error **TB5162**, *[DebugOnly] is only allowed on Sub procedures*. @@ -356,7 +432,7 @@ Marks the event a control nominates as its primary one. Each control of the VB p Syntax: **[DefaultMember** [ **(** **True** \| **False** **)** ] **]** -Applicable to: [procedure in a **Class**](../Gloss#procedure) +Applicable to: [procedure in a **Class**](../Gloss#procedure), [procedure](../Gloss#procedure) prototype in an [**Interface**](Interface) Default members are accessed under the instance of the object itself, without specifying their name. For example, a class that offers indexable elements may have an **Item** property that is the default member: @@ -382,14 +458,29 @@ Module DefaultMemberDemo End Module ``` +On a prototype in an **Interface**, the member gets the dispatch ID 0, so it is the default member of a variable declared as that interface, whichever class is behind it. + + + ## Description (String) {: #description } Syntax: **[Description("** arbitrary text **")]** -Applicable to: [**Class**](Class), [**CoClass**](CoClass), [**Const**](Const), [**Declare** (API declaration)](Declare), [**Interface**](Interface), [**Module**](Module), [procedure](../Gloss#procedure), [**Type** (UDT)](Type) +Applicable to: [**Class**](Class), [**CoClass**](CoClass), [**Const**](Const), [**Declare** (API declaration)](Declare), [**Event**](Event) declaration in a [**Class**](Class), variable in a [**Class**](Class), [**Interface**](Interface), [**Module**](Module), [procedure](../Gloss#procedure), [**Type** (UDT)](Type) + +Provides a description in information popups in the IDE, and is exported as a `helpstring` attribute in the type library (if applicable). The type library holds one for each public procedure, property, event and variable of a class, and for each prototype of an interface; an **Enum**, a **Type**, a **Type** member and a parameter get none. -Provides a description in information popups in the IDE, and is exported as a `helpstring` attribute in the type library (if applicable). + The value is a **String** whose content is Markdown. The IDE renders it when it displays the popup, so headings, code spans and fenced code blocks all work. The attribute takes a single string literal, so a description running to several lines is assembled with `& vbCrLf & _` continuations, one source line per line of Markdown. @@ -428,9 +519,14 @@ Five details of that are easy to get wrong: Syntax: **[DispId(** 123 **)]** -Applicable to: [procedure in an Interface](../Gloss#procedure) +Applicable to: [procedure in an Interface](../Gloss#procedure), [procedure in a **Class**](../Gloss#procedure), [**Event**](Event) declaration in a [**Class**](Class) -Defines a dispatch ID associated with the procedure when exposed via **IDispatch**. +Defines a dispatch ID associated with the procedure when exposed via **IDispatch**. In a **Class**, it sets the dispatch ID of the member in the interface the class exposes through COM, or, on an **Event**, in its event interface; the accessors of a property share one ID. + + ## DispInterface {: #dispinterface } @@ -476,9 +572,14 @@ The attribute takes no **Boolean**: `[DllExport(True)]` and `[DllExport(False)]` Syntax: **[DLLStackCheck** [ **( True** \| **False)** ] **]** -Applicable to: [**Declare** (API declaration)](Declare) +Applicable to: [**Declare** (API declaration)](Declare), [**Delegate**](Delegate) -Gives minor codegen size reduction on 32-bit API calls on the Intel platform. Has no effect on other platforms. +Gives minor codegen size reduction on 32-bit API calls on the Intel platform. Has no effect on other platforms. On a **Delegate** it changes the code generated for calls through the delegate in the same way; the calls give the same results. + + ## DualInterface {: #dualinterface } @@ -500,18 +601,18 @@ See [**LibraryId**](#libraryid) for what a generated **Library** module looks li Syntax: **[EnforceErrors** **(** **TBnnnn** [ **,** **TBmmmm** ]... **)** **]** -Applicable to: [**Class**](Class), [**Module**](Module), [procedure](../Gloss#procedure) +Applicable to: [**Class**](Class), [**Interface**](Interface), [**Module**](Module), [procedure](../Gloss#procedure) -Reports the named warnings as errors within the class, module or procedure the attribute is applied to. The codes are written bare, as for [IgnoreWarnings](#ignorewarnings). With `[EnforceErrors(TB0009)]` on a procedure, a call to a procedure marked [Unimplemented](#unimplemented) is an error where it would otherwise be warning TB0009. Written bare it is accepted and has no effect. With **True** or **False** in place of a code it is error TB5169, *Expected an error code in the format of TBxxxx*. +Reports the named warnings as errors within the class, interface, module or procedure the attribute is applied to. The codes are written bare, as for [IgnoreWarnings](#ignorewarnings). With `[EnforceErrors(TB0009)]` on a procedure, a call to a procedure marked [Unimplemented](#unimplemented) is an error where it would otherwise be warning TB0009. Written bare it is accepted and has no effect. With **True** or **False** in place of a code it is error TB5169, *Expected an error code in the format of TBxxxx*. ## EnforceWarnings (Warning code list) {: #enforcewarnings } Syntax: **[EnforceWarnings** **(** **TBnnnn** [ **,** **TBmmmm** ]... **)** **]** -Applicable to: [**Class**](Class), [**Module**](Module), [procedure](../Gloss#procedure) +Applicable to: [**Class**](Class), [**Interface**](Interface), [**Module**](Module), [procedure](../Gloss#procedure) -Takes warning codes written bare, as [IgnoreWarnings](#ignorewarnings) does. A warning it names is still reported as a warning. With **True** or **False** in place of a code the attribute is TB5169, *Expected an error code in the format of TBxxxx*. +Takes warning codes written bare, as [IgnoreWarnings](#ignorewarnings) does. A warning it names is reported as a warning, also one the compiler does not report by default: with `[EnforceWarnings(TB0018)]`, assigning a **Long** to an **Integer** gives warning TB0018, *implicit narrowing conversion*, which is otherwise not reported. With **True** or **False** in place of a code the attribute is TB5169, *Expected an error code in the format of TBxxxx*. ## Enumerator {: #enumerator } @@ -616,10 +717,12 @@ Applicable to: [**Class**](Class) Syntax: **[Hidden** [ **(** **True** \| **False** **)** ] **]** -Applicable to: [**Class**](Class), [**CoClass**](CoClass), [**Interface**](Interface), [**Module**](Module), a [procedure](../Gloss#procedure) in a Class or Module, a procedure in an Interface, and a variable in a Class +Applicable to: [**Class**](Class), [**CoClass**](CoClass), [**Interface**](Interface), [**Module**](Module), a [procedure](../Gloss#procedure) in a Class or Module, a procedure in an Interface, variable in a Class, variable in a Module, [**Const**](Const), [**Event**](Event) declaration in a Class, [**Declare**](Declare) Hides the declaration from certain IntelliSense and other lists. It applies to a whole type --- a **Class**, **CoClass**, **Interface** or **Module** --- and equally to a single member of one, so a member can be kept out of those lists without hiding the type that declares it. Within a **Class** that covers procedures, variables, constants and events; within an **Interface**, the member prototypes; within a **Module**, procedures, variables, constants and [**Declare**](Declare) statements. +In the type library of an ActiveX build, it sets the `hidden` flag of each public member of a class and each prototype of an interface it is written on; on a property, the flag goes on every accessor, whichever one carries the attribute. + > [!NOTE] > A **CoClass** can only be hidden whole. Its body holds nothing but **Interface** lines, and the attribute is refused there with TB5155 --- unlike [**Default**](#default) and [**Source**](#source), which are interface-line attributes. It is likewise refused on an **Enum** or [**Type**](Type) declaration, on a **Type** member, and on a procedure parameter. An **Enum** member cannot be hidden either: written before the member on the same line, the attribute is **TB5182**, and on a line of its own inside the **Enum**, `[Hidden]` declares a member named `Hidden` (see [**Enum**](Enum)). @@ -640,7 +743,11 @@ Hides the declaration from certain IntelliSense and other lists. It applies to a Type, a Delegate, Implements and Inherits. TB5182: a Type member, a procedure parameter. Note the census finds NO use on a whole Class, though the probe compiles - -- absence of a use is not absence of applicability. --> + -- absence of a use is not absence of applicability. + Type library (BETA 1005, ActiveX DLL dumped with TLI): FUNCFLAG_FHIDDEN + (0x40) on class procedures, properties (both accessors), events, public + variables and interface prototypes; (False) is as without it. Module and + Private members never reach a type library. --> ## IdeButton (String) @@ -655,9 +762,9 @@ Applicable to: [procedure](../Gloss#procedure) definition in a module. Syntax: **[IgnoreWarnings** **(** **TBnnnn** [ **,** **TBmmmm** ]... **)** **]** -Applicable to: [**Class**](Class), [**Module**](Module), [procedure](../Gloss#procedure) +Applicable to: [**Class**](Class), [**Interface**](Interface), [**Module**](Module), [procedure](../Gloss#procedure) -Suppresses the named warnings within the class, module or procedure the attribute is applied to. The codes are written bare, exactly as the compiler prints them, and are **not** quoted: +Suppresses the named warnings within the class, interface, module or procedure the attribute is applied to. The codes are written bare, exactly as the compiler prints them, and are **not** quoted: ```tb check_build [IgnoreWarnings(TB0001)] @@ -734,7 +841,13 @@ Dividing the most negative **Integer**, **Long** or **LongLong** by -1 with [**\ Syntax: **[InterfaceId( "**00000000-0000-0000-0000-000000000000**" )]** -Applicable to: [**Interface**](Interface) +Applicable to: [**Interface**](Interface), [**Class**](Class) + +On a **Class**, it fixes the identifier of the interface the class exposes through COM, which is otherwise generated anew by each build. [EventInterfaceId](#eventinterfaceid) does the same for the class's event interface. A control class ([COMControl](#comcontrol)) should have both, and the compiler warns when it does not. + + twinBASIC supports defining COM interfaces using BASIC syntax, rather than needing an type library with IDL and C++. These are only supported in .twin files, not in legacy .bas or .cls files. They must appear *before* the [**Class**](Class) or [**Module**](Module) statement, and will always have a project-wide scope. the The generic form for is as follows: @@ -825,16 +938,21 @@ On a class the attribute is error **TB5155**, *This attribute is not supported i Syntax: **[NonBrowsable** [ **( True** \| **False )** ] **]** -Applicable to: variables and [procedures](../Gloss#procedure) in a [**Class**](Class) +Applicable to: variable in a [**Class**](Class), [procedure in a **Class**](../Gloss#procedure), [**Event**](Event) declaration in a [**Class**](Class), [procedure](../Gloss#procedure) prototype in an [**Interface**](Interface) Keeps a member out of the surfaces that list a class's members, while leaving it callable. The twinBASIC packages apply it to members that exist for the framework's own use, such as `InternalSectionId` and `hWndHeader`. +In the type library of an ActiveX build, it sets the `nonbrowsable` flag of a public procedure, property or event of a class and of a prototype of an interface; on a property, the flag goes on every accessor. A public variable of a class gets no flag. + This is distinct from [Hidden](#hidden), which reaches the same member as well as the whole type, and from [Restricted](#restricted). + affects is not established. + Type library (BETA 1005, ActiveX DLL dumped with TLI): FUNCFLAG_FNONBROWSABLE + (0x400) on class procedures, properties (both accessors), events and + interface prototypes; a public class variable's Get/Put pair stays 0x0. --> ## OleAutomation (optional Bool) {: #oleautomation } @@ -845,6 +963,13 @@ Applicable to: [**Interface**](Interface) Controls whether this attribute is applied in the typelibrary. This attribute is set to **True** by default. +It changes only an interface that extends **IUnknown**. An interface that extends **IDispatch** is a dual interface, which is OLE Automation compatible whatever the attribute says. + + + ## PackingAlignment (Integer) {: #packingalignment index="packing alignment" } @@ -930,6 +1055,16 @@ When set, a global instance of the class is created when the application starts. This attribute is equivalent to the `VB_PredeclaredId` attribute in VBx .cls files. +> [!NOTE] BETA 1005 +> In an ActiveX build, the class is marked `appobject` in the type library as well as `predeclid`, which is what [AppObject](#appobject) gives it, so a client that references the library treats the members of the class as global names. + + + ## PreserveSig (optional Bool) {: #preservesig } @@ -954,6 +1089,52 @@ can be rewritten as Public Declare PtrSafe Function SHGetDesktopFolder Lib "shell32" () As IShellFolder ``` +## PropertyPage (String) +{: #propertypage } + +Syntax: **[PropertyPage("** page name **")]** + +Applicable to: variables and [procedures](../Gloss#procedure) in a [**Class**](Class) + +Legacy VB attribute: *VB_ProcData.VB_Invoke_Property* + +Ties a property of a [**UserControl**](../Packages/VB/UserControl/) to one of the project's property pages, so that a host that shows a property page for a single property (the **...** button of a property sheet) opens that one. The argument is the class name of the property page, as **Add Windows PropertyPage** created it, matched exactly, case included. The control reports the page through **IPerPropertyBrowsing.MapPropertyToPage**. + +```tb inert=designer +[PropertyPage("CaptionPage")] +Public Property Get Caption() As String + Return m_Caption +End Property + +Public Property Let Caption(ByVal Value As String) + m_Caption = Value + PropertyChanged "Caption" +End Property +``` + +A property takes the attribute on its first accessor in the source, normally the **Property Get**; written on a later accessor, it is ignored. The legacy line `Attribute Caption.VB_ProcData.VB_Invoke_Property = "CaptionPage"` inside the **Property Get** means the same. The argument must be a **String**: `[PropertyPage]` with no argument, or with a **Boolean** or a number, is TB5163, *Expected a single String argument*. An empty string ties the property to no page. + +> [!NOTE] BETA 1005 +> A name that matches no property page in the project is not reported: the build succeeds, and the property is tied to no page. The same holds for a name that differs only in case. + +> [!NOTE] BETA 1005 +> VB6 stores a property's page as `"PageName;Category"` in the legacy attribute, with the category the property sheet groups the property under. twinBASIC reads such a legacy line and drops what follows the semicolon. **[PropertyPage]** takes the whole string as the name, so `[PropertyPage("CaptionPage;Appearance")]` matches no page. + +The pages the control offers as a whole are listed in its [**PropertyPages**](../Packages/VB/UserControl/#propertypages) property. + + + ## RedirectToStaticImplementation (String) {: #redirecttostaticimplementation } @@ -989,11 +1170,18 @@ End Interface Syntax: **[Restricted** [ **( True** \| **False )** ] **]** -Applicable to: [**Interface**](Interface) +Applicable to: [**Interface**](Interface), [**Class**](Class), [procedure in a **Class**](../Gloss#procedure), variable in a [**Class**](Class), [**Event**](Event) declaration in a [**Class**](Class), [procedure](../Gloss#procedure) prototype in an [**Interface**](Interface) Restricts the interface methods from being called in most contexts. -This is attribute has the same function as the [**restricted** MIDL attribute][MIDL restricted]. +This is attribute has the same function as the [**restricted** MIDL attribute][MIDL restricted]. On a **Class**, it marks the class and the interface it exposes through COM as restricted in the type library. On a single member, it marks that member; on a property, only the accessor it is written on. + + [MIDL restricted]: https://learn.microsoft.com/en-us/windows/win32/midl/restricted @@ -1002,7 +1190,7 @@ This is attribute has the same function as the [**restricted** MIDL attribute][M Syntax: **[RunAfterBuild** [ **( True** \| **False )** ] **]** -Applicable to: [**Function**](Function), [**Sub**](Sub) +Applicable to: [**Function**](Function), [**Sub**](Sub), [**Property Get**](Property) in a [**Module**](Module) Specifies a function that runs after your exe is built. There's `App.LastBuildPath` to know where it is if you're e.g. signing the executable. @@ -1013,15 +1201,35 @@ The procedure runs whatever the project's modules and procedures are called: a m > [!NOTE] > VB6 refuses a module that has the project's name, with *Name conflicts with existing module, project, or object library*. It allows a procedure named like its module. +> [!WARNING] BETA 1005 +> The compiler also accepts the attribute on a generic **Sub**, such as `Sub Signed(Of T)()`, and counts it as the project's one **[RunAfterBuild]**. The build gives no error or warning, and the DEBUG CONSOLE reports the procedure as executed, but its body does not run. + + + ## RunBeforeStartupObject {: #runbeforestartupobject } Syntax: **[RunBeforeStartupObject]** -Applicable to: [**Function**](Function) in a [**Module**](Module), returning a **Boolean** +Applicable to: [procedure](../Gloss#procedure) in a [**Module**](Module) Runs the function before the project's startup object. Returning **True** suppresses the startup object entirely; returning **False** lets startup proceed as normal. +A **Function** is the usual form, returning a **Boolean**. A **Function** of another type, or a **Property Get**, works the same way: a result of 0 or **Nothing** lets startup proceed, and any other result suppresses the startup object. A **Sub** runs too, and then the startup object never runs. + +> [!WARNING] BETA 1005 +> On a generic procedure, such as `Sub Pre(Of T)()`, the project compiles without an error, but building it crashes the compiler. + + + The CEF package uses it to intercept the sub-process launches Chromium makes of the host executable, which must not run the application's own `Sub Main`: ```tb check_build project=packages @@ -1138,10 +1346,18 @@ Allows populating Intellisense with an enum for types other than **Long**. Syntax: **[Unimplemented** [ **( True** \| **False )** ] **]** -Applicable to: [procedure](../Gloss#procedure) definitions +Applicable to: [**Declare** (API declaration)](Declare), variable in a [**Class**](Class), [procedure](../Gloss#procedure) definitions Makes the compiler issue a warning about the procedure being unimplemented wherever it's called. You can upgrade it to an error too. +On a **Declare** the warning, TB0009, comes at each call in the same way. On a **Public** variable of a class it comes at each use of the variable from outside the class; a use inside the class gives none. + + + ## UseGetLastError (optional Bool) {: #usegetlasterror } diff --git a/docs/Reference/Built-In/WinNativeCommonCtls/ListView/index.md b/docs/Reference/Built-In/WinNativeCommonCtls/ListView/index.md index c1120c082..e79438545 100644 --- a/docs/Reference/Built-In/WinNativeCommonCtls/ListView/index.md +++ b/docs/Reference/Built-In/WinNativeCommonCtls/ListView/index.md @@ -203,7 +203,10 @@ The [**ImageList**](../ImageList/) used for small icons in **lvwSmallIcon**, **l ### TextBackground {: .no_toc } -Whether item-label text has an opaque background. A member of [**ListTextBackgroundConstants**](#listtextbackgroundconstants). Default: **lvwTransparent**. Changing it repaints the list at once. +Whether item-label text has an opaque background. A member of [**ListTextBackgroundConstants**](#listtextbackgroundconstants). Default: **lvwTransparent**. Changing it repaints the list at once. The opaque background is [**BackColor**](#backcolor), the color the list is already drawn in, so the two settings look the same. + +> [!NOTE] BETA 1005 +> **TextBackground** has no visible effect: the control has no **Picture** property, the background image an opaque text background would hide. ### View {: .no_toc } diff --git a/docs/Reference/Built-In/WinNativeCommonCtls/Slider.md b/docs/Reference/Built-In/WinNativeCommonCtls/Slider.md index eae8197b6..0be1f8b35 100644 --- a/docs/Reference/Built-In/WinNativeCommonCtls/Slider.md +++ b/docs/Reference/Built-In/WinNativeCommonCtls/Slider.md @@ -51,7 +51,10 @@ Properties ### BackColor {: .no_toc } -The background color of the channel. **OLE_COLOR**. Default: **vbButtonFace**. +The background color of the control. **OLE_COLOR**. Default: **vbButtonFace**. + +> [!NOTE] BETA 1005 +> A change to **BackColor** does not repaint the control, and neither does invalidating it: the new color shows only when the user drags the thumb, or when the control is hidden and shown again. ### BorderStyle {: .no_toc } diff --git a/docs/Reference/COM-Interfaces/IUnknown.md b/docs/Reference/COM-Interfaces/IUnknown.md index 2736cda3f..ea7629aeb 100644 --- a/docs/Reference/COM-Interfaces/IUnknown.md +++ b/docs/Reference/COM-Interfaces/IUnknown.md @@ -76,14 +76,42 @@ twinBASIC issues these calls itself, so the language has no statement for them: - **Set** *a* **= Nothing**, and a variable going out of scope, each release one reference. - **Set** to a variable of an interface type asks the object for that interface with **QueryInterface**. -The three methods themselves cannot be called by name: +The three methods cannot be called by name through the types the language provides: - On a variable declared **As stdole.IUnknown**, `u.AddRef`, `u.Release` and `u.QueryInterface` are compile errors, TB5027 *Unrecognized member*. The same holds for a variable of a project's own interface that extends **stdole.IUnknown**. - On an **Object** variable, `o.AddRef` and `CallByName(o, "AddRef", VbMethod)` raise run-time error 438 (*Object doesn't support this property or method*). -- A project cannot redeclare **IUnknown** under its own name and call it. An **Interface** declared with no **Extends** clause derives from **IDispatch**, not from **IUnknown**, so its first method is at slot 7 and the three declared methods are not the three of **IUnknown**. Declared with **Extends stdole.IUnknown**, the first method is at slot 3. Neither reaches slots 0 to 2. + +The first method of an **Interface** declared with no **Extends** clause is at slot 7, because the interface derives from **IDispatch**, and the first method of one declared **Extends stdole.IUnknown** is at slot 3. An interface declared **Extends Nothing** has no base, so its first method is at slot 0. Declared with the identifier of **IUnknown** and its three methods in their order, it calls them by name: + +```tb check_build projname=com-iunknown slot=file +[InterfaceId("00000000-0000-0000-C000-000000000046")] +[OleAutomation(False)] +Private Interface IUnknownCallable Extends Nothing + [PreserveSig] + Function QueryInterface(ByVal riid As LongPtr, ByRef ppvObject As LongPtr) As Long + [PreserveSig] + Function AddRef() As Long + [PreserveSig] + Function Release() As Long +End Interface +``` + +**Set** to a variable of this type asks the object for **IUnknown**, which every object supports. With any other identifier, the **Set** fails with `E_NOINTERFACE`, because the object does not know the interface. The counts include the reference that each variable holds: + +```tb check_run projname=com-iunknown +Dim c As New Collection +Dim u As IUnknownCallable = c +Debug.Print u.AddRef() +Debug.Print u.Release() +' Output: +' 3 +' 2 +``` + +The [OLEGuids](../../tB/Packages/Contributed/OLEGuids/IUnknownUnrestricted) package declares this interface as **IUnknownUnrestricted**. > [!NOTE] -> Code that has to read or change a reference count goes through the object's method table. The example below does this with **DispCallFunc** from oleaut32.dll. [**vbaObjAddref**](../../tB/Modules/HiddenModule/vbaObjAddref) calls **AddRef** on a raw pointer without reading the result. +> Without such an interface, code that has to read or change a reference count goes through the object's method table. The example below does this with **DispCallFunc** from oleaut32.dll. [**vbaObjAddref**](../../tB/Modules/HiddenModule/vbaObjAddref) calls **AddRef** on a raw pointer without reading the result. Calling **IUnknown** methods by hand is a low-level operation. A wrong slot number, or a **Release** with no matching **AddRef**, ends in an access violation or in an object freed while a variable still refers to it. @@ -133,10 +161,10 @@ The class's `Class_Terminate` procedure runs inside the **Release** that brings A class never needs to implement **IUnknown**: the compiler gives every class an implementation, and an interface that extends **stdole.IUnknown** is satisfied without a body for those three methods. -`Implements stdole.IUnknown` compiles, and the class works as before. A project's own **Interface** that carries the identifier of **IUnknown** also compiles, and a class can implement it. +`Implements stdole.IUnknown` compiles, and the class works as before. A project's own **Interface** that carries the identifier of **IUnknown** and extends another interface also compiles, and a class can implement it. > [!WARNING] BETA 1005 -> That interface is unusable. **Set** to a variable of that type gives the object's ordinary **IUnknown** pointer, which has none of the interface's methods, and calling one ends the program with an access violation, and any unsaved data is lost. The compiler gives no diagnostic. Do not declare an **Interface** with the identifier of **IUnknown**; use `stdole.IUnknown`. +> That interface is unusable. **Set** to a variable of that type gives the object's ordinary **IUnknown** pointer, which has only the three methods of **IUnknown**. Calling a method the interface declares after them ends the program with an access violation, and any unsaved data is lost. The compiler gives no diagnostic. Give the identifier of **IUnknown** only to an interface declared **Extends Nothing** with the three methods of **IUnknown**, as in [Calling it from twinBASIC](#calling-it-from-twinbasic); otherwise use `stdole.IUnknown`. ## Example diff --git a/docs/Reference/Contributed/Fmt/ExponentialSFI.md b/docs/Reference/Contributed/Fmt/ExponentialSFI.md index ecc24fd67..ceb83b629 100644 --- a/docs/Reference/Contributed/Fmt/ExponentialSFI.md +++ b/docs/Reference/Contributed/Fmt/ExponentialSFI.md @@ -14,7 +14,7 @@ Formats a number in exponential notation for the **E** identifier, and reads the ## Output -The value is passed to **Format$** with a format of the form `0.00000#E-#`. The letter in the format is the identifier as written, so `E` gives an upper-case `E` in the result and `e` a lower-case `e`. The exponent has no plus sign and no leading zeros. +The value is passed to **Format$** with a format of the form `0.00000#E-#`. The letter in the format is the identifier as written, so `E` gives an upper-case `E` in the result and `e` a lower-case `e`. The exponent has no plus sign and no leading zeros. The decimal point follows the Windows regional settings. - Without a precision the mantissa has six decimal places. The sixth is optional, so a trailing zero there is dropped. - With a precision *n* greater than zero the mantissa has *n* decimal places, and the last of them is optional. A precision of `0` is the same as none. diff --git a/docs/Reference/Contributed/Fmt/GeneralNumericSFI.md b/docs/Reference/Contributed/Fmt/GeneralNumericSFI.md index 506bdfcb8..8a36e8aca 100644 --- a/docs/Reference/Contributed/Fmt/GeneralNumericSFI.md +++ b/docs/Reference/Contributed/Fmt/GeneralNumericSFI.md @@ -20,7 +20,7 @@ The result depends on the **TypeName** of the value: - A **Double** is first formatted by [**ExponentialSFI**](ExponentialSFI) with the same precision. The exponent of that text is read back with [**ParseExponent**](ExponentialSFI#parseexponent). When the exponent is greater than -5 and its absolute value is less than the precision, the value is formatted again by [**FixedPointSFI**](FixedPointSFI), with the precision as the count of decimal places. Otherwise the exponential text is the result. Without a precision the second test cannot succeed, so a **Double** is always in exponential notation. - A value of any other type, such as a **Single**, **Currency**, **Byte**, **Decimal** or a numeric **String**, gives an empty string. -The precision is therefore a count of decimal places and a limit for the exponent. It is not a count of significant digits, as it is in .NET. The case of the identifier is kept: `g` gives a lower-case `e` in exponential notation. +The precision is therefore a count of decimal places and a limit for the exponent. It is not a count of significant digits, as it is in .NET. The case of the identifier is kept: `g` gives a lower-case `e` in exponential notation. The decimal point follows the Windows regional settings. ```tb check_build check_run Debug.Print StringFormat("{0:G}", 42) ' 42 diff --git a/docs/Reference/Default/VB/UserControl/index.md b/docs/Reference/Default/VB/UserControl/index.md index 3f18ec68d..b66c66430 100644 --- a/docs/Reference/Default/VB/UserControl/index.md +++ b/docs/Reference/Default/VB/UserControl/index.md @@ -77,7 +77,7 @@ End Property [**Ambient**](#ambient) exposes the host's ambient properties (the colours, font, locale, and design/run-mode flags the host wants child controls to honour). Changes to any of those raise the [**AmbientChanged**](#ambientchanged) event with the name of the affected property. -[**Verbs**](#verbs) and [**VerbInvoked**](#verbinvoked) let the control register host-invokable commands (entries that appear on the host's context menu for the control). [**PropertyPages**](#propertypages) registers the **CLSID**s of additional [**PropertyPage**](../PropertyPage) classes that the host should offer through the property browser. +[**Verbs**](#verbs) and [**VerbInvoked**](#verbinvoked) let the control register host-invokable commands (entries that appear on the host's context menu for the control). [**PropertyPages**](#propertypages) names the [**PropertyPage**](../PropertyPage) classes that the host should offer through the property browser. ## Drawing surface @@ -464,16 +464,23 @@ Whether the [**PreKeyDown**](#prekeydown) and [**PreKeyUp**](#prekeyup) events a ### PropertyPages {: .no_toc } -An array of **String** **CLSID**s identifying the [**PropertyPage**](../PropertyPage) classes the host should offer through the **(Custom)** entry on the property browser. Read at the host's `ISpecifyPropertyPages::GetPages` call. Order matters --- the array order is the tab order in the property-sheet dialog. +An array of **String**s naming the [**PropertyPage**](../PropertyPage) classes of the project that the host should offer through the **(Custom)** entry on the property browser. Each element is a class name, as **Add Windows PropertyPage** created it; a name that matches no property page is skipped. Read at the host's `ISpecifyPropertyPages::GetPages` call. Order matters --- the array order is the tab order in the property-sheet dialog. ```tb check_build Private Sub UserControl_Initialize() ReDim PropertyPages(0 To 1) - PropertyPages(0) = "{}" - PropertyPages(1) = "{}" + PropertyPages(0) = "GeneralPage" + PropertyPages(1) = "ColourPage" End Sub ``` +To tie a single property to a page, use the [**PropertyPage**](../../../Core/Attributes#propertypage) attribute. + + + ### Public {: .no_toc } diff --git a/docs/Tutorials/WebView2/Getting started.md b/docs/Tutorials/WebView2/Getting started.md index cb8f9cfde..be2ddc1c6 100644 --- a/docs/Tutorials/WebView2/Getting started.md +++ b/docs/Tutorials/WebView2/Getting started.md @@ -26,7 +26,7 @@ Once you've added the package references, you should find that the WebView2 cont We use the WebView2 control just like any ordinary control: -![Animation of a WebView2 control being drawn onto a blank form in the designer](Images/tbWebView2InAForm.gif){:width="1000" height="530"} +![Animation of a WebView2 control being drawn onto a blank form in the designer](Images/tbWebView2InAForm.webp){:width="1000" height="530"} ## WebView2 control properties diff --git a/docs/Tutorials/WebView2/Images/tbWebView2InAForm.gif b/docs/Tutorials/WebView2/Images/tbWebView2InAForm.gif deleted file mode 100644 index a9c264b43..000000000 Binary files a/docs/Tutorials/WebView2/Images/tbWebView2InAForm.gif and /dev/null differ diff --git a/docs/Tutorials/WebView2/Images/tbWebView2InAForm.webp b/docs/Tutorials/WebView2/Images/tbWebView2InAForm.webp new file mode 100644 index 000000000..a7fcc6ed2 Binary files /dev/null and b/docs/Tutorials/WebView2/Images/tbWebView2InAForm.webp differ diff --git a/docs/_book.yml b/docs/_book.yml index d6985b495..a489d7023 100644 --- a/docs/_book.yml +++ b/docs/_book.yml @@ -713,6 +713,9 @@ left_out: - reason: Links to video recordings, with nothing to read in print page: /Videos + - reason: Download links, with nothing to read in print + page: /Downloads + - reason: Time-limited community contests page: /Challenges diff --git a/docs/_config.yml b/docs/_config.yml index f4e0d82a7..894765277 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -32,22 +32,13 @@ gh_edit_branch: "main" gh_edit_source: docs gh_edit_view_mode: "tree" -# Footer "Download offline copy" link -- points at the offline-site -# zip attached to the latest GitHub release (cut by the manual -# `workflow_dispatch` path in .github/workflows/jekyll-gh-pages.yml). -# Rendered inside the same flex row as the "Edit this page" link, -# with id="download-offline" on the . Emitted in both the online -# _site/ and the offline _site-offline/ trees -- online readers use -# it to fetch the offline copy, offline readers use it to refresh -# theirs against the latest release. -gh_offline_link: true -gh_offline_link_url: "https://github.com/twinbasic/documentation/releases/latest/download/twinbasic-docs-offline.zip" -# The released asset is "twinBASIC Book.pdf", with a space. GitHub replaces spaces -# with dots in release download URLs, so the dotted spelling here is correct and the -# spaced one 404s. Verified against the live release. Do not "fix" this to match the -# workflow's filename. It also depends on make_latest: 'true' in tbdocs-gh-pages.yml, -# which is what makes /releases/latest/download/ resolve at all. -gh_pdf_link_url: "https://github.com/twinbasic/documentation/releases/latest/download/twinBASIC.Book.pdf" +# Footer "Read Offline" link -- a site path, to the Downloads page's +# section on the offline copy and the PDF book, which link to the +# latest GitHub release. Rendered inside the same flex row as the +# "Edit this page" link, with id="read-offline-link" on the , in both +# the online _site/ and the offline _site-offline/ trees -- offline +# readers use it to refresh their copy against the latest release. +read_offline_link: "/Downloads#read-offline" # When true, tbdocs's Phase 7 writes a `file://`-browsable mirror of # `_site/` to `_site-offline/` with every URL rewritten to a page- diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss index 50cff4a80..72ee101e3 100644 --- a/docs/_sass/custom/custom.scss +++ b/docs/_sass/custom/custom.scss @@ -905,3 +905,67 @@ div.highlight:focus-visible, margin-left: auto; } } + +// ── Download cards (the Downloads page) ────────────────────────────────── +// +// `
` holds `
`s, each a +// heading, a paragraph, a `.download-meta` line and a `.download-actions` row +// of `.btn` links (the theme's, styled in both themes) and one +// `.download-doc` link. The grid takes as many 18rem columns as fit, and one +// at phone width. The actions row follows the text directly. +// +// The heading's top margin is the theme's `.main-content h3`, which the dark +// re-emission raises to (0,2,2); `> :first-child` here is (0,3,0) and wins in +// both themes (see "The specificity trap" in Builder.md). +.download-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr)); + gap: 1rem; + margin: 1rem 0 1.5rem; +} + +.download-card { + padding: 1rem 1.25rem; + border: 1px solid #e6e1e8; // $grey-lt-300 + border-radius: 6px; + + > p { + margin: 0 0 0.75rem; + } +} + +.main-content .download-card > :first-child { + margin-top: 0; +} + +@include dark-theme { + .download-card { + border-color: #44434d; // dark $border-color + } +} + +.download-card > .download-meta { + font-size: 0.875em; +} + +.download-card > .download-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin: 0; +} + +.download-size { + font-weight: 400; + font-size: 0.875em; +} + +// Pushed to the right end of the row, after the buttons. +.download-doc { + margin-left: auto; + + &::after { + content: " \2192" / ""; + } +} diff --git a/docs/assets/images/dot/scheduler-dag.dot b/docs/assets/images/dot/scheduler-dag.dot index eb7786284..dc79a0cf6 100644 --- a/docs/assets/images/dot/scheduler-dag.dot +++ b/docs/assets/images/dot/scheduler-dag.dot @@ -64,10 +64,12 @@ digraph scheduler { AP [label=[M]>, fillcolor="#fef7e0", color="#f9ab00"] WX [label=[M]>, fillcolor="#fef7e0", color="#f9ab00"] WO [label=[M]>, fillcolor="#fef7e0", color="#f9ab00"] - WP [label=[M]>, fillcolor="#fef7e0", color="#f9ab00"] + HA [label=[W]>, fillcolor="#e8f0fe", color="#4285f4"] + BP [label=[M]>, fillcolor="#fef7e0", color="#f9ab00"] + WP [label=[W]>, fillcolor="#e8f0fe", color="#4285f4"] LJ [label=[M]>, fillcolor="#fef7e0", color="#f9ab00"] - CB [label=[M]>, fillcolor="#fef7e0", color="#f9ab00"] + CB [label=[W] pinned>, fillcolor="#e8f0fe", color="#4285f4"] CR [label=[M]>, fillcolor="#fef7e0", color="#f9ab00"] // ── Edges ─────────────────────────────────────────────────────────── @@ -124,6 +126,7 @@ digraph scheduler { SYM -> AP PD -> AP + HA -> AP WA -> WX SDt -> WX @@ -134,11 +137,16 @@ digraph scheduler { WX -> WO WA -> WO - FJ -> WP - RJ -> WP - DT -> WP - RBC -> WP - HI -> WP [style=dashed] + FJ -> HA + SCSS -> HA + WO -> HA + + FJ -> BP + RJ -> BP + DT -> BP + RBC -> BP + HI -> BP [style=dashed] + BP -> WP FJ -> LJ WX -> LJ diff --git a/docs/assets/images/dot/scheduler-dag.svg b/docs/assets/images/dot/scheduler-dag.svg index a2f9d5978..4925cb8be 100644 --- a/docs/assets/images/dot/scheduler-dag.svg +++ b/docs/assets/images/dot/scheduler-dag.svg @@ -1,633 +1,677 @@ - - + + scheduler CF - -config -[M] + +config +[M] HI - -highlighterInit -[M] + +highlighterInit +[M] CF->HI - - + + DI - -discover -[M] + +discover +[M] CF->DI - - + + BI - -buildInfo -[W] + +buildInfo +[W] DP - -dispatch -[M] + +dispatch +[M] BI->DP - - + + SL - -scssLight -[W] + +scssLight +[W] SCSS - -scss -[M] + +scss +[M] SL->SCSS - - + + SD - -scssDark -[W] + +scssDark +[W] SD->SCSS - - + + LD - -loadData -[M] + +loadData +[M] HI->LD - - + + WA - -writeAssets -[M] + +writeAssets +[M] HI->WA - - + + - - -WP - -writePdf -[M] + + +BP + +bookPayload +[M] - - -HI->WP - - + + +HI->BP + + RBC - -resolveBookChapters -[M] + +resolveBookChapters +[M] LD->RBC - - + + WI - -warmInit -[W] per-lane -on_demand, idle + +warmInit +[W] per-lane +on_demand, idle REI - -renderEnvInit -[W] per-lane + +renderEnvInit +[W] per-lane WI->REI - - + + VA - -vendorAssets -[M] + +vendorAssets +[M] DI->VA - - + + NV - -nav -[M] + +nav +[M] DI->NV - - + + BU - -buildInit -[M] + +buildInit +[M] DI->BU - - + + MI - -markdownInit -[M] + +markdownInit +[M] DI->MI - - + + DR - -deriveRedirects -[M] + +deriveRedirects +[M] DI->DR - - + + VA->MI - - + + VA->WA - - + + NV->DP - - + + BU->DP - - + + MI->DP - - + + DR->DP - - + + WX - -writeAux -[M] + +writeAux +[M] - + DR->WX - - + + DT - -dot -[W] + +dot +[W] DT->DP - - + + DT->WA - - + + - - -DT->WP - - + + +DT->BP + + DSi - -deriveSitemap -[M] + +deriveSitemap +[M] DSi->RBC - - + + - + DSi->WX - - + + - - -RBC->WP - - + + +RBC->BP + + DP->DSi - - + + PD - -prepDest -[M] + +prepDest +[M] DP->PD - - + + DP->REI - - + + Ri - -render:i -[W] + +render:i +[W] DP->Ri - - + + PPD - -prepPageDirs -[M] + +prepPageDirs +[M] PD->PPD - - + + PD->SCSS - - + + SDt - -searchData -[M] + +searchData +[M] PD->SDt - - + + SYM - -symbolIndex -[M] + +symbolIndex +[M] PD->SYM - - + + AP - -addinProject -[M] + +addinProject +[M] PD->AP - - + + Fi - -flush:i -[W] pinned + +flush:i +[W] pinned PPD->Fi - - + + PPD->WA - - + + REI->Ri - - + + RJ - -renderJoin -[M] + +renderJoin +[M] Ri->RJ - - + + Ri->Fi - - + + RJ->SDt - - + + RJ->SYM - - + + - - -RJ->WP - - + + +RJ->BP + + + + + +HA + +helpArchive +[W] + + + +SCSS->HA + + - + CR - -checkReport -[M] + +checkReport +[M] - + SCSS->CR - - + + FJ - -flushJoin -[M] + +flushJoin +[M] Fi->FJ - - + + - + FJ->WX - - + + - - -FJ->WP - - + + +FJ->HA + + + + + +FJ->BP + + - + LJ - -linkJoin -[M] + +linkJoin +[M] - + FJ->LJ - - + + - + WA->WX - - + + WO - -writeOffline -[M] + +writeOffline +[M] - + WA->WO - - + + - + SDt->WX - - + + SYM->SDt - - + + SYM->AP - - + + - + SYM->CR - - + + - + AP->CR - - + + - + WX->WO - - + + - + WX->LJ - - + + + + + +WO->HA + + - + WO->LJ - - + + - + + +HA->AP + + + + +WP + +writePdf +[W] + + + +BP->WP + + + + + CB - -checkBook -[M] + +checkBook +[W] pinned - + WP->CB - - + + - + LJ->CR - - + + - + CB->CR - - + + diff --git a/docs/index.md b/docs/index.md index d36c48789..d05997fc9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -90,22 +90,10 @@ The [**IDE section**](tB/IDE) documents the [code editor and the panels beside i - [Create a Custom ActiveX Control with twinBASIC](https://nolongerset.com/create-activex-control-with-twinbasic/) - [Create a Tool Window in the VBIDE with twinBASIC](https://nolongerset.com/create-a-vbe-addin-with-twinbasic/) -## Reading offline, and the PDF book - -Everything on this site is also published in two forms you can keep, both attached to the -[documentation releases](https://github.com/twinbasic/documentation/releases) on GitHub: - -- **`twinbasic-docs-offline.zip`** --- the whole site, browsable with no server and no - network. Extract it anywhere and open `index.html` in any browser; links, navigation, - dark mode and the search box all work. -- **`twinBASIC Book.pdf`** --- the complete reference as one PDF with bookmarks for every - section. It is A4 and runs to a little under 2,000 pages, so it suits a tablet or a - printer better than a small e-reader. - -Releases are cut on demand rather than on every change, so check the date against what -you need; this site is always current. If the newest release is older than you want, -you can build both yourself from the repository --- see -[Building and Deployment](Documentation/Development/Building). +## Reading offline + +This whole site is also available as a PDF book and as an offline copy that opens in any +browser: see [**Read offline**](Downloads#read-offline) on the Downloads page. ## Contributing to the documentation diff --git a/eval/usecases.md b/eval/usecases.md index 3764a43ff..77c149927 100644 --- a/eval/usecases.md +++ b/eval/usecases.md @@ -294,7 +294,7 @@ goal, so do not mix them in a re-run. | UC-40 | *(re-run)* A gate refused a regex I added to the builder. Understand it and fix it. | **H** the answering section appeared in none of seven queries, including the gate's own filename | | UC-44 | *(re-run)* `serve.bat` never shows a builder change. Diagnose it and give a workflow. | **H** the answer was an un-anchored NOTE; both pages documenting `serve.bat` were silent | | UC-45 | *(re-run)* Hand somebody a copy to read on a laptop with no network. | **H** four reader-phrased queries missed; no reader-facing page said the copy existed | -| UC-52 | Cut a release carrying the offline site copy and the PDF book, and say what a consumer gets. | **H** only a manual dispatch attaches them, and the release can lag the live site arbitrarily | +| UC-52 | Publish a release carrying the offline site copy, the PDF book and the Downloads page's files, and say what a consumer gets. | **H** only a manual dispatch attaches them, and the release can lag the live site arbitrarily | ### Persona D: a twinBASIC developer on the published site *(new)* diff --git a/lib/help-archive.mjs b/lib/help-archive.mjs index 5756840ef..e2d9e064f 100644 --- a/lib/help-archive.mjs +++ b/lib/help-archive.mjs @@ -1,10 +1,12 @@ // The help add-in's documentation archive: the built offline tree as a zip. // -// const stats = await writeHelpArchive({ src, out }); // stats.zip is the archive's bytes -// const problems = await checkHelpArchive({ src, zip, mayDiffer }); +// const stats = await writeHelpArchive({ src, out, skip }); // stats.zip is the archive's bytes +// const problems = await checkHelpArchive({ src, zip, mayDiffer, skip }); // // checkHelpArchive reads an archive written earlier against the tree as it is now: // the same files, the same bytes, except in the files named in `mayDiffer`. +// `skip(name)` leaves a file of the tree out of the archive, and out of the +// comparison; pass both functions the same one. // // The IDE help add-in serves the documentation offline from this zip, embedded in // its DLL as the resource HELP/site.zip (a twinBASIC project embeds @@ -102,15 +104,18 @@ async function mapLimit(items, limit, task) { return results; } -/** Every file under `dir`, as { name, file }, `name` relative with forward slashes, in code-unit order. */ -async function listFiles(dir) { +/** + * Every file under `dir` that `skip` keeps, as { name, file }, `name` relative + * with forward slashes, in code-unit order. + */ +async function listFiles(dir, skip = () => false) { const found = []; const walk = async (d, prefix) => { for (const e of await readdir(d, { withFileTypes: true })) { const file = path.join(d, e.name); const name = prefix + e.name; if (e.isDirectory()) await walk(file, `${name}/`); - else if (e.isFile()) found.push({ name, file }); + else if (e.isFile() && !skip(name)) found.push({ name, file }); } }; await walk(dir, ""); @@ -332,17 +337,19 @@ async function verifyArchive(zipFile, files, limit, mayDiffer = new Set()) { * @param {string} o.src the tree to archive, an absolute path * @param {string} o.out the zip to write, an absolute path; written under a * temporary name and renamed, so a reader never sees half of it + * @param {(name: string) => boolean} [o.skip] true for a file (relative, forward + * slashes) the archive leaves out * @returns {Promise<{entries: number, rawBytes: number, zipBytes: number, storedBytes: number, ms: number}>} * @throws {Error} as the header describes: `verifyFailed` (with `problems` and * `stats`), `tooLarge`, or the system's error */ -export async function writeHelpArchive({ src, out }) { +export async function writeHelpArchive({ src, out, skip }) { const started = Date.now(); const limit = os.availableParallelism(); let files; try { - files = await listFiles(src); + files = await listFiles(src, skip); } catch (err) { throw new Error(`cannot read ${src}: ${err.message}`, { cause: err }); } @@ -389,10 +396,11 @@ export async function writeHelpArchive({ src, out }) { * @param {string} o.src the tree, an absolute path * @param {string} o.zip the archive, an absolute path * @param {string[]} [o.mayDiffer] names (relative, forward slashes) allowed to differ in content + * @param {(name: string) => boolean} [o.skip] the files the archive leaves out, as writeHelpArchive was given * @returns {Promise} the differences found, each naming its entry; empty when none */ -export async function checkHelpArchive({ src, zip, mayDiffer = [] }) { - const files = await listFiles(src); +export async function checkHelpArchive({ src, zip, mayDiffer = [], skip }) { + const files = await listFiles(src, skip); const inTree = new Set(files.map((f) => f.name)); const entries = archiveNames(await readFile(zip)); if (entries === null) return ["(archive): the end-of-central-directory record is not where it should be"]; diff --git a/scripts/check_attribute_sweep.mjs b/scripts/check_attribute_sweep.mjs index 722a74d25..1729ebcbc 100644 --- a/scripts/check_attribute_sweep.mjs +++ b/scripts/check_attribute_sweep.mjs @@ -191,7 +191,7 @@ check( // A site in no family is one no `Applicable to:` phrase can cover, so accepting it is // always reported as undocumented. That is right for these and a decision for a new // site, which is what this list is for. -const NO_FAMILY = ["DELEGATE", "TYPE_FIELD", "TYPE_FIELD_LINE", "CONST_CLASS", "VAR_LOCAL", "INHERITS"]; +const NO_FAMILY = ["TYPE_FIELD", "TYPE_FIELD_LINE", "CONST_CLASS", "VAR_LOCAL", "INHERITS"]; check( "families: the sites in no family are the ones decided", same( @@ -801,6 +801,66 @@ for (const [app, want] of [ ["Sub", ["SUB_MODULE"]], ["procedures and Declare statements in a Module", ["PROC_MODULE", "DECLARE"]], ["Class, Module, procedure", ["CLASS", "MODULE", "PROC_ANY"]], + ["procedure prototype in an Interface, procedure in a Class", ["PROC_INTERFACE", "PROC_CLASS"]], + ["CoClass, Class", ["COCLASS", "CLASS"]], + ["Interface, Class", ["INTERFACE", "CLASS"]], + ["procedure in a Class, procedure prototype in an Interface", ["PROC_CLASS", "PROC_INTERFACE"]], + [ + "Class, CoClass, Const, Declare (API declaration), Event declaration in a Class, variable in a Class, Interface, Module, procedure, Type (UDT)", + ["CLASS", "COCLASS", "CONST", "DECLARE", "EVENT_CLASS", "VAR_CLASS", "INTERFACE", "MODULE", "TYPE", "PROC_ANY"], + ], + [ + "procedure in an Interface, procedure in a Class, Event declaration in a Class", + ["PROC_INTERFACE", "PROC_CLASS", "EVENT_CLASS"], + ], + [ + "Class, CoClass, Interface, Module, a procedure in a Class or Module, a procedure in an Interface, variable in a Class, variable in a Module, Const, Event declaration in a Class, Declare", + [ + "CLASS", + "COCLASS", + "INTERFACE", + "MODULE", + "PROC_CLASS", + "PROC_MODULE", + "PROC_INTERFACE", + "VAR_CLASS", + "VAR_MODULE", + "CONST", + "EVENT_CLASS", + "DECLARE", + ], + ], + [ + "Declare (API declaration), variable in a Module, variable in a Class, procedure", + ["DECLARE", "VAR_MODULE", "VAR_CLASS", "PROC_ANY"], + ], + [ + "Declare (API declaration), Const, variable in a Module, variable in a Class, Event declaration in a Class, procedure definitions", + ["DECLARE", "CONST", "VAR_MODULE", "VAR_CLASS", "EVENT_CLASS", "PROC_ANY"], + ], + [ + "Function in a Module, Property Get in a Module. The compiler rejects it on a method in a Class.", + ["FUNC_MODULE", "PROPGET_MODULE"], + ], + ["Module, Class, procedure in a Class or Module", ["MODULE", "CLASS", "PROC_CLASS", "PROC_MODULE"]], + ["Sub, Declare Sub", ["SUB_MODULE", "DECLARE_SUB"]], + ["Declare (API declaration), Delegate", ["DECLARE", "DELEGATE"]], + ["Class, Interface, Module, procedure", ["CLASS", "INTERFACE", "MODULE", "PROC_ANY"]], + [ + "Module, and to procedures, Declare statements and Enums in a Module", + ["MODULE", "PROC_MODULE", "DECLARE", "ENUM"], + ], + ["Function, Sub, Property Get in a Module", ["FUNC_MODULE", "SUB_MODULE", "PROPGET_MODULE"]], + ["procedure in a Module", ["PROC_MODULE"]], + ["Declare (API declaration), variable in a Class, procedure definitions", ["DECLARE", "VAR_CLASS", "PROC_ANY"]], + [ + "variable in a Class, procedure in a Class, Event declaration in a Class, procedure prototype in an Interface", + ["VAR_CLASS", "PROC_CLASS", "EVENT_CLASS", "PROC_INTERFACE"], + ], + [ + "Interface, Class, procedure in a Class, variable in a Class, Event declaration in a Class, procedure prototype in an Interface", + ["INTERFACE", "CLASS", "PROC_CLASS", "VAR_CLASS", "EVENT_CLASS", "PROC_INTERFACE"], + ], ]; const wrong = LINES.filter(([app, want]) => !same(targetsOf(app), want)).map( ([app]) => `${app} -> ${show(targetsOf(app))}`, diff --git a/scripts/check_code_regions.mjs b/scripts/check_code_regions.mjs index 5ee2ebcb1..286765cd8 100644 --- a/scripts/check_code_regions.mjs +++ b/scripts/check_code_regions.mjs @@ -83,7 +83,7 @@ import path from "node:path"; import MarkdownIt from "markdown-it"; import deflist from "markdown-it-deflist"; -import { validateCountNames } from "../builder/counts.mjs"; +import { checkPlaceholders, formatSize, validateCountNames } from "../builder/counts.mjs"; import { discover } from "../builder/discover.mjs"; import { applyPostRenderRewrites, applyPreRenderRewrites, createMarkdownIt } from "../builder/render.mjs"; import { injectAnchorHeadings } from "../builder/template.mjs"; @@ -509,8 +509,89 @@ async function hashProbe() { } } +// The download placeholders ({{tbdocs:size:}}, {{tbdocs:packageTestRows}}) are expanded +// in the page's source between the code mask and its restore: one in a fence, a code span and a +// definition-list fence stays as written, one in a raw HTML block and one on a table line is +// expanded, links relative to the page. The validator refuses a path the build does not publish, +// one it cannot size, the help add-in's project, and an argument where there is none, each at +// its line of the file. +function downloadProbe() { + const tests = { + dest: "tB/Packages/Contributed", + packages: [{ pkg: "Fmt", rel: "tB/Packages/Contributed/Fmt/Fmt-tests.twinproj", bytes: 2048 }], + }; + const md = createMarkdownIt({ + highlighter: null, + linkTables: null, + baseurl: "", + staticFiles: new Set(), + downloads: { sizes: { "a/x.bin": 43 * 1024 + 5, "a/y.bin": 1.25 * 1048576 }, tests }, + }); + const src = [ + "```tb", + "{{tbdocs:size:a/x.bin}}", + "```", + "", + "x `{{tbdocs:size:a/x.bin}}` y", + "", + "T", + ": ```", + " {{tbdocs:packageTestRows}}", + " ```", + "", + '

x {{tbdocs:size:a/x.bin}} {{tbdocs:size:a/y.bin}}

', + "", + "| P | F | S |", + "|---|---|---|", + "{{tbdocs:packageTestRows}}", + "", + ].join("\n"); + const out = applyPreRenderRewrites(src, md, { permalink: "/Downloads" }).split("\n"); + assert.deepEqual(out.slice(0, 9), src.split("\n").slice(0, 9)); + assert.equal(out[11], '

x 43 KB 1.3 MB

'); + assert.equal( + out[15], + '| [Fmt](tB/Packages/Contributed/Fmt/) | Fmt-tests.twinproj | 2 KB |', + ); + assert.match( + applyPreRenderRewrites("{{tbdocs:packageTestRows}}", md, { permalink: "/tB/Packages/Contributed/" }), + /^\| \[Fmt\]\(Fmt\/\) \| /, + ); + + const downloads = { + files: new Map([["a/x.bin", 1]]), + refused: new Map([ + ["tB/add-in.twinproj", "the help add-in's project embeds the offline tree"], + ["s.css", "rewritten"], + ]), + }; + const page = (body) => ({ srcRel: "p.md", contentLine: 4, rawContent: body }); + const check = (body) => + checkPlaceholders([page(body)], { pages: 1 }, downloads, md).problems.map((p) => + p.split("\n").slice(0, 2).join(" "), + ); + assert.deepEqual(check("a\n\n{{tbdocs:size:a/x.bin}} {{tbdocs:size:a/z.bin}}\n{{tbdocs:size}}\n{{tbdocs:pages:x}}"), [ + "p.md:6 {{tbdocs:size:a/z.bin}}: the build does not publish a/z.bin; a size is known for the static files under docs/, the bundle_extra files and the package test projects", + "p.md:7 {{tbdocs:size}}: a size needs the site path of a file: {{tbdocs:size:}}", + "p.md:8 {{tbdocs:pages:x}}: a count name takes no argument", + ]); + assert.match(check("{{tbdocs:size:tB/add-in.twinproj}}")[0], /embeds the offline tree/); + assert.match(check("{{tbdocs:size:s.css}}")[0], /rewritten/); + assert.deepEqual(check("`{{tbdocs:size:nope}}`\n\n```\n{{tbdocs:size:nope}}\n```"), []); + assert.deepEqual([1, 1023, 1024, 44000, 1048000, 1048576, 1.25 * 1048576].map(formatSize), [ + "1 KB", + "1 KB", + "1 KB", + "43 KB", + "1023 KB", + "1.0 MB", + "1.3 MB", + ]); +} + const DISCOVER_PROBES = [ ["the count validator's references and lines", countProbe], + ["the download placeholders' expansion and validation", downloadProbe], ["discover's warning about an unquoted value that ends in #", hashProbe], ]; @@ -636,6 +717,7 @@ async function main(argv) { console.log(`ok ${DASH_PROBES.length} probes: the dash normaliser converts prose and nothing else`); console.log(`ok ${MODULE_PROBES.length} probes: lib/markdown.mjs and lib/frontmatter.mjs`); console.log("ok 1 probe: the count validator skips code and names the file's line"); + console.log("ok 1 probe: the download placeholders are expanded outside code and validated"); console.log("ok 1 probe: discover warns about an unquoted value that ends in #"); } diff --git a/scripts/check_package_tests.mjs b/scripts/check_package_tests.mjs index 25c0315d4..7f74e043e 100644 --- a/scripts/check_package_tests.mjs +++ b/scripts/check_package_tests.mjs @@ -48,6 +48,7 @@ import { } from "../lib/package-tests.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { createProbes } from "./lib/gate-probes.mjs"; +import { findCases, judgeCompiles } from "./lib/tb-tests.mjs"; import { expectationFor, expectFrom, @@ -125,6 +126,21 @@ const { check, report } = createProbes("check_package_tests", "see test/package- // ---------------------------------------------------------------- probes +{ + // The compiler counts a lone CR as a line end (BETA 1005), so a compile case + // is numbered the same whichever of the three ends the lines above it. + const body = ["Module M", " Sub S()", " x = 1 ' CASE C1: TB5079", " End Sub", "End Module"]; + const lines = (eol) => findCases([{ name: "M.twin", text: body.join(eol) }]).compiles.map((c) => c.line); + const mixed = findCases([{ name: "M.twin", text: `${body[0]}\r${body[1]}\r\n${body.slice(2).join("\n")}` }]); + check( + "a compile case is on the same line after CRLF, LF, a lone CR or a mix of them", + [lines("\r\n"), lines("\n"), lines("\r"), mixed.compiles.map((c) => c.line)].every((l) => l.join() === "3"), + JSON.stringify([lines("\r\n"), lines("\n"), lines("\r"), mixed.compiles]), + ); + const [judged] = judgeCompiles(mixed.compiles, ["{ERROR} /P/Sources/M.twin [3,9]: TB5079 Unrecognized symbol 'x'"]); + check("a compile case is judged by the diagnostic on its line", judged?.pass === true, JSON.stringify(judged)); +} + { const page = [ "Text.", diff --git a/scripts/gen_attribute_probes.mjs b/scripts/gen_attribute_probes.mjs index 9e54cf1ad..8425a47e9 100644 --- a/scripts/gen_attribute_probes.mjs +++ b/scripts/gen_attribute_probes.mjs @@ -856,6 +856,18 @@ function render(target, tag, attr, needsHintEnum, idx) { `Public Module ${tag}\n ${attr}\n Public Declare Function Probe Lib ` + '"kernel32" Alias "GetTickCount" () As Long\nEnd Module\n' ); + case "PROPGET_MODULE": + return ( + `Public Module ${tag}\n${hint} ${attr}\n` + + " Public Property Get Probe() As Long\n End Property\nEnd Module\n" + ); + case "DECLARE_SUB": + return ( + `Public Module ${tag}\n ${attr}\n Public Declare Sub Probe Lib ` + + '"kernel32" Alias "Sleep" (ByVal Milliseconds As Long)\nEnd Module\n' + ); + case "DELEGATE": + return `Public Module ${tag}\n ${attr}\n Public Delegate Sub ProbeDelegate()\nEnd Module\n`; case "TYPE": return ( `Public Module ${tag}\n ${attr}\n Public Type ProbeUdt\n` + @@ -906,7 +918,9 @@ const HUMAN = { SUB_MODULE: "on a Sub in a Module", FUNC_MODULE: "on a Function in a Module", PROC_CLASS: "on a Sub in a Class", PROC_INTERFACE: "on a prototype in an Interface", - DECLARE: "on a Declare", TYPE: "on a Type (UDT)", ENUM: "on an Enum", + PROPGET_MODULE: "on a Property Get in a Module", + DECLARE: "on a Declare", DECLARE_SUB: "on a Declare Sub", DELEGATE: "on a Delegate", + TYPE: "on a Type (UDT)", ENUM: "on an Enum", CONST: "on a Const", ENUM_MEMBER: "on an Enum member", VAR_CLASS: "on a variable in a Class", VAR_MODULE: "on a variable in a Module", PARAM: "on a procedure parameter", diff --git a/scripts/impexp.mjs b/scripts/impexp.mjs index b6cc7d579..c28aef2d9 100644 --- a/scripts/impexp.mjs +++ b/scripts/impexp.mjs @@ -26,7 +26,10 @@ // the two verbs do, with the same arguments and { overwrite }. Each returns // what the command line prints -- the counts, the skipped and added entries, // the warnings -- and throws where it reports an error: a Refusal, whose -// reason names its exit code, has written nothing. readProject(file) reads a +// reason names its exit code, has written nothing. packFiles(files, name) +// packs a list of { rel, bytes } held in memory as importProject packs the +// same files in a folder of that name, and returns the project file's bytes +// without touching the disk. readProject(file) reads a // project file into its tree and writes nothing; it throws the Refusal the // verbs report for a file that is missing, is a folder, or is damaged or is // not a project at all. parseProject(bytes) is the same reading for a project @@ -463,9 +466,9 @@ function toCrlf(name, content) { // The IDE names the root entry after the project; every project and package // an installation ships agrees with its own Settings on that. The folder's // name is the fallback, for a Settings file without one. -function projectNameIn(settingsPath) { +function projectNameIn(settings) { try { - let json = fs.readFileSync(settingsPath, "utf8"); + let json = settings.toString("utf8"); if (json.charCodeAt(0) === 0xfeff) json = json.slice(1); // a byte order mark const name = JSON.parse(json)["project.name"]; return typeof name === "string" && name ? name : null; @@ -474,10 +477,54 @@ function projectNameIn(settingsPath) { } } -function buildTree(dirPath, rel, self, skipped, added) { +// What buildTree reads a tree through, so that a folder on disk and a list of +// files in memory pack the same way. `names(rel)` lists a folder, `kind(rel)` +// says what an entry is ("folder", "file", "self" for the project file being +// written, or null for anything else), and `read(rel)` returns a file's bytes. +// rel is "" for the top folder, else a path with forward slashes. +function diskSource(folder, self) { + const full = (rel) => (rel ? path.join(folder, ...rel.split("/")) : folder); + return { + names: (rel) => fs.readdirSync(full(rel)), + kind(rel) { + const st = fs.statSync(full(rel)); + if (st.isDirectory()) return "folder"; + if (!st.isFile()) return null; + return pathKey(full(rel)) === self ? "self" : "file"; + }, + read: (rel) => fs.readFileSync(full(rel)), + }; +} + +function memorySource(files) { + const folders = new Map([["", new Set()]]); + const contents = new Map(); + for (const { rel, bytes } of files) { + const parts = rel.split("/"); + for (let i = 0; i < parts.length; i++) { + const parent = parts.slice(0, i).join("/"); + const here = parts.slice(0, i + 1).join("/"); + folders.get(parent).add(parts[i]); + if (i < parts.length - 1) { + if (contents.has(here)) throw new Refusal("invalid", `${here} is both a file and a folder`); + if (!folders.has(here)) folders.set(here, new Set()); + } else { + if (folders.has(here)) throw new Refusal("invalid", `${here} is both a file and a folder`); + contents.set(here, Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)); + } + } + } + return { + names: (rel) => [...folders.get(rel)], + kind: (rel) => (folders.has(rel) ? "folder" : "file"), + read: (rel) => contents.get(rel), + }; +} + +function buildTree(src, name, rel, skipped, added) { const subdirs = [], files = []; - const names = fs.readdirSync(dirPath); + const names = src.names(rel); // The standard folders this tree lacks, if it is the project or a package // embedded in it. const missing = new Set(); @@ -485,23 +532,21 @@ function buildTree(dirPath, rel, self, skipped, added) { const have = new Set(names.map((n) => n.toLowerCase())); for (const folder of STANDARD_FOLDERS) if (!have.has(folder.toLowerCase())) missing.add(folder); } - for (const name of [...names, ...missing].sort(byCodePoint)) { - const full = path.join(dirPath, name); - const entryRel = rel ? `${rel}/${name}` : name; - if (missing.has(name)) { - subdirs.push(name); + for (const entry of [...names, ...missing].sort(byCodePoint)) { + const entryRel = rel ? `${rel}/${entry}` : entry; + if (missing.has(entry)) { + subdirs.push(entry); added.push(entryRel); continue; } - if (name.toLowerCase() === GIT_NAME) { + if (entry.toLowerCase() === GIT_NAME) { skipped.push({ rel: entryRel, why: "Git's own folder" }); continue; } - const st = fs.statSync(full); - if (st.isDirectory()) subdirs.push(name); - else if (!st.isFile()) continue; - else if (pathKey(full) === self) skipped.push({ rel: entryRel, why: "the project file itself" }); - else files.push(name); + const kind = src.kind(entryRel); + if (kind === "folder") subdirs.push(entry); + else if (kind === "self") skipped.push({ rel: entryRel, why: "the project file itself" }); + else if (kind === "file") files.push(entry); } const children = []; @@ -509,7 +554,7 @@ function buildTree(dirPath, rel, self, skipped, added) { children.push( missing.has(d) ? { kind: "directory", name: d, revision: 0x0000, flags: FLAGS.None, category: categoryFor(d), children: [] } - : buildTree(path.join(dirPath, d), rel ? `${rel}/${d}` : d, self, skipped, added), + : buildTree(src, d, rel ? `${rel}/${d}` : d, skipped, added), ); } for (const f of files) { @@ -519,11 +564,10 @@ function buildTree(dirPath, rel, self, skipped, added) { revision: 0x0002, flags: FLAGS.None, category: categoryFor(f), - content: toCrlf(f, fs.readFileSync(path.join(dirPath, f))), + content: toCrlf(f, src.read(rel ? `${rel}/${f}` : f)), revisions: [], }); } - const name = path.basename(dirPath); return { kind: "directory", name, @@ -534,6 +578,30 @@ function buildTree(dirPath, rel, self, skipped, added) { }; } +// The project's root entry, named after the project. The top folder's name is +// the fallback, for a Settings file without one. +function projectRoot(src, folderName, skipped, added) { + const root = buildTree(src, folderName, "", skipped, added); + root.name = projectNameIn(src.read("Settings")) ?? root.name; + root.category = CATEGORY.Default; + return root; +} + +function tally(root) { + let files = 0, + folders = 0; + (function walk(e) { + for (const c of e.children) { + if (c.kind === "file") files++; + else { + folders++; + walk(c); + } + } + })(root); + return { files, folders }; +} + export function importProject(projectPath, folder, { overwrite = false } = {}) { // Everything is checked before the tree is read, so a refusal leaves the // project file exactly as it was. @@ -552,24 +620,32 @@ export function importProject(projectPath, folder, { overwrite = false } = {}) { const skipped = [], added = []; - const root = buildTree(path.resolve(folder), "", pathKey(projectPath), skipped, added); - root.name = projectNameIn(settings) ?? root.name; - root.category = CATEGORY.Default; + const top = path.resolve(folder); + const root = projectRoot(diskSource(top, pathKey(projectPath)), path.basename(top), skipped, added); const buf = serialize(root); writeReplacing(projectPath, buf); + return { name: root.name, size: buf.length, ...tally(root), skipped, added }; +} - let fileCount = 0, - dirCount = 0; - (function tally(e) { - for (const c of e.children) { - if (c.kind === "file") fileCount++; - else { - dirCount++; - tally(c); - } - } - })(root); - return { name: root.name, size: buf.length, files: fileCount, folders: dirCount, skipped, added }; +/** + * Pack a project from files in memory, as importProject packs the same files + * from a folder named `folderName`, and return the project file's bytes. + * Nothing is read from or written to disk. + * + * @param {{rel: string, bytes: Buffer | Uint8Array}[]} files each file's path + * in the project, with forward slashes, and its bytes; Settings among them + * @param {string} folderName the top folder's name, which names the project + * when Settings does not + * @returns {{bytes: Buffer, name: string, files: number, folders: number, added: string[]}} + */ +export function packFiles(files, folderName) { + const src = memorySource(files); + if (src.read("Settings") === undefined) + throw new Refusal("invalid", "the files have no Settings file at the top, so they are not a project", HINT_IMPORT); + const skipped = [], + added = []; + const root = projectRoot(src, folderName, skipped, added); + return { bytes: serialize(root), name: root.name, ...tally(root), added }; } // -------------------------- Printing a root document ------------------------- diff --git a/scripts/lib/attribute-sites.mjs b/scripts/lib/attribute-sites.mjs index 7f25a87f1..8522f560e 100644 --- a/scripts/lib/attribute-sites.mjs +++ b/scripts/lib/attribute-sites.mjs @@ -241,7 +241,10 @@ export const FAMILIES = { PROC_MODULE: ["SUB_MODULE", "SUB_MODULE_PRIVATE", "SUB_STATIC_MODULE", "SUB_GENERIC", "FUNC_MODULE", "FUNC_MODULE_BOOL", "FUNC_MODULE_OBJECT", "PROPGET_MODULE"], SUB_MODULE: ["SUB_MODULE", "SUB_MODULE_PRIVATE", "SUB_STATIC_MODULE"], - FUNC_MODULE: ["FUNC_MODULE", "FUNC_MODULE_OBJECT"], + // A Boolean Function is a Function. FUNC_MODULE_BOOL is also a target of its + // own, for the line that names the return type. + FUNC_MODULE: ["FUNC_MODULE", "FUNC_MODULE_OBJECT", "FUNC_MODULE_BOOL"], + PROPGET_MODULE: ["PROPGET_MODULE"], FUNC_MODULE_BOOL: ["FUNC_MODULE_BOOL"], PROC_CLASS: ["PROC_CLASS", "FUNC_CLASS", "FUNC_CLASS_OBJECT", "PROPGET_CLASS", "PROPLET_CLASS", "PROPSET_CLASS", "SUB_FRIEND_CLASS", "CLASS_INITIALIZE"], @@ -252,6 +255,8 @@ export const FAMILIES = { "PROPLET_CLASS", "PROPSET_CLASS", "SUB_FRIEND_CLASS", "CLASS_INITIALIZE", "PROC_INTERFACE", "FUNC_INTERFACE", "FUNC_INTERFACE_OBJECT", "PROPGET_INTERFACE"], DECLARE: ["DECLARE", "DECLARE_SUB", "DECLARE_PRIVATE", "DECLARE_CLASS", "DECLARE_PTRSAFE", "DECLARE_WIDE"], + DECLARE_SUB: ["DECLARE_SUB"], + DELEGATE: ["DELEGATE"], TYPE: ["TYPE", "TYPE_CLASS"], ENUM: ["ENUM", "ENUM_EMPTY", "ENUM_CLASS"], ENUM_MEMBER: ["ENUM_MEMBER"], diff --git a/scripts/lib/attributes-doc.mjs b/scripts/lib/attributes-doc.mjs index 1c31e9da8..e1e18de71 100644 --- a/scripts/lib/attributes-doc.mjs +++ b/scripts/lib/attributes-doc.mjs @@ -92,16 +92,22 @@ const RULES = [ [/variable.*\bin\s+a\s+class/i, ["VAR_CLASS"]], [/variable.*\bin\s+a\s+module/i, ["VAR_MODULE"]], [/variable/i, ["VAR_MODULE"]], + // A Declare Sub before the bare Declare: [DebugOnly] takes a Declare Sub and + // refuses a Declare Function, so the two are different targets. + [/declare\s+sub\b/i, ["DECLARE_SUB"]], [/declare|api\s+declaration/i, ["DECLARE"]], + [/^delegate\b/i, ["DELEGATE"]], [/^type\b/i, ["TYPE"]], // An Enum MEMBER is a different target from the Enum, and must win over the // bare rule below. It is not ^-anchored because the phrase carries an article // where it appears -- "and an Enum member" -- which a ^ rule cannot reach. [/enum\s+member/i, ["ENUM_MEMBER"]], - [/^enum\b/i, ["ENUM"]], + // "Enums in a Module" -- \b after "enum" fails on the plural, as for "const". + [/^enums?\b/i, ["ENUM"]], // "Const", but also "constants in a module." -- \b after "const" fails on // the plural, which would silently drop a target. [/^const(ant)?s?\b/i, ["CONST"]], + [/^property\s+get\b/i, ["PROPGET_MODULE"]], [/^sub\b/i, ["SUB_MODULE"]], [/^function\b/i, ["FUNC_MODULE"]], [/^coclass\b/i, ["COCLASS"]], diff --git a/scripts/lib/axe-scan.mjs b/scripts/lib/axe-scan.mjs index 671dca559..4bb1816dc 100644 --- a/scripts/lib/axe-scan.mjs +++ b/scripts/lib/axe-scan.mjs @@ -104,6 +104,10 @@ export const SAMPLE_PAGES = [ // bare `span { color }`, which differs between the themes. A label goes when // its defect is fixed, so --check names another page when this one loses it. "/tB/Packages/Contributed/TBMANLIB/ToolsCN.html", + // The site's only download cards and its only theme `.btn` links: the + // buttons' colour against the card in each theme, and target-size between + // a card's buttons, which wrap onto two rows at the mobile viewport. + "/Downloads.html", ]; // --------------------------------------------------------------------------- diff --git a/scripts/lib/tb-tests.mjs b/scripts/lib/tb-tests.mjs index eb5381b85..b29e89949 100644 --- a/scripts/lib/tb-tests.mjs +++ b/scripts/lib/tb-tests.mjs @@ -63,7 +63,9 @@ export function findCases(files) { for (const { name: file, text } of files) { let fixture = null; let pending = []; - text.split(/\r?\n/).forEach((raw, i) => { + // A lone CR ends a line as CRLF and LF do: the compiler counts it, so a file + // with one numbered its lines past every case below it (BETA 1005). + text.split(/\r\n|\r|\n/).forEach((raw, i) => { const line = i + 1; const marker = CASE_RE.exec(raw); if (marker) { diff --git a/scripts/pick_a11y_sample.mjs b/scripts/pick_a11y_sample.mjs index f10312735..e541660bf 100644 --- a/scripts/pick_a11y_sample.mjs +++ b/scripts/pick_a11y_sample.mjs @@ -95,6 +95,11 @@ const FAMILIES = { // in the site reports anything. detailsStacked: { re: /]/g, min: 8, why: "target-size between consecutive elements at the mobile scale" }, video: { re: /class="[^"]*video-link/g, min: 1, why: "link-name and image-alt on video cards" }, + downloadCard: { + re: /class="[^"]*download-card/g, + min: 1, + why: "color-contrast on the theme's .btn, target-size between a card's buttons", + }, // One family per admonition variant, not one for all of them. Each // variant has its own tint and its own title colour in each theme, so a // page carrying two NOTEs is not coverage of IMPORTANT. Keying on the diff --git a/scripts/sweep_attributes.mjs b/scripts/sweep_attributes.mjs index 4e3064c07..e43ff08c6 100644 --- a/scripts/sweep_attributes.mjs +++ b/scripts/sweep_attributes.mjs @@ -696,21 +696,19 @@ function report(agg, dep, meta) { if (placed.length) { a("Recognised at a site, so **candidates for a new entry** (accepted, or recognised with an error):"); a(); + // Each form under the state it drew: a cell's state is its best form's, and + // listing every recognised form under it read "ACCEPT via bare/true/str" for + // an attribute whose bare and Boolean forms are recognised with an error. + const viaByState = (c) => { + const by = new Map(); + for (const [k, st] of Object.entries(c.forms)) if (recognised(st)) by.set(st, [...(by.get(st) ?? []), k]); + return [...by] + .map(([st, ks]) => `${st}${soft(st) ? ` (${[...c.msgs].slice(0, 1).join("")})` : ""} via ${ks.join("/")}`) + .join(", "); + }; for (const n of placed) { const cells = [...agg.get(n)].filter(([, c]) => recognised(c.state)); - a( - `- \`[${n}]\`: ` + - cells - .map( - ([s, c]) => - `\`${s}\` ${c.state}${soft(c.state) ? ` (${[...c.msgs].slice(0, 1).join("")})` : ""}` + - ` via ${Object.entries(c.forms) - .filter(([, st]) => recognised(st)) - .map(([k]) => k) - .join("/")}`, - ) - .join("; "), - ); + a(`- \`[${n}]\`: ${cells.map(([s, c]) => `\`${s}\` ${viaByState(c)}`).join("; ")}`); } } else a("None was recognised at any site."); a(); diff --git a/test/addin-project.test.mjs b/test/addin-project.test.mjs index 6219cab99..0dcc870d0 100644 --- a/test/addin-project.test.mjs +++ b/test/addin-project.test.mjs @@ -26,9 +26,10 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { after, describe, test } from "node:test"; import { addinProjectOf, formatAddinProject, packAddinProject, writeAddinProject } from "../builder/addin-project.mjs"; +import { downloadsKept, helpArchiveSkips } from "../builder/help-archive-step.mjs"; import { checkHelpArchive, writeHelpArchive } from "../lib/help-archive.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; -import { readProject } from "../scripts/impexp.mjs"; +import { importProject, packFiles, readProject } from "../scripts/impexp.mjs"; const ADDIN = path.join(REPO_ROOT, "add-in"); const tmp = mkdtempSync(path.join(tmpdir(), "addin-project-test-")); @@ -137,6 +138,23 @@ describe("the help add-in's project file", () => { assert.equal(got.get("Resources/SHELL/shell.js").toString(), "a\r\nb\n", "a resource was changed"); }); + test("is the same bytes as impexp's importProject of the files in a folder", async () => { + const dir = fixture("disk", { ...TRACKED, "Packages/Lib/Settings": "{}\n", "Packages/Lib/Sources/A.twin": "x\n" }); + const files = Object.keys(TRACKED).map((rel) => ({ rel, bytes: readFileSync(path.join(dir, ...rel.split("/"))) })); + files.push({ rel: "Packages/Lib/Settings", bytes: Buffer.from("{}\n") }); + files.push({ rel: "Packages/Lib/Sources/A.twin", bytes: Buffer.from("x\n") }); + const fromDisk = path.join(tmp, "from-disk.twinproj"); + rmSync(path.join(dir, ".git"), { recursive: true, force: true }); + importProject(fromDisk, dir); + assert.ok(packFiles(files, path.basename(dir)).bytes.equals(readFileSync(fromDisk)), "the two packs differ"); + // Without a project name in Settings, both name the project after the folder. + const unnamed = files.map((f) => (f.rel === "Settings" ? { rel: "Settings", bytes: Buffer.from("{}\n") } : f)); + writeFileSync(path.join(dir, "Settings"), "{}\n"); + importProject(fromDisk, dir, { overwrite: true }); + assert.ok(packFiles(unnamed, path.basename(dir)).bytes.equals(readFileSync(fromDisk)), "unnamed packs differ"); + assert.equal(readProject(fromDisk).name, "disk"); + }); + test("packs the archive it is given as Resources/HELP/site.zip, byte for byte", async () => { const dir = fixture("archive", { ...TRACKED, "Resources/HELP/site.zip": "stale, tracked by mistake" }); // Every byte value, and the line endings a text pass would change. @@ -260,4 +278,33 @@ describe("the help archive checked against the offline tree", () => { "late.html: in the tree, not in the archive", ]); }); + + test("leaves out what the build's list skips, and does not compare it", async () => { + const src = tree("h-skip", { ...FILES, "Videos/tB.html": "videos" }); + const zip = path.join(tmp, "h-skip.zip"); + const stats = await writeHelpArchive({ src, out: zip, skip: helpArchiveSkips }); + assert.equal(stats.entries, 2); + // The chart goes in after the archive, into files the archive does not hold. + writeFileSync(path.join(src, ...CHART[0].split("/")), "the chart"); + assert.deepEqual(await checkHelpArchive({ src, zip, skip: helpArchiveSkips }), []); + const problems = await checkHelpArchive({ src, zip }); + assert.deepEqual(problems.sort(), [ + "Documentation/Development/BuildInfo.html: in the tree, not in the archive", + "Videos/tB.html: in the tree, not in the archive", + "assets/images/gantt.svg: in the tree, not in the archive", + ]); + }); + + test("names a file the Downloads page offers that the archive would hold", async () => { + const src = tree("h-downloads", { + "Downloads.html": [ + '', + '', + '', + '', + ].join("\n"), + }); + assert.deepEqual(await downloadsKept(src, helpArchiveSkips), ["Features/elsewhere/tool.mjs"]); + assert.deepEqual(await downloadsKept(path.join(tmp, "h-none"), helpArchiveSkips), []); + }); }); diff --git a/test/addin/help.test.mjs b/test/addin/help.test.mjs index c3233feff..d517d6991 100644 --- a/test/addin/help.test.mjs +++ b/test/addin/help.test.mjs @@ -30,6 +30,7 @@ import fs from "node:fs"; import path from "node:path"; import { before, test } from "node:test"; import { fileURLToPath } from "node:url"; +import { helpArchiveSkips } from "../../builder/help-archive-step.mjs"; import { createStaticHandler } from "../../builder/static-files.mjs"; import { writeHelpArchive } from "../../lib/help-archive.mjs"; import { attach } from "../../scripts/lib/tb-cdp.mjs"; @@ -373,7 +374,11 @@ scenario("the help add-in: F1 and the help pane", (lane) => { const fresh = spawnSync(process.execPath, [FRESH_GATE, "--tree", BUILT_OFFLINE], { encoding: "utf8" }); assert.equal(fresh.status, 0, `${BUILT_OFFLINE} is stale or missing; run build.bat first:\n${fresh.stderr}`); try { - await writeHelpArchive({ src: BUILT_OFFLINE, out: path.join(src, "Resources", "HELP", "site.zip") }); + await writeHelpArchive({ + src: BUILT_OFFLINE, + out: path.join(src, "Resources", "HELP", "site.zip"), + skip: helpArchiveSkips, + }); } catch (err) { assert.fail(`writeHelpArchive failed: ${err.message}${err.problems ? `\n${err.problems.join("\n")}` : ""}`); } @@ -427,7 +432,31 @@ scenario("the help add-in: F1 and the help pane", (lane) => { r = await get("/Features/Advanced/"); assert.ok(Buffer.from(await r.arrayBuffer()).equals(tree("Features/Advanced/index.html"))); - r = await get("/no/such/page"); + // A file the archive lacks, a page or a download, is on the site, the one + // the lane serves. + assert.ok(fs.existsSync(path.join(BUILT_OFFLINE, "Documentation/Development/Building.html"))); + r = await get("/Documentation/Development/Building?pane=1"); + assert.equal(r.status, 302); + assert.equal(r.headers.get("location"), `${origin}/Documentation/Development/Building?pane=1`); + await r.body?.cancel(); + r = await get("/Videos/"); + assert.equal(r.status, 302); + assert.equal(r.headers.get("location"), `${origin}/Videos/`); + await r.body?.cancel(); + r = await get("/no/such/page.html"); + assert.equal(r.status, 302); + assert.equal(r.headers.get("location"), `${origin}/no/such/page.html`); + await r.body?.cancel(); + assert.ok(fs.existsSync(path.join(BUILT_OFFLINE, "Features/Packages/downloads/impexp.py"))); + r = await get("/Features/Packages/downloads/impexp.py"); + assert.equal(r.status, 302); + assert.equal(r.headers.get("location"), `${origin}/Features/Packages/downloads/impexp.py`); + await r.body?.cancel(); + r = await get("/no/such/picture.png"); + assert.equal(r.status, 302); + await r.body?.cancel(); + // A path the server refuses is not sent on. + r = await get("/tB%5Cindex.html"); assert.equal(r.status, 404); await r.body?.cancel(); r = await get("/tB/Modules/Interaction/MsgBox", { method: "HEAD" }); diff --git a/test/ide/lanes.mjs b/test/ide/lanes.mjs index d7d347719..e40d65ecb 100644 --- a/test/ide/lanes.mjs +++ b/test/ide/lanes.mjs @@ -35,4 +35,5 @@ export default [ { file: "memory.test.mjs" }, { file: "debugging.test.mjs" }, { file: "forms.test.mjs" }, + { file: "vb6-import.test.mjs" }, ]; diff --git a/test/ide/probes/vb6-import/AttrProj.vbp b/test/ide/probes/vb6-import/AttrProj.vbp new file mode 100644 index 000000000..244ffe3ad --- /dev/null +++ b/test/ide/probes/vb6-import/AttrProj.vbp @@ -0,0 +1,40 @@ +Type=OleDll +Reference=*\G{00020430-0000-0000-C000-000000000046}#2.0#0#..\..\..\Windows\SysWOW64\stdole2.tlb#OLE Automation +Module=modAttrs; modAttrs.bas +Class=clsAttrs; clsAttrs.cls +Class=clsGlobal; clsGlobal.cls +Class=clsNotCreatable; clsNotCreatable.cls +Class=clsPrivate; clsPrivate.cls +Class=clsPlain; clsPlain.cls +Form=frmAttrs.frm +UserControl=ctlAttrs.ctl +PropertyPage=pagAttrs.pag +Startup="(None)" +HelpFile="" +Title="AttrProj" +ExeName32="AttrProj.dll" +Command32="" +Name="AttrProj" +HelpContextID="0" +CompatibleMode="1" +MajorVer=1 +MinorVer=0 +RevisionVer=0 +AutoIncrementVer=0 +ServerSupportFiles=0 +CompilationType=0 +OptimizationType=0 +FavorPentiumPro(tm)=0 +CodeViewDebugInfo=0 +NoAliasing=0 +BoundsCheck=0 +OverflowCheck=0 +FlPointCheck=0 +FDIVCheck=0 +UnroundedFP=0 +StartMode=1 +Unattended=0 +Retained=0 +ThreadPerObject=0 +MaxNumberOfThreads=1 +ThreadingModel=1 diff --git a/test/ide/probes/vb6-import/clsAttrs.cls b/test/ide/probes/vb6-import/clsAttrs.cls new file mode 100644 index 000000000..8b9b0aa25 --- /dev/null +++ b/test/ide/probes/vb6-import/clsAttrs.cls @@ -0,0 +1,86 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True + Persistable = 0 'NotPersistable + DataBindingBehavior = 0 'vbNone + DataSourceBehavior = 0 'vbNone + MTSTransactionMode = 0 'NotAnMTSObject +END +Attribute VB_Name = "clsAttrs" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = True +Attribute VB_PredeclaredId = True +Attribute VB_Exposed = True +Attribute VB_Description = "A class with every member attribute VB6 writes." +Attribute VB_HelpID = 2000 +Attribute VB_Ext_KEY = "SavedWithClassBuilder6" ,"Yes" +Option Explicit + +Private m_Items As Collection +Private m_Value As Long + +Public Caption As String +Attribute Caption.VB_VarDescription = "A public field with a description." +Attribute Caption.VB_VarUserMemId = 8 +Attribute Caption.VB_VarHelpID = 2002 + +Public HiddenField As Long +Attribute HiddenField.VB_VarMemberFlags = "40" + +Public UnbrowsedField As Long +Attribute UnbrowsedField.VB_VarMemberFlags = "400" + +Public Event Changed() +Attribute Changed.VB_Description = "Raised when Value changes." + +Public Property Get Value() As Long +Attribute Value.VB_Description = "The default property." +Attribute Value.VB_HelpID = 2001 +Attribute Value.VB_UserMemId = 0 + Value = m_Value +End Property + +Public Property Let Value(ByVal NewValue As Long) + m_Value = NewValue + RaiseEvent Changed +End Property + +Public Function NewEnum() As IUnknown +Attribute NewEnum.VB_UserMemId = -4 +Attribute NewEnum.VB_MemberFlags = "40" + Set NewEnum = m_Items.[_NewEnum] +End Function + +Public Function WithId() As Long +Attribute WithId.VB_UserMemId = 7 + WithId = 7 +End Function + +Public Function HiddenMethod() As Long +Attribute HiddenMethod.VB_MemberFlags = "40" + HiddenMethod = 40 +End Function + +Public Property Get Unbrowsed() As Long +Attribute Unbrowsed.VB_MemberFlags = "400" + Unbrowsed = 400 +End Property + +Public Property Get HiddenUnbrowsed() As Long +Attribute HiddenUnbrowsed.VB_MemberFlags = "440" + HiddenUnbrowsed = 440 +End Property + +Public Property Get UiDefault() As Long +Attribute UiDefault.VB_MemberFlags = "200" + UiDefault = 200 +End Property + +Public Property Get Bound() As Long +Attribute Bound.VB_MemberFlags = "14" + Bound = 14 +End Property + +Private Sub Class_Initialize() + Set m_Items = New Collection +End Sub diff --git a/test/ide/probes/vb6-import/clsGlobal.cls b/test/ide/probes/vb6-import/clsGlobal.cls new file mode 100644 index 000000000..5b7691717 --- /dev/null +++ b/test/ide/probes/vb6-import/clsGlobal.cls @@ -0,0 +1,18 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True + Persistable = 0 'NotPersistable + DataBindingBehavior = 0 'vbNone + DataSourceBehavior = 0 'vbNone + MTSTransactionMode = 0 'NotAnMTSObject +END +Attribute VB_Name = "clsGlobal" +Attribute VB_GlobalNameSpace = True +Attribute VB_Creatable = True +Attribute VB_PredeclaredId = False +Attribute VB_Exposed = True +Option Explicit + +Public Function GlobalHelper() As Long + GlobalHelper = 3 +End Function diff --git a/test/ide/probes/vb6-import/clsNotCreatable.cls b/test/ide/probes/vb6-import/clsNotCreatable.cls new file mode 100644 index 000000000..cbc68eea1 --- /dev/null +++ b/test/ide/probes/vb6-import/clsNotCreatable.cls @@ -0,0 +1,18 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True + Persistable = 0 'NotPersistable + DataBindingBehavior = 0 'vbNone + DataSourceBehavior = 0 'vbNone + MTSTransactionMode = 0 'NotAnMTSObject +END +Attribute VB_Name = "clsNotCreatable" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = False +Attribute VB_PredeclaredId = False +Attribute VB_Exposed = True +Option Explicit + +Public Function Answer() As Long + Answer = 42 +End Function diff --git a/test/ide/probes/vb6-import/clsPlain.cls b/test/ide/probes/vb6-import/clsPlain.cls new file mode 100644 index 000000000..aaa3b10db --- /dev/null +++ b/test/ide/probes/vb6-import/clsPlain.cls @@ -0,0 +1,18 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True + Persistable = 0 'NotPersistable + DataBindingBehavior = 0 'vbNone + DataSourceBehavior = 0 'vbNone + MTSTransactionMode = 0 'NotAnMTSObject +END +Attribute VB_Name = "clsPlain" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = True +Attribute VB_PredeclaredId = False +Attribute VB_Exposed = True +Option Explicit + +Public Function Answer() As Long + Answer = 1 +End Function diff --git a/test/ide/probes/vb6-import/clsPrivate.cls b/test/ide/probes/vb6-import/clsPrivate.cls new file mode 100644 index 000000000..06b3952e1 --- /dev/null +++ b/test/ide/probes/vb6-import/clsPrivate.cls @@ -0,0 +1,18 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 'True + Persistable = 0 'NotPersistable + DataBindingBehavior = 0 'vbNone + DataSourceBehavior = 0 'vbNone + MTSTransactionMode = 0 'NotAnMTSObject +END +Attribute VB_Name = "clsPrivate" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = True +Attribute VB_PredeclaredId = False +Attribute VB_Exposed = False +Option Explicit + +Public Function Answer() As Long + Answer = 2 +End Function diff --git a/test/ide/probes/vb6-import/ctlAttrs.ctl b/test/ide/probes/vb6-import/ctlAttrs.ctl new file mode 100644 index 000000000..35a684fd6 --- /dev/null +++ b/test/ide/probes/vb6-import/ctlAttrs.ctl @@ -0,0 +1,87 @@ +VERSION 5.00 +Begin VB.UserControl ctlAttrs + ClientHeight = 1500 + ClientLeft = 0 + ClientTop = 0 + ClientWidth = 2400 + PropertyPages = "ctlAttrs.ctx":0000 + ScaleHeight = 1500 + ScaleWidth = 2400 +End +Attribute VB_Name = "ctlAttrs" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = True +Attribute VB_PredeclaredId = False +Attribute VB_Exposed = False +Option Explicit + +Private m_Caption As String +Private m_Amount As Long +Private m_Picture As StdPicture + +Public Note As String +Attribute Note.VB_VarProcData = ";Text" + +Public Property Get BackColor() As OLE_COLOR +Attribute BackColor.VB_Description = "The background colour." +Attribute BackColor.VB_ProcData.VB_Invoke_Property = "StandardColor;Appearance" +Attribute BackColor.VB_UserMemId = -501 + BackColor = UserControl.BackColor +End Property + +Public Property Let BackColor(ByVal NewColor As OLE_COLOR) + UserControl.BackColor = NewColor + PropertyChanged "BackColor" +End Property + +Public Property Get Caption() As String +Attribute Caption.VB_ProcData.VB_Invoke_Property = "pagAttrs" +Attribute Caption.VB_UserMemId = -518 + Caption = m_Caption +End Property + +Public Property Let Caption(ByVal NewCaption As String) + m_Caption = NewCaption + PropertyChanged "Caption" +End Property + +Public Property Let Amount(ByVal NewAmount As Long) +Attribute Amount.VB_ProcData.VB_Invoke_PropertyPut = "pagAttrs;Behavior" + m_Amount = NewAmount + PropertyChanged "Amount" +End Property + +Public Property Get Amount() As Long + Amount = m_Amount +End Property + +Public Property Set Font(ByVal NewFont As StdFont) +Attribute Font.VB_ProcData.VB_Invoke_PropertyPutRef = "StandardFont;Font" +Attribute Font.VB_UserMemId = -512 + Set UserControl.Font = NewFont + PropertyChanged "Font" +End Property + +Public Property Get Font() As StdFont + Set Font = UserControl.Font +End Property + +Public Property Get Picture() As StdPicture +Attribute Picture.VB_ProcData.VB_Invoke_Property = "StandardPicture" + Set Picture = m_Picture +End Property + +Public Property Set Picture(ByVal NewPicture As StdPicture) + Set m_Picture = NewPicture + PropertyChanged "Picture" +End Property + +Public Property Get Tally() As Long +Attribute Tally.VB_ProcData.VB_Invoke_Property = ";Misc" + Tally = m_Amount * 2 +End Property + +Public Sub AboutBox() +Attribute AboutBox.VB_UserMemId = -552 +Attribute AboutBox.VB_MemberFlags = "40" +End Sub diff --git a/test/ide/probes/vb6-import/frmAttrs.frm b/test/ide/probes/vb6-import/frmAttrs.frm new file mode 100644 index 000000000..ccd681837 --- /dev/null +++ b/test/ide/probes/vb6-import/frmAttrs.frm @@ -0,0 +1,23 @@ +VERSION 5.00 +Begin VB.Form frmAttrs + Caption = "frmAttrs" + ClientHeight = 1500 + ClientLeft = 60 + ClientTop = 450 + ClientWidth = 2400 + LinkTopic = "Form1" + ScaleHeight = 1500 + ScaleWidth = 2400 + StartUpPosition = 3 'Windows Default +End +Attribute VB_Name = "frmAttrs" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = False +Attribute VB_PredeclaredId = True +Attribute VB_Exposed = False +Option Explicit + +Public Property Get Title() As String +Attribute Title.VB_Description = "The form's caption." + Title = Me.Caption +End Property diff --git a/test/ide/probes/vb6-import/modAttrs.bas b/test/ide/probes/vb6-import/modAttrs.bas new file mode 100644 index 000000000..d51930eab --- /dev/null +++ b/test/ide/probes/vb6-import/modAttrs.bas @@ -0,0 +1,12 @@ +Attribute VB_Name = "modAttrs" +Option Explicit + +Public Function Helper() As Long +Attribute Helper.VB_Description = "A module function with a description." + Helper = 1 +End Function + +Public Function HelperWithHelpId() As Long +Attribute HelperWithHelpId.VB_HelpID = 3001 + HelperWithHelpId = 2 +End Function diff --git a/test/ide/probes/vb6-import/pagAttrs.pag b/test/ide/probes/vb6-import/pagAttrs.pag new file mode 100644 index 000000000..61a7676bd --- /dev/null +++ b/test/ide/probes/vb6-import/pagAttrs.pag @@ -0,0 +1,21 @@ +VERSION 5.00 +Begin VB.PropertyPage pagAttrs + Caption = "Attrs" + ClientHeight = 1500 + ClientLeft = 0 + ClientTop = 0 + ClientWidth = 2400 + PaletteMode = 0 'Halftone + ScaleHeight = 1500 + ScaleWidth = 2400 +End +Attribute VB_Name = "pagAttrs" +Attribute VB_GlobalNameSpace = False +Attribute VB_Creatable = True +Attribute VB_PredeclaredId = False +Attribute VB_Exposed = False +Option Explicit + +Private Sub PropertyPage_ApplyChanges() + SelectedControls(0).Caption = "applied" +End Sub diff --git a/test/ide/vb6-import.test.mjs b/test/ide/vb6-import.test.mjs new file mode 100644 index 000000000..bdd2d922b --- /dev/null +++ b/test/ide/vb6-import.test.mjs @@ -0,0 +1,427 @@ +// The IDE's VB6 import, tested for what it makes of the Attribute lines VB6 +// writes: the module attributes (VB_Name, VB_GlobalNameSpace, VB_Creatable, +// VB_PredeclaredId, VB_Exposed, VB_Description, VB_HelpID, VB_Ext_KEY) and the +// member attributes (VB_Description, VB_VarDescription, VB_UserMemId, +// VB_VarUserMemId, VB_MemberFlags, VB_VarMemberFlags, VB_HelpID, VB_VarHelpID, +// VB_ProcData.VB_Invoke_Property / PropertyPut / PropertyPutRef, +// VB_VarProcData), and of a UserControl's PropertyPages list. +// +// The project is probes/vb6-import, an ActiveX DLL written as VB6 writes one: +// a .bas, five .cls, a .frm, a .ctl and a .pag. It is staged in the lane's work +// folder with CRLF line ends, as VB6 writes them, and with the .ctl's binary +// part, ctlAttrs.ctx, written here (ctx below). It is imported as a person does +// it: a double-click on the New / Open Project dialog's Import from VBP... +// tile, with the file the native Open dialog would return stood in +// (standIn), then Continue in New Project Options and Continue Anyway in the +// box that says an ActiveX DLL registers itself in HKEY_LOCAL_MACHINE. The +// imported project is saved with Ctrl+S, the native Save As dialog stood in +// the same way, and the saved .twinproj is unpacked with impexp and read. +// +// The import rewrites no Attribute line. A .bas or .cls file is kept as VB6 +// wrote it; a .frm, .ctl or .pag becomes a .twin file whose Class holds the +// module's code, Attribute lines and all, beside a designer file. So what an +// Attribute line does is the compiler's business, and the last tests build the +// project and read the type library the DLL carries. The build cannot register +// anything: the project is opened again with project.dllRegisterAfterBuild +// false, and built into the lane's work folder. The type library is read with +// LoadTypeLibEx and REGKIND_NONE, by a 64-bit PowerShell (typeLibrary), which +// cannot run the 32-bit DLL's code. +// +// Each test states what BETA 1005 does. If one fails after an IDE update, the +// IDE has changed: update the FAQ's VB6-import answer +// (docs/Miscellaneous/FAQs.md), the entry "Some VB6 attribute lines have no +// effect in a built type library" in BUGS-TO-REPORT.md or bugs/filed/, and +// then this file. +// +// Run it with ide-test.bat, which gives it a lane; on its own it is skipped. + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { buildProject } from "../../scripts/lib/tb-ide.mjs"; +import { click, pressKey, waitFor } from "../../scripts/lib/tb-operate.mjs"; +import { unpackProject } from "../../scripts/lib/tb-project.mjs"; +import { scenario } from "../addin/scenario.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = path.join(HERE, "probes", "vb6-import"); +const NAME = "AttrProj"; + +// ctlAttrs' PropertyPages, as VB6's Connect Property Pages dialog lists them: +// the project's own page, then three of the standard ones. +const PAGES = ["pagAttrs", "StandardColor", "StandardFont", "StandardPicture"]; + +// A UserControl's PropertyPages list as VB6 keeps it in the .ctx, at the offset +// the .ctl's `PropertyPages = "ctlAttrs.ctx":0000` names: a 4-byte count, then +// each name as a 2-byte length and its ANSI characters. VB6's /make loads a +// control written so; written with a 2-byte count, or with 4-byte lengths, it +// refuses the control ("Property PropertyPages in ctlAttrs could not be set"). +function ctx(names) { + const count = Buffer.alloc(4); + count.writeUInt32LE(names.length); + const parts = [count]; + for (const name of names) { + const length = Buffer.alloc(2); + length.writeUInt16LE(name.length); + parts.push(length, Buffer.from(name, "latin1")); + } + return Buffer.concat(parts); +} + +const crlf = (text) => text.replace(/\r?\n/g, "\r\n"); +const fixture = (file) => readFileSync(path.join(FIXTURE, file), "utf8"); + +// The titles of the modal dialogs showing, top one last. +const modals = (c) => + c.evaluate(`[...document.querySelectorAll(".modalDialogContainer")] + .filter((m) => m.getBoundingClientRect().width) + .map((m) => m.querySelector(".modalTitleBar")?.innerText.trim() ?? "")`); + +// Wait for a dialog whose title contains `title`. +const waitModal = (c, title, timeout = 60 * 1000) => + waitFor(c, async () => (await modals(c)).some((t) => t.includes(title)), { timeout, interval: 150 }); + +// The answer a native dialog would give, stood in for once: the page asks the +// host object for a file name (OpenProjectVBP, SaveProjectAs), and a dialog on +// the private desktop could not be answered. Every other call goes on to the +// real host object, and the first call to `method` puts it back. +const standIn = (c, method, value) => + c.evaluate(`(() => { + const real = hostAppObject; + hostAppObject = new Proxy(real, { + get(target, key) { + if (key === ${JSON.stringify(method)}) { + return () => { + hostAppObject = real; + return Promise.resolve(${JSON.stringify(value)}); + }; + } + const v = Reflect.get(target, key); + return typeof v === "function" ? v.bind(target) : v; + }, + }); + })()`); + +// The code of a .frm, .ctl or .pag: its lines after the designer block, which +// ends with the first line that is "End" alone. +function vb6Code(text) { + const lines = text.split(/\r?\n/); + return lines.slice(lines.indexOf("End") + 1); +} + +// A .twin file holding one Class: the attribute lines in front of it, the +// class's name, and its body with the four spaces of indent taken off. +function twinClass(text) { + const lines = text.split(/\r?\n/); + const start = lines.findIndex((l) => /^Class \w+$/.test(l)); + const end = lines.lastIndexOf("End Class"); + assert.ok(start >= 0 && end > start, `no Class block in:\n${text}`); + return { + attributes: lines.slice(0, start), + name: lines[start].slice("Class ".length), + body: lines.slice(start + 1, end).map((l) => l.replace(/^ {4}/, "")), + }; +} + +// The trailing empty lines of a list of lines, taken off. +function trimEnd(lines) { + const out = [...lines]; + while (out.length && out[out.length - 1].trim() === "") out.pop(); + return out; +} + +// The types of the type library in a file, each with its members, read through +// .NET's ComTypes in PowerShell: LoadTypeLibEx with REGKIND_NONE registers +// nothing. The script goes in with -EncodedCommand, as tb-registry.mjs's does, +// and the file's path in an environment variable. +const TYPELIB_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices.ComTypes; +using System.Text; +public static class TbTypeLibrary { + [System.Runtime.InteropServices.DllImport("oleaut32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode, PreserveSig = false)] + static extern ITypeLib LoadTypeLibEx(string file, int regkind); + static string Q(string s) { + if (s == null) return "null"; + var b = new StringBuilder("\""); + foreach (char ch in s) { + if (ch == '"' || ch == '\\') b.Append('\\').Append(ch); + else if (ch < ' ') b.AppendFormat("\\u{0:x4}", (int)ch); + else b.Append(ch); + } + return b.Append('"').ToString(); + } + static string Member(ITypeInfo ti, string kind, int memid, int invkind, int flags) { + string name, doc, file; int ctx; + ti.GetDocumentation(memid, out name, out doc, out ctx, out file); + return "{\"kind\":" + Q(kind) + ",\"name\":" + Q(name) + ",\"memid\":" + memid + ",\"invkind\":" + invkind + + ",\"flags\":" + flags + ",\"doc\":" + Q(doc) + ",\"helpContext\":" + ctx + "}"; + } + public static string Read(string path) { + ITypeLib lib = LoadTypeLibEx(path, 2); + var types = new List(); + int n = lib.GetTypeInfoCount(); + for (int i = 0; i < n; i++) { + ITypeInfo ti; + lib.GetTypeInfo(i, out ti); + IntPtr pa; + ti.GetTypeAttr(out pa); + var ta = (TYPEATTR)System.Runtime.InteropServices.Marshal.PtrToStructure(pa, typeof(TYPEATTR)); + ti.ReleaseTypeAttr(pa); + var members = new List(); + for (int f = 0; f < ta.cFuncs; f++) { + IntPtr p; + ti.GetFuncDesc(f, out p); + var fd = (FUNCDESC)System.Runtime.InteropServices.Marshal.PtrToStructure(p, typeof(FUNCDESC)); + ti.ReleaseFuncDesc(p); + members.Add(Member(ti, "func", fd.memid, (int)fd.invkind, fd.wFuncFlags & 0xFFFF)); + } + for (int v = 0; v < ta.cVars; v++) { + IntPtr p; + ti.GetVarDesc(v, out p); + var vd = (VARDESC)System.Runtime.InteropServices.Marshal.PtrToStructure(p, typeof(VARDESC)); + ti.ReleaseVarDesc(p); + members.Add(Member(ti, "var", vd.memid, 0, vd.wVarFlags & 0xFFFF)); + } + string name, doc, file; int ctx; + ti.GetDocumentation(-1, out name, out doc, out ctx, out file); + types.Add("{\"name\":" + Q(name) + ",\"typekind\":" + (int)ta.typekind + ",\"flags\":" + ((int)ta.wTypeFlags & 0xFFFF) + + ",\"doc\":" + Q(doc) + ",\"helpContext\":" + ctx + ",\"members\":[" + string.Join(",", members) + "]}"); + } + return "[" + string.Join(",", types) + "]"; + } +} +'@ +try { + [Console]::Out.Write([TbTypeLibrary]::Read($env:TB_TYPELIB_FILE)) +} catch { + [Console]::Out.Write((ConvertTo-Json -InputObject ([ordered]@{ error = $_.Exception.Message }) -Compress)) +} +`; + +function typeLibrary(file) { + const out = execFileSync( + "powershell", + ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(TYPELIB_SCRIPT, "utf16le").toString("base64")], + { + encoding: "utf8", + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, TB_TYPELIB_FILE: file }, + maxBuffer: 16 * 1024 * 1024, + }, + ); + const answer = JSON.parse(out.replace(/^\u{FEFF}/u, "")); + if (answer.error !== undefined) throw new Error(`reading the type library of ${file}: ${answer.error}`); + return answer; +} + +// TYPEKIND, and the flags the tests look for. VB_MemberFlags is the member's +// FUNCFLAGS in hex: "40" FHIDDEN, "400" FNONBROWSABLE, "200" FUIDEFAULT and "14" +// FBINDABLE | FDISPLAYBIND, as VB6's Procedure Attributes dialog writes them. +const TKIND_DISPATCH = 4; +const TKIND_COCLASS = 5; +const TYPEFLAG = { FAPPOBJECT: 0x1, FCANCREATE: 0x2, FPREDECLID: 0x8 }; +const FUNCFLAG = { FHIDDEN: 0x40, FNONBROWSABLE: 0x400 }; +const INVOKE_PROPERTYGET = 2; + +scenario("VB6 import", (lane) => { + let c; + const vbpDir = path.join(lane.work, "vb6", NAME); + const vbp = path.join(vbpDir, `${NAME}.vbp`); + const saved = path.join(lane.work, "saved", `${NAME}.twinproj`); + const unpacked = path.join(lane.work, "unpacked"); + const dll = path.join(lane.work, "out", `${NAME}.dll`); + let tlb = null; // the built DLL's type library, once the build test has run + + const source = (file) => readFileSync(path.join(unpacked, "Sources", file), "utf8"); + // A coclass, or the dispatch interface VB6 names after it with "_". + const coclass = (name) => tlb?.find((t) => t.typekind === TKIND_COCLASS && t.name === name); + const dispatch = (name) => tlb?.find((t) => t.typekind === TKIND_DISPATCH && t.name === `_${name}`); + function member(cls, name, invkind = null) { + assert.ok(tlb, "no type library: the build test did not pass"); + const iface = dispatch(cls); + assert.ok(iface, `the type library has no _${cls}`); + const m = iface.members.find((m) => m.name === name && (invkind === null || m.invkind === invkind)); + assert.ok(m, `_${cls} has no ${name}`); + return m; + } + + before(async () => { + mkdirSync(vbpDir, { recursive: true }); + mkdirSync(path.dirname(saved), { recursive: true }); + for (const f of readdirSync(FIXTURE)) writeFileSync(path.join(vbpDir, f), crlf(fixture(f))); + writeFileSync(path.join(vbpDir, "ctlAttrs.ctx"), ctx(PAGES)); + + c = await lane.openNoProject(); + assert.ok(await waitModal(c, "New / Open"), "the IDE showed no New / Open Project dialog"); + // The tiles are listed once the IDE has the samples and projects from its host. + await waitFor( + c, + () => + c.evaluate( + `[...document.querySelectorAll(".frontPageListViewItem")].some((e) => e.textContent.trim() === "Import from VBP...")`, + ), + { timeout: 30 * 1000 }, + ); + await standIn(c, "OpenProjectVBP", vbp); + await click(c, { css: ".frontPageListViewItem", text: "Import from VBP..." }, { clickCount: 2 }); + assert.ok(await waitModal(c, "New Project Options"), "the import showed no New Project Options dialog"); + await click(c, "OKBTN"); + assert.ok( + await waitFor( + c, + () => + c.evaluate(`[...document.querySelectorAll(".modalDialogContainer")].some((m) => + m.getBoundingClientRect().width && /HKEY_LOCAL_MACHINE/.test(m.innerText))`), + { timeout: 60 * 1000 }, + ), + "the import of an ActiveX DLL showed no box about HKEY_LOCAL_MACHINE", + ); + await click(c, { css: ".msgBoxButton", text: "Continue Anyway" }); + const loaded = await waitFor( + c, + async () => + !(await modals(c)).length && + (await c.evaluate(`/OPERATIONAL/.test(compilerStatus.textContent) && + !!fs.tree.resolvePath(${JSON.stringify(`twinbasic:/${NAME}/Sources/clsAttrs.cls`)})`)), + { timeout: 60 * 1000 }, + ); + assert.ok(loaded, "the imported project never showed in Project Explorer"); + + await standIn(c, "SaveProjectAs", saved); + await pressKey(c, "s", { ctrl: true }); + const done = await waitFor( + c, + async () => + existsSync(saved) && (await c.evaluate(`projectFilePath === ${JSON.stringify(saved)} && !projectIsDirty`)), + { timeout: 60 * 1000 }, + ); + assert.ok(done, `Ctrl+S did not save the imported project as ${saved}`); + unpackProject(saved, unpacked); + }); + + test("the import log names each file it imported", () => { + const log = source("__IMPORT_LOG.md"); + for (const line of [ + "imported module 'modAttrs.bas'", + "imported class 'clsAttrs.cls'", + "imported class 'clsGlobal.cls'", + "imported class 'clsNotCreatable.cls'", + "imported class 'clsPrivate.cls'", + "imported class 'clsPlain.cls'", + "imported form 'frmAttrs.frm'", + "imported usercontrol 'ctlAttrs.ctl'", + "imported propertypage 'pagAttrs.pag'", + ]) { + assert.ok(log.includes(line), `the log has no "${line}":\n${log}`); + } + }); + + test("a .bas and a .cls are kept as VB6 wrote them, every Attribute line included", () => { + const files = readdirSync(FIXTURE).filter((f) => /\.(bas|cls)$/.test(f)); + assert.equal(files.length, 6); + for (const f of files) assert.equal(source(f), crlf(fixture(f)), `${f} changed`); + }); + + test("a .frm, a .ctl and a .pag become a Class in a .twin file that holds their code, Attribute lines and all", () => { + for (const f of ["frmAttrs.frm", "ctlAttrs.ctl", "pagAttrs.pag"]) { + const twin = twinClass(source(`${f}.twin`)); + assert.equal(twin.name, f.split(".")[0]); + assert.deepEqual(trimEnd(twin.body), trimEnd(vb6Code(fixture(f))), `${f}.twin's Class is not the module's code`); + } + }); + + test("the attributes in front of the Class are the designer's own, none made from an Attribute line", () => { + const names = (f) => + twinClass(source(`${f}.twin`)) + .attributes.filter((l) => l.trim()) + .map((l) => l.replace(/\(.*$/, "")); + const ids = ["[FormDesignerId", "[ClassId", "[InterfaceId", "[EventInterfaceId"]; + // A form's VB_PredeclaredId = True stays an Attribute line: no [PredeclaredId]. + assert.deepEqual(names("frmAttrs.frm"), ids); + assert.deepEqual(names("ctlAttrs.ctl"), [...ids, "[COMControl]"]); + assert.deepEqual(names("pagAttrs.pag"), ids); + // Nor anywhere else in the project's sources. + const made = + /^\s*\[(Description|DispId|DefaultMember|Enumerator|Hidden|NonBrowsable|PropertyPage|AppObject|COMCreatable)\b/m; + for (const f of readdirSync(path.join(unpacked, "Sources"))) { + assert.doesNotMatch(source(f), made, `${f} has a twinBASIC attribute the import wrote`); + } + }); + + test("a UserControl's PropertyPages list goes into its .tbcontrol designer file", () => { + const [designer] = JSON.parse(source("ctlAttrs.ctl.tbcontrol")); + assert.deepEqual(designer.PropertyPages, PAGES); + assert.ok(existsSync(path.join(unpacked, "Sources", "pagAttrs.pag.tbppage")), "the .pag has no .tbppage"); + }); + + test("the imported project compiles with no errors and builds without registering", async () => { + await lane.closeProject(); + const settingsFile = path.join(unpacked, "Settings"); + const settings = JSON.parse(readFileSync(settingsFile, "utf8")); + assert.equal(settings["project.buildType"], "ActiveX DLL"); + settings["project.dllRegisterAfterBuild"] = false; + writeFileSync(settingsFile, JSON.stringify(settings, null, "\t")); + c = await lane.open(unpacked, { buildPath: dll }); // refuses a project with errors + const built = await buildProject(c); + assert.ok(built.ok, `the build failed: ${built.message}\n${built.log.join("\n")}`); + assert.ok(existsSync(dll), `no ${dll}`); + tlb = typeLibrary(dll); + }); + + test("VB_Description and VB_VarDescription are the help strings of the class, its members and its events", () => { + assert.equal(coclass("clsAttrs")?.doc, "A class with every member attribute VB6 writes."); + assert.equal(member("clsAttrs", "Value", INVOKE_PROPERTYGET).doc, "The default property."); + assert.equal(member("clsAttrs", "Caption", INVOKE_PROPERTYGET).doc, "A public field with a description."); + const events = tlb.find((t) => t.name === "__clsAttrs"); + assert.equal(events?.members.find((m) => m.name === "Changed")?.doc, "Raised when Value changes."); + }); + + test("VB_UserMemId and VB_VarUserMemId are the dispatch ids, a field's included", () => { + assert.equal(member("clsAttrs", "Value", INVOKE_PROPERTYGET).memid, 0); + assert.equal(member("clsAttrs", "NewEnum").memid, -4); + assert.equal(member("clsAttrs", "WithId").memid, 7); + assert.equal(member("clsAttrs", "Caption", INVOKE_PROPERTYGET).memid, 8); + }); + + test('VB_MemberFlags "40", "400" and "440" are hidden, nonbrowsable and both', () => { + assert.equal(member("clsAttrs", "NewEnum").flags, FUNCFLAG.FHIDDEN); + assert.equal(member("clsAttrs", "HiddenMethod").flags, FUNCFLAG.FHIDDEN); + assert.equal(member("clsAttrs", "Unbrowsed").flags, FUNCFLAG.FNONBROWSABLE); + assert.equal(member("clsAttrs", "HiddenUnbrowsed").flags, FUNCFLAG.FHIDDEN | FUNCFLAG.FNONBROWSABLE); + }); + + test("VB_Exposed, VB_Creatable, VB_GlobalNameSpace and VB_PredeclaredId decide which classes the type library has and how", () => { + const { FAPPOBJECT, FCANCREATE, FPREDECLID } = TYPEFLAG; + assert.equal(coclass("clsPlain")?.flags, FCANCREATE); + assert.equal(coclass("clsNotCreatable")?.flags, 0); + assert.equal(coclass("clsGlobal")?.flags, FAPPOBJECT | FCANCREATE | FPREDECLID); + // VB_PredeclaredId = True alone gives the same three flags as [PredeclaredId] does. + assert.equal(coclass("clsAttrs")?.flags, FAPPOBJECT | FCANCREATE | FPREDECLID); + for (const name of ["clsPrivate", "frmAttrs", "ctlAttrs", "pagAttrs"]) { + assert.equal(coclass(name), undefined, `${name}, VB_Exposed = False, is in the type library`); + } + }); + + // The entry "Some VB6 attribute lines have no effect in a built type library" + // in BUGS-TO-REPORT.md. Each assertion states the defect; one that fails means + // that line now has its effect. + test("VB_HelpID, VB_VarHelpID, VB_VarMemberFlags and VB_MemberFlags other than hidden and nonbrowsable have no effect", () => { + assert.equal(coclass("clsAttrs")?.helpContext, 0, "VB_HelpID = 2000 on the class"); + assert.equal(member("clsAttrs", "Value", INVOKE_PROPERTYGET).helpContext, 0, "VB_HelpID = 2001"); + assert.equal(member("clsAttrs", "Caption", INVOKE_PROPERTYGET).helpContext, 0, "VB_VarHelpID = 2002"); + assert.equal(member("clsAttrs", "HiddenField", INVOKE_PROPERTYGET).flags, 0, 'VB_VarMemberFlags = "40"'); + assert.equal(member("clsAttrs", "UnbrowsedField", INVOKE_PROPERTYGET).flags, 0, 'VB_VarMemberFlags = "400"'); + assert.equal(member("clsAttrs", "UiDefault").flags, 0, 'VB_MemberFlags = "200"'); + assert.equal(member("clsAttrs", "Bound").flags, 0, 'VB_MemberFlags = "14"'); + }); +});