diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index 2d0df8a5a1b..e4a0423550c 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -50,8 +50,13 @@ jobs: docs: - 'docs/developer-guide/**' - 'docs/demos/common/src/main/snippets/**' - - 'scripts/developer-guide/migrate-inline-guide-snippets.py' - - 'scripts/developer-guide/validate-guide-snippets.py' + # The whole directory, not two named files. on.pull_request.paths + # already triggers on scripts/developer-guide/**, so a change to any + # other script here started the workflow while leaving this filter + # false -- which skipped the very steps that script governs. A + # change to compare-screenshots.py could have merged without the + # screenshot check ever running it. + - 'scripts/developer-guide/**' - '.github/workflows/developer-guide-docs.yml' # Triggering the workflow is not enough on its own: the HTML and PDF # build and the steps beside it are gated on this filter, so the @@ -122,6 +127,7 @@ jobs: run: | set -euo pipefail GENERATED_DIR="$RUNNER_TEMP/pre-advanced-theming-screenshots" + echo "GUIDE_GENERATED_SCREENSHOTS=$GENERATED_DIR" >> "$GITHUB_ENV" rm -rf "$GENERATED_DIR" mkdir -p "$GENERATED_DIR" xvfb-run -a mvn -B -ntp \ @@ -131,23 +137,25 @@ jobs: -P guide-screenshot-generator \ -Dguide.screenshot.output="$GENERATED_DIR" \ verify - COUNT="$(find "$GENERATED_DIR" -maxdepth 1 -type f -name '*.png' | wc -l | tr -d ' ')" - if [ "$COUNT" != "24" ]; then - echo "::error::Expected 24 generated pre-Advanced Theming screenshots, found $COUNT" - exit 1 - fi - for generated in "$GENERATED_DIR"/*.png; do - name="$(basename "$generated")" - committed="docs/developer-guide/img/$name" - if [ ! -f "$committed" ]; then - echo "::error::Generated screenshot has no committed counterpart: $name" - exit 1 - fi - if ! cmp -s "$generated" "$committed"; then - echo "::error::Committed screenshot is stale: $name" - exit 1 - fi - done + pip install --user --quiet Pillow + # Byte equality is still the rule; a figure only gets a bounded + # difference if it carries a .tolerance sidecar saying why. + python3 scripts/developer-guide/compare-screenshots.py \ + --generated "$GENERATED_DIR" \ + --committed docs/developer-guide/img \ + --expected-count 24 + + # When the byte compare fails, the message names the file but not what it + # actually rendered, which leaves no way to tell a real regression from an + # environment difference. Publishing what this runner produced makes the + # two distinguishable without adding a debugging round trip to CI. + - name: Upload generated screenshots when they do not match + if: failure() && env.GUIDE_GENERATED_SCREENSHOTS != '' + uses: actions/upload-artifact@v7 + with: + name: guide-generated-screenshots + path: ${{ env.GUIDE_GENERATED_SCREENSHOTS }} + if-no-files-found: warn - name: Verify developer guide images are referenced if: github.event_name != 'pull_request' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.demos == 'true' || steps.changes.outputs.workflow == 'true' @@ -271,6 +279,25 @@ jobs: echo "Asciidoctor exited with status $STATUS — the final quality-gate step will fail the build." >&2 fi + # These four cover defects the gates above are structurally blind to: a + # chapter swallowed by the one before it, a cross-reference that resolves + # to nothing, prose that promises a code block which is not there, and a + # link the website does not serve. Asciidoctor reports none of them, and + # every one of them shipped. The structure and cross-reference checks + # render the book, so they must run after the Asciidoctor install. + - name: Check developer guide structure, cross-references, code blocks and links + run: | + set -euo pipefail + python3 scripts/developer-guide/check-guide-structure.py + python3 scripts/developer-guide/check-guide-xrefs.py + python3 scripts/developer-guide/check-missing-code-blocks.py + # Note this only catches a link the GUIDE breaks. When the website + # moves the route instead -- a page renamed, deleted or re-slugged -- + # the same script runs from website-docs.yml, which triggers on + # docs/website/**. Putting those paths here would run this whole job + # (maven install, demo build, screenshots) for every blog post. + python3 scripts/developer-guide/check-guide-links.py + - name: Build Developer Guide HTML and PDF if: github.event_name != 'pull_request' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.demos == 'true' || steps.changes.outputs.workflow == 'true' run: | diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 8fc0a7b1a02..f6bb22d1392 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -20,6 +20,10 @@ on: - 'vm/ByteCodeTranslator/**' - 'vm/JavaAPI/**' - 'CodenameOne/src/**' + # The other root build_javadocs.sh generates from, and the other one + # check-guide-links.py indexes. Without it, deleting a linked CLDC type + # merges unvalidated and the next scheduled build is where it surfaces. + - 'Ports/CLDC11/src/**' # The developer guide this site renders includes a build hint table that is # generated rather than committed, so a change to the catalog or to the # renderer changes the published page without touching docs/. The @@ -46,6 +50,10 @@ on: - 'vm/ByteCodeTranslator/**' - 'vm/JavaAPI/**' - 'CodenameOne/src/**' + # The other root build_javadocs.sh generates from, and the other one + # check-guide-links.py indexes. Without it, deleting a linked CLDC type + # merges unvalidated and the next scheduled build is where it surfaces. + - 'Ports/CLDC11/src/**' # As above: the build hint table is generated, not committed. - 'maven/build-hint-catalog/**' - 'maven/build-hint-tools/**' @@ -277,6 +285,17 @@ jobs: --root-dir public public/**/*.html + # The developer guide links into this site, so a page renamed, deleted or + # re-slugged here breaks the guide. The check lives in THIS workflow rather + # than in developer-guide-docs.yml because it is the route data that + # changed: widening that workflow's paths to docs/website/** would run the + # full guide build -- maven install, demo build, screenshot generation -- + # on every blog post the daily publisher pushes, to run one Python script. + # This job already triggers on docs/website/**, and the check needs nothing + # but a checkout. + - name: Check developer guide links against the routes this site serves + run: python3 scripts/developer-guide/check-guide-links.py + - name: Reject absolute codenameone.com links run: | set -euo pipefail diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/PreAdvancedThemingScreenshots.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/PreAdvancedThemingScreenshots.java index b168677eabf..effa4dc28ae 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/PreAdvancedThemingScreenshots.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/PreAdvancedThemingScreenshots.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codenameone.developerguide.screenshots; import com.codename1.components.SpanLabel; @@ -5,6 +27,7 @@ import com.codename1.ui.Button; import com.codename1.ui.Component; import com.codename1.ui.Container; +import com.codename1.ui.Display; import com.codename1.ui.CN; import com.codename1.ui.FontImage; import com.codename1.ui.Font; @@ -12,6 +35,7 @@ import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.Label; +import com.codename1.ui.TextArea; import com.codename1.ui.TextField; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.BoxLayout; @@ -40,10 +64,33 @@ public final class PreAdvancedThemingScreenshots { private static final int BLUE = 0x0b57d0; private static final int GREEN = 0x06a806; private static final int WHITE = 0xffffff; - private static final Font TITLE_FONT = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE) - .derive(35, Font.STYLE_PLAIN); - private static final Font BLOCK_FONT = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE) - .derive(24, Font.STYLE_PLAIN); + private static final Font TITLE_FONT = screenshotFont(35); + private static final Font BLOCK_FONT = screenshotFont(24); + /// Sized to match the height the theme's own default font had (13px), so + /// pinning the face on components the block styling does not touch keeps + /// their layout exactly where it was. + private static final Font FIELD_FONT = screenshotFont(11); + + /// Loads a figure font from the port's bundled Roboto rather than from the host. + /// + /// `Font.createSystemFont` resolves through `JavaSEPort.fontFaceSystem`, which is + /// "Arial" on macOS and Linux alike. Arial exists on a developer's Mac and not on a + /// stock CI runner, so AWT silently substitutes a different face and every glyph in + /// every figure changes -- which is why none of these screenshots could be + /// regenerated outside CI and byte-compared against what was committed. The + /// `native:` scheme reads `/com/codename1/impl/javase/Roboto-*.ttf` off the + /// classpath, so the result does not depend on what the machine happens to have + /// installed. It is also what this project's font rule requires everywhere. + private static Font screenshotFont(int pixelSize) { + Font font = Font.createTrueTypeFont("native:MainRegular", "native:MainRegular"); + if (font == null) { + // Falling back to a host font would quietly restore the very + // non-determinism this exists to remove, so refuse instead. + throw new IllegalStateException( + "the native font scheme is unavailable, so figures would render with a host font"); + } + return font.derive(pixelSize, Font.STYLE_PLAIN); + } private PreAdvancedThemingScreenshots() { } @@ -53,6 +100,32 @@ public interface ScreenshotSink { } public static void generate(ScreenshotSink sink) throws IOException { + // JavaSEPort.loadTrueTypeFont has an earlier branch for native: fonts: + // when isIOS is set -- which loadSkinFile does for any skin whose + // systemFontFamily contains "helvetica" -- it resolves to the first + // INSTALLED SF or Helvetica family and never reaches the bundled Roboto. + // This generator never loads a skin, so that branch is not taken, and the + // measurement agrees: figures rendered on a Mac match the Linux runner + // byte for byte, which could not happen if one side were using Helvetica + // Neue. Guard it anyway, because a future change that loads a skin here + // would put the host's fonts back into the output without any other + // symptom. + String platform = Display.getInstance().getPlatformName(); + if ("ios".equals(platform)) { + throw new IllegalStateException( + "an iOS skin is active, so native: fonts would resolve to installed " + + "system faces instead of the bundled ones and these figures would " + + "stop being reproducible off this machine"); + } + + // MigLayout picks its default gaps from PlatformDefaults, which reads + // System.getProperty("os.name") and returns MAC_OSX, GNOME or WINDOWS_XP. + // The gaps differ per platform, so mig-layout.png came out with macOS + // spacing on a Mac and GNOME spacing on the Linux runner -- a 12.8% pixel + // difference that has nothing to do with fonts. Pin it so the figure shows + // the same thing wherever it is generated. + com.codename1.ui.layouts.mig.PlatformDefaults.setPlatform( + com.codename1.ui.layouts.mig.PlatformDefaults.GNOME); write(sink, "flow-layout.png", flow("Flow Layout", Component.LEFT, Component.TOP), PORTRAIT_WIDTH, PORTRAIT_HEIGHT); write(sink, "flow-layout-center.png", flow("Flow Layout", Component.CENTER, Component.TOP), PORTRAIT_WIDTH, PORTRAIT_HEIGHT); write(sink, "flow-layout-right.png", flow("Flow Layout", Component.RIGHT, Component.TOP), PORTRAIT_WIDTH, PORTRAIT_HEIGHT); @@ -328,6 +401,22 @@ private static void applyScreenshotStyle(Form form) { private static void applyBlockStyleToContent(Container container) { for (int i = 0; i < container.getComponentCount(); i++) { Component component = container.getComponentAt(i); + // Pin the font on EVERY component rather than on the types that also + // get block colours. Anything left on the theme's default font + // resolves through the host, and that is what made + // guibuilder-2-insets-3.png -- the only figure containing a + // TextField -- differ between a Mac and CI while the other 23 + // matched byte for byte. Enumerating the types that carry text + // would leave the next one to be added broken in the same way. + component.getAllStyles().setFont(FIELD_FONT); + if (component instanceof TextArea) { + // The hint is painted by a Label that is not in the component + // tree, so the walk above never reaches it. + Label hint = ((TextArea) component).getHintLabel(); + if (hint != null) { + hint.getAllStyles().setFont(FIELD_FONT); + } + } if (component instanceof Label || component instanceof Button) { styleBlock(component); } diff --git a/docs/demos/common/src/main/snippets/developer-guide/working-with-windows.sh b/docs/demos/common/src/main/snippets/developer-guide/working-with-windows.sh index f1beb009283..1230d8383f6 100644 --- a/docs/demos/common/src/main/snippets/developer-guide/working-with-windows.sh +++ b/docs/demos/common/src/main/snippets/developer-guide/working-with-windows.sh @@ -1,5 +1,5 @@ // Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. // tag::working-with-windows-bash-001[] -mvn -pl common package -Dcodename1.platform=windows -Dcodename1.buildTarget=windows-device +mvn package -Dcodename1.platform=win -Dcodename1.buildTarget=windows-device // end::working-with-windows-bash-001[] diff --git a/docs/developer-guide/Advanced-Theming.asciidoc b/docs/developer-guide/Advanced-Theming.asciidoc index 2ebb5ab324a..29dfe0f9713 100644 --- a/docs/developer-guide/Advanced-Theming.asciidoc +++ b/docs/developer-guide/Advanced-Theming.asciidoc @@ -728,7 +728,7 @@ Will set the foreground color of the https://www.codenameone.com/javadoc/com/cod When a Codename One https://www.codenameone.com/javadoc/com/codename1/ui/Component.html[Component] is instantiated it requests a https://www.codenameone.com/javadoc/com/codename1/ui/plaf/Style.html[Style] object from the https://www.codenameone.com/javadoc/com/codename1/ui/plaf/UIManager.html[UIManager] class. The `Style` object is based on the settings within the theme and can be modified through code or by using the theme. -You can replace the theme dynamically in runtime and refresh the styles assigned to the various components using the https://www.codenameone.com/javadoc/com/codename1/ui/Component.html#refreshTheme--[refreshTheme()] method. +You can replace the theme dynamically in runtime and refresh the styles assigned to the various components using the https://www.codenameone.com/javadoc/com/codename1/ui/Component.html#refreshTheme()[refreshTheme()] method. NOTE: It's a common mistake to invoke `refreshTheme()` without actually changing the theme. You see developers doing it when all they need is a `repaint()` or `revalidate()`. Since `refreshTheme()` is **** expensive recommend that you don't use it unless you need to... diff --git a/docs/developer-guide/Events.asciidoc b/docs/developer-guide/Events.asciidoc index 3f9ec37d048..33e0ad643b1 100644 --- a/docs/developer-guide/Events.asciidoc +++ b/docs/developer-guide/Events.asciidoc @@ -55,9 +55,9 @@ Quite a few high-level event types exist that are more specific to requirements. When an action event is fired it's given a type, but this type might change as the event evolves for example, a command triggered by a pointer event won't include details of the original pointer event. -You can get the event type from https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.html#getEventType--[getEventType()], this also gives you a rather exhaustive list of the possible event types for the action event. +You can get the event type from https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.html#getEventType()[getEventType()], this also gives you a rather exhaustive list of the possible event types for the action event. -Modern gesture components also publish custom action types. For example, https://www.codenameone.com/javadoc/com/codename1/ui/SwipeableContainer.html[SwipeableContainer] dispatches https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.Type.html#Swipe-[ActionEvent.Type.Swipe] when the top component is fully opened, allowing code to react to swipe gestures without monitoring low-level drags. Listening for these higher level events keeps gesture handling portable across touch and desktop targets. +Modern gesture components also publish custom action types. For example, https://www.codenameone.com/javadoc/com/codename1/ui/SwipeableContainer.html[SwipeableContainer] dispatches https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.Type.html#Swipe[ActionEvent.Type.Swipe] when the top component is fully opened, allowing code to react to swipe gestures without monitoring low-level drags. Listening for these higher level events keeps gesture handling portable across touch and desktop targets. ===== Source of event @@ -123,7 +123,7 @@ These seem similar but they have one important distinction. The latter code is i ===== MessageEvent and the Cross-Platform message bus -Bridging to native code often means passing messages between Codename One Java and platform specific code. The cross-platform message bus provides a high-level abstraction for that by letting you post messages to the native layer and subscribe for messages that originate there. Use https://www.codenameone.com/javadoc/com/codename1/ui/Display.html#postMessage-com.codename1.ui.events.MessageEvent-[Display.postMessage()] to send an https://www.codenameone.com/javadoc/com/codename1/ui/events/MessageEvent.html[MessageEvent] to the platform, and https://www.codenameone.com/javadoc/com/codename1/ui/Display.html#addMessageListener-com.codename1.ui.events.ActionListener-[Display.addMessageListener()] to receive events that arrive from native code, JavaScript bridges, or background services. Message events include helpers like `isPromptForAudioRecorder()`, `isPromptForAudioPlayer()`, and `getPromptPromise()` that allow you to hook into permission prompts emitted by the JavaScript port so custom UI can respond to platform requests while keeping the event dispatch on the EDT. This API complements `NetworkEvent` by covering native-to-Java messaging without requiring direct low-level callbacks. +Bridging to native code often means passing messages between Codename One Java and platform specific code. The cross-platform message bus provides a high-level abstraction for that by letting you post messages to the native layer and subscribe for messages that originate there. Use https://www.codenameone.com/javadoc/com/codename1/ui/Display.html#postMessage(com.codename1.ui.events.MessageEvent)[Display.postMessage()] to send an https://www.codenameone.com/javadoc/com/codename1/ui/events/MessageEvent.html[MessageEvent] to the platform, and https://www.codenameone.com/javadoc/com/codename1/ui/Display.html#addMessageListener(com.codename1.ui.events.ActionListener)[Display.addMessageListener()] to receive events that arrive from native code, JavaScript bridges, or background services. Message events include helpers like `isPromptForAudioRecorder()`, `isPromptForAudioPlayer()`, and `getPromptPromise()` that allow you to hook into permission prompts emitted by the JavaScript port so custom UI can respond to platform requests while keeping the event dispatch on the EDT. This API complements `NetworkEvent` by covering native-to-Java messaging without requiring direct low-level callbacks. ==== DataChangeListener @@ -175,7 +175,7 @@ for the component class but not an important event for general user code. It's r ==== Component state change events -Component instances now publish lifecycle hooks that fire when they become initialized on a form and when they're removed. You can subscribe with https://www.codenameone.com/javadoc/com/codename1/ui/Component.html#addStateChangeListener-com.codename1.ui.events.ActionListener-[Component.addStateChangeListener()] to receive https://www.codenameone.com/javadoc/com/codename1/ui/events/ComponentStateChangeEvent.html[ComponentStateChangeEvent] instances that show whether the component is transitioning to the initialized state. This is useful for running setup or teardown logic alongside focus, scroll, and selection listeners. +Component instances now publish lifecycle hooks that fire when they become initialized on a form and when they're removed. You can subscribe with https://www.codenameone.com/javadoc/com/codename1/ui/Component.html#addStateChangeListener(com.codename1.ui.events.ActionListener)[Component.addStateChangeListener()] to receive https://www.codenameone.com/javadoc/com/codename1/ui/events/ComponentStateChangeEvent.html[ComponentStateChangeEvent] instances that show whether the component is transitioning to the initialized state. This is useful for running setup or teardown logic alongside focus, scroll, and selection listeners. ==== Event dispatcher @@ -243,9 +243,9 @@ The pointer events (touch events) can be intercepted by overriding one or more o Notice that most pointer events have a version that accepts an array as an argument, this allows for multi-touch event handling by sending all the touched coordinates. Desktop and pen-enabled devices can also trigger hover events without a press. To respond to those you can override the `pointerHover*` callbacks on `Form` or `Component`, which are invoked before a button receives focus or a drag begins on those platforms. -While you can override `longPointerPress`, there is no need. The dedicated https://www.codenameone.com/javadoc/com/codename1/ui/Component.html#addLongPressListener-com.codename1.ui.events.ActionListener-[Component.addLongPressListener()] helper wires long press detection into an action listener so you can keep gesture logic in the high-level API. +While you can override `longPointerPress`, there is no need. The dedicated https://www.codenameone.com/javadoc/com/codename1/ui/Component.html#addLongPressListener(com.codename1.ui.events.ActionListener)[Component.addLongPressListener()] helper wires long press detection into an action listener so you can keep gesture logic in the high-level API. -Drag lifecycles also expose a completion hook. When `Component.addDragFinishedListener()` is registered it receives https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.Type.html#DragFinished-[ActionEvent.Type.DragFinished] once the framework has completed its cleanup, allowing you to reset state or trigger follow-up actions that should occur after the drag image is hidden. +Drag lifecycles also expose a completion hook. When `Component.addDragFinishedListener()` is registered it receives https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.Type.html#DragFinished[ActionEvent.Type.DragFinished] once the framework has completed its cleanup, allowing you to reset state or trigger follow-up actions that should occur after the drag image is hidden. ==== Drag event sanitation diff --git a/docs/developer-guide/Game-Development.asciidoc b/docs/developer-guide/Game-Development.asciidoc index 10772acf784..f5bdc9bcce2 100644 --- a/docs/developer-guide/Game-Development.asciidoc +++ b/docs/developer-guide/Game-Development.asciidoc @@ -102,7 +102,7 @@ rendered positions between physics states: `update(double)` runs on the render thread, together with drawing. Keep it non-blocking -- offload asset loading, networking or other long work to a background thread and hand the result back with -https://www.codenameone.com/javadoc/com/codename1/ui/CN.html#callSerially-java.lang.Runnable-[`CN.callSerially`]. +https://www.codenameone.com/javadoc/com/codename1/ui/CN.html#callSerially(java.lang.Runnable)[`CN.callSerially`]. === Input: `GameInput` diff --git a/docs/developer-guide/Introduction.asciidoc b/docs/developer-guide/Introduction.asciidoc index 3ba32d08f6d..2f9172686f7 100644 --- a/docs/developer-guide/Introduction.asciidoc +++ b/docs/developer-guide/Introduction.asciidoc @@ -60,13 +60,13 @@ Codename One uses a SaaS-based approach so the information in this appendix migh Since Android is already based on Java, Codename One is already native to Android and works with the Android VM (ART/Dalvik). -On iOS, Codename One built and open-sourced ParparVM, which is a conservative VM. ParparVM features a concurrent, non-blocking GC and is written entirely in Java/C. ParparVM is a transpiler that generates C source code matching the given Java bytecode. This means that an Xcode project is generated and compiled on the build servers. It's as if you hand-coded a native app and is thus future-proof against changes that Apple introduces. For example, Apple migrated to 64-bit and later introduced bitcode support to iOS. ParparVM needed no modifications to meet those changes. +On iOS, Codename One built and open-sourced ParparVM, which is a conservative VM. ParparVM features a concurrent, non-blocking GC and is written entirely in Java/C. ParparVM is a transpiler that generates C source code matching the given Java bytecode. This means that an Xcode project is generated and compiled on the build servers. It's as if you hand-coded a native app and is thus future-proof against changes that Apple introduces. For example, Apple migrated to 64-bit, then introduced bitcode, then withdrew bitcode again. ParparVM needed no modifications for any of those changes. NOTE: Codename One translates the bytecode to C, which is faster than Swift/Objective-C. The port code that invokes iOS APIs is hand coded in Objective-C Codename One earlier offered a UWP (Universal Windows Platform) target based on iKVM. That target was discontinued in release 7.0.229 and is preserved as historical context in older documentation and blog posts. -JavaScript build targets use TeaVM to do the translation statically. TeaVM supports threading using JavaScript by breaking the app down in a rather elaborate way. To support the complex UI Codename One uses the HTML5 Canvas API which allows absolute flexibility for building applications. +JavaScript build targets translate the bytecode statically with ParparVM, the same translator that generates the C sources for iOS. Cloud builds keep the original TeaVM-based compiler as a compatibility fallback, which you select with the `javascript.port` build hint. To support the complex UI Codename One uses the HTML5 Canvas API which allows absolute flexibility for building applications. For desktop builds Codename One uses `javapackager`, since both Macs and Windows machines are available in the cloud, the platform-specific nature of `javapackager` isn't a problem. @@ -87,7 +87,7 @@ Lightweight components date back to Smalltalk frameworks, this notion was popula ===== Why ParparVM -On iOS, Codename One uses https://github.com/codenameone/CodenameOne/tree/master/vm[ParparVM] which translates Java bytecode to C code and boasts a non-blocking GC as well as 64 bit/bitcode support. This VM is fully open source in the https://github.com/codenameone/CodenameOne/[Codename One git repository]. In the past Codename One used http://www.xmlvm.org/[XMLVM] to generate native code similarly, but the XMLVM solution was too generic for the needs of Codename One. https://github.com/codenameone/CodenameOne/tree/master/vm[ParparVM] boasts a unique architecture of translating code to C (similarly to XMLVM), because of that Codename One is the only solution of its kind that can **guarantee** future iOS compatibility since the officially supported iOS toolchain is always used instead of undocumented behaviors. +On iOS, Codename One uses https://github.com/codenameone/CodenameOne/tree/master/vm[ParparVM] which translates Java bytecode to C code and boasts a non-blocking GC. This VM is fully open source in the https://github.com/codenameone/CodenameOne/[Codename One git repository]. In the past Codename One used http://www.xmlvm.org/[XMLVM] to generate native code similarly, but the XMLVM solution was too generic for the needs of Codename One. https://github.com/codenameone/CodenameOne/tree/master/vm[ParparVM] boasts a unique architecture of translating code to C (similarly to XMLVM), because of that Codename One is the only solution of its kind that can **guarantee** future iOS compatibility since the officially supported iOS toolchain is always used instead of undocumented behaviors. NOTE: XMLVM could guarantee that as well, but it's no longer maintained and lacked the API layer support @@ -107,7 +107,7 @@ NOTE: The UWP target was discontinued in release 7.0.229 and is no longer part o ===== JavaScript port -The JavaScript port of Codename One uses ParparVM to translate Java bytecode into JavaScript. Cloud builds retain the original http://teavm.org:[TeaVM-based builder] as a compatibility fallback, selected with the public `javascript.port=teavm` build hint. +The JavaScript port of Codename One uses ParparVM to translate Java bytecode into JavaScript. Cloud builds retain the original https://teavm.org/[TeaVM-based builder] as a compatibility fallback, selected with the public `javascript.port=teavm` build hint. The JavaScript port allows unmodified Codename One applications to run within a desktop or mobile browser. The port itself is based on the HTML5 Canvas API, which provides a pixel-perfect implementation of the Codename One API. @@ -175,9 +175,9 @@ Scrolling poses another challenge in touch-based interfaces. In desktop applicat Some developers single out this wide range of resolutions and densities as "`device fragmentation.`" While it does contribute to development complexity, it isn't a challenging problem to overcome. -Densities aren't the cause of device fragmentation. Device fragmentation is caused by many OS versions with different behaviors. This is clear on Android and relates to the slow rollout of Android vendor versions compared to Google rollout. For example, 7 months after the Android 8 (Oreo) release in 2018, it was still available on 1.1% of the devices. The damning statistic is that 12% of the devices in mid 2018 run Android 4.4 Kitkat released in 2013! (((Google))) +Densities aren't the cause of device fragmentation. Device fragmentation is caused by many OS versions with different behaviors. This is clear on Android and relates to the slow rollout of Android vendor versions compared to Google rollout. A new release reaches only a small fraction of devices in its first year, and handsets several major versions behind stay in circulation long after their vendor stops shipping updates for them. (((Google))) -This makes QA difficult as the disparity between these versions is pretty big. These numbers will be out of date by the time you read this, but the core problem remains. It's hard to get all device manufacturers aligned, so this problem will probably remain in the foreseeable future despite everything. +This makes QA difficult as the disparity between these versions is pretty big. It's hard to get all device manufacturers aligned, so this problem will probably remain in the foreseeable future despite everything. ==== Performance @@ -414,7 +414,7 @@ First, the good news: In iOS Apple issues the certificates for your applications. That way the certificate is trusted by Apple and is assigned to your Apple iOS developer account. One important caveat applies: You need an iOS Developer Account and Apple charges a 99USD Annual fee for that. -TIP: The 99USD price and need have been around since the introduction of the iOS developer program for 10 years at the time of this writing. It might change at some point though +TIP: The fee and the requirement have been part of the iOS developer program since it was introduced. Check Apple's developer site for the current price. Apple also requires a "`provisioning profile`" which is a special file bound to your certificate and app. This file describes some details about the app to the iOS installation process. One of the details it includes during development is the list of permitted devices. @@ -453,12 +453,7 @@ One important aspect of provisioning on iOS is the device list in the provisioni WARNING: Many apps and tools offer the UDID of the device, but they aren't necessarily reliable and might give a fake number! -.Get the UDID of a Device -image::img/get-device-udid.png[Get the UDID of a Device] - -TIP: You can right-click the UDID and select #copy# to copy it - -The simplest and most reliable process for getting a UDID is through iTunes. Other approaches have worked in the past but this approach is guaranteed. +The <<_whats_udid,UDID section of the signing chapter>> covers the reliable ways to read that value. NOTE: Ad hoc provisioning allows 1000 beta testers for your application but it's a more complex process that you won't discuss here although it's supported by Codename One @@ -466,10 +461,9 @@ NOTE: Ad hoc provisioning allows 1000 beta testers for your application but it's Before you continue with the build you should sign up at https://www.codenameone.com/build-server.html where you can soon follow the progress of your builds. You need a Codename One account to build for the device. -Now that you have certificates, the process of device builds is a right click away for both OSes. You can right-click the project and select #Codename One# -> #Send iOS Debug Build# or #Codename One# -> #Send Android Build#. +Now that you have certificates, a device build is a single Maven goal. From the project's root directory run `mvn cn1:buildAndroid` for Android, or `mvn cn1:buildIos` for an iOS debug build. Each one packages the app, sends it to the Codename One build servers and reports where the result lands. -.Right click menu options for sending device builds -image::img/getting-started-right-click-menu.png[Right click menu options for sending device builds,scaledwidth=50%] +WARNING: Run these from the root, not from a module. The build goals skip any project that isn't the execution root, so `mvn -pl common cn1:buildAndroid` prints "`Skipping execution for non-root project`" and then reports success without building anything. NOTE: The first time you send a build you will be prompted for the email and password you provided when signing up for Codename One diff --git a/docs/developer-guide/Maven-Appendix-API.adoc b/docs/developer-guide/Maven-Appendix-API.adoc index 69328d953c9..5b7202bb5f3 100644 --- a/docs/developer-guide/Maven-Appendix-API.adoc +++ b/docs/developer-guide/Maven-Appendix-API.adoc @@ -10,7 +10,7 @@ See https://www.codenameone.com/javadoc/[the JavaDocs] for a full list of suppor NOTE: The Codename One source is open source. Released under GPLv2 with Classpath Exception. -Codename One is much more than an API library. It provides a full tool-chain and eco-system for developing beautiful, performant native mobile apps with a single codebase in Java and Kotlin. Please see the https://www.codenameone.com/developer-guide.html#_introduction[introduction in the Developer guide] for a proper overview of Codename One. +Codename One is much more than an API library. It provides a full tool-chain and eco-system for developing beautiful, performant native mobile apps with a single codebase in Java and Kotlin. Please see the <<_introduction,introduction in the Developer guide>> for a proper overview of Codename One. === Limitations @@ -27,5 +27,5 @@ Add-on libraries can be added to your library in the common/pom.xml file, but, i Codename One supports its own library format (cn1lib) which sort of "certifies" that it's compatible with Codename One. Run `mvn cn1:settings` and open *Extensions* to browse the growing catalog of available cn1libs. See <>. -For more information about cn1libs, see https://www.codenameone.com/developer-guide.html#_libraries_cn1lib[the cn1libs section] of the developer guide. +For more information about cn1libs, see <<_libraries_cn1lib,the cn1libs section>> of the developer guide. diff --git a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc index 13c8c180b98..a034a900512 100644 --- a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc +++ b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc @@ -25,7 +25,7 @@ Some components are composites and derive from the https://www.codenameone.com/j - You can't cast it to the type it relates to. For example, you can't cast `MultiButton` to `Button`. -- Events can be more nuanced. For example, if you rely on https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.html#getSource--[ActionEvent.getSource()] or https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.html#getComponent--[ActionEvent.getComponent()], they may not behave as expected. For a `MultiButton`, they return the underlying `Button`. To work around that, use https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.html#getActualComponent--[ActionEvent.getActualComponent()]. +- Events can be more nuanced. For example, if you rely on https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.html#getSource()[ActionEvent.getSource()] or https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.html#getComponent()[ActionEvent.getComponent()], they may not behave as expected. For a `MultiButton`, they return the underlying `Button`. To work around that, use https://www.codenameone.com/javadoc/com/codename1/ui/events/ActionEvent.html#getActualComponent()[ActionEvent.getActualComponent()]. [[lead-component-sidebar]] .Lead Component @@ -34,7 +34,7 @@ Codename One includes a feature for creating composite components called "lead c Lead components work by assigning one component as the "leader." That leader determines the style state for every component in the hierarchy. If a `Container` is led by a `Button`, the button determines whether the selected or pressed state applies to the entire hierarchy. -This means a single `Component` can contain multiple nested `UIID`s. For example, `MultiButton` has `UIID`s such as `MultiLine1`, which you can customize with APIs such as https://www.codenameone.com/javadoc/com/codename1/components/MultiButton.html#setUIIDLine1-java.lang.String-[setUIIDLine1]. +This means a single `Component` can contain multiple nested `UIID`s. For example, `MultiButton` has `UIID`s such as `MultiLine1`, which you can customize with APIs such as https://www.codenameone.com/javadoc/com/codename1/components/MultiButton.html#setUIIDLine1(java.lang.String)[setUIIDLine1]. The lead component also handles events from a single source. Clicking another component in the hierarchy sends the event to the leading `Button`, which can make action events route to a different target than the apparent click target. That's why `getActualComponent()` exists. @@ -188,8 +188,8 @@ NOTE: `hi` is the name of the parent `Form` in the sample above. ==== Styling dialogs -It's important to style a `Dialog` using https://www.codenameone.com/javadoc/com/codename1/ui/Dialog.html#getDialogStyle--[getDialogStyle()] or -https://www.codenameone.com/javadoc/com/codename1/ui/Dialog.html#setDialogUIID-java.lang.String-[setDialogUIID] methods rather than styling the dialog object directly. +It's important to style a `Dialog` using https://www.codenameone.com/javadoc/com/codename1/ui/Dialog.html#getDialogStyle()[getDialogStyle()] or +https://www.codenameone.com/javadoc/com/codename1/ui/Dialog.html#setDialogUIID(java.lang.String)[setDialogUIID] methods rather than styling the dialog object directly. The reason for this is that the `Dialog` is a `Form` that takes up the whole screen. The `Form` that's visible behind the `Dialog` is rendered as a screenshot. Customizing the actual `UIID` of the `Dialog` won't produce the desired results. @@ -367,7 +367,7 @@ Codename One. - Blinking cursor is rendered on `TextField` - https://www.codenameone.com/javadoc/com/codename1/ui/events/DataChangedListener.html[DataChangeListener] is available in `TextField`. This is crucial for character by character input event tracking -- https://www.codenameone.com/javadoc/com/codename1/ui/TextField.html#setDoneListener-com.codename1.ui.events.ActionListener-[Done listener] is available in the `TextField` +- https://www.codenameone.com/javadoc/com/codename1/ui/TextField.html#setDoneListener(com.codename1.ui.events.ActionListener)[Done listener] is available in the `TextField` - Different `UIID` NOTE: The semantic difference between `TextField` & `TextArea` dates back to the ancestor of Codename One: LWUIT. Feature phones don’t have "proper" in-place editing capabilities & thus `TextField` was introduced to allow such input. @@ -375,7 +375,7 @@ NOTE: The semantic difference between `TextField` & `TextArea` dates back to the Because it lacks the blinking cursor capability `TextArea` is often used as a multi-line label and is used internally in `SpanLabel`, `SpanButton` etc. -TIP: A common use case is to have an important text component in edit mode as you enter a `Form`. Codename One forms support this exact use case through the https://www.codenameone.com/javadoc/com/codename1/ui/Form.html#setEditOnShow-com.codename1.ui.TextArea-[Form.setEditOnShow(TextArea)] method. +TIP: A common use case is to have an important text component in edit mode as you enter a `Form`. Codename One forms support this exact use case through the https://www.codenameone.com/javadoc/com/codename1/ui/Form.html#setEditOnShow(com.codename1.ui.TextArea)[Form.setEditOnShow(TextArea)] method. `TextField` & `TextArea` support constraints for various types of input such as `NUMERIC`, `EMAIL`, `URL`, etc. Those usually @@ -820,7 +820,7 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g .Infinite progress image::img/infinite-progress.png[Infinite progress,scaledwidth=10%] -The image used in the `InfiniteProgress` animation is defined by the native theme. You can override that definition either by defining the theme constant `infiniteImage` or by invoking the https://www.codenameone.com/javadoc/com/codename1/components/InfiniteProgress.html#setAnimation-com.codename1.ui.Image-[setAnimation] method. +The image used in the `InfiniteProgress` animation is defined by the native theme. You can override that definition either by defining the theme constant `infiniteImage` or by invoking the https://www.codenameone.com/javadoc/com/codename1/components/InfiniteProgress.html#setAnimation(com.codename1.ui.Image)[setAnimation] method. NOTE: Despite the name of the method `setAnimation` expects a static image that will be rotated internally. Don't use an animated image. @@ -1734,7 +1734,7 @@ You can bind a `BrowserNavigationCallback` by invoking `setBrowserNavigationCall IMPORTANT: The `shouldNavigate` method from the `BrowserNavigationCallback` is invoked in a native thread and **NOT ON THE EDT**! + it's crucial that this method returns and that it won't do any changes on the UI. -The `shouldNavigate` indicates to the native code whether navigation should proceed or not. For example: if a user clicks a specific link you might choose to do something in the Java code so you can return false and block the navigation. You can invoke https://www.codenameone.com/javadoc/com/codename1/ui/Display.html#callSerially java.lang.Runnable-[callSerially] to do the actual task in the Java side: +The `shouldNavigate` indicates to the native code whether navigation should proceed or not. For example: if a user clicks a specific link you might choose to do something in the Java code so you can return false and block the navigation. You can invoke https://www.codenameone.com/javadoc/com/codename1/ui/Display.html#callSerially(java.lang.Runnable)[callSerially] to do the actual task in the Java side: [source,java] ---- @@ -1755,7 +1755,7 @@ NOTE: The JavaScript Bridge is implemented on top of the `BrowserNavigationCallb TIP: The JavaScript bridge is sometimes confused with the JavaScript Port. The JavaScript bridge allows you to communicate with JavaScript from Java (and vice versa). The JavaScript port allows you to compile the Codename One application into a JavaScript application that runs in a standard web browser without code changes (think GWT without source changes and with thread support).+ You discuss the JavaScript port further later in the guide. -Use the `BrowserComponent` API to interact with JavaScript. It replaces the deprecated https://www.codenameone.com/javadoc/com/codename1/JavaScript/package-summary.html[com.codename1.JavaScript package]. +Use the `BrowserComponent` API to interact with JavaScript. It replaces the deprecated https://www.codenameone.com/javadoc/com/codename1/javascript/package-summary.html[`com.codename1.javascript` package]. ===== What was wrong with the old API @@ -1773,7 +1773,7 @@ The new API fully embraces the asynchronous nature of JavaScript. It uses callba NOTE: In all the sample code below, you can assume that variables named `bc` represent an instance of https://www.codenameone.com/javadoc/com/codename1/ui/BrowserComponent.html[BrowserComponent]: -This code should output "The result was 7" to the console. It's fully asynchronous, so you can include this code anywhere without worrying about it "bogging down" your code. The full signature of this form of the https://www.codenameone.com/javadoc/com/codename1/ui/BrowserComponent.html#execute-java.lang.String-com.codename1.util.SuccessCallback-[execute()] method is: +This code should output "The result was 7" to the console. It's fully asynchronous, so you can include this code anywhere without worrying about it "bogging down" your code. The full signature of this form of the https://www.codenameone.com/javadoc/com/codename1/ui/BrowserComponent.html#execute(java.lang.String,com.codename1.util.SuccessCallback)[execute()] method is: The first parameter is a JavaScript expression. This JavaScript *MUST* call either `callback.onSuccess(result)` or `callback.onError(message, errCode)` at some point in order for your callback to be called. @@ -1809,7 +1809,7 @@ Now it will work no matter how many times the button is clicked. ===== Passing parameters to JavaScript -Often, the JavaScript expressions that you execute will include parameters from your Java code. Escaping these parameters is tricky at worst, and annoying at best. For example: If you’re passing a string, you need to make sure that it escapes quotes and new lines or it will cause the JavaScript to have a syntax error. You provide variants of `execute()` and https://www.codenameone.com/javadoc/com/codename1/ui/BrowserComponent.html#addJSCallback-java.lang.String-com.codename1.util.SuccessCallback-[addJSCallback()] that allow you to pass your parameters and have them automatically escaped. +Often, the JavaScript expressions that you execute will include parameters from your Java code. Escaping these parameters is tricky at worst, and annoying at best. For example: If you’re passing a string, you need to make sure that it escapes quotes and new lines or it will cause the JavaScript to have a syntax error. You provide variants of `execute()` and https://www.codenameone.com/javadoc/com/codename1/ui/BrowserComponent.html#addJSCallback(java.lang.String,com.codename1.util.SuccessCallback)[addJSCallback()] that allow you to pass your parameters and have them automatically escaped. For example, suppose you want to pass a string with text to set in a textarea within the webpage. You can do something like: @@ -1859,7 +1859,7 @@ Coupled with `shouldNavigate` you can effectively do everything which is what th While it's possible to build everything on top of `execute` and `shouldNavigate`, both of these methods have their limits. That's why Codename One introduced the JavaScript package, it allows you to communicate with JavaScript using intuitive code/syntax. -The https://www.codenameone.com/javadoc/com/codename1/JavaScript/JavascriptContext.html[JavascriptContext] class lays the foundation by enabling you to call JavaScript code directly from Java. It provides automatic type conversion between Java and JavaScript types as follows: +The https://www.codenameone.com/javadoc/com/codename1/javascript/JavascriptContext.html[JavascriptContext] class lays the foundation by enabling you to call JavaScript code directly from Java. It provides automatic type conversion between Java and JavaScript types as follows: .Java to JavaScript [cols="2*",options="header"] @@ -1888,7 +1888,7 @@ The https://www.codenameone.com/javadoc/com/codename1/JavaScript/JavascriptConte | `undefined` | `null` |==== -NOTE: This conversion table is more verbose than necessary, since JavaScript functions and arrays are, in fact Objects themselves, so those rows are redundant. All JavaScript objects are converted to https://www.codenameone.com/javadoc/com/codename1/JavaScript/JSObject.html[JSObject]. +NOTE: This conversion table is more verbose than necessary, since JavaScript functions and arrays are, in fact Objects themselves, so those rows are redundant. All JavaScript objects are converted to https://www.codenameone.com/javadoc/com/codename1/javascript/JSObject.html[JSObject]. You can access JavaScript variables from the context by using code like this: diff --git a/docs/developer-guide/Working-With-Windows.asciidoc b/docs/developer-guide/Working-With-Windows.asciidoc index 33aa88bc93e..d9f97a2b141 100644 --- a/docs/developer-guide/Working-With-Windows.asciidoc +++ b/docs/developer-guide/Working-With-Windows.asciidoc @@ -84,7 +84,7 @@ above. Trigger it like any other cloud target: include::../demos/common/src/main/snippets/developer-guide/working-with-windows.sh[tag=working-with-windows-bash-001,indent=0] ---- -The convenience goal `mvn -pl common cn1:buildWin32` does the same thing. A regular +The convenience goal `mvn cn1:buildWin32`, run from the project root, does the same thing. A regular (release) build returns **two** binaries -- x64 and arm64, both stripped release exes. Setting the `windows.debug` build hint instead returns a **single** x64 exe with symbols, for diagnosis. (To build locally on a Windows box, use diff --git a/docs/developer-guide/appendix_goal_generate_native_interfaces.adoc b/docs/developer-guide/appendix_goal_generate_native_interfaces.adoc index 50293736521..84408f4d2b9 100644 --- a/docs/developer-guide/appendix_goal_generate_native_interfaces.adoc +++ b/docs/developer-guide/appendix_goal_generate_native_interfaces.adoc @@ -4,11 +4,11 @@ Generates stub implementations for all native interfaces defined in the project. You should run this goal explicitly after you create a native interface in your class. -See the Codename One Developer guide section on https://www.codenameone.com/developer-guide.html#_native_interfaces[native interfaces] for more information on creating native interfaces. +See the Codename One Developer guide section on <<_native_interfaces,native interfaces>> for more information on creating native interfaces. ==== Usage example -Suppose you've created a native interface as the Java interface `com.mycompany.myapp.MyNative`, as described in the example in https://www.codenameone.com/developer-guide.html#_native_interfaces[native interfaces]. +Suppose you've created a native interface as the Java interface `com.mycompany.myapp.MyNative`, as described in the example in <<_native_interfaces,native interfaces>>. After creating this (and possibly other) native interfaces in your project, run the `generate-native-interfaces` Maven goal as follows: diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc index eb415d6d4e8..06bc04d4d4b 100644 --- a/docs/developer-guide/css.asciidoc +++ b/docs/developer-guide/css.asciidoc @@ -188,7 +188,7 @@ a|3D lowered text. For example, `text-decoration: cn1-3d-lowered;` image:img/cn1 |3D text with north shadow. For example, `text-decoration: cn1-3d-shadow-north;` image:img/cn1-3d-shadow-north.png[cn1-3d-shadow-north screenshot] |=== -For other CSS font settings see link:Fonts[the Fonts section] +For other CSS font settings see <> [[border]] ==== Border @@ -241,10 +241,10 @@ include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-011,i `cn1-pill-border` and `cn1-round-border` don't support the standard CSS `box-shadow` property. This is because the `box-shadow` property parameters don't map onto the shadow parameters for the Codename One `RoundBorder` class. To get shadows on the `cn1-pill-border`, you should use one or more of the following CSS properties: -* `cn1-box-shadow-spread`: Accepts values in any scalar unit (for example, px, mm, cm, etc.). This maps directly to the border's https://www.codenameone.com/javadoc/com/codename1/ui/plaf/RoundBorder.html#shadowSpread-int-boolean-[shadowSpread] property. -* `cn1-box-shadow-h`: Accepts values in real values or integers (not a scalar unit). This maps directly to the border's https://www.codenameone.com/javadoc/com/codename1/ui/plaf/RoundBorder.html#shadowX-float-[shadowX] property. -* `cn1-box-shadow-v`: Accepts values in real values or integers (not a scalar unit). This maps directly to the border's https://www.codenameone.com/javadoc/com/codename1/ui/plaf/RoundBorder.html#shadowY-float-[shadowY] property. -* `cn1-box-shadow-blur`: Scalar value. Maps to the border's https://www.codenameone.com/javadoc/com/codename1/ui/plaf/RoundBorder.html#shadowBlur-float-[shadowBlur] property. +* `cn1-box-shadow-spread`: Accepts values in any scalar unit (for example, px, mm, cm, etc.). This maps directly to the border's https://www.codenameone.com/javadoc/com/codename1/ui/plaf/RoundBorder.html#shadowSpread(int,boolean)[shadowSpread] property. +* `cn1-box-shadow-h`: Accepts values in real values or integers (not a scalar unit). This maps directly to the border's https://www.codenameone.com/javadoc/com/codename1/ui/plaf/RoundBorder.html#shadowX(float)[shadowX] property. +* `cn1-box-shadow-v`: Accepts values in real values or integers (not a scalar unit). This maps directly to the border's https://www.codenameone.com/javadoc/com/codename1/ui/plaf/RoundBorder.html#shadowY(float)[shadowY] property. +* `cn1-box-shadow-blur`: Scalar value. Maps to the border's https://www.codenameone.com/javadoc/com/codename1/ui/plaf/RoundBorder.html#shadowBlur(float)[shadowBlur] property. * `cn1-box-shadow-color`: The shadow color * `cn1-box-shadow-inset`: Set to `inset` to render an inner shadow instead of the default outer shadow spread. @@ -270,7 +270,7 @@ WARNING: 9-piece Image borders always take precedence over background settings i ===== Background images -See link:Images[Images] +See <> ===== Gradients @@ -558,6 +558,7 @@ include::../demos/common/src/main/css/guide-snippets-theme.css[tag=css-css-026,i CN1 resource files support both PNG and JPEG images, but PNG is the default. Multi-images that are generated by the CSS compiler will be PNG if they include alpha transparency, and JPEG otherwise. This is to try to reduce the file size as much as possible while not sacrificing quality. +[[Fonts]] === Fonts This library supports the https://developer.mozilla.org/en/docs/Web/CSS/font[font], https://developer.mozilla.org/en/docs/Web/CSS/font-size[font-size], https://developer.mozilla.org/en/docs/Web/CSS/font-family[font-family], https://developer.mozilla.org/en/docs/Web/CSS/font-style[font-style], https://developer.mozilla.org/en/docs/Web/CSS/font-weight[font-weight], and https://developer.mozilla.org/en/docs/Web/CSS/text-decoration[text-decoration] properties, as well as the https://developer.mozilla.org/en/docs/Web/CSS/@font-face[@font-face] CSS "at" rule for including TrueType and OpenType fonts. @@ -701,7 +702,7 @@ NOTE: When a new iPhone model ships with a resolution that isn't yet in the iOS ==== `text-decoration` -See link:Supported-Properties#text-decoration[the text-decoration section] in the "Supported Properties" page. +See <>. ==== Some sample CSS directives diff --git a/docs/developer-guide/graphics.asciidoc b/docs/developer-guide/graphics.asciidoc index 968fd224f1e..a3e14e36d37 100644 --- a/docs/developer-guide/graphics.asciidoc +++ b/docs/developer-guide/graphics.asciidoc @@ -198,14 +198,14 @@ to allow for smoother edges by using quadratic curves instead of lines. Codename One's `GeneralPath` class includes two methods for drawing curves: -1. https://www.codenameone.com/javadoc/com/codename1/ui/geom/GeneralPath.html#quadTo(float,%20float,%20float,%20float)[`quadTo()`] : +1. https://www.codenameone.com/javadoc/com/codename1/ui/geom/GeneralPath.html#quadTo(float,float,float,float)[`quadTo()`] : Appends a quadratic Bézier curve. It takes 2 points: a control point, and an end point. -2. link:https://www.codenameone.com/javadoc/com/codename1/ui/geom/GeneralPath.html#curveTo(float,%20float,%20float,%20float,%20float,%20float)[`curveTo()`] : +2. link:https://www.codenameone.com/javadoc/com/codename1/ui/geom/GeneralPath.html#curveTo(float,float,float,float,float,float)[`curveTo()`] : Appends a cubic Bézier curve, taking 3 points: 2 control points, and an end point. See the https://www.codenameone.com/javadoc/com/codename1/ui/geom/GeneralPath.html[General Path javadocs] for the full API. -You will make use of the link:https://www.codenameone.com/javadoc/com/codename1/ui/geom/GeneralPath.html#quadTo(float,%20float,%20float,%20float)[`quadTo()`] +You will make use of the link:https://www.codenameone.com/javadoc/com/codename1/ui/geom/GeneralPath.html#quadTo(float,float,float,float)[`quadTo()`] method to append curves to the drawing as follows: @@ -450,7 +450,7 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g .Shape Clipping used to clip the image of duke within the given shape image::img/shaped-clipping.png[Shape Clipping used to clip the image of duke within the given shape,scaledwidth=20%] -TIP: Notice that this functionality isn't available on all platforms so you need to test if shaped clipping is supported using https://www.codenameone.com/javadoc/com/codename1/ui/Graphics.html#isShapeClipSupported--[isShapeClipSupported()]. +TIP: Notice that this functionality isn't available on all platforms so you need to test if shaped clipping is supported using https://www.codenameone.com/javadoc/com/codename1/ui/Graphics.html#isShapeClipSupported()[isShapeClipSupported()]. === The coordinate system @@ -631,7 +631,7 @@ Here are the pros/cons and logic behind every image type. This covers the logic ==== Loaded Image This is the basic image you get when loading an image from the jar or network using -https://www.codenameone.com/javadoc/com/codename1/ui/Image.html#createImage-java.lang.String-[Image.createImage(String)], https://www.codenameone.com/javadoc/com/codename1/ui/Image.html#createImage-java.io.InputStream-[Image.createImage(InputStream)] & https://www.codenameone.com/javadoc/com/codename1/ui/Image.html#createImage-byte:A-int-int-[Image.`createImage(byte[], int, int)`],... +https://www.codenameone.com/javadoc/com/codename1/ui/Image.html#createImage(java.lang.String)[Image.createImage(String)], https://www.codenameone.com/javadoc/com/codename1/ui/Image.html#createImage(java.io.InputStream)[Image.createImage(InputStream)] & https://www.codenameone.com/javadoc/com/codename1/ui/Image.html#createImage(byte%5B%5D,int,int)[Image.`createImage(byte array, int, int)`],... TIP: Some other APIs might return this image type but those APIs do so explicitly! @@ -659,7 +659,7 @@ There are two types of RGB constructed images that are different from one anothe ===== Internal -This is a close cousin of the loaded image. This image is created using the method https://www.codenameone.com/javadoc/com/codename1/ui/Image.html#createImage-int:A-int-int-[Image.createImage(int array, int, int)] and receives the AARRGGBB data to form the image. It's more efficient than the Codename One RGB image but can't be modified, at least not on the pixel level. +This is a close cousin of the loaded image. This image is created using the method https://www.codenameone.com/javadoc/com/codename1/ui/Image.html#createImage(int%5B%5D,int,int)[Image.createImage(int array, int, int)] and receives the AARRGGBB data to form the image. It's more efficient than the Codename One RGB image but can't be modified, at least not on the pixel level. The goal of this image type is to provide an easy way to render RGB data that isn't modified efficiently at platform native speeds. It's technically a <> internally. @@ -689,7 +689,7 @@ When drawing an `EncodedImage` it checks the weak reference cache and if the ima `EncodedImage` isn't final and can be derived to produce complex image fetching strategies for example: the https://www.codenameone.com/javadoc/com/codename1/ui/URLImage.html[URLImage] class that can dynamically download its content from the web. -`EncodedImage` can be instantiated through the create methods in the `EncodedImage` class. Pretty much any image can be converted into an `EncodedImage` through the https://www.codenameone.com/javadoc/com/codename1/ui/EncodedImage.html#createFromImage-com.codename1.ui.Image-boolean-[createFromImage(Image, boolean)] method. +`EncodedImage` can be instantiated through the create methods in the `EncodedImage` class. Pretty much any image can be converted into an `EncodedImage` through the https://www.codenameone.com/javadoc/com/codename1/ui/EncodedImage.html#createFromImage(com.codename1.ui.Image,boolean)[createFromImage(Image, boolean)] method. .EncodedImage Locking diff --git a/docs/developer-guide/img/border-layout-RTL.png b/docs/developer-guide/img/border-layout-RTL.png index 95e79675f1f..edf98faecda 100644 Binary files a/docs/developer-guide/img/border-layout-RTL.png and b/docs/developer-guide/img/border-layout-RTL.png differ diff --git a/docs/developer-guide/img/border-layout-center.png b/docs/developer-guide/img/border-layout-center.png index 398b2658b15..aec5b063013 100644 Binary files a/docs/developer-guide/img/border-layout-center.png and b/docs/developer-guide/img/border-layout-center.png differ diff --git a/docs/developer-guide/img/border-layout.png b/docs/developer-guide/img/border-layout.png index b88ae544390..b31ed1a2f83 100644 Binary files a/docs/developer-guide/img/border-layout.png and b/docs/developer-guide/img/border-layout.png differ diff --git a/docs/developer-guide/img/box-layout-x-no-grow.png b/docs/developer-guide/img/box-layout-x-no-grow.png index df53706a6c4..e6daaeaab7b 100644 Binary files a/docs/developer-guide/img/box-layout-x-no-grow.png and b/docs/developer-guide/img/box-layout-x-no-grow.png differ diff --git a/docs/developer-guide/img/box-layout-x.png b/docs/developer-guide/img/box-layout-x.png index a676564e83f..df40eb73e99 100644 Binary files a/docs/developer-guide/img/box-layout-x.png and b/docs/developer-guide/img/box-layout-x.png differ diff --git a/docs/developer-guide/img/box-layout-y.png b/docs/developer-guide/img/box-layout-y.png index 6c5aa6e082b..aff6e8fa8ba 100644 Binary files a/docs/developer-guide/img/box-layout-y.png and b/docs/developer-guide/img/box-layout-y.png differ diff --git a/docs/developer-guide/img/flow-layout-center-middle.png b/docs/developer-guide/img/flow-layout-center-middle.png index f67dfa2281b..291aff0a991 100644 Binary files a/docs/developer-guide/img/flow-layout-center-middle.png and b/docs/developer-guide/img/flow-layout-center-middle.png differ diff --git a/docs/developer-guide/img/flow-layout-center.png b/docs/developer-guide/img/flow-layout-center.png index abb0f26a28a..1f1e88ca095 100644 Binary files a/docs/developer-guide/img/flow-layout-center.png and b/docs/developer-guide/img/flow-layout-center.png differ diff --git a/docs/developer-guide/img/flow-layout-right.png b/docs/developer-guide/img/flow-layout-right.png index 333ffa7c2b2..7bd6e9838dc 100644 Binary files a/docs/developer-guide/img/flow-layout-right.png and b/docs/developer-guide/img/flow-layout-right.png differ diff --git a/docs/developer-guide/img/flow-layout.png b/docs/developer-guide/img/flow-layout.png index 609f84de599..20825fa27fb 100644 Binary files a/docs/developer-guide/img/flow-layout.png and b/docs/developer-guide/img/flow-layout.png differ diff --git a/docs/developer-guide/img/get-device-udid.png b/docs/developer-guide/img/get-device-udid.png deleted file mode 100644 index 1ce13e38367..00000000000 Binary files a/docs/developer-guide/img/get-device-udid.png and /dev/null differ diff --git a/docs/developer-guide/img/getting-started-right-click-menu.png b/docs/developer-guide/img/getting-started-right-click-menu.png deleted file mode 100644 index 23021c621ce..00000000000 Binary files a/docs/developer-guide/img/getting-started-right-click-menu.png and /dev/null differ diff --git a/docs/developer-guide/img/grid-layout-2x2.png b/docs/developer-guide/img/grid-layout-2x2.png index ba2329cce9b..898c1124e72 100644 Binary files a/docs/developer-guide/img/grid-layout-2x2.png and b/docs/developer-guide/img/grid-layout-2x2.png differ diff --git a/docs/developer-guide/img/grid-layout-2x4.png b/docs/developer-guide/img/grid-layout-2x4.png index af3242b504a..34f340e6257 100644 Binary files a/docs/developer-guide/img/grid-layout-2x4.png and b/docs/developer-guide/img/grid-layout-2x4.png differ diff --git a/docs/developer-guide/img/grid-layout-autofit-landscape.png b/docs/developer-guide/img/grid-layout-autofit-landscape.png index 4cf353d5626..e01088fc2ed 100644 Binary files a/docs/developer-guide/img/grid-layout-autofit-landscape.png and b/docs/developer-guide/img/grid-layout-autofit-landscape.png differ diff --git a/docs/developer-guide/img/grid-layout-autofit-portrait.png b/docs/developer-guide/img/grid-layout-autofit-portrait.png index 7e62c765a54..4404b3936a5 100644 Binary files a/docs/developer-guide/img/grid-layout-autofit-portrait.png and b/docs/developer-guide/img/grid-layout-autofit-portrait.png differ diff --git a/docs/developer-guide/img/gridbag-layout.png b/docs/developer-guide/img/gridbag-layout.png index e9c6b977d5f..651217b3ded 100644 Binary files a/docs/developer-guide/img/gridbag-layout.png and b/docs/developer-guide/img/gridbag-layout.png differ diff --git a/docs/developer-guide/img/group-layout.png b/docs/developer-guide/img/group-layout.png index 9a886156db2..c1f087123ca 100644 Binary files a/docs/developer-guide/img/group-layout.png and b/docs/developer-guide/img/group-layout.png differ diff --git a/docs/developer-guide/img/guibuilder-2-insets-1.png b/docs/developer-guide/img/guibuilder-2-insets-1.png index 179a9cebcea..257fd9e0448 100644 Binary files a/docs/developer-guide/img/guibuilder-2-insets-1.png and b/docs/developer-guide/img/guibuilder-2-insets-1.png differ diff --git a/docs/developer-guide/img/guibuilder-2-insets-2.png b/docs/developer-guide/img/guibuilder-2-insets-2.png index e3051918c76..939d38580af 100644 Binary files a/docs/developer-guide/img/guibuilder-2-insets-2.png and b/docs/developer-guide/img/guibuilder-2-insets-2.png differ diff --git a/docs/developer-guide/img/guibuilder-2-insets-3.png b/docs/developer-guide/img/guibuilder-2-insets-3.png index 3aa0bfbf478..a200270d4b4 100644 Binary files a/docs/developer-guide/img/guibuilder-2-insets-3.png and b/docs/developer-guide/img/guibuilder-2-insets-3.png differ diff --git a/docs/developer-guide/img/layered-layout.png b/docs/developer-guide/img/layered-layout.png index af04164ae34..2c8aa052ecd 100644 Binary files a/docs/developer-guide/img/layered-layout.png and b/docs/developer-guide/img/layered-layout.png differ diff --git a/docs/developer-guide/img/layered-layout.tolerance b/docs/developer-guide/img/layered-layout.tolerance new file mode 100644 index 00000000000..4f1e24ed098 --- /dev/null +++ b/docs/developer-guide/img/layered-layout.tolerance @@ -0,0 +1,15 @@ +# This figure contains a FontImage material glyph. Measured against the CI +# runner's own output, the glyph lands at exactly the same size and position -- +# a 55x49 bounding box at the same origin -- and differs only in antialiased +# edge coverage: 946 fully-white pixels against 916, 173 differing pixels in +# total, 0.113% of the image. Java2D rasterizes the same glyph from the same +# bundled font at the same size slightly differently on macOS and Linux. +# +# The two bounds are independent and both bind. maxMismatchPercent limits how +# much of the image may change AT ALL -- every differing pixel counts, not only +# those past the delta -- so a wholesale recolour cannot slip through by moving +# each channel a little at a time. maxChannelDelta then caps how far any single +# pixel may move: 160 clears the measured worst of 141 with room, while a solid +# overwrite still fails on it. +maxChannelDelta=160 +maxMismatchPercent=0.3 diff --git a/docs/developer-guide/img/mig-layout.png b/docs/developer-guide/img/mig-layout.png index 910e722133c..4f57360b62f 100644 Binary files a/docs/developer-guide/img/mig-layout.png and b/docs/developer-guide/img/mig-layout.png differ diff --git a/docs/developer-guide/img/table-layout-2x2.png b/docs/developer-guide/img/table-layout-2x2.png index 7a066de29dc..dae9bdd4053 100644 Binary files a/docs/developer-guide/img/table-layout-2x2.png and b/docs/developer-guide/img/table-layout-2x2.png differ diff --git a/docs/developer-guide/img/table-layout-constraints.png b/docs/developer-guide/img/table-layout-constraints.png index c9afd65acc6..8ef75925dd6 100644 Binary files a/docs/developer-guide/img/table-layout-constraints.png and b/docs/developer-guide/img/table-layout-constraints.png differ diff --git a/docs/developer-guide/img/table-layout-enclose.png b/docs/developer-guide/img/table-layout-enclose.png index 0bce1e15513..90eb64903a3 100644 Binary files a/docs/developer-guide/img/table-layout-enclose.png and b/docs/developer-guide/img/table-layout-enclose.png differ diff --git a/docs/developer-guide/io.asciidoc b/docs/developer-guide/io.asciidoc index 43a688bb488..d1b15a90a20 100644 --- a/docs/developer-guide/io.asciidoc +++ b/docs/developer-guide/io.asciidoc @@ -905,11 +905,11 @@ For example, the `URLImage` assumes that you know the size of the image in advan The download methods mentioned above are great alternatives but they're a bit verbose when working with images and don't provide fine grained control over the `ConnectionRequest` for example: making a `POST` request to get an image. TIP: Adding global headers is another use case but you can use -https://www.codenameone.com/javadoc/com/codename1/io/NetworkManager.html#addDefaultHeader-java.lang.String-java.lang.String-[addDefaultHeader] +https://www.codenameone.com/javadoc/com/codename1/io/NetworkManager.html#addDefaultHeader(java.lang.String,java.lang.String)[addDefaultHeader] to add those. To make this process simpler there is a set of helper methods in -https://www.codenameone.com/javadoc/com/codename1/io/ConnectionRequest.html#downloadImageToStorage-java.lang.String-com.codename1.util.SuccessCallback-[ConnectionRequest that downloads images directly]. +https://www.codenameone.com/javadoc/com/codename1/io/ConnectionRequest.html#downloadImageToStorage(java.lang.String,com.codename1.util.SuccessCallback)[ConnectionRequest that downloads images directly]. These methods complement the `Util` methods but go a bit further and feature terse syntax for example: you can download a `ConnectionRequest` to `Storage` using code like this: diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildWin32Mojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildWin32Mojo.java index 0e30ea08ac6..3dcc73ad09e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildWin32Mojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildWin32Mojo.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.maven.buildWrappers; @@ -23,7 +45,14 @@ public class BuildWin32Mojo extends AbstractBuildWrapperMojo { @Override protected String getPlatform() { - return "windows"; + // "win", not "windows". This value activates the module profile in the + // generated project's root pom, and that profile matches the value the + // win module itself declares -- which is "win". Passing "windows" + // matched no profile, so the win module never entered the reactor and + // the wrapper's nested build reported success having produced nothing. + // Nothing else reads the platform as "windows"; the build TARGET stays + // "windows-device", which is a separate namespace. + return "win"; } @Override diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildWindowsDeviceMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildWindowsDeviceMojo.java index 4b098dd4861..cf1ec7e44dd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildWindowsDeviceMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildWindowsDeviceMojo.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.maven.buildWrappers; @@ -15,7 +37,14 @@ public class BuildWindowsDeviceMojo extends AbstractBuildWrapperMojo { @Override protected String getPlatform() { - return "windows"; + // "win", not "windows". This value activates the module profile in the + // generated project's root pom, and that profile matches the value the + // win module itself declares -- which is "win". Passing "windows" + // matched no profile, so the win module never entered the reactor and + // the wrapper's nested build reported success having produced nothing. + // Nothing else reads the platform as "windows"; the build TARGET stays + // "windows-device", which is a separate namespace. + return "win"; } @Override diff --git a/scripts/developer-guide/check-guide-links.py b/scripts/developer-guide/check-guide-links.py new file mode 100755 index 00000000000..9782e320550 --- /dev/null +++ b/scripts/developer-guide/check-guide-links.py @@ -0,0 +1,1060 @@ +#!/usr/bin/env python3 +"""Check the developer guide's links against the site that has to serve them. + +Three failures the guide shipped, none of which any existing gate could see: + +* Fifteen ``codenameone.com/manual/.html`` deep links. ``_redirects`` + covers ``/manual`` and ``/manual/`` exactly and has no splat, so every one of + them 404s -- and every one points at a section inside this same book, which an + internal cross-reference would have reached. +* ``https://stackoverflow/tags/codenameone/`` -- a host with no dot in it, which + resolves nowhere. +* Plain ``http://`` links to hosts that have not answered on port 80 for years. + +Rather than curate a list of good links, this derives the set of paths the site +actually serves -- the redirect table plus the Hugo content tree -- and reports +any codenameone.com link that lands outside it. The baseline is a ratchet: it may +shrink, never grow. +""" +from __future__ import annotations + +import argparse +import collections +import datetime +import re +import sys +from pathlib import Path, PurePosixPath +from urllib.parse import urlsplit + +ASCIIDOC_EXTENSIONS = {".adoc", ".asciidoc"} +URL_RE = re.compile(r"\bhttps?://[^\s\[\]<>\"'`)]+", re.IGNORECASE) +# A URL assembled from an attribute is invisible to the scan above: the +# declaration holds a valid site root and the use site holds only "{name}", so the +# path that actually ships is never checked. Expanding attributes properly means +# reimplementing asciidoctor's attribute resolution, including inheritance through +# includes, which is a lot of machinery for a construct the guide does not use -- +# measured: zero URL-valued attribute declarations and zero link:{...} targets. +# So both spellings are refused instead, which keeps the gap from opening quietly. +ATTRIBUTE_URL_DECL_RE = re.compile(r"^:[A-Za-z0-9_-]+:\s*https?://") +ATTRIBUTE_LINK_RE = re.compile( + r"""(?:\blink:|\bxref:|\bhref\s*=\s*["']?)\{[^}]+\}""", re.IGNORECASE +) +# An array parameter written raw ends the AsciiDoc link macro at the "[", so the +# URL is cut mid-signature and the label is lost -- #createImage(byte[],int,int) +# rendered as a bare link to "#createImage(byte". It has to be written %5B%5D. +# Detected in the source, because URL_RE stops at that "[" and never sees it. An +# empty bracket pair is unambiguous: a label is never empty. +RAW_ARRAY_IN_JAVADOC_RE = re.compile(r"/javadoc/[^\s\[]*#[^\s\[]*\[\]") +# A root-relative target names a website route just as an absolute URL does, but +# it carries no scheme, so URL_RE never sees it -- and check-guide-xrefs.py skips +# hrefs beginning with "/" because they are not same-page anchors. Between the two +# gates the route went unchecked, so it is run through the same route model here. +ROOT_RELATIVE_RE = re.compile( + # The quote is optional: is valid HTML, and the character class + # already stops at whitespace or ">", which is exactly where an unquoted + # attribute value ends. + r"""(?:\blink:|\bxref:|\bhref\s*=\s*["']?)(/[^\s\[\]"'`>]*)""", re.IGNORECASE +) +# The one tree that genuinely cannot be enumerated from this repository: the +# Javadoc is produced from the framework sources at build time. Everything else, +# /developer-guide/ included, is derived below -- whitelisting a prefix silently +# exempts every path under it from the check. +GENERATED_PREFIXES = ("/javadoc/",) +_JAVADOC_ROOT: Path | None = None +# http:// is correct for these: RFC 3161 timestamping servers reject TLS, and +# example.com URLs are illustrative rather than fetched. +TLS_EXEMPT_HOSTS = {"timestamp.digicert.com", "example.com", "www.example.com"} +# Hosts with no dot that are still real destinations. +LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0"} +# Writing a scheme's own default port changes nothing about where the request goes. +DEFAULT_PORTS = {"http": 80, "https": 443} +# Only the marketing site is served from the redirect table and the Hugo content +# tree. cloud.codenameone.com and friends are separate services. +SITE_HOSTS = {"codenameone.com", "www.codenameone.com"} +# The routes that serve this very book. A link from inside the guide to one of +# these, carrying a fragment, is a cross-reference wearing a URL: it leaves the +# reader's PDF or offline copy to fetch a page they are already reading, and no +# gate can tell that a renamed section broke the fragment, because the anchor +# lives in the rendered book rather than in the site tree. Written as `<>` +# instead, check-guide-xrefs.py resolves it against the rendered anchors. +SELF_PATHS = {"/developer-guide", "/developer-guide.html", "/manual", "/manual.html"} + + +def strip_inline_comment(raw: str) -> str: + """Drop a YAML/TOML trailing comment, leaving a quoted value untouched. + + `draft: true # not ready` stored the whole tail, so the draft test compared + against "true # not ready" and the page counted as published. A quoted value + keeps everything inside the quotes -- a url may legitimately contain "#". + """ + text = raw.strip() + if text[:1] in {'"', "'"}: + quote = text[0] + end = text.find(quote, 1) + return text[1:end] if end != -1 else text[1:] + if text.startswith("#"): + return "" + cut = re.search(r"\s#", text) + return (text[: cut.start()] if cut else text).strip().strip("\"'") + + +def front_matter(page: Path) -> dict[str, object]: + """Pull the few front-matter keys that decide a page's published route. + + Deliberately not a YAML parse: the tree mixes YAML and TOML front matter and + only a few keys matter here. Note the list is an allowlist -- a key absent from + it reads as empty downstream, which is how publishDate and expiryDate came to + be queried by is_published() while never being stored. + """ + text = page.read_text(encoding="utf-8", errors="ignore") + lines = text.split("\n") + if not lines or lines[0].strip() not in {"---", "+++"}: + return {} + fence = lines[0].strip() + out: dict[str, object] = {} + aliases: list[str] = [] + in_aliases = False + for line in lines[1:]: + if line.strip() == fence: + break + if in_aliases: + stripped = line.strip() + if stripped in {"]", "],"}: + in_aliases = False + continue + # YAML block sequence ("- /x") and TOML/flow array ("\"/x\",") both + # appear in this tree, so accept either continuation shape. + if stripped.startswith("-") or stripped.startswith(("\"", "'")): + aliases.append(strip_inline_comment(stripped.lstrip("- ").strip().rstrip(","))) + continue + in_aliases = False + # Hugo treats front-matter keys case-insensitively, and this tree mixes + # YAML and TOML, so match either spelling and store one canonical form. + match = re.match( + r'^(url|slug|aliases|draft|date|publishdate|expirydate)\s*[:=]\s*(.*)$', + line, + re.IGNORECASE, + ) + if not match: + continue + key, raw = match.group(1).lower(), match.group(2).strip() + if key == "aliases": + if raw in {"", "[", "[]"}: + in_aliases = raw != "[]" + else: + aliases.extend(strip_inline_comment(v) for v in raw.strip("[]").split(",") if v.strip()) + continue + out[key] = strip_inline_comment(raw) + if aliases: + out["aliases"] = aliases + return out + + +def parse_moment(value: str) -> datetime.datetime | None: + """Parse a Hugo front-matter timestamp, with or without a time of day.""" + text = value.strip().strip("\"'") + if not text: + return None + text = text.replace("Z", "+00:00") + if " " in text and "T" not in text: + text = text.replace(" ", "T", 1) + try: + moment = datetime.datetime.fromisoformat(text) + except ValueError: + try: + moment = datetime.datetime.fromisoformat(text[:10]) + except ValueError: + return None + if moment.tzinfo is None: + moment = moment.replace(tzinfo=datetime.timezone.utc) + return moment + + +def is_published(meta: dict[str, object], now: datetime.datetime) -> bool: + """Hugo defaults buildDrafts and buildFuture to false, so neither reaches the site. + + The date test makes the result depend on when it runs, which is not ideal in a + gate. It is kept because it mirrors what the site actually serves: a link to a + post that has not been published yet is genuinely broken until it is. + + publishDate DECIDES availability when it is set; date is only the fallback. + Treating either as disqualifying rejected a page that carries a future `date` + and a past `publishDate`, which Hugo publishes. And the comparison keeps the + time of day: the site rebuilds once a day, so a page scheduled for later today + is genuinely absent until the next build, and truncating to the calendar day + called it live. + """ + if str(meta.get("draft", "")).strip().strip("\"'").lower() in {"true", "yes"}: + return False + + published_at = parse_moment(str(meta.get("publishdate", ""))) or parse_moment( + str(meta.get("date", "")) + ) + if published_at and published_at > now: + return False + expires_at = parse_moment(str(meta.get("expirydate", ""))) + return not (expires_at and expires_at <= now) + + +def normalize_path(value: str) -> str: + value = value.strip() + if not value: + return "" + if not value.startswith("/"): + value = "/" + value + return value.rstrip("/") or "/" + + +def redirect_pattern(source: str) -> tuple[re.Pattern[str], list[str]] | None: + """Compile a _redirects source that is not a literal path. + + Netlify sources may end in a `*` splat or contain `:placeholder` segments, and + 21 of the rules in this file do. Recording `/files/cn1libs/*` as a literal + string means a real link to `/files/cn1libs/foo.cn1lib` matches nothing and is + reported as broken. Returns the pattern and the capture names in order, so the + destination can be reconstructed from a match. + """ + if "*" not in source and ":" not in source: + return None + names: list[str] = [] + pattern = "" + for part in re.split(r"(\*|:[A-Za-z_][A-Za-z0-9_]*)", source): + if not part: + continue + if part == "*": + names.append("splat") + pattern += "(.*)" + elif part.startswith(":"): + names.append(part[1:]) + pattern += "([^/]+)" + else: + pattern += re.escape(part) + return re.compile("^" + pattern + "/?$"), names + + +def exact_redirect_pattern(source: str) -> re.Pattern[str] | None: + """Compile a source so it matches ONLY the spelling it declares. + + redirect_pattern() ends every rule with "/?" so a link resolves whichever way + it is written, which is right for routing and wrong for asking "does the table + declare this exact form". `/*.html/ /:splat/` really does cover /download.html/ + -- the slash is part of the source -- and matching it slash-insensitively would + have said the same about /download.html, which it does not cover. + """ + if "*" not in source and ":" not in source: + return None + pattern = "" + for part in re.split(r"(\*|:[A-Za-z_][A-Za-z0-9_]*)", source): + if not part: + continue + if part == "*": + pattern += "(.*)" + elif part.startswith(":"): + pattern += "([^/]+)" + else: + pattern += re.escape(part) + return re.compile("^" + pattern + "$") + + +# build_javadocs.sh generates the API docs from these two roots, so the published +# tree is derivable from the repository without building it. +JAVADOC_SOURCE_ROOTS = ("CodenameOne/src", "Ports/CLDC11/src") +# Pages javadoc emits for a package rather than for a type. +# build_javadocs.sh filters these out of its source list, passes -exclude for them +# and then guards that they never reached the output. Recording them here would +# accept a link to a page the published tree deliberately does not contain. +JAVADOC_EXCLUDED_PACKAGES = ("com/codename1/impl",) +# The generator runs javadoc with -protected, which documents public and protected +# types only. A top-level type cannot be protected, so in practice: public or it +# gets no page. Comments are stripped before this is applied, because a sample in +# a javadoc block can easily contain the word "public" next to a class name. +JAVA_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.S) +JAVA_LINE_COMMENT_RE = re.compile(r"//[^\n]*") +PUBLIC_TYPE_RE = ( + r"\bpublic\b[^;{{]*?\b(?:class|interface|enum|record|@interface)\s+{stem}\b" +) +# A NESTED type may be protected as well as public, and javadoc -protected +# documents both. It is declared inside the outer type's own file, so that is +# where to look. +NESTED_TYPE_RE = ( + r"\b(?:public|protected)\b[^;{{]*?\b(?:class|interface|enum|record|@interface)\s+{stem}\b" +) +# What a --release 8 -protected run actually writes beside a package, checked by +# running it: package-frame.html is a pre-JDK-11 artifact and is never emitted, +# and package-use.html needs -use, which the generator does not pass -- the same +# reason class-use/ is not accepted. +JAVADOC_PACKAGE_PAGES = { + "package-summary.html", + "package-tree.html", +} +# The finite set javadoc writes at the root of the tree. Taken from a real run -- +# the first seven always appear; the rest are emitted only when the sources have +# anything to put in them, so they are accepted without being required. +JAVADOC_ROOT_PAGES = { + "index.html", + "overview-summary.html", + "overview-tree.html", + "allclasses-index.html", + "allpackages-index.html", + "index-all.html", + "help-doc.html", + "constant-values.html", + "deprecated-list.html", + "serialized-form.html", + "search.html", +} +# javadoc writes legal/, resources/ and script-dir/ beside the packages for its own +# plumbing -- stylesheets, jQuery, licence texts. Their contents are version +# specific (JDK 17 ships jquery-3.6.1.min.js; another release ships another), so +# enumerating them here would hardcode a claim about a tree this repository does +# not build and would rot at the next JDK bump. The guide links into none of them, +# and documentation prose has no business pointing at javadoc's internals, so a +# path into one is rejected rather than waved through on its first segment. +_javadoc_index: tuple[set[str], set[str]] | None = None + + +def is_public_type(source: Path, stem: str) -> bool: + """Whether the file declares its top-level type public, so javadoc documents it. + + package-info carries no type and is excluded by name; javadoc emits its content + into package-summary.html, which the package check already covers. + """ + if stem == "package-info": + return False + try: + text = source.read_text(encoding="utf-8", errors="ignore") + except OSError: + return False + blanked = blank_java_noise(text) + match = re.search(PUBLIC_TYPE_RE.format(stem=re.escape(stem)), blanked) + if match is None: + return False + # javadoc drops an element tagged @hidden entirely, so a public type carrying + # it gets no page. The tag lives in the doc comment, which blank_java_noise has + # just erased, so read it from the ORIGINAL text at the same offsets. Both + # comment styles occur here: /** */ and Java's /// markdown form, which is what + # com.codename1.vpn.tunnel.TunnelBuffers and TunnelHost use. + return "@hidden" not in doc_comment_before(text, match.start()) + + +def doc_comment_before(text: str, position: int) -> str: + """The doc comment attached to a declaration, in either comment style.""" + before = text[:position] + block = re.search(r"/\*\*((?:(?!\*/).)*)\*/\s*(?:@\w+(?:\([^)]*\))?\s*)*$", before, re.S) + if block is not None: + return block.group(1) + collected: list[str] = [] + for line in reversed(before.split("\n")): + stripped = line.strip() + if stripped.startswith("///"): + collected.append(stripped) + elif stripped == "" or stripped.startswith("@"): + continue + else: + break + return "\n".join(collected) + + +def javadoc_index(repo_root: Path) -> tuple[set[str], set[str]]: + """Package directories and class names the generated Javadoc will contain.""" + global _javadoc_index + if _javadoc_index is not None: + return _javadoc_index + packages: set[str] = set() + classes: set[str] = set() + for root_name in JAVADOC_SOURCE_ROOTS: + root = repo_root / root_name + if not root.exists(): + continue + for source in root.rglob("*.java"): + relative = source.relative_to(root) + package = relative.parent.as_posix() + if any( + package == excluded or package.startswith(excluded + "/") + for excluded in JAVADOC_EXCLUDED_PACKAGES + ): + continue + packages.add(package) + if is_public_type(source, relative.stem): + classes.add(f"{package}/{relative.stem}") + _javadoc_index = (packages, classes) + return _javadoc_index + + +JAVA_TOKEN_RE = re.compile( + r"(?P\b(?Pclass|interface|enum|record)\s+(?P[A-Za-z_$][\w$]*))" + r"|(?P\{)|(?P\})" +) + + +def blank_java_noise(text: str) -> str: + """Blank comments and literals in one pass, preserving every offset. + + Order matters and separate regexes get it wrong in both directions: running the + line-comment pattern first eats from the "//" inside "https://..." to the end of + that line, which silently swallowed an opening brace and sent the depth count + negative; running the literal pattern first lets an apostrophe inside a comment + open a char literal that runs to the next one, somewhere else entirely. A single + scan has no ordering to get wrong. + """ + out = list(text) + i, n = 0, len(text) + while i < n: + ch = text[i] + if ch == "/" and i + 1 < n and text[i + 1] in "/*": + block = text[i + 1] == "*" + end = text.find("*/", i + 2) if block else text.find("\n", i) + end = (end + 2) if (block and end != -1) else (n if end == -1 else end) + for j in range(i, end): + if out[j] != "\n": + out[j] = " " + i = end + continue + if ch in "\"'": + quote, j = ch, i + 1 + while j < n: + if text[j] == "\\": + j += 2 + continue + if text[j] == quote or text[j] == "\n": + break + j += 1 + for k in range(i, min(j + 1, n)): + if out[k] != "\n": + out[k] = " " + i = j + 1 + continue + i += 1 + return "".join(out) + + +def documented_type_chains(source: Path) -> set[str]: + """Every dotted type chain javadoc will emit a page for, from one source file. + + The earlier version asked only whether each name was declared SOMEWHERE in the + file, which accepts two siblings written as if one contained the other -- + CommonProgressAnimations.CircleProgress.EmptyAnimation named two types that are + both real and neither nested in the other. Getting that right needs the actual + nesting, so this walks braces and keeps the enclosing stack. + + Comments and string literals are blanked first, or a brace inside either would + shift the depth for the rest of the file. A chain is recorded only when every + level of it is public or protected, which is what javadoc -protected emits. + """ + text = blank_java_noise(source.read_text(encoding="utf-8", errors="ignore")) + + chains: set[str] = set() + stack: list[tuple[str, bool, int, str]] = [] + pending: tuple[str, bool, str] | None = None + depth = 0 + for match in JAVA_TOKEN_RE.finditer(text): + if match.group("decl"): + # Modifiers sit between the previous statement boundary and the keyword. + boundary = max( + text.rfind(";", 0, match.start()), + text.rfind("{", 0, match.start()), + text.rfind("}", 0, match.start()), + ) + modifiers = text[boundary + 1 : match.start()] + documented = re.search(r"\b(?:public|protected)\b", modifiers) is not None + # A member of an interface or annotation type is implicitly public, so + # javadoc documents it whether or not the modifier is written. Route.Routes + # is declared as a bare `@interface Routes` inside `public @interface Route` + # and was being rejected. + if not documented and stack and stack[-1][3] == "interface": + documented = True + pending = (match.group("name"), documented, match.group("kind")) + elif match.group("open"): + depth += 1 + if pending is not None: + name, documented, kind = pending + stack.append((name, documented, depth, kind)) + if all(level[1] for level in stack): + chains.add(".".join(level[0] for level in stack)) + pending = None + else: + if stack and stack[-1][2] == depth: + stack.pop() + depth -= 1 + pending = None + return chains + + +def documented_chain_exists(package: str, chain: list[str]) -> bool: + """Whether javadoc emits a page for this dotted chain in this package.""" + if _JAVADOC_ROOT is None: + return True + for root_name in JAVADOC_SOURCE_ROOTS: + source = _JAVADOC_ROOT / root_name / package / f"{chain[0]}.java" + if source.exists(): + return ".".join(chain) in documented_type_chains(source) + return True # the outer source moved; the class check above already spoke + + +# What javadoc actually emits, confirmed by generating some and reading the ids: +# a method is "name(java.lang.String)" or "name()" with NO spaces, an array is +# "byte[]", and a field or enum constant is a bare name. The dashed spelling +# "name-java.lang.String-" was a JDK 9-only style; the generator here runs JDK 25 +# and emits none of it, so a dashed fragment names an anchor that is not on the +# page. Thirty-five links in the guide still carried it. +LEGACY_JAVADOC_FRAGMENT_RE = re.compile(r"^[A-Za-z_$][\w$.]*(-|\s)") + + +def javadoc_fragment_is_current(fragment: str) -> bool: + """Whether a /javadoc/ fragment is a shape modern javadoc can emit.""" + if not fragment: + return True + if "%20" in fragment or " " in fragment: + return False # an id never contains a space + return not LEGACY_JAVADOC_FRAGMENT_RE.match(fragment) + + +def javadoc_path_exists(target: str) -> bool: + """Whether a /javadoc/ path names something the generated tree will hold. + + The prefix used to be accepted wholesale, on the grounds that the tree is + generated at build time and cannot be enumerated here. It can: the generator + runs over two fixed source roots, so a package is a directory and a class page + is a .java file. That distinction matters -- the guide linked three times to + /javadoc/com/codename1/JavaScript/, and the package is lowercase, so a + case-sensitive host served 404s while this reported success. + + Anything that is not a recognisable package or class page is still accepted: + javadoc emits index and overview pages this does not model, and reporting those + would be a false alarm rather than a finding. + """ + if _JAVADOC_ROOT is None: + return True + path = target[len("/javadoc/"):] if target.startswith("/javadoc/") else target + path = path.strip("/") + packages, classes = javadoc_index(_JAVADOC_ROOT) + if not path.endswith(".html"): + # A directory: the tree root, one of javadoc's own asset directories, or a + # package. Anything else names a directory the generator never creates -- + # accepting every non-.html path let /javadoc/com/codename1/DefinitelyMissing/ + # through on the strength of having no file extension. + # Only the tree root. A package directory has no index.html -- javadoc + # writes package-summary.html and package-tree.html and nothing else -- + # so /javadoc/com/codename1/ui/ is a 404 even though the package is real. + # The root does have one, and build.sh moves it aside to render a Hugo + # page in its place. The guide links to the root five times and to no + # package directory at all; a link to a package should name the summary. + return not path + package = str(PurePosixPath(path).parent) + page = PurePosixPath(path).name + if package == ".": + # A page at the root of the tree. The set javadoc writes there is finite, + # so an invented one -- /javadoc/DefinitelyMissing.html -- is a 404 rather + # than something outside the model. + return page in JAVADOC_ROOT_PAGES + # No class-use/ exemption: the generator does not pass -use, and without it + # javadoc emits no usage pages at all -- verified by running it both ways. An + # earlier version stripped the segment and validated the enclosing package, + # which accepted a link to a page that is never built. + if package not in packages: + # The package itself will not exist in the generated tree. This is the + # case that mattered: com/codename1/JavaScript is spelt lowercase in the + # sources, so the published path 404s on a case-sensitive host. + return False + if page in JAVADOC_PACKAGE_PAGES: + return True + # A nested type is documented as Outer.Inner.html, generated from Outer.java. + parts = page[:-5].split(".") + if f"{package}/{parts[0]}" not in classes: + return False + if len(parts) == 1: + return True + # javadoc emits a page for a nested type only where one is declared, and every + # level of the chain is declared inside the outermost type's file. Checking the + # ends alone let an invented middle through: + # CommonProgressAnimations.Fake.CircleProgress.html passed because both + # CommonProgressAnimations and CircleProgress are real. Each name is checked + # now. What this still does not verify is that they nest in the ORDER given -- + # that needs brace-depth parsing, and a page naming two real siblings the wrong + # way round is a far less likely mistake than naming one that does not exist. + return documented_chain_exists(package, parts) + + +def resolves(target: str, known: set[str], rules: list, depth: int = 0) -> bool: + """Whether a path is served, following wildcard redirects to their destination. + + Accepting every path that merely *matches* a wildcard source is the same + mistake as whitelisting a prefix. `/*.html -> /:splat/ 301` matches any + root-level .html path at all, so `/does-not-exist.html` would pass while + redirecting to a page that is not there. Substitute the captures into the + destination and check that instead. + """ + if target in known: + return True + if target.startswith(GENERATED_PREFIXES) or target + "/" in GENERATED_PREFIXES: + # Reachable both directly and by following a redirect into it, so the + # test belongs here rather than only at the call site. The javadoc tree is + # not accepted wholesale -- javadoc_path_exists() checks the package and + # class against the sources it is generated from. + return javadoc_path_exists(target) + if depth > 4: # a redirect loop in the table should not hang the check + return False + for compiled, names, destination in rules: + match = compiled.match(target) + if not match: + continue + # The FIRST matching rule wins and the others never run, which is how the + # host evaluates this file. Trying later rules after an early one leads + # somewhere dead would pass a link whose reader lands on a deleted page. + if not destination or not destination.startswith("/"): + return True # redirects off-site; nothing here can verify it + resolved = destination + for name, value in zip(names, match.groups()): + resolved = resolved.replace(":" + name, value or "") + resolved = normalize_path(resolved) + if resolved == target: + return False + return resolves(resolved, known, rules, depth + 1) + return False + + +def site_paths(repo_root: Path) -> tuple[set[str], list, set[str], list]: + """Every path the website is known to answer on, derived rather than listed. + + Returns the literal paths and every redirect rule, each as a matcher, its + capture names and its destination, so a link can be followed rather than + accepted for merely matching. + """ + paths: set[str] = set() + patterns: list = [] + # The sources exactly as written. Every rule is compiled slash-insensitively + # ("^...$/?"), which is right for matching but loses the distinction the site + # actually draws, so the trailing-slash rule needs the raw spelling. + declared: set[str] = set() + # Wildcard sources, matched exactly: /*.html/ declares the slashed form of every + # root-level .html route, and a literal-set lookup cannot see that. + declared_patterns: list[re.Pattern[str]] = [] + + redirects = repo_root / "docs/website/static/_redirects" + if redirects.exists(): + for line in redirects.read_text(encoding="utf-8").split("\n"): + parts = line.split() + if not parts or parts[0].startswith("#"): + continue + destination = parts[1] if len(parts) > 1 else "" + declared.add(parts[0]) + exact = exact_redirect_pattern(parts[0]) + if exact is not None: + declared_patterns.append(exact) + compiled = redirect_pattern(parts[0]) + if compiled is not None: + patterns.append((compiled[0], compiled[1], destination)) + else: + # A literal source is not a served route either -- it is a rule, + # and a rule pointing at a page that was deleted redirects the + # reader to a 404. Follow it like any other, rather than treating + # the fact that a rule exists as proof the link works. + patterns.append( + (re.compile("^" + re.escape(normalize_path(parts[0])) + "/?$"), [], destination) + ) + # Only the SOURCE counts. A rule whose destination was deleted still + # sits in this file, so trusting destinations would accept a guide + # link to a page that no longer exists. + + # Cloudflare Pages Functions serve a fallback the redirect table does not + # mention: docs/website/functions/[[path]].js runs only after context.next() + # has already 404ed, and then sends anything under /files/ or /demos/ to + # download.codenameone.com. Those paths are therefore served, and this was + # recording a real one -- /files/iOS_UI-Kit.psd -- as a broken link. The + # destination is off-site, so it lands in the same bucket as every other + # off-site redirect: reachable, and not verifiable from this repository. + # Appended AFTER the _redirects rules because the function is a fallback and + # the first matching rule wins, mirroring the order the host evaluates. + function = repo_root / "docs/website/functions/[[path]].js" + if function.exists(): + # Read the prefixes out of the Function rather than restating them here, + # so removing a fallback removes it from the model too. If it is ever + # rewritten in a shape this cannot read, the derivation yields nothing and + # links under those prefixes start failing -- loudly, which is the safe + # direction; a hardcoded pair would have gone on accepting them. + for prefix in sorted( + set( + re.findall( + r'path\.startsWith\("/([^/"]+)/"\)', + function.read_text(encoding="utf-8"), + ) + ) + ): + patterns.append( + ( + re.compile(rf"^/{re.escape(prefix)}(/.*)?$"), + [], + "https://download.codenameone.com/", + ) + ) + + # Hugo's published route is the section path plus the page's slug, which + # 1055 of the content pages override; deriving it from the filename instead + # both invents routes that are never generated and rejects real ones. + # + # Known gap: taxonomy term pages (/tags//) are generated by Hugo from + # front-matter tags rather than from a file, so they are not derived here. No + # guide link targets one today. If one is ever added it will be reported as + # broken, which is the safe direction for a gate to be wrong in. + paths.add("/") # Hugo always renders the home page, _index.md or not + + # scripts/website/build.sh renders the guide to /developer-guide/ and rsyncs + # this directory alongside it so relative image links resolve, excluding the + # Sketch sources and the AsciiDoc itself. That makes every served path under + # the guide enumerable, so it does not need a blanket exemption. + guide = repo_root / "docs/developer-guide" + if guide.exists(): + paths.add("/developer-guide") + for asset in guide.rglob("*"): + if not asset.is_file(): + continue + relative = asset.relative_to(guide) + if relative.parts[0] == "sketch" or relative.suffix in {".asciidoc", ".adoc"}: + continue + paths.add(normalize_path("developer-guide/" + relative.as_posix())) + + # This derives a Hugo route as "section path + slug", which is true only while + # the site leaves routing alone. A [permalinks] rule or uglyURLs would rewrite + # every route underneath and this would keep accepting links to paths Hugo no + # longer publishes -- accepting a dead link is exactly the failure this script + # exists to prevent. hugo.toml declares neither today, so rather than model a + # configuration that is not there, notice when it appears. + hugo_config = repo_root / "docs/website/hugo.toml" + if hugo_config.exists(): + config = hugo_config.read_text(encoding="utf-8", errors="ignore") + overrides = [ + name + for name, probe in (("[permalinks]", r"^\s*\[permalinks\]"), ("uglyURLs", r"^\s*uglyURLs\s*=")) + if re.search(probe, config, re.M) + ] + if overrides: + raise SystemExit( + f"hugo.toml now sets {', '.join(overrides)}, which rewrites the routes " + f"this script derives from the content tree. Derive them from the built " + f"docs/website/public tree instead, or teach this function the rule -- " + f"until then every link it accepts is unverified." + ) + + now = datetime.datetime.now(datetime.timezone.utc) + content = repo_root / "docs/website/content" + if content.exists(): + for page in content.rglob("*.md"): + relative = page.relative_to(content).with_suffix("") + meta = front_matter(page) + if not is_published(meta, now): + continue + for alias in meta.get("aliases", []) or []: + if isinstance(alias, str): + paths.add(normalize_path(alias)) + if meta.get("url"): + paths.add(normalize_path(str(meta["url"]))) + continue + parts = list(relative.parts) + if parts and parts[-1] in {"_index", "index"}: + parts.pop() + if meta.get("slug"): + parts = parts[:-1] + [str(meta["slug"])] if parts else [str(meta["slug"])] + paths.add(normalize_path("/".join(parts)) if parts else "/") + + # Some redirects are written into _redirects at deploy time rather than + # committed, so the file in the tree does not list them. Read the paths out + # of the script that emits them instead of assuming a prefix is safe. + for emitter in sorted((repo_root / "scripts/website").glob("*redirect*.sh")): + for match in re.finditer( + r"printf\s+'(/[^\s']+)\s+%s[^']*'", emitter.read_text(encoding="utf-8") + ): + paths.add(normalize_path(match.group(1))) + + # Anything committed under static/ is served at its own path. + static = repo_root / "docs/website/static" + if static.exists(): + for asset in static.rglob("*"): + if not asset.is_file(): + # A directory is not a route. static/uploads holds assets and no + # index page, so recording the directory itself would accept a + # link to /uploads that resolves to nothing. + continue + paths.add(normalize_path(asset.relative_to(static).as_posix())) + if asset.name == "index.html": + paths.add(normalize_path(asset.parent.relative_to(static).as_posix())) + + return paths, patterns, declared, declared_patterns + + +def bare_authority(split, host: str, port: int | None) -> bool: + """Whether the authority carries nothing but the host and, at most, its port.""" + netloc = split.netloc.lower() + if netloc.endswith("."): + netloc = netloc[:-1] # the DNS root dot, already stripped from host + elif ":" in netloc and netloc.rsplit(":", 1)[0].endswith("."): + netloc = netloc.replace(".:", ":", 1) + return netloc == (host if port is None else f"{host}:{port}") + + +SELF_LINK_REASON = ( + "links into this book's own body; use an xref so the anchor is checked" +) + + +def links_into_this_book(path: str, fragment: str) -> bool: + """A link to one of this book's own routes that names an anchor inside it. + + Applied to root-relative targets as well as absolute URLs. The absolute branch + had this and the root-relative one did not, so link:/developer-guide/#missing + reached neither gate -- check-guide-xrefs.py skips hrefs starting with "/" + because they are not same-page anchors. + """ + return bool(fragment) and (path.rstrip("/") or "/") in SELF_PATHS + + +def undeclared_file_slash(path: str, declared: set[str], declared_patterns: list) -> bool: + """A file-like path wearing a trailing slash the redirect table does not spell out. + + The site treats "/x.html" and "/x.html/" as separate routes and declares both + where both work -- 32 such pairs in _redirects. Every rule compiled here is + slash-insensitive, so normalising would silently validate the variant that was + not asked for. Directory routes such as /blog/ and /javadoc/com/codename1/io/ + have no dot in the last segment and never match. + """ + if not path.endswith("/"): + return False + if "." not in path.rstrip("/").rsplit("/", 1)[-1]: + return False + if path in declared: + return False + return not any(pattern.match(path) for pattern in declared_patterns) + + +def findings_for(path: Path, known: set[str], patterns: list, declared: set[str], declared_patterns: list) -> list[tuple[str, str]]: + # Only http:// and https:// are extracted. Protocol-relative links were raised + # as a gap; measured, the guide contains no `link://` macro at all, and its one + # bare `//host/path` is a JavaScript string inside a source block, so widening + # URL_RE to match `//` would start reporting code as a broken link. The scheme + # requirement is what keeps this off code. + # + # Fragments are checked only against this book's own routes. On an ordinary + # same-site page the anchors live in Hugo's rendered output, which this does + # not build, so a fragment there cannot be resolved from the repository. + # Measured: of the 42 same-site URLs carrying a fragment, 38 are /javadoc/ -- + # generated at build time and exempt for the same reason -- and the other four + # pointed into this book and are now xrefs, which check-guide-xrefs.py resolves + # against the rendered anchors. That leaves nothing this could check today. + # + # Every URL in the source is checked, including any inside an AsciiDoc `//` + # line comment or `////` block. That is deliberate. Across the guide's 120 + # files there is not one commented-out URL and not one `////` block, so + # tracking comment state would buy nothing today -- and it would hand the + # gate a way to be silenced: comment the line out, the finding disappears, + # the ratchet shrinks, and the dead link is still sitting in the source + # waiting to be uncommented. Deleting the link is the fix. (Ordinary `//` + # comments do exist here, for editorial notes; none carries a URL.) + out: list[tuple[str, str]] = [] + for number, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1): + if ATTRIBUTE_URL_DECL_RE.match(line.strip()): + out.append((line.strip().split()[0], "an attribute holding a URL: any link built from it is unchecked, because this does not expand attributes")) + for hit in RAW_ARRAY_IN_JAVADOC_RE.findall(line): + out.append((hit, "an unencoded array bracket ends the link macro here; write %5B%5D")) + if ATTRIBUTE_LINK_RE.search(line): + out.append((ATTRIBUTE_LINK_RE.search(line).group(0), "a link target built from an attribute, which this cannot expand or check")) + for target in ROOT_RELATIVE_RE.findall(line): + path, _, fragment = target.partition("#") + path = path.split("?", 1)[0] + if links_into_this_book(path, fragment): + out.append((target, SELF_LINK_REASON)) + continue + if undeclared_file_slash(path, declared, declared_patterns): + # Same rule as for an absolute URL: normalize_path would drop the + # slash and validate the variant that was not asked for. + out.append((target, "a file path with a trailing slash that _redirects does not declare")) + continue + normalized = normalize_path(path) + if not resolves(normalized, known, patterns): + out.append((target, "the website serves no such path (checked _redirects and the content tree)")) + for match in URL_RE.finditer(line): + url = match.group(0) + # Trailing punctuation is sentence punctuation after a BARE url, and + # part of the path inside an explicit macro, where the "[" delimits the + # target: link:https://host/download.[label] really does request + # "/download.". Trimming unconditionally validated a different route. + explicit = line[: match.start()].rstrip().endswith("link:") or line[ + match.end() : match.end() + 1 + ] == "[" + if not explicit: + url = url.rstrip(".,;:") + split = urlsplit(url) + # urlsplit lowercases the host but keeps the root label's trailing dot, + # so the fully qualified spelling "www.codenameone.com." misses + # SITE_HOSTS and skips route validation entirely -- the same path that + # is rejected without the dot sails through with it. DNS treats the two + # as the same name, so strip it before classifying. + host = (split.hostname or "").rstrip(".") + # hostname strips the port whether or not it is a number, so a typo in + # the authority hides behind an otherwise correct host and every check + # below passes on a URL no browser can open. Reading .port is what + # surfaces it: urlsplit defers the parse until then and raises. + try: + port = split.port + except ValueError: + out.append((url, "the port is not a number, so this cannot be opened at all")) + continue + if "." not in host and host not in LOCAL_HOSTS: + out.append((url, f"host '{host}' has no dot in it and resolves nowhere")) + continue + if split.scheme == "http" and host not in TLS_EXEMPT_HOSTS | LOCAL_HOSTS: + out.append((url, "plain http, not https")) + if host in SITE_HOSTS and not bare_authority(split, host, port): + # Everything below identifies the site by hostname alone, and + # urlsplit is forgiving about what else the authority may carry: + # userinfo, a bracketed literal, mixed case. Each is a different + # way of writing something this route model has not been shown to + # describe, so classify on the bare form only and report the rest, + # rather than growing one rule per spelling. Measured: the guide + # has no URL with userinfo, a non-ASCII host, an IPv6 literal or + # mixed case in the authority. + out.append((url, f"authority '{split.netloc}' is not a bare hostname")) + continue + if ( + host in SITE_HOSTS + and port is not None + and port != DEFAULT_PORTS.get(split.scheme) + ): + # The route model below describes the site on its default port. A + # NONSTANDARD port is a different endpoint that model says nothing + # about, so accepting the path would be accepting an unchecked URL. + # Spelling out the scheme's own default (":443" under https) is + # redundant but reaches the identical endpoint, so it is allowed. + # Local services keep their ports either way: http://localhost:11434 + # is the Ollama endpoint the AI chapter documents on purpose. + out.append((url, f"port {port} is not where the site is served")) + continue + if host in SITE_HOSTS and split.path.startswith("/javadoc/"): + if not javadoc_fragment_is_current(split.fragment): + out.append((url, "a javadoc anchor in the retired JDK 9 dashed form; modern javadoc emits name(Type)")) + continue + if host in SITE_HOSTS: + target = split.path.rstrip("/") or "/" + if links_into_this_book(target, split.fragment): + out.append((url, SELF_LINK_REASON)) + elif undeclared_file_slash(split.path, declared, declared_patterns): + out.append((url, "a file path with a trailing slash that _redirects does not declare")) + elif not resolves(target, known, patterns): + out.append((url, "the website serves no such path (checked _redirects and the content tree)")) + return out + + +def load_baseline(path: Path) -> collections.Counter: + """Read the baseline as a multiset: one line per occurrence. + + A file that mentions the same broken URL twice has two problems, not one. + Collapsing them into a set understated the real count -- 35 recorded against + 38 occurrences -- and left a second occurrence of an already-baselined link + free to appear without the check noticing. + """ + if not path.exists(): + return collections.Counter() + return collections.Counter( + line.rstrip("\n") + for line in path.read_text(encoding="utf-8").split("\n") + if line.strip() and not line.startswith("#") + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--guide-dir", default="docs/developer-guide", type=Path) + parser.add_argument("--repo-root", default=".", type=Path) + parser.add_argument( + "--baseline", default="scripts/developer-guide/guide-links-baseline.txt", type=Path + ) + parser.add_argument("--write-baseline", action="store_true") + parser.add_argument( + "--allow-new", + action="store_true", + help="permit --write-baseline to ADD entries; without it the baseline may only shrink", + ) + args = parser.parse_args() + + guide_dir = args.guide_dir.resolve() + global _JAVADOC_ROOT + _JAVADOC_ROOT = args.repo_root.resolve() + known, patterns, declared, declared_patterns = site_paths(args.repo_root.resolve()) + if not known: + raise SystemExit("could not derive any site paths; is --repo-root correct?") + + current: collections.Counter = collections.Counter() + reasons: dict[str, str] = {} + for path in sorted(guide_dir.rglob("*")): + if path.suffix not in ASCIIDOC_EXTENSIONS or not path.is_file(): + continue + name = path.relative_to(guide_dir).as_posix() + for url, reason in findings_for(path, known, patterns, declared, declared_patterns): + entry = f"{name}\t{url}" + current[entry] += 1 + reasons[entry] = reason + + if args.write_baseline: + # The command that banks a fix is the same command that could bury a new + # failure. Shrinking is free; growing needs --allow-new, so recording new + # debt is a deliberate act that shows up in the command as well as in the + # baseline diff a reviewer reads. + added = sorted((current - load_baseline(args.baseline)).elements()) + if added and not args.allow_new: + for entry in added: + name, _, url = entry.partition("\t") + print(f"{name}: {url}", file=sys.stderr) + print( + f"\nRefusing to add {len(added)} entr(ies) to the baseline. Fix the " + f"link, or pass --allow-new if this is debt you mean to record.", + file=sys.stderr, + ) + return 1 + args.baseline.write_text( + "\n".join( + [ + "# Developer guide links that do not resolve, or are not TLS.", + "# One line per occurrence: a file naming the same bad URL twice gets", + "# two lines, because that is two things to fix.", + "# A ratchet: entries may be removed as links are fixed, never added.", + "# Regenerate with check-guide-links.py --write-baseline.", + ] + + sorted(current.elements()) + ) + + "\n", + encoding="utf-8", + ) + print(f"Wrote baseline with {sum(current.values())} entr(ies).") + return 0 + + baseline = load_baseline(args.baseline) + new = sorted((current - baseline).elements()) + # A baselined entry that no longer reproduces has to leave the file. Leaving it + # keeps a slot open: a later change can restore that exact file+URL and + # `current - baseline` stays empty, so the regression sails through. The + # ratchet only ratchets if fixes are banked. + stale = sorted((baseline - current).elements()) + if new or stale: + for entry in new: + name, _, url = entry.partition("\t") + print(f"{name}: {url} -- {reasons[entry]}", file=sys.stderr) + for entry in stale: + name, _, url = entry.partition("\t") + print( + f"{name}: {url} -- no longer broken, but still in the baseline. Run " + f"check-guide-links.py --write-baseline to bank the fix.", + file=sys.stderr, + ) + print( + f"\n{len(new)} new broken or insecure link(s), {len(stale)} stale baseline entr(ies).", + file=sys.stderr, + ) + return 1 + + print( + f"Links: {sum(current.values())} known bad link(s) against {len(known)} known site paths " + f"and {len(patterns)} redirect rule(s); none new, none stale." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/developer-guide/check-guide-structure.py b/scripts/developer-guide/check-guide-structure.py new file mode 100755 index 00000000000..2db89e25dec --- /dev/null +++ b/scripts/developer-guide/check-guide-structure.py @@ -0,0 +1,681 @@ +#!/usr/bin/env python3 +"""Verify that the developer guide's book structure is what the manifest says. + +Three defects motivated this check, all of which shipped in a green build: + +* ``Working-With-Linux.asciidoc`` was included on the line directly after + ``Working-With-Windows.asciidoc``, whose last line is a paragraph. After + include expansion the Linux chapter's ``== `` title became a continuation of + that paragraph, so the whole chapter rendered as subsections of the Windows + chapter and its title vanished. Asciidoctor reports nothing. +* Three chapters opened with a level-0 ``= `` heading. Under ``doctype: book`` + that turns each into a *part*, promotes its own sections to chapters and drops + its title from the numbered sequence. Also silent. +* Six complete chapters sat in the tree while being included by nothing, so they + never reached a reader at all. + +The first two are caught by rendering the book and checking that every included +chapter's title survives into the output; the third by walking the include graph. +""" +from __future__ import annotations + +import argparse +import collections +import html +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +ASCIIDOC_EXTENSIONS = {".adoc", ".asciidoc"} +INCLUDE_RE = re.compile(r"^include::([^\[]+)\[([^\]]*)\]\s*$") +INLINE_CONDITIONAL_INCLUDE_RE = re.compile(r"^(ifdef|ifndef|ifeval)::[^\[]*\[.*include::") +HEADING_RE = re.compile(r"^(=+) +(\S.*)$") +# Literal-content blocks only. The container delimiters -- ==== example, +# **** sidebar, ____ quote -- hold ordinary AsciiDoc, so an include or a heading +# inside one is real and must not be skipped. +# Literal and comment blocks -- an example (====) or sidebar (****) block contains +# live markup, so its headings and includes are real and must keep counting. A +# delimiter may be LONGER than four characters, and closes on one of the same +# character AND length, so a four-dash line inside a five-dash block is content +# rather than the close. Matching exactly four left every ----- block's contents +# live: the guide has 20 such lines, all [source] blocks wrapping an include::. +# +# //// is here because asciidoctor drops a comment block entirely, so an include +# inside one does not put its target in the book and prose inside one promises +# the reader nothing. Note check-guide-links.py deliberately does NOT skip +# comments: a dead link commented out is still dead debt in the source, and +# letting the ratchet shrink for it would turn "comment the line out" into a way +# to silence that gate. Reachability is the opposite case -- treating a +# commented-out include as reachable states something about the book that is +# simply untrue. +FENCE_RE = re.compile(r"^(-{4,}|\.{4,}|`{4,}|\+{4,}|/{4,})\s*$") +# Directives are a different question from content, and asciidoctor answers them +# differently. Measured: +# +# inside ---- / ----- / .... inside //// +# include:: PROCESSED dropped +# ifdef:: / ifeval:: ACTIVE dropped +# heading / prose literal text dropped +# +# A literal block hides CONTENT but not DIRECTIVES: include:: and ifdef:: are +# resolved before block parsing and fire from inside a listing exactly as they +# would outside it. Only a comment block removes them. So first_heading() keeps +# FENCE_RE -- a heading in a listing really is only text -- while the include walk +# and the conditional test use this narrower one. +COMMENT_FENCE_RE = re.compile(r"^(/{4,})\s*$") +# Only the BLOCK forms vanish. ifdef/ifndef with empty brackets open a region and +# leave nothing behind, and endif closes one; but the single-line form carries its +# content in the brackets and EXPANDS to it -- measured, `ifdef::feature[== Inline +# Chapter]` renders as a real chapter when the attribute is set. So the spacing +# scan may step over the first kind and must treat the second as content, or it +# would walk past a heading that the preceding paragraph is about to swallow. +BLOCK_DIRECTIVE_RE = re.compile(r"^(ifdef|ifndef)::[^\[]*\[\s*\]\s*$|^ifeval::\[|^endif::") +# Inline AsciiDoc markup that never survives into the rendered heading text. +INLINE_MARKUP_RE = re.compile(r"[`*_#]|\[\[[^\]]*\]\]|\[[^\]]*\]") + + +def parse_lines(path: Path) -> list[str]: + return path.read_text(encoding="utf-8").split("\n") + + +def heading_is_conditional(path: Path) -> bool: + """Whether the file's first heading sits inside a backend conditional. + + A chapter whose own title is conditional has no title in the branch that + excludes it, and its body merges into whatever precedes it there. The outcome + check cannot see that from one render: first_heading() reads the source, so + the title is found, and the HTML render contains it. Rather than render the + book a second time for a construct the guide does not use -- measured, zero + headings sit inside a backend conditional -- refuse the construct. + """ + open_fence: str | None = None + depth = 0 + for line in parse_lines(path): + fence = COMMENT_FENCE_RE.match(line) + if fence: + token = fence.group(1) + open_fence = token if open_fence is None else (None if token == open_fence else open_fence) + continue + if open_fence is not None: + continue + if BLOCK_DIRECTIVE_RE.match(line.strip()): + depth += 1 if not line.strip().startswith("endif::") else -1 + depth = max(0, depth) + continue + if HEADING_RE.match(line): + return depth > 0 + return False + + +def first_heading(path: Path) -> tuple[int, str] | None: + """Return (level, title) of the file's first heading outside a fenced block.""" + open_fence: str | None = None + for line in parse_lines(path): + fence = FENCE_RE.match(line) + if fence: + token = fence.group(1) + if open_fence is None: + open_fence = token + elif token == open_fence: + open_fence = None + continue + if open_fence is not None: + continue + match = HEADING_RE.match(line) + if match: + return len(match.group(1)), match.group(2).strip() + return None + + +def normalize(title: str) -> str: + """Reduce a heading to something comparable across AsciiDoc and HTML.""" + text = html.unescape(title) + text = INLINE_MARKUP_RE.sub("", text) + return re.sub(r"\s+", " ", text).strip().lower() + + +class Walker: + """Expands the include graph, recording every edge for the adjacency check.""" + + def __init__(self, root: Path, guide_dir: Path) -> None: + self.root = root + self.guide_dir = guide_dir + self.reachable: dict[Path, str] = {} + self.direct: set[Path] = set() + # Direct entries that sit inside an ifdef/ifndef region. Only one branch + # renders, so the outcome check cannot demand every branch's title. + self.conditional: set[Path] = set() + # Counted at the EDGE, not per visited file: _visit returns early on a + # revisit, so a document included twice would otherwise leave no trace. + # Only direct manifest entries are counted. A nested fragment may be + # reused from two parents on purpose, and a file included under + # mutually exclusive ifdef/ifndef branches appears twice in the source + # while rendering once -- neither is a duplicated chapter. + # Keyed by (parent, target) rather than by target alone. A fragment reused + # from two different parents is deliberate -- that is what a fragment is + # for -- but the SAME parent including the SAME file twice renders it + # twice, at the root or nested. Keying only by target missed the nested + # case entirely, and _visit() returns early on a revisit, so the second + # edge left no trace at all: the outcome check then saw one declaration + # and two rendered titles and passed, because it only asks whether a + # title appears AT LEAST as often as it is declared. + self.include_edges: collections.Counter = collections.Counter() + self.include_attrs: dict[Path, set[str]] = {} + self.errors: list[str] = [] + self._visit(root, "") + + def _visit(self, path: Path, attrs_from_parent: str) -> None: + if path in self.reachable: + return + self.reachable[path] = attrs_from_parent + lines = parse_lines(path) + open_fence: str | None = None + for index, line in enumerate(lines): + fence = COMMENT_FENCE_RE.match(line) + if fence: + token = fence.group(1) + if open_fence is None: + open_fence = token + elif token == open_fence: + open_fence = None + continue + if open_fence is not None: + continue + if INLINE_CONDITIONAL_INCLUDE_RE.match(line): + # asciidoctor expands the bracket content and processes the include + # inside it; INCLUDE_RE is anchored, so the line looked like nothing + # at all and the edge went uncounted. Refused for the same reason as + # any other conditional include: neither the duplicate count nor the + # rendered-title check can model one. + self.errors.append( + f"{path.name}:{index + 1}: an include inside an inline conditional. " + f"asciidoctor expands and processes it, but neither the duplicate " + f"check nor the rendered-title check can model a conditional " + f"include, so this refuses it rather than skipping it silently." + ) + continue + match = INCLUDE_RE.match(line) + if not match: + continue + target_raw, attrs = match.group(1), match.group(2) + if "{" in target_raw: + # An attribute-built target resolves to a literal "{name}" here, which + # has no .adoc suffix and was therefore filed as a snippet and ignored. + # asciidoctor expands it and includes the chapter, so a second copy + # rendered with no edge counted and no extra title expected. Expanding + # attributes means reimplementing asciidoctor's resolution, inheritance + # through includes included; the guide uses none, so refuse instead. + self.errors.append( + f"{path.name}:{index + 1}: include target {target_raw} is built from " + f"an attribute. This does not expand attributes, so the target " + f"cannot be identified or counted; write the path literally." + ) + continue + target = (path.parent / target_raw).resolve() + if target.suffix not in ASCIIDOC_EXTENSIONS: + continue # a snippet include, validated by validate-guide-snippets.py + if not target.exists(): + self.errors.append( + f"{path.name}:{index + 1}: include target does not exist: {target_raw}" + ) + continue + self._check_include_spacing(path, index, lines, target_raw, attrs, target) + if path == self.root: + # A conditional entry is still a chapter: it must open at chapter + # level and be spaced correctly. Only the DUPLICATE count skips + # it, because the same chapter under two exclusive branches + # appears twice in the source and once in the output. + self.direct.add(target) + # Conditional includes are refused at EVERY depth, not just in the + # manifest. Nothing in the guide is conditional today (measured: zero + # include:: lines sit inside a conditional anywhere), and neither + # check that matters can validate one. The edge count has to skip it, + # because the same file under two exclusive branches is one rendering; + # the rendered-title check has to skip it too, for the same reason. + # Skipping BOTH silently means a file included twice inside a single + # ACTIVE branch passes -- _visit() deduplicates the second target and + # the title check only requires the title once. Refusing the construct + # is the honest answer while nothing uses it; an earlier version + # refused it only under path == self.root and left exactly that hole + # one level down. + if self._inside_conditional(lines, index): + self.conditional.add(target) + self.errors.append( + f"{path.name}:{index + 1}: {target_raw} is included inside a " + f"conditional. Neither the duplicate check nor the rendered-title " + f"check can validate a conditional include, so this refuses it " + f"rather than skipping it silently. Teach the checker which " + f"branches are mutually exclusive before adding one." + ) + else: + self.include_edges[(path, target)] += 1 + # _visit() records the attributes of the FIRST inclusion and returns + # early on every later one, so a fragment brought in twice with + # different leveloffsets was validated against whichever came first -- + # and its heading renders at two different depths. Collected per + # target so the disagreement can be reported. + self.include_attrs.setdefault(target, set()).add( + " ".join(attrs.split()) + ) + self._visit(target, attrs) + + @staticmethod + def _inside_conditional(lines: list[str], index: int) -> bool: + """Whether this line sits inside an ifdef/ifndef/ifeval region. + + A chapter included once per branch of a conditional appears twice in the + source and once in the output, so counting it as a duplicate would reject + valid markup. + """ + depth = 0 + open_fence: str | None = None + for line in lines[:index]: + # Only a comment block, for the reason given at COMMENT_FENCE_RE: a + # conditional written inside a listing is not "displayed", it is + # active, so treating the listing as a hiding place would make this + # disagree with the renderer. + fence = COMMENT_FENCE_RE.match(line) + if fence: + token = fence.group(1) + if open_fence is None: + open_fence = token + elif token == open_fence: + open_fence = None + continue + if open_fence is not None: + continue + # ifdef/ifndef open a block only with EMPTY brackets -- with content + # they are the single-line form and guard just that line. ifeval has + # no single-line form and always carries its expression in the + # brackets, so requiring them empty meant this could never match one. + if re.match(r"^(ifdef|ifndef)::[^\[]*\[\s*\]\s*$", line) or re.match( + r"^ifeval::\[", line + ): + depth += 1 + elif re.match(r"^endif::", line): + depth = max(0, depth - 1) + return depth > 0 + + # A delimited block, a heading, an attribute entry or a directive all close the + # paragraph context. Only ordinary paragraph text leaves it open, and only an + # open paragraph can absorb the heading that follows it. + _CLOSES_PARAGRAPH = re.compile( + r"^(=+\s|:[^:]+:|//|\[|\||([-=_.*+/])\2{3,}\s*$)" + ) + + def _check_include_spacing( + self, + path: Path, + index: int, + lines: list[str], + target_raw: str, + attrs: str, + target: Path, + ) -> None: + """Reject an include whose target can swallow whatever follows it. + + This is the defect the whole checker was written for: two adjacent + include:: lines put the first file's last line against the second file's + first line, and if the first ends mid-paragraph the second's title becomes + a continuation of it. Asciidoctor emits no warning, and the native Linux + chapter spent its life rendered as subsections of the Windows one. + + Measured with a minimal reproduction rather than assumed, because three of + the four ways out are not obvious: + + * a blank line in the parent separates them -- safe; + * leveloffset= wraps the include in :leveloffset: attribute entries, and + those lines close the paragraph -- safe, which is why the five adjacent + includes in Maven-Project-Workflow.asciidoc render correctly; + * the included file ending on a blank line -- safe; + * the included file ending on a delimiter, table row, heading, attribute or + comment -- safe, which is why _generated-build-hints.adoc ending on + "|===" does not eat the "Versioned builds" heading after it. + + What is left -- an adjacent include, no leveloffset, whose file ends on + ordinary paragraph text -- is the one shape that silently deletes content. + """ + # Look PAST preprocessor directives rather than treating one as a + # separator. Asciidoctor removes ifdef/ifndef/ifeval/endif during + # preprocessing, so they leave nothing behind to close the paragraph -- + # measured: a heading with `ifdef::backend-html5[]` between it and the + # preceding paragraph is swallowed exactly as if the directive were not + # there. An earlier version of this rule returned here and would have + # passed that. + cursor = index + 1 + while cursor < len(lines) and BLOCK_DIRECTIVE_RE.match(lines[cursor].strip()): + cursor += 1 + # The same hazard on the other side: paragraph text immediately BEFORE the + # include absorbs the included file's first heading, and the rendered-title + # count cannot see it when another file happens to share that title -- + # "Getting started" is in the book three times. Measured, the exemptions + # are the same ones: a blank line, a line that closes the paragraph, or a + # leveloffset, whose attribute entry does the closing. + if "leveloffset" not in attrs: + back = index - 1 + while back >= 0 and re.match( + r"^(ifdef|ifndef|ifeval|endif)::", lines[back].strip() + ): + back -= 1 + preceding = lines[back].strip() if back >= 0 else "" + if preceding and not self._CLOSES_PARAGRAPH.match(preceding): + try: + first = next( + ( + line + for line in parse_lines(target) + if line.strip() + ), + "", + ) + except OSError: + first = "" + if HEADING_RE.match(first): + self.errors.append( + f"{path.name}:{index + 1}: include of {target_raw} follows a " + f"paragraph with no blank line between them, so that paragraph " + f"absorbs {target.name}'s heading and the section disappears. " + f"Add a blank line before the include, or a leveloffset " + f"attribute." + ) + + following = lines[cursor] if cursor < len(lines) else "" + if not following.strip(): + return + # A leveloffset on EITHER include protects: asciidoctor brackets the + # included content with :leveloffset: attribute entries, and an attribute + # entry closes the paragraph. The one on the following include lands + # between the paragraph and the heading, so it works just as well as the + # one on this include. Measured both ways. + if "leveloffset" in attrs: + return + following_include = INCLUDE_RE.match(following.strip()) + if following_include and "leveloffset" in following_include.group(2): + return + try: + text = target.read_text(encoding="utf-8", errors="ignore") + except OSError: + return + if text.endswith("\n\n") or not text.strip(): + return + last = next( + (line for line in reversed(text.split("\n")) if line.strip()), "" + ) + if self._CLOSES_PARAGRAPH.match(last.strip()): + return + self.errors.append( + f"{path.name}:{index + 1}: include of {target_raw} is followed immediately " + f"by content, and {target.name} ends on a paragraph. That paragraph will " + f"absorb whatever comes next, deleting it from the book without a warning. " + f"Add a blank line after the include, or a leveloffset attribute, or end " + f"{target.name} with a blank line." + ) + + +def occurrence_counts(root: Path, edges: collections.Counter) -> dict[Path, int]: + """How many times each document renders, following multiplicity down the graph. + + A file included twice renders twice, and so does every file IT includes. The + include graph is a DAG -- asciidoctor rejects a cycle -- so each node's count + is the sum over its incoming edges of the parent's count times the edge's + multiplicity, with the root rendering once. + """ + incoming: dict[Path, list[tuple[Path, int]]] = collections.defaultdict(list) + for (parent, child), count in edges.items(): + incoming[child].append((parent, count)) + + counts: dict[Path, int] = {} + visiting: set[Path] = set() + + def count_for(node: Path) -> int: + if node == root: + return 1 + if node in counts: + return counts[node] + if node in visiting: + return 1 # a cycle asciidoctor would reject; do not spin on it + visiting.add(node) + total = sum(count_for(parent) * n for parent, n in incoming[node]) or 1 + visiting.discard(node) + counts[node] = total + return total + + for child in incoming: + count_for(child) + return counts + + +def render(root: Path) -> str: + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "guide.html" + result = subprocess.run( + ["asciidoctor", "--require", "rouge", "-o", str(out), str(root)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(result.stderr, file=sys.stderr) + raise SystemExit("asciidoctor failed to render the guide") + return out.read_text(encoding="utf-8") + + +def rendered_titles(markup: str) -> tuple[dict[str, int], dict[str, int]]: + """Count rendered headings: all levels, and chapter level (h2) separately. + + Counting every level lets an unrelated subsection stand in for a chapter that + was swallowed -- "Analytics" is a chapter and also a subsection of Commerce, + and "Getting started" collides the same way. A chapter renders as h2, so + holding manifest entries to that count removes the substitution. + """ + body = markup.split('id="content"', 1)[-1] + counts: dict[str, int] = {} + chapters: dict[str, int] = {} + for match in re.finditer(r"]*>(.*?)", body, re.S): + text = re.sub(r"<[^>]+>", "", match.group(2)) + text = re.sub(r"^(Appendix [A-Z]:|(\d+|[A-Z])(\.\d+)*\.)\s*", "", html.unescape(text).strip()) + key = normalize(text) + counts[key] = counts.get(key, 0) + 1 + if match.group(1) == "2": + chapters[key] = chapters.get(key, 0) + 1 + return counts, chapters + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--guide-dir", default="docs/developer-guide", type=Path) + args = parser.parse_args() + + guide_dir = args.guide_dir.resolve() + root = guide_dir / "developer-guide.asciidoc" + if not root.exists(): + raise SystemExit(f"guide root not found: {root}") + + walker = Walker(root, guide_dir) + errors = list(walker.errors) + + # A chapter included twice by the manifest is rendered twice. The title check + # below cannot see it, because it only asks whether a title appears AT LEAST + # as often as it is declared. + # A chapter reached from the manifest AND from some nested document renders + # twice, but each (parent, target) pair counts one, so the duplicate rule sees + # nothing and the title check only asks for "at least once". Cross-parent reuse + # stays legitimate for a FRAGMENT; a direct chapter is a different thing -- + # it already has its place in the book. + for target, seen in sorted( + walker.include_attrs.items(), key=lambda item: item[0].name + ): + if len(seen) > 1: + spellings = ", ".join(repr(a) for a in sorted(seen)) + errors.append( + f"{target.name}: is included with different attributes ({spellings}), " + f"so its heading renders at more than one depth. Use the same " + f"attributes everywhere, or give each parent its own fragment." + ) + + for target in sorted(walker.direct, key=lambda path: path.name): + others = sorted( + parent.name + for (parent, other) in walker.include_edges + if other == target and parent != walker.root + ) + if others: + errors.append( + f"{target.name}: is a chapter in the manifest and is also included by " + f"{', '.join(others)}, so the book renders it twice. Include it in one " + f"place." + ) + + for (parent, target), count in sorted( + walker.include_edges.items(), key=lambda item: (item[0][0].name, item[0][1].name) + ): + if count > 1: + errors.append( + f"{parent.name}: includes {target.name} {count} times, so the book " + f"renders it {count} times. Remove the duplicate include." + ) + + # 1. Every chapter in the tree is either in the book or declared out of it. + declared_path = guide_dir / "not-in-book.txt" + declared = set() + if declared_path.exists(): + for line in declared_path.read_text(encoding="utf-8").split("\n"): + line = line.split("#", 1)[0].strip() + if line: + declared.add(line) + + on_disk = { + path.resolve() + for path in guide_dir.rglob("*") + if path.suffix in ASCIIDOC_EXTENSIONS and path.is_file() + } + unreachable = sorted(on_disk - set(walker.reachable)) + for path in unreachable: + name = path.relative_to(guide_dir).as_posix() + if name not in declared: + errors.append( + f"{name}: present in the guide directory but included by nothing, so it " + f"never reaches a reader. Include it, delete it, or list it in " + f"not-in-book.txt with a reason." + ) + reachable_names = {p.relative_to(guide_dir).as_posix() for p in walker.reachable} + for name in sorted(declared & reachable_names): + errors.append(f"not-in-book.txt lists {name}, but it is included. Remove the entry.") + + # 2. A chapter's own heading level decides whether it is a chapter at all. + # A level-0 heading turns it into a book PART; a level-3 heading (or none) + # makes it a subsection of whatever chapter precedes it. Both nest silently, + # and the rendered-title check below cannot see either, because it accepts a + # title at any depth. Only entries the manifest includes DIRECTLY are held + # to this: nested fragments such as the appendix_goal_* files legitimately + # start at level 3 under their parent. + for path, attrs in sorted(walker.reachable.items()): + if path == root: + continue + heading = first_heading(path) + if heading and heading[0] == 1 and "leveloffset" not in attrs: + errors.append( + f"{path.name}: opens with a level-0 '= {heading[1]}' heading. Under " + f"doctype:book that renders as a PART and promotes its own sections to " + f"chapters. Use '== ' or include it with leveloffset=+1." + ) + if path not in walker.direct: + continue + if heading is None: + errors.append( + f"{path.name}: is included directly by developer-guide.asciidoc but has " + f"no heading, so its content is absorbed into the chapter before it." + ) + continue + # A leveloffset shifts every heading in the included file, so what decides + # whether this renders as a chapter is the declared level PLUS the offset. + # Exempting offset includes entirely would leave the same nesting bug one + # step further along: '== Chapter' at leveloffset=+1 renders as a + # subsection, and the outcome check cannot see it because it accepts a + # title at any depth. + offset = 0 + match = re.search(r"leveloffset=([+-]?\d+)", attrs) + if match: + offset = int(match.group(1)) + effective = heading[0] + offset + if effective != 2: + detail = ( + f"level {heading[0]} with leveloffset={offset:+d}" + if offset + else f"level {heading[0]}" + ) + errors.append( + f"{path.name}: is included directly by developer-guide.asciidoc at " + f"{detail}, so it renders at level {effective} rather than as a chapter. " + f"A direct manifest entry must come out at level 2." + ) + + render_counts = occurrence_counts(root, walker.include_edges) + + # 3. Outcome check: every included chapter's title survives into the book. + rendered, rendered_chapters = rendered_titles(render(root)) + expected: dict[str, list[str]] = {} + expected_chapters: dict[str, list[str]] = {} + for path in sorted(walker.reachable): + if path == root or path in walker.conditional: + # A conditional entry renders in one branch only, so requiring its + # title in this render would report a chapter that is deliberately + # absent. Its level and spacing are still checked above. + continue + if heading_is_conditional(path): + errors.append( + f"{path.name}: its own title sits inside a conditional. In the branch " + f"that excludes it the chapter has no heading and its body merges into " + f"whatever precedes it, which one render cannot show. Put the title " + f"outside the conditional." + ) + heading = first_heading(path) + if not heading: + continue + key = normalize(heading[1]) + # A direct entry must appear at CHAPTER level. A nested fragment sits at + # whatever depth its parent puts it, so it is only counted at all. + # + # How many times this file RENDERS, which is not how many edges point at + # it. Multiplicity multiplies down the graph: a fragment two parents both + # include renders twice, and so does everything it includes in turn -- + # counting the single edge recorded before _visit() returned early on the + # second traversal expected one title for two renderings, and a swallowed + # one hid behind the survivor. + occurrences = render_counts.get(path, 1) + target = expected_chapters if path in walker.direct else expected + for _ in range(occurrences): + target.setdefault(key, []).append(path.name) + + for title, sources in sorted(expected_chapters.items()): + found = rendered_chapters.get(title, 0) + if found < len(sources): + errors.append( + f"{', '.join(sources)}: the title '{title}' appears {found} time(s) as a " + f"chapter in the rendered book but {len(sources)} manifest entr(ies) " + f"declare it. A chapter was swallowed by whatever precedes it." + ) + for title, sources in sorted(expected.items()): + if rendered.get(title, 0) < len(sources): + errors.append( + f"{', '.join(sources)}: the title '{title}' appears " + f"{rendered.get(title, 0)} time(s) in the rendered book but " + f"{len(sources)} document(s) declare it. A fragment was swallowed by " + f"whatever precedes it." + ) + + if errors: + for error in errors: + print(f"::error::{error}" if sys.stdout.isatty() is False else error, file=sys.stderr) + print(f"\n{len(errors)} guide structure problem(s).", file=sys.stderr) + return 1 + print( + f"Guide structure OK: {len(walker.reachable) - 1} included documents, " + f"{len(declared)} declared out of book." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/developer-guide/check-guide-xrefs.py b/scripts/developer-guide/check-guide-xrefs.py new file mode 100755 index 00000000000..a5418f6bfa3 --- /dev/null +++ b/scripts/developer-guide/check-guide-xrefs.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Report developer guide cross-references that point at nothing. + +Asciidoctor does not fail, or even warn, on a dangling internal reference. A +bare ``<>`` at least renders as the literal text ``[missing]``, which a +reader might notice; but ``<>`` renders as an ordinary link +with the right words and a href to an id that does not exist, so it looks +perfectly fine and silently goes nowhere. The introduction shipped two of those +pointing at an "Application Lifecycle" sidebar nobody ever wrote. + +Working from the rendered HTML rather than the AsciiDoc source means both forms +are caught, along with anchors declared in any of AsciiDoc's several syntaxes. +""" +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tempfile +from collections import defaultdict +from pathlib import Path +from urllib.parse import unquote + +ASCIIDOC_SUFFIXES = {".adoc", ".asciidoc"} +# Only a comment block. A conditional written inside a listing is not displayed +# text -- asciidoctor resolves ifdef/ifeval before block parsing, so it fires from +# in there exactly as it would outside. Measured; an earlier version treated a +# listing as a hiding place and would have skipped a genuinely active directive. +FENCE_RE = re.compile(r"^(/{4,})\s*$") +ID_RE = re.compile(r'\bid="([^"]+)"') +HREF_RE = re.compile(r'href="#([^"]+)"') +# The guide renders as one page, so a RELATIVE href resolves against wherever that +# page happens to be served and reaches nothing that ships with it. Three survived +# from the wiki this book replaced -- link:Images[], link:Fonts[] and +# link:Supported-Properties#text-decoration[] -- naming pages that were never +# carried across, and a reader following one landed on a 404 while every gate +# reported success. Absolute URLs (checked by check-guide-links.py), root-relative +# paths and same-page fragments are all excluded. +RELATIVE_HREF_RE = re.compile(r'href="(?!#|/|[a-zA-Z][a-zA-Z0-9+.-]*:)([^"]+)"') +# Asciidoctor falls back to printing the raw id in brackets when a reference +# resolves to an anchor that carries no title -- an anchor on an image or a +# paragraph rather than on a section. The link works; the sentence reads +# "see [watch-complications]". +RAW_ID_LINK_RE = re.compile(r'\[\1\]') +# Anchor syntaxes, used only to point the reader at the offending source file. +ANCHOR_SOURCE_RE = re.compile(r"<<([^>,]+)") + + +# The book has ifdef::backend-pdf[] branches, and the PDF is published alongside +# the HTML. Rendering only the default backend drops that content before any +# reference in it can be examined, so a dangling PDF-only xref would ship +# unchecked. Setting the attribute on an HTML render selects exactly the content +# the PDF build includes, without needing asciidoctor-pdf here. The two runs are +# checked SEPARATELY, never pooled: an anchor that exists only in the HTML branch +# must not be allowed to satisfy a reference made in the PDF branch. +# asciidoctor-pdf defines basebackend-pdf alongside backend-pdf, and the guide may +# legitimately test either, so the surrogate defines both. Missing the second one +# meant ifdef::basebackend-pdf[] content vanished from the render that was meant +# to be inspecting it. +BACKENDS = (("html", ()), ("pdf", ("backend-pdf", "basebackend-pdf"))) +# The surrogate has one blind spot, and it cannot be closed from the command line. +# Setting backend-pdf makes `ifndef::backend-pdf[]` content disappear and +# `ifdef::backend-pdf[]` content appear, which is exactly right -- verified -- and +# is the only form this guide uses. It does NOT undefine `backend-html5`: the HTML +# converter sets that itself, after command-line attributes are applied, so even +# `-a backend-html5!` leaves it defined (measured). Content guarded by +# `ifdef::backend-html5[]` would therefore survive into the surrogate PDF render +# and could satisfy a PDF-only reference that the real asciidoctor-pdf build +# leaves dangling. The guide has no such conditional, so rather than model a +# construct that is not there -- or shell out to asciidoctor-pdf and try to read +# anchors back out of a PDF -- notice if one appears. +# Both spellings of the same thing: the boolean attribute the converter defines, +# and an ifeval comparing the {backend} value. The surrogate can model neither -- +# it sets backend-pdf on an HTML render, so backend-html5 stays defined AND +# {backend} still reads "html5". +UNMODELLED_CONDITIONAL_RE = re.compile( + r"^\s*if(n?def|eval)::.*" + r"(\bbackend-html5\b|\bbasebackend-html\b|\{backend\}|\{basebackend\})" +) + + +def reject_unmodelled_conditionals(guide_dir: Path) -> None: + for path in sorted(guide_dir.rglob("*")): + if path.suffix not in ASCIIDOC_SUFFIXES or not path.is_file(): + continue + open_fence: str | None = None + for number, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1): + # A directive inside a comment block is dropped by asciidoctor, so it + # cannot affect the render and must not abort the gate. + fence = FENCE_RE.match(line) + if fence: + token = fence.group(1) + if open_fence is None: + open_fence = token + elif token == open_fence: + open_fence = None + continue + if open_fence is not None: + continue + if UNMODELLED_CONDITIONAL_RE.match(line): + raise SystemExit( + f"{path.name}:{number}: this file guards content on the HTML " + f"backend attribute. The PDF render here is an HTML render with " + f"backend-pdf set, and the converter re-defines backend-html5 " + f"afterwards, so that content cannot be excluded and a PDF-only " + f"reference into it would pass unchecked. Guard on backend-pdf " + f"(ifndef::backend-pdf[]) as the rest of the guide does, or teach " + f"this script to drive asciidoctor-pdf." + ) + + +def render(root: Path, attributes: tuple[str, ...] = ()) -> str: + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "guide.html" + command = ["asciidoctor", "--require", "rouge"] + for attribute in attributes: + command += ["-a", attribute] + command += ["-o", str(out), str(root)] + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + print(result.stderr, file=sys.stderr) + raise SystemExit("asciidoctor failed to render the guide") + return out.read_text(encoding="utf-8") + + +def packaged_asset(guide_dir: Path, target: str) -> bool: + """Whether a relative href names a file that ships beside the rendered page. + + A link only works if BOTH published outputs carry the file, so this reproduces + both filters rather than asking whether the path exists in the repository: + + * the HTML archive copies only the SUBDIRECTORIES of docs/developer-guide next + to developer-guide.html, and skips `sketch`, so a root-level file such as + Introduction.asciidoc is never in the zip however much it exists here; + * the website rsync excludes `sketch/`, `*.asciidoc` and `*.adoc`, so a source + file nested inside a packaged directory still does not reach the site. + + Getting this wrong in the permissive direction is the expensive one: it + suppresses a finding for a link readers cannot follow. + """ + path = target.split("#", 1)[0].split("?", 1)[0] + if not path: + return False + candidate = (guide_dir / unquote(path)).resolve() + try: + relative_path = candidate.relative_to(guide_dir) + except ValueError: + return False # escapes the guide directory, so neither output carries it + if len(relative_path.parts) < 2: + return False # a root-level file; the archive copies directories only + if relative_path.parts[0] == "sketch": + return False + if candidate.suffix in ASCIIDOC_SUFFIXES: + return False # excluded from the website copy at any depth + return candidate.is_file() + + +def source_locations(guide_dir: Path, target: str) -> list[str]: + """Find where a dangling target is referenced, so the error is actionable.""" + hits = [] + for path in sorted(guide_dir.rglob("*")): + if path.suffix not in {".adoc", ".asciidoc"} or not path.is_file(): + continue + for number, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1): + for match in ANCHOR_SOURCE_RE.finditer(line): + if match.group(1).strip() == target: + hits.append(f"{path.name}:{number}") + return hits + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--guide-dir", default="docs/developer-guide", type=Path) + args = parser.parse_args() + + guide_dir = args.guide_dir.resolve() + reject_unmodelled_conditionals(guide_dir) + root = guide_dir / "developer-guide.asciidoc" + dangling: dict[str, int] = defaultdict(int) + raw_id_links: dict[str, int] = defaultdict(int) + relative: dict[str, int] = defaultdict(int) + branches: dict[str, set[str]] = defaultdict(set) + anchor_total = 0 + + for name, attributes in BACKENDS: + markup = render(root, attributes) + ids = set(ID_RE.findall(markup)) + anchor_total = max(anchor_total, len(ids)) + for target in HREF_RE.findall(markup): + if target not in ids: + dangling[target] += 1 + branches[target].add(name) + for target in RAW_ID_LINK_RE.findall(markup): + raw_id_links[target] += 1 + branches[target].add(name) + for target in RELATIVE_HREF_RE.findall(markup): + if packaged_asset(guide_dir, target): + continue + relative[target] += 1 + branches[target].add(name) + + if not dangling and not raw_id_links and not relative: + print( + f"Cross-references OK: {anchor_total} anchors, every internal link " + f"resolves in both the default and backend-pdf renders." + ) + return 0 + + for target in sorted(dangling): + where = source_locations(guide_dir, target) + location = ", ".join(where) if where else "not found in source (generated content?)" + where_rendered = "/".join(sorted(branches[target])) + " render" + print( + f"{location}: <<{target}>> points at an id that does not exist in the " + f"rendered book ({dangling[target]} reference(s), {where_rendered}).", + file=sys.stderr, + ) + for target in sorted(raw_id_links): + where = source_locations(guide_dir, target) + location = ", ".join(where) if where else "unknown" + print( + f"{location}: <<{target}>> resolves to an anchor with no title, so the " + f"sentence renders the raw id as \"[{target}]\" " + f"({raw_id_links[target]} reference(s)). Give the reference link text " + f"(<<{target},some words>>) or move the anchor onto the section.", + file=sys.stderr, + ) + for target in sorted(relative): + print( + f"css.asciidoc or elsewhere: link:{target}[] renders as a relative URL " + f"({relative[target]} reference(s)). The guide is one page, so this " + f"reaches nothing that ships with it -- use an xref, or an absolute URL " + f"if the destination really is off-site.", + file=sys.stderr, + ) + print( + f"\n{len(dangling)} dangling, {len(raw_id_links)} untitled and " + f"{len(relative)} relative cross-reference target(s).", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/developer-guide/check-missing-code-blocks.py b/scripts/developer-guide/check-missing-code-blocks.py new file mode 100755 index 00000000000..16e77c6a9c7 --- /dev/null +++ b/scripts/developer-guide/check-missing-code-blocks.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Find prose that promises a code block where no block follows. + +Commit bbdc6058f0 ("Extract developer guide snippets into demos") converted +inline listings into ``include::`` directives and dropped roughly 415 of them on +the floor, leaving the introducing sentence, its colon, and two blank lines where +the code used to be. The Commerce chapter documents a paid service and, after +that pass, contained no code at all. + +``validate-guide-snippets.py`` cannot see this: it validates the blocks that are +present, not the ones that should be. The signature here is the hole itself -- +a sentence ending in a colon followed by two blank lines, which is what a removed +block leaves behind (a real block is separated from its introduction by exactly +one blank line). + +The baseline is a ratchet: it may shrink, never grow. Entries are keyed by the +text of the promising sentence rather than by line number, so ordinary editing +elsewhere in a chapter does not churn the file. +""" +from __future__ import annotations + +import argparse +import collections +import re +import sys +from pathlib import Path + +ASCIIDOC_EXTENSIONS = {".adoc", ".asciidoc"} +# Only blocks whose CONTENT is literal: listing, literal, fenced code and +# passthrough. The container delimiters -- ==== example, **** sidebar, ____ quote +# -- hold ordinary prose, and skipping them hid six real holes in basics.asciidoc +# alone, among them the setSameWidth example this check was written to find. +# +# Markdown's three-backtick fence is deliberately absent. The guide contains none, +# and validate-guide-snippets.py requires every listing to be [source,LANG] with a +# bare include:: inside ---- delimiters, so a three-backtick block would fail that +# gate before reaching this one. Add it here if that convention ever changes. +# Literal and comment blocks -- an example (====) or sidebar (****) block contains +# live markup, so its headings and includes are real and must keep counting. A +# delimiter may be LONGER than four characters, and closes on one of the same +# character AND length, so a four-dash line inside a five-dash block is content +# rather than the close. Matching exactly four left every ----- block's contents +# live: the guide has 20 such lines, all [source] blocks wrapping an include::. +# +# //// is here because asciidoctor drops a comment block entirely, so an include +# inside one does not put its target in the book and prose inside one promises +# the reader nothing. Note check-guide-links.py deliberately does NOT skip +# comments: a dead link commented out is still dead debt in the source, and +# letting the ratchet shrink for it would turn "comment the line out" into a way +# to silence that gate. Reachability is the opposite case -- treating a +# commented-out include as reachable states something about the book that is +# simply untrue. +FENCE_RE = re.compile(r"^(-{4,}|\.{4,}|`{4,}|\+{4,}|/{4,})\s*$") +# Lines that end in a colon without promising a listing: headings, attributes, +# comments, block titles, list markers, table cells and block delimiters. +NON_PROSE_PREFIX = ("//", "|", "=", ".", ":", "*", "-", "+", "[", "<") +# A list marker is followed by whitespace; a block title (.Title) and bold text +# (*text*) are not. Stripping the marker lets the prose test see the sentence, +# so an introduction written as a list item is not mistaken for markup. +LIST_MARKER_RE = re.compile(r"^([*\-]+|\.{1,5}|[0-9]+\.)\s+") +# What a real block looks like when it starts. image:: is deliberately absent: a +# figure is not the listing a sentence promised, which is what the 33 baseline +# entries of the form "looks something like this:" followed by a titled image +# already record. Counting it made the answer depend on whether the figure +# happened to carry a title. An introduction separated from its +# listing by more than one blank line is untidy, not a hole, and reporting it +# would make the gate reject valid AsciiDoc spacing. Deliberately conservative: +# only unambiguous starts, so a genuine hole is never explained away. +# An admonition is prose, so it can never be the listing a sentence promised. +# Excluded by name rather than by whitelisting the block kinds that ARE code: +# measured, the bracket lines that legitimately answer a promising sentence +# already span [source], [listing], [cols=...], [options=...], [quote] and an +# anchored image, and a whitelist would report the next kind nobody thought of. +ADMONITION = "NOTE|TIP|IMPORTANT|WARNING|CAUTION" +# A bracketed attribute list or a block title. Both attach to the block BELOW them, +# so neither answers the question "is a listing here". +ATTRIBUTE_LINE_RE = re.compile( + r'^(\[\[[^\]]+\]\]\s*$' # [[anchor]] + r'|\[(?!(?:' + "NOTE|TIP|IMPORTANT|WARNING|CAUTION" + r')[,\]])[a-zA-Z%.#"][^\]]*\]\s*$' + r'|\.[^.\s].*$)' # .Block title +) +BLOCK_START_RE = re.compile( + # The name may be followed by "]" or by further attributes, as in + # [NOTE,caption="Aside"] -- requiring the bracket immediately made an + # attributed admonition look like the listing the sentence promised. + r'^(\[(?!(?:' + ADMONITION + r')[,\]])[a-zA-Z%.#"]' + r'|include::|\|===|(----|\.\.\.\.|````|\+\+\+\+|====|\*\*\*\*|____)\s*$)' +) + + +def normalize(line: str) -> str: + return re.sub(r"\s+", " ", line.strip()) + + +def scan(path: Path) -> list[tuple[int, str]]: + lines = path.read_text(encoding="utf-8").split("\n") + open_fence: str | None = None + findings = [] + for index, line in enumerate(lines): + fence = FENCE_RE.match(line) + if fence: + token = fence.group(1) + if open_fence is None: + open_fence = token + elif token == open_fence: + open_fence = None + continue + if open_fence is not None: + continue + stripped = line.rstrip() + if not stripped.endswith(":"): + continue + body = stripped.lstrip() + marker = LIST_MARKER_RE.match(body) + if marker: + body = body[marker.end():] + if not body or body.startswith(NON_PROSE_PREFIX): + continue + if index + 2 >= len(lines): + continue + if lines[index + 1].strip() or lines[index + 2].strip(): + continue + following = index + 1 + while following < len(lines) and not lines[following].strip(): + following += 1 + # An attribute line decorates whatever comes next; it is not itself the + # block. [source,java] is followed by the delimiter, [cols=...] by the + # table, an anchor by an image -- and [#next-section] by nothing but an + # ordinary paragraph, which is exactly the hole this gate looks for. So + # step over attribute lines and block titles and classify what they attach + # to. Admonitions never reach here; ADMONITION excludes them above. + while following < len(lines) and ATTRIBUTE_LINE_RE.match(lines[following].strip()): + following += 1 + while following < len(lines) and not lines[following].strip(): + following += 1 + if following < len(lines) and BLOCK_START_RE.match(lines[following].strip()): + continue + findings.append((index + 1, normalize(stripped))) + return findings + + +def load_baseline(path: Path) -> collections.Counter: + """Read the baseline as a multiset: one line per occurrence. + + Two identical introducing sentences in one chapter are two holes to fill, and + keying by text alone would let the second appear for free. + """ + baseline: collections.Counter = collections.Counter() + if not path.exists(): + return baseline + for raw in path.read_text(encoding="utf-8").split("\n"): + if raw.startswith("#") or not raw.strip(): + continue + name, _, text = raw.rstrip("\n").partition("\t") + if text: + baseline[f"{name}\t{text}"] += 1 + return baseline + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--guide-dir", default="docs/developer-guide", type=Path) + parser.add_argument( + "--baseline", + default="scripts/developer-guide/missing-code-blocks-baseline.txt", + type=Path, + ) + parser.add_argument( + "--write-baseline", + action="store_true", + help="rewrite the baseline from the current tree", + ) + parser.add_argument( + "--allow-new", + action="store_true", + help="permit --write-baseline to ADD entries; without it the baseline may only shrink", + ) + args = parser.parse_args() + + guide_dir = args.guide_dir.resolve() + current: collections.Counter = collections.Counter() + located: dict[str, list[int]] = {} + for path in sorted(guide_dir.rglob("*")): + if path.suffix not in ASCIIDOC_EXTENSIONS or not path.is_file(): + continue + name = path.relative_to(guide_dir).as_posix() + for number, text in scan(path): + entry = f"{name}\t{text}" + current[entry] += 1 + located.setdefault(entry, []).append(number) + + if args.write_baseline: + # Same reasoning as check-guide-links.py: the command that banks a fix + # must not silently bury a new hole. + added = sorted((current - load_baseline(args.baseline)).elements()) + if added and not args.allow_new: + for entry in added: + print(entry.replace("\t", ": "), file=sys.stderr) + print( + f"\nRefusing to add {len(added)} entr(ies) to the baseline. Restore the " + f"block, or pass --allow-new if this is debt you mean to record.", + file=sys.stderr, + ) + return 1 + lines = [ + "# Prose that promises a code block where none follows.", + "# A ratchet: entries may be removed as holes are filled, never added.", + "# Regenerate with check-missing-code-blocks.py --write-baseline.", + ] + lines.extend(sorted(current.elements())) + args.baseline.write_text("\n".join(lines) + "\n", encoding="utf-8") + total = sum(current.values()) + print(f"Wrote baseline with {total} entr(ies).") + return 0 + + baseline = load_baseline(args.baseline) + new: list[str] = [] + for entry in sorted((current - baseline).elements()): + name, _, text = entry.partition("\t") + where = ", ".join(str(n) for n in located.get(entry, [])) + new.append(f"{name}:{where}: promises a code block that is not there: {text[:100]}") + + # A filled hole has to leave the baseline. Leaving it keeps a slot open: a + # later change can empty that exact block again and `current - baseline` stays + # empty, so the regression passes. The ratchet only ratchets if fixes are banked. + stale: list[str] = [] + for entry in sorted((baseline - current).elements()): + name, _, text = entry.partition("\t") + stale.append( + f"{name}: filled, but still in the baseline: {text[:80]}. Run " + f"check-missing-code-blocks.py --write-baseline to bank the fix." + ) + + total = sum(current.values()) + if new or stale: + for entry in new + stale: + print(entry, file=sys.stderr) + print( + f"\n{len(new)} new hole(s) and {len(stale)} stale baseline entr(ies). Restore " + f"the block (the originals are recoverable from bbdc6058f0~1) or rewrite the " + f"sentence so it stops promising one.", + file=sys.stderr, + ) + return 1 + + print(f"Missing code blocks: {total} known hole(s), none new, none stale.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/developer-guide/compare-screenshots.py b/scripts/developer-guide/compare-screenshots.py new file mode 100755 index 00000000000..a57efcaca40 --- /dev/null +++ b/scripts/developer-guide/compare-screenshots.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Compare generated guide figures against the committed ones. + +Byte equality is the default and is what nearly every figure achieves: with the +font loaded from the port's bundled Roboto and MigLayout's platform pinned, 23 of +the 24 render identically on macOS and on the Linux runner. + +The exception is a figure containing a `FontImage` material glyph. Measured +against the runner's own output, the glyph lands at exactly the same size and +position -- a 55x49 bounding box at the same origin -- and differs only in +antialiased edge coverage, 946 fully-white pixels against 916. That is Java2D +rasterizing the same glyph from the same font at the same size slightly +differently on the two platforms, and no amount of pinning on our side changes +it. Demanding byte equality there would mean either deleting legitimate content +from the figure or carrying a permanently red check. + +So a figure may carry a `.tolerance` sidecar, in the same key=value shape +the CN1SS screenshot suites already use, and only then is a bounded difference +accepted. Everything without a sidecar must still match exactly. + +The two bounds are applied independently, which is where this differs from the +CN1SS comparator: `maxMismatchPercent` limits how much of the image may change at +all, and `maxChannelDelta` caps how far any single pixel may move. Counting only +the pixels that exceed the delta would let an unlimited number of sub-threshold +changes through. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +try: + from PIL import Image +except ImportError: # pragma: no cover - the workflow installs Pillow + print("Pillow is required to compare screenshots", file=sys.stderr) + raise + + +def read_tolerance(path: Path) -> tuple[int, float] | None: + if not path.exists(): + return None + max_delta, max_percent = 0, 0.0 + for line in path.read_text(encoding="utf-8").split("\n"): + line = line.split("#", 1)[0].strip() + if not line or "=" not in line: + continue + key, _, value = line.partition("=") + if key.strip() == "maxChannelDelta": + max_delta = int(value) + elif key.strip() == "maxMismatchPercent": + max_percent = float(value) + return max_delta, max_percent + + +def compare(generated: Path, committed: Path, tolerance: tuple[int, float] | None) -> str | None: + """Return None when the pair is acceptable, else a description of the failure.""" + if generated.read_bytes() == committed.read_bytes(): + return None + if tolerance is None: + return "differs and has no tolerance sidecar" + max_delta, max_percent = tolerance + # RGBA, not RGB: these figures are saved with an alpha channel, and dropping + # it would make a change that touches only transparency invisible here -- + # including one that turned the whole figure see-through while leaving every + # colour channel intact. + a = Image.open(generated).convert("RGBA") + b = Image.open(committed).convert("RGBA") + if a.size != b.size: + return f"size changed: generated {a.size[0]}x{a.size[1]}, committed {b.size[0]}x{b.size[1]}" + pa, pb = a.load(), b.load() + width, height = a.size + changed = 0 + worst = 0 + for y in range(height): + for x in range(width): + first, second = pa[x, y], pb[x, y] + if first == second: + continue + # Every changed pixel counts toward the area budget, and the channel + # delta is a separate ceiling. Counting only the pixels that EXCEED + # the delta -- which is what the CN1SS comparator does -- leaves an + # unbounded hole: with maxChannelDelta=160, recolouring these figures' + # green #06a806 to #a608a6 moves every channel by exactly 160, so not + # one pixel would be counted and a completely different image would + # pass. + changed += 1 + worst = max(worst, max(abs(first[i] - second[i]) for i in range(4))) + percent = 100.0 * changed / (width * height) + if percent > max_percent: + return f"{percent:.3f}% of pixels changed (allowed {max_percent}%)" + if worst > max_delta: + return f"worst channel delta {worst} exceeds maxChannelDelta={max_delta}" + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--generated", required=True, type=Path) + parser.add_argument("--committed", required=True, type=Path) + parser.add_argument("--expected-count", type=int, required=True) + args = parser.parse_args() + + produced = sorted(args.generated.glob("*.png")) + if len(produced) != args.expected_count: + print( + f"::error::Expected {args.expected_count} generated figures, found {len(produced)}", + file=sys.stderr, + ) + return 1 + + failures = 0 + tolerated = 0 + for image in produced: + committed = args.committed / image.name + if not committed.exists(): + print(f"::error::Generated figure has no committed counterpart: {image.name}", file=sys.stderr) + failures += 1 + continue + sidecar = args.committed / (image.stem + ".tolerance") + tolerance = read_tolerance(sidecar) + problem = compare(image, committed, tolerance) + if problem: + print(f"::error::{image.name}: {problem}", file=sys.stderr) + failures += 1 + elif tolerance is not None and image.read_bytes() != committed.read_bytes(): + tolerated += 1 + print(f"{image.name}: within its tolerance sidecar") + + if failures: + print(f"\n{failures} figure(s) do not match.", file=sys.stderr) + return 1 + print(f"All {len(produced)} figures match ({tolerated} within a tolerance sidecar).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/developer-guide/guide-links-baseline.txt b/scripts/developer-guide/guide-links-baseline.txt new file mode 100644 index 00000000000..56648f6ac60 --- /dev/null +++ b/scripts/developer-guide/guide-links-baseline.txt @@ -0,0 +1,41 @@ +# Developer guide links that do not resolve, or are not TLS. +# One line per occurrence: a file naming the same bad URL twice gets +# two lines, because that is two things to fix. +# A ratchet: entries may be removed as links are fixed, never added. +# Regenerate with check-guide-links.py --write-baseline. +About-This-Guide.asciidoc http://github.com/chen-fishbein/ +About-This-Guide.asciidoc http://github.com/shannah/ +Advanced-Theming.asciidoc http://freebiesbug.com/psd-freebies/iphone-6-ui-kit/ +Advanced-Topics-Under-The-Hood.asciidoc http://developer.android.com/reference/android/content/Context.html +Advanced-Topics-Under-The-Hood.asciidoc http://developer.freshdesk.com/mobihelp/android/api/reference/com/freshdesk/mobihelp/Mobihelp.html +Advanced-Topics-Under-The-Hood.asciidoc http://developer.freshdesk.com/mobihelp/android/api/reference/com/freshdesk/mobihelp/Mobihelp.html +Advanced-Topics-Under-The-Hood.asciidoc http://developer.freshdesk.com/mobihelp/android/api/reference/com/freshdesk/mobihelp/package-summary.html +Advanced-Topics-Under-The-Hood.asciidoc http://developer.freshdesk.com/mobihelp/android/api/reference/com/freshdesk/mobihelp/package-summary.html +Advanced-Topics-Under-The-Hood.asciidoc http://developer.freshdesk.com/mobihelp/android/integration_guide/ +Advanced-Topics-Under-The-Hood.asciidoc http://developer.freshdesk.com/mobihelp/ios/api/ +Advanced-Topics-Under-The-Hood.asciidoc http://developer.freshdesk.com/mobihelp/ios/api/ +Advanced-Topics-Under-The-Hood.asciidoc http://developer.freshdesk.com/mobihelp/ios/integration_guide/ +Advanced-Topics-Under-The-Hood.asciidoc http://developer.freshdesk.com/mobihelp/ios/integration_guide/#getting-started +Advanced-Topics-Under-The-Hood.asciidoc http://groovy-lang.org/ +Advanced-Topics-Under-The-Hood.asciidoc http://jruby.org/ +Advanced-Topics-Under-The-Hood.asciidoc http://shannah.github.io/cn1-freshdesk/ +Advanced-Topics-Under-The-Hood.asciidoc http://stackoverflow.com/questions/11421048/android-ios-custom-uri-protocol-handling +Advanced-Topics-Under-The-Hood.asciidoc http://www.mirah.org/ +Introduction.asciidoc http://www.xmlvm.org/ +Miscellaneous-Features.asciidoc http://wiki.akosma.com/IPhone_URL_Schemes +The-Components-Of-Codename-One.asciidoc http://awoiaf.westeros.org/index.php/A_Game_of_Thrones +The-Components-Of-Codename-One.asciidoc http://awoiaf.westeros.org/index.php/Portal:Books +The-Components-Of-Codename-One.asciidoc http://www.georgerrmartin.com/ +The-EDT---Event-Dispatch-Thread.asciidoc http://foxtrot.sourceforge.net/ +basics.asciidoc http://docs.oracle.com/Java +basics.asciidoc http://www.miglayout.com/QuickStart.pdf +graphics.asciidoc http://fontello.com/ +io.asciidoc http://twillo.com/ +io.asciidoc http://www.anapioficeandfire.com/api/characters?page=5&pageSize=3 +io.asciidoc http://www.codenameone.com/javadoc/com/codename1/io/ConnectionRequest.html +io.asciidoc http://www.codenameone.com/javadoc/com/codename1/io/NetworkManager.html +performance.asciidoc http://optipng.sourceforge.net/ +signing.asciidoc http://developer.android.com/guide/publishing/app-signing.html +signing.asciidoc http://developer.apple.com/ +signing.asciidoc http://get.udid.io/ +signing.asciidoc http://stackoverflow.com/questions/6320255/if-i-revoke-an-existing-distribution-certificate-will-it-mess-up-anything-with diff --git a/scripts/developer-guide/missing-code-blocks-baseline.txt b/scripts/developer-guide/missing-code-blocks-baseline.txt new file mode 100644 index 00000000000..ff6cb31277f --- /dev/null +++ b/scripts/developer-guide/missing-code-blocks-baseline.txt @@ -0,0 +1,407 @@ +# Prose that promises a code block where none follows. +# A ratchet: entries may be removed as holes are filled, never added. +# Regenerate with check-missing-code-blocks.py --write-baseline. +3D-Graphics.asciidoc ARGB pixels: +3D-Graphics.asciidoc Querying capabilities at runtime: +3D-Graphics.asciidoc own material, so a textured model renders with no extra setup: +Advanced-Topics-Under-The-Hood.asciidoc For the release build, you will also need to inject some proguard configuration so that important classes don't get stripped out at build time. The FreshDesk SDK instructions state: +Advanced-Topics-Under-The-Hood.asciidoc You can override these methods in the draggable components: +Advertising.asciidoc (for example "Sponsored"): +Advertising.asciidoc anchored at the top or bottom) and call `load()`: +Advertising.asciidoc identifier. The recommended order is to initialize, gather consent, then load: +Advertising.asciidoc manager and provider handle the foreground hook and freshness window for you: +Advertising.asciidoc method that registers its provider with `AdManager`: +Advertising.asciidoc next one when the current ad is dismissed: +Advertising.asciidoc rewards, verify server side rather than trusting the client: +Advertising.asciidoc transitions, no more often than a given interval: +Ai-And-Speech.asciidoc `ChatMessage` values to JSON under a named key: +Ai-And-Speech.asciidoc `SecureStorage` overloads: +Ai-And-Speech.asciidoc `chatStream(...)` can mutate the view directly: +Ai-And-Speech.asciidoc `cn1-ai-stablediffusion` cn1lib (when present in the consumer project): +Ai-And-Speech.asciidoc accepts either inline bytes plus a MIME type or a remote HTTPS URL: +Ai-And-Speech.asciidoc and streams the same text into a `ChatView`: +Ai-And-Speech.asciidoc calling `cancel()` on the resource closes the underlying socket: +Ai-And-Speech.asciidoc clustering: +Ai-And-Speech.asciidoc conversation: +Ai-And-Speech.asciidoc one user message: +Ai-And-Speech.asciidoc returns a string the runtime can hand to `JSONParser`: +Ai-And-Speech.asciidoc safe to log: +Ai-And-Speech.asciidoc you need a custom send pipeline: +Analytics.asciidoc Consent is broken down by category, so you can honor granular choices -- for example allowing crash reporting while declining behavioral analytics: +Analytics.asciidoc Each device is identified by a pseudonymous client id. It's generated on first use and stored locally -- it isn't derived from any hardware identifier. To honor an erasure request ("right to be forgotten"), reset it: +Analytics.asciidoc If you would rather not gate at all, switch the default so collection is active unless the user withdraws it. This places the compliance responsibility on you as the integrator: +Analytics.asciidoc If your application has already obtained consent through its own mechanism -- a custom prompt, a third-party consent-management platform, an enterprise device-management policy, or a jurisdiction where you have determined consent isn't required -- record it once at startup so reporting flows without a second prompt: +Analytics.asciidoc Once at least one provider is registered, report usage from anywhere in your app: +Analytics.asciidoc You can also seed every category and then revoke just one with the builder, for example to allow analytics but not ad storage: +Analytics.asciidoc `FirebaseAnalyticsProvider` forwards to the native Firebase Analytics SDK on Android and iOS. Register it like any other provider: +Analytics.asciidoc `GoogleAnalyticsProvider` uses the GA4 Measurement Protocol. Create it with a measurement id (`G-XXXXXXXX`) and a Measurement Protocol API secret from the GA4 admin console: +Analytics.asciidoc `MatomoAnalyticsProvider` targets Matomo (formerly Piwik) through its HTTP tracking API. Matomo can be self-hosted and supports IP anonymization, which makes it a good fit for privacy-sensitive deployments: +Animations.asciidoc That one command will enable swiping back from `currentForm`. https://www.codenameone.com/javadoc/com/codename1/util/LazyValue.html[LazyValue] allows you to pass a value lazily: +Animations.asciidoc builder: +Annotation-Component-Binding.asciidoc ASM and inserts the equivalent of: +Annotation-Component-Binding.asciidoc After binding, drive validation through the returned handle: +Annotation-JSON-XML-Mapping.asciidoc Hand-write a `Mapper` and register it at startup: +Annotation-JSON-XML-Mapping.asciidoc mutation: +Annotation-SQLite-ORM.asciidoc dao surface isn't enough. Transactions: +App-Review.asciidoc By default the low-rating feedback is collected through an e-mail to the address passed to `setSupportEmail(String)`. To deliver feedback through your own backend, register a `FeedbackListener`: +App-Review.asciidoc Rather than picking the moment yourself, you can let `AppReview` decide based on simple engagement heuristics: how many times the app was launched, how long ago it was installed, and how long since it last asked. Configure it once (for example in your app's `init` or `start` method) and call `registerSession()` on every launch: +App-Review.asciidoc The simplest usage is a single call at a moment that makes sense in your app -- typically right after the user completed something rewarding (finished a level, saved a document, completed an order): +Apple-Wallet-Extension.asciidoc In Java, publish the user's cards whenever they change (typically after login) and keep a fresh token published so Wallet can skip the login screen: +Authentication-And-Identity.asciidoc Firebase Auth isn't an OIDC provider -- it issues Google-Identity-Toolkit-style tokens via REST endpoints. `com.codename1.social.FirebaseAuth` wraps those endpoints: +Authentication-And-Identity.asciidoc For federated sign-in (Google / Apple / Microsoft as Firebase providers), first obtain an ID token via the matching `*Connect` class, then swap it for a Firebase session: +Authentication-And-Identity.asciidoc Or, if the provider exposes a discovery document: +Authentication-And-Identity.asciidoc Pseudo-code (the actual HTTP calls depend on your server library): +Authentication-And-Identity.asciidoc Refresh the Firebase session at app launch: +Authentication-And-Identity.asciidoc `OidcClient` saves the response under a per-issuer + per-client-ID key using `com.codename1.io.oidc.TokenStore.DefaultStorageTokenStore` (which serializes to `com.codename1.io.Storage`). To restore on next launch: +Authentication-And-Identity.asciidoc `com.codename1.social.FacebookConnect` exposes both the old SDK-based `doLogin()` and a new SDK-free `signIn(...)` that uses the system browser via `OidcClient`. Use the new method for the simulator, the web port, and for apps that don't want to bundle the Facebook SDK at all: +Authentication-And-Identity.asciidoc `com.codename1.social.GoogleConnect` now offers a modern `signIn(...)` method that runs entirely through `OidcClient`: +Authentication-And-Identity.asciidoc becomes: +Commerce.asciidoc After a purchase, or on app start, validate the device's receipts with the cloud and refresh the entitlement cache. `refresh()` makes a blocking network call, so run it off the EDT: +Commerce.asciidoc Drive purchases through the manager (these delegate to the `Purchase` API): +Commerce.asciidoc In your `Lifecycle.init` (or wherever you set up purchases): +Commerce.asciidoc The core idea is the *entitlement*: an abstract access right such as `pro` or `remove_ads`. You map one or more store products to an entitlement, and your code only ever checks the entitlement: +Crash-Protection.asciidoc In your `Lifecycle.init`: +Deep-Links-Routing.asciidoc Or annotate a static factory method: +Deep-Links-Routing.asciidoc `AssetLinksBuilder` produces the payload: +Deep-Links-Routing.asciidoc attempts so they can confirm before discarding unsaved work: +Deep-Links-Routing.asciidoc without redirects. The plugin's `AasaBuilder` produces the payload: +Desktop-Integration.asciidoc same scheduling and handling code is shared across phone and desktop: +Device-Input-And-Form-Factors.asciidoc (or `Display`), mirroring the existing `CN.isShiftKeyDown()` family: +Device-Input-And-Form-Factors.asciidoc Any pointer listener receives an `ActionEvent`; call `getPointerEvent()` on it: +Device-Input-And-Form-Factors.asciidoc React to fold changes with a posture listener: +Device-Input-And-Form-Factors.asciidoc handles desktop and touch: +Device-Input-And-Form-Factors.asciidoc https://www.codenameone.com/javadoc/com/codename1/ui/DevicePosture.html[`DevicePosture`]: +Device-Input-And-Form-Factors.asciidoc pointer is a stylus or eraser: +Events.asciidoc The pointer events (touch events) can be intercepted by overriding one or more of these methods in `Component` or `Form`. Notice that unless you want to block functionality you should probably invoke `super` when overriding: +Events.asciidoc Then when you need to broadcast the event use: +Events.asciidoc https://www.codenameone.com/javadoc/com/codename1/ui/events/StyleListener.html[StyleListener] allows components to track changes to the style objects. For example, if the developer does something like: +Game-Assets.asciidoc is registered as solid. Applications add their own without changing the level format: +Game-Builder.asciidoc Loading and playing a level at runtime is three lines: +Game-Builder.asciidoc `start()` it: +Game-Builder.asciidoc the editor: +Game-Development.asciidoc as 2:1 diamonds -- columns step right-and-down, rows step left-and-down: +Game-Development.asciidoc build the frames yourself, which is what `ScrollerGameSample`'s runner does: +Game-Development.asciidoc come from one piece of geometry: +Game-Development.asciidoc each card's bounding box: +Game-Development.asciidoc engine can report completion, so guard with `isVoiceCompletionSupported()`: +Game-Development.asciidoc handle: +Game-Development.asciidoc layers the sprites within a cell: +Game-Development.asciidoc never recompute coordinates: +Game-Development.asciidoc objects, and step it once per frame from your `update`: +Game-Development.asciidoc rendered positions between physics states: +Game-Development.asciidoc required: +Game-Development.asciidoc rotation automatically (in pixels, screen space) -- and the scene draws it there: +Game-Development.asciidoc runs once before the first frame: +Game-Development.asciidoc that always faces the camera, so your existing 2D art keeps working in 3D: +Game-Development.asciidoc the thin point reads exactly the same to the eye: +Game-Development.asciidoc uploading it with `com.codename1.gpu.GraphicsDevice#createTexture(com.codename1.ui.Image)`: +In-Car-Experiences.asciidoc Head units enforce a hard cap on the number of rows/items they display (driver-distraction rules). Query it and trim accordingly: +In-Car-Experiences.asciidoc Register a single `CarApplication` from your app's `init()` -- before a head unit connects -- and return a root `CarScreen` that builds a template: +In-Car-Experiences.asciidoc `CarContext` manages a back stack, mirroring `androidx.car.app`'s `ScreenManager` and CarPlay's `CPInterfaceController`: +In-Car-Experiences.asciidoc `CarScreen` exposes optional lifecycle hooks -- `onCreate()`, `onResume()`, `onPause()`, `onDestroy()` -- and `CarApplication` is notified of connection changes via `onCarConnected(CarContext)` / `onCarDisconnected()`. You can also observe connection globally: +Introduction.asciidoc In a cold start `init(Object)` is invoked followed by the `start()` method. For example, `start()` can be invoked more than once if an app is minimized and restored, see the sidebar <>: +Introduction.asciidoc Next consider the first lifecycle method `init(Object)`. The <> discusses the lifecycle in depth: +Introduction.asciidoc Now that you have a general sense of the lifecycle lets look at the last two lifecycle methods: +Introduction.asciidoc The hello world Java source file looks like this (removed some comments and whitespace): +Maps.asciidoc A native provider only renders on the platforms that ship its SDK. To show a provider's map *everywhere* -- including platforms with no native SDK for it -- register a `WebMapProvider`, which hosts the provider's JavaScript SDK inside a `BrowserComponent`: +Maps.asciidoc Because it needs only a web view, the web provider (id `web`) is the natural last step before the pure-vector fallback in a provider chain. The order is fully customizable from code, so you can express per-platform preferences -- for example "try the native Google SDK, then the web map, then the vector `MapView`": +Maps.asciidoc Every map -- vector or native -- exposes the same operations through `MapSurface`: +Maps.asciidoc For a real app you point `MapView` at a hosted tile service. The simplest keyless option is OpenFreeMap (OpenStreetMap-based vector tiles); for branded styles or higher quotas use a keyed provider such as MapTiler or your own self-hosted Protomaps/TileServer GL endpoint: +Maps.asciidoc When a provider is selected the build server injects that provider's implementation into your app and wires it in; with no provider selected (or when the provider is unavailable at runtime, for example when Google Play Services is missing) `NativeMap` simply renders the vector `MapView` fallback. You can configure the fallback basemap explicitly: +Maven-Creating-CN1Libs.adoc Now try it out. Try adding the following code to your application project's main class (or anywhere in the application project, for that matter): +Maven-Creating-CN1Libs.adoc The simulator dispatches every action on the Codename One EDT through `Display.callSerially`, so your method can call `Display.getInstance()`, `Form.show()`, `Dialog.show()`, `ToastBar.showInfoMessage()` and any other CN1 API. Reflection uses the same classloader that loaded `Display`, so cn1lib internals (including package-private classes) resolve normally: +Media-And-Audio.asciidoc `AudioEffects` provides small platform-neutral PCM transforms that compose with the mixer: +Miscellaneous-Features.asciidoc A typical use of this API would be something like this: +Miscellaneous-Features.asciidoc Easy thread can be created like this: +Miscellaneous-Features.asciidoc For Android 11+ (API 30+), Codename One detects if background location is needed and presents a dialog explaining the need before redirecting the user to the app settings. You can customize the permission prompt message using the localization key `android.permission.ACCESS_BACKGROUND_LOCATION`: +Miscellaneous-Features.asciidoc For example: you can copy the `Image` to `Storage` using: +Miscellaneous-Features.asciidoc However, it gets better, say you want to return a value: +Miscellaneous-Features.asciidoc If you need to apply the scale manually for custom fonts or layout calculations, read the values directly: +Miscellaneous-Features.asciidoc It allows formatting numbers/dates & time based on platform locale. It also provides a great deal of the information you need such as the language/locale information you need to pick the proper resource bundle: +Miscellaneous-Features.asciidoc Or, you can use the `Media`, `MediaManager` and `MediaRecorderBuilder` APIs to capture audio, as a more customizable approach than using the Capture API: +Miscellaneous-Features.asciidoc The `Capture` API also includes a callback based API that uses the `ActionListener` interface to implement capture. For example: you can adapt the previous sample to use this API as such: +Miscellaneous-Features.asciidoc The following code demonstrates usage of the GeoFence API: +Miscellaneous-Features.asciidoc The list of available languages in the resource bundle could be retrieved like this. Notice that this a list that was set by you and doesn't need to confirm to the ISO language code standards: +Miscellaneous-Features.asciidoc These asynchronous calls make things a bit painful to wade through, so the API wraps them in a simplified synchronous version: +Miscellaneous-Features.asciidoc To solve this sort of used case you have two APIs in `Display`: +Miscellaneous-Features.asciidoc You can add more than one attachment by putting them directly into the attachment map for example: +Miscellaneous-Features.asciidoc You can install the bundle using code like this: +Miscellaneous-Features.asciidoc You can send a task to the thread using: +Monetization.asciidoc And you also provide a button to allow the user to manually synchronize the receipts: +Monetization.asciidoc And your `itemPurchased()` callback will need to add a world: +Monetization.asciidoc At the end of the `start()` method: +Monetization.asciidoc At this point, the app can track the sale of the world. To make it more useful, add `ToastBar` feedback for purchase completion: +Monetization.asciidoc In the hello world app you'll use this information in a few different places. On your main form you'll include a label to show the current expiry date, and you allow the user to press a button to synchronize receipts manually if they think the value is out of date: +Monetization.asciidoc In the main form, you want two buttons to subscribe to the `World`, for one month and one year respectively. They look like: +Monetization.asciidoc Now in the `start()` method, add a button that lets the user buy the world: +Monetization.asciidoc Now you'll change your buy code as follows: +Monetization.asciidoc On the server-side, your REST controller is a standard JAX-RS REST interface. The Netbeans web service wizard generated it and then it was modified to suit the purposes here. The methods of the `ReceiptsFacadeREST` class for the REST API are shown here: +Monetization.asciidoc Once implemented, your `fetchReceipts()` method will look like: +Monetization.asciidoc The `createRESTClient()` method shown there creates a `RESTfulWebServiceClient` and configuring it to use basic authentication with a username and password. The idea is that your user would have logged into your app at some point, and you would have a username and password on hand to pass back to the web service with the receipt data so that you can connect the subscription to a user account. The source of that method is listed here: +Monetization.asciidoc The `submitReceipt()` method is a little more complex, as it needs to calculate the new expiry date for your subscription: +Monetization.asciidoc The buy callbacks are like the ones implemented in the regular in-app purchase examples: +Monetization.asciidoc The general usage is as follows: +Monetization.asciidoc The source for your ReceiptStore is as follows: +Monetization.asciidoc To build the signed discount payload required by Apple you can use the `ApplePromotionalOffer` helper: +Monetization.asciidoc You are now ready to see the full magic of the `validateAndSaveReceipt()` method in all its glory: +Monetization.asciidoc You'll register it in your app's init() method so that it's always available: +Monetization.asciidoc You'll set these in your constants: +Motion-Sensors.asciidoc Callbacks arrive on the EDT, so the listener can update the UI directly. Sampling is reference counted: the hardware sensor draws power only while at least one listener is registered. Remove the listener once the screen is no longer visible, typically from the form's `removeNotify`, so the sensor powers down: +Motion-Sensors.asciidoc Register a `GestureListener` for one of the `GestureEvent.TYPE_*` gestures. The accelerometer powers on for the duration that a gesture listener stays registered: +Motion-Sensors.asciidoc The detection thresholds suit most apps as shipped, and you can tune them when a gesture needs to be more or less sensitive: +Motion-Sensors.asciidoc `MotionSensorManager.getInstance()` always returns a manager. On a device without motion hardware every sensor reports as unsupported, so application code can call the API without a null check on the manager itself. `getSensor(int)` returns `null` when the requested sensor isn't available on the current device: +Native-Themes.asciidoc `UIManager.addThemeProps` after the theme has been installed: +Near-Field-Communication.asciidoc Beyond NDEF, the discovered `Tag` exposes accessors for the underlying technologies: +Near-Field-Communication.asciidoc For FeliCa on iOS, set the system codes in `NfcReadOptions`: +Near-Field-Communication.asciidoc Host Card Emulation (HCE) lets the device pretend to be a contactless smart card. A nearby reader (Android phone, payment terminal, access control gate) sends ISO 7816 APDUs and your app responds. Subclass `HostCardEmulationService` and register the instance: +Network-Connectivity.asciidoc To advertise an HTTP server you wrote on port 8080: +Network-Connectivity.asciidoc To associate the device with a specific SSID: +Notifications-And-Background-Execution.asciidoc before; the bean has been extended with fluent, backward-compatible setters: +Notifications-And-Background-Execution.asciidoc then reference it from a notification with `setChannelId(...)`: +Printing.asciidoc `Printer.printImage(image, listener)` prints a `com.codename1.ui.Image` directly. The image is encoded to a temporary PNG file behind the scenes and the file is deleted once the print flow finishes: +SVG-Transcoder.asciidoc the generated class directly: +The-Components-Of-Codename-One.asciidoc A common source of confusion in the `MultiButton` is the difference between the icon and the emblem, since both may have an icon image associated with them. The icon is an image representing the entry while the emblem is an optional visual representation of the action that will be undertaken when the element is pressed. Both may be used simultaneously or individually of one another: +The-Components-Of-Codename-One.asciidoc A form is a container with a title, a content area, and an optional menu or menu-bar area. When you call methods such as `add` or `remove` on a form, you're invoking something that maps to this: +The-Components-Of-Codename-One.asciidoc A more practical "real world" example would be working with XML data. You can use something like this to show an XML `Tree`: +The-Components-Of-Codename-One.asciidoc A picker can work with your existing sample using code like this: +The-Components-Of-Codename-One.asciidoc A popup dialog is a common mobile paradigm showing a `Dialog` that points at a specific component. It's a standard `Dialog` that's shown in a unique way: +The-Components-Of-Codename-One.asciidoc And call its methods: +The-Components-Of-Codename-One.asciidoc Call the builder from a Maven plugin, an Ant task or a one-shot `main`: +The-Components-Of-Codename-One.asciidoc For example, if you wish to query a database or a web service you will need to derive the class and perform more advanced filtering by overriding the `filter` method: +The-Components-Of-Codename-One.asciidoc For example, suppose you want to pass a string with text to set in a textarea within the webpage. You can do something like: +The-Components-Of-Codename-One.asciidoc For example, this behavior might not be desired so to block that you can do: +The-Components-Of-Codename-One.asciidoc For example, to enclose the `CheckBox` components in a vertical `ComponentGroup` and the `RadioButton's` in a horizontal group, change the last line of the code above as such: +The-Components-Of-Codename-One.asciidoc For example, you can change the focus `Component` to have an icon: +The-Components-Of-Codename-One.asciidoc For example: +The-Components-Of-Codename-One.asciidoc For example: You might want to create a proxy for the https://developer.mozilla.org/en-You/docs/Web/API/Window/location[window.location] object so that you can access its properties more from Java: +The-Components-Of-Codename-One.asciidoc For example: the demo below uses the GRRM demo data from above to build a `ComboBox`: +The-Components-Of-Codename-One.asciidoc Here is a simple example of a `MultiList` containing a highly popular subject matter: +The-Components-Of-Codename-One.asciidoc Here is a simple example that also shows the difference between the `scale to fill` and `scale to fit` modes: +The-Components-Of-Codename-One.asciidoc If you add to the sample above a `Validator`: +The-Components-Of-Codename-One.asciidoc If you call `Display` directly instead of using `ShareButton`, pass the listener as the final argument to the new overload: +The-Components-Of-Codename-One.asciidoc In the sample below you fetch all the contacts from the device and enable search through them, notice it expects and image called `duke.png` which is the default Codename One icon renamed and placed in the src folder: +The-Components-Of-Codename-One.asciidoc It's also recommended to place the list in the `CENTER` location of a https://www.codenameone.com/javadoc/com/codename1/ui/layouts/BorderLayout.html[BorderLayout] to produce the most effective results. For example: +The-Components-Of-Codename-One.asciidoc Let start by exploring how you can achieve this UI that fetches data from a webservice: +The-Components-Of-Codename-One.asciidoc Masking allows you to accept partial input in one field and implicitly move to the next, this can be used to all types of complex input thanks to the text component API. E.g with the code above you can mask the credit card input so the cursor jumps to the next field implicitly using this code: +The-Components-Of-Codename-One.asciidoc NOTE: In all the sample code below, you can assume that variables named `bc` represent an instance of https://www.codenameone.com/javadoc/com/codename1/ui/BrowserComponent.html[BrowserComponent]: +The-Components-Of-Codename-One.asciidoc Normally, to build the model for a renderer of this type, you use something like: +The-Components-Of-Codename-One.asciidoc Notice in the sample below that you associate all the radio buttons with a group but don't do anything with the group as the radio buttons keep the reference internally. You also show the opposite side functionality and icon behavior: +The-Components-Of-Codename-One.asciidoc Notice that this feature works with the on-top and permanent versions of the side menu and not with the legacy versions: +The-Components-Of-Codename-One.asciidoc Notice that when you work with numeric values or anything related to the types mentioned above your code must be aware of the typing. For example: in this case the type is `Double` and not `String`: +The-Components-Of-Codename-One.asciidoc Now look at a more advanced Renderer: +The-Components-Of-Codename-One.asciidoc Now make it a bit more useful: +The-Components-Of-Codename-One.asciidoc Now that you have a webservice lets proceed to create the UI. Check out the code annotations below: +The-Components-Of-Codename-One.asciidoc One of the features of label that moved into `SpanLabel` to some extent is the ability to position the icon. For example, unlike a `Label` the icon position is determined by the layout manager of the composite so `setIconPosition` accepts a `BorderLayout` constraint: +The-Components-Of-Codename-One.asciidoc Or synchronously: +The-Components-Of-Codename-One.asciidoc Simple usage of the `Calendar` class looks something like this: +The-Components-Of-Codename-One.asciidoc Simple usage of the `ToastBar` class looks something like this: +The-Components-Of-Codename-One.asciidoc The Renderer is a simple interface with 2 methods: +The-Components-Of-Codename-One.asciidoc The callbacks you pass to `execute()` and `executeAndWait()` are single-use callbacks. You can’t, for example, store the `callback` variable on the JavaScript side for later use (for example: to respond to a button click event). If you need a "multi-use" callback, you should use the `addJSCallback()` method instead. Its usage looks identical to `execute()`, the difference is that the callback will live on after its first use. For example: Consider the following code: +The-Components-Of-Codename-One.asciidoc The code below provides a brief overview of these options: +The-Components-Of-Codename-One.asciidoc The data of the `Tree` arrives from a model for example: this: +The-Components-Of-Codename-One.asciidoc The first step is creating the webservice call, you won't go into too much detail here as webservices & IO are discussed later in the guide: +The-Components-Of-Codename-One.asciidoc The https://www.codenameone.com/javadoc/com/codename1/ui/list/ListModel.html[ListModel] interface can be implemented by anyone in this case you did a stupid simple implementation: +The-Components-Of-Codename-One.asciidoc The model for the XML hierarchy is implemented as such: +The-Components-Of-Codename-One.asciidoc The most simple/naive implementation may choose to implement the renderer as follows: +The-Components-Of-Codename-One.asciidoc The old API provided a synchronous wrapper around an inherently asynchronous process, and made extensive use of `invokeAndBlock()` underneath the covers. This resulted in a nice API with high-level abstractions that played with a synchronous programming model, but it came with a price-tag in performance, complexity, and predictability. Let’s take a simple example, getting a reference to the "window" object: +The-Components-Of-Codename-One.asciidoc The screenshot above was produced using the following code: +The-Components-Of-Codename-One.asciidoc The split pane component is a bit desktop specific but works reasonably well on devices. To get the image below you changed `SalesDemo.java` in the kitchen sink by changing this: +The-Components-Of-Codename-One.asciidoc Then implement the method `automoveToNext` as: +The-Components-Of-Codename-One.asciidoc Then you can retrieve its properties using the `get()` method: +The-Components-Of-Codename-One.asciidoc This code should output "The result was 7" to the console. It's fully asynchronous, so you can include this code anywhere without worrying about it "bogging down" your code. The full signature of this form of the https://www.codenameone.com/javadoc/com/codename1/ui/BrowserComponent.html#execute(java.lang.String,com.codename1.util.SuccessCallback)[execute()] method is: +The-Components-Of-Codename-One.asciidoc This sample below continues from the place where the <> stopped by adding validation to that code: +The-Components-Of-Codename-One.asciidoc This swipe gesture is commonly used in touch interfaces to expose features such as delete, edit etc. It's trivial to use this component by determining the components placed on top and bottom (the revealed component): +The-Components-Of-Codename-One.asciidoc To make the code tighter, keep a reference to the `Component` or extend it as https://www.codenameone.com/javadoc/com/codename1/ui/list/DefaultListCellRenderer.html[DefaultListCellRenderer] does: +The-Components-Of-Codename-One.asciidoc Unlike the `InfiniteScrollAdapter` you can't use the `ContentPane` directly so you've to use a `BorderLayout` and place the `InfiniteContainer` there: +The-Components-Of-Codename-One.asciidoc Unlike the `MultiButton` it uses the `TextArea` internally to break lines seamlessly. The `SpanButton` is far simpler than the `MultiButton` and as a result isn't as configurable: +The-Components-Of-Codename-One.asciidoc What if you want componentName to be red? Just use: +The-Components-Of-Codename-One.asciidoc When using lightweight picker mode (`setUseLightweightPopup(true)`), you can add custom quick-action buttons to the popup. This is useful for actions like setting the date to "Today" or "+7 Days" without scrolling the wheels manually: +The-Components-Of-Codename-One.asciidoc With this: +The-Components-Of-Codename-One.asciidoc You can access JavaScript variables from the context by using code like this: +The-Components-Of-Codename-One.asciidoc You can also delay the showing of the status message using `showDelayed` as such: +The-Components-Of-Codename-One.asciidoc You can also query the context for objects and change their value for example: +The-Components-Of-Codename-One.asciidoc You can also set its properties: +The-Components-Of-Codename-One.asciidoc You can automatically clear a status message/progress after a timeout using the `setExpires` method as such: +The-Components-Of-Codename-One.asciidoc You can combine some demos above including the <> to rank GRRM's books in an interactive way: +The-Components-Of-Codename-One.asciidoc You can convert the sample above to use toggle buttons as such: +The-Components-Of-Codename-One.asciidoc You can dynamically download images directly into the `ImageViewer` with a custom list model like this: +The-Components-Of-Codename-One.asciidoc You can now replace the existing model by removing all the model related logic and changing the constructor call as such: +The-Components-Of-Codename-One.asciidoc You can show a progress indicator in the ToastBar like this: +The-Components-Of-Codename-One.asciidoc You can use the `ImageViewer` as a tool to view a single image which allows you to zoom in/out to that image as such: +The-Components-Of-Codename-One.asciidoc You can wrap a `TextField` with a clearable wrapper to get this effect on all platforms. For example: replace this: +The-Components-Of-Codename-One.asciidoc You need to change this code to use the `addJSCallback()` method as follows: +The-Components-Of-Codename-One.asciidoc You will need to define the following theme constants for the arrow to work: +The-Components-Of-Codename-One.asciidoc `ImageViewer` also supports optional side arrows (material font icons) and an optional thumbnail strip for direct image navigation: +The-Components-Of-Codename-One.asciidoc `InfiniteProgress` can be used in one of two ways either by embedding the component into the UI through something like this: +The-Components-Of-Codename-One.asciidoc `Label` text can be positioned in one of 4 locations as such: +The-Components-Of-Codename-One.asciidoc `Slider` is highly customizable for example: a slider can be used to replicate a 5-star rating widget as such. Notice that this slider will work when its given its preferred size otherwise more stars will appear. That's why you place it within a `FlowLayout`: +The-Components-Of-Codename-One.asciidoc `setRenderingPrototype` accepts a "fake" value that represents a reasonably large amount of data and it will be used to calculate the preferred size. For example: for a multiList that should render 2 lines of text with 20 characters and a 5mm square icon you can do something like this: +The-Components-Of-Codename-One.asciidoc app for specific examples, but here is a high-level view of some code that creates a Pie Chart: +The-Components-Of-Codename-One.asciidoc doing this, you need to define this entry for all entries, for example: +The-Components-Of-Codename-One.asciidoc or a go icon using a hint such as this: +The-EDT---Event-Dispatch-Thread.asciidoc A simplistic approach is to do something like this: +The-EDT---Event-Dispatch-Thread.asciidoc For example, instead of using operation names lets use a more "real world" example: +The-EDT---Event-Dispatch-Thread.asciidoc However, `updateUIWithContentOfFile` should be executed on the EDT and not on a random thread. The right way to do this would therefore be something like this: +The-EDT---Event-Dispatch-Thread.asciidoc IMPORTANT: The Runnable passed to the `callSerially` and `callSeriallyAndWait` methods isn't a `Thread`. Use the `Runnable` interface as a convenient callback interface: +The-EDT---Event-Dispatch-Thread.asciidoc Invoke and block solves this in a unique way you can get almost the exact same behavior by using this: +The-EDT---Event-Dispatch-Thread.asciidoc Or this with Java 8 syntax: +The-EDT---Event-Dispatch-Thread.asciidoc TIP: You can write this code more concisely using Java 8 lambda code as such: +The-EDT---Event-Dispatch-Thread.asciidoc The `callSeriallyAndWait(Runnable)` method blocks the current thread until the method completes, this is useful for cases such as user notification e.g.: +The-EDT---Event-Dispatch-Thread.asciidoc This is best explained by an example. Typical Java code reads as a single sequence as such: +The-EDT---Event-Dispatch-Thread.asciidoc To explain how invokeAndBlock works you can return to the sample above of how the EDT works: +The-EDT---Event-Dispatch-Thread.asciidoc You can visualize the EDT as a loop such as this: +The-EDT---Event-Dispatch-Thread.asciidoc `invokeAndBlock()` works in a similar way to this pseudo-code: +Working-With-iOS.asciidoc Repeating notifications will continue until they're canceled by the app. You can cancel a single notification by calling: +appendix_goal_generate_graphql.adoc The `@GraphQLClient` interface looks like: +appendix_goal_generate_graphql.adoc an `OnComplete>`: +appendix_goal_generate_graphql.adoc ends the stream: +appendix_goal_generate_grpc.adoc Call sites use the static factory: +appendix_goal_generate_grpc.adoc The `@GrpcClient` interface looks like: +appendix_goal_generate_openapi.adoc Call sites use the static factory: +basics.asciidoc For example: see this code where: +basics.asciidoc From that point on you can write code that looks like this: +basics.asciidoc In the race to make code "`tighter`" you can make this even shorter. Most layout managers have their own custom terse syntax style for example: +basics.asciidoc Instead of: +basics.asciidoc Instead of: +basics.asciidoc Some things were changed so you won't have too many conflicts for example, `Log.p` or `Log.e` would have been problematic so you now have: +basics.asciidoc The UIID's translate the theme elements into a set of `Style` objects. These `Style` objects get their initial values from the theme but can be further manipulated after the fact. To make the text field's foreground color red you could use this code: +basics.asciidoc The same applies for most network manager calls e.g.: +basics.asciidoc This works great for regular layouts but might not for constraint based layouts. A constraint based layout accepts another argument. For example: `BorderLayout` needs a location for the `Component`: +basics.asciidoc and `setSameHeight` methods, for example: +graphics.asciidoc (that's: where the numbers appear), and the remaining marks (corresponding with seconds) will be short: +graphics.asciidoc 3. Invert the translation performed in step 1: +graphics.asciidoc A `URLImage` can be created with a mask adapter to apply an effect to an image. This allows you to round downloaded images or apply any sort of masking for example: you can adapt the round mask code above as such: +graphics.asciidoc And you will translate it down slightly so that it overlaps the center. This translation will be performed on the `GeneralPath` object directly rather than through the `Graphics` context: +graphics.asciidoc Center: +graphics.asciidoc For example: a `pointerPressed()` callback method can look like this: +graphics.asciidoc The `animate()` method in the `AnalogClock` class: +graphics.asciidoc The code to instantiate the clock, and start the animation would be something like: +graphics.asciidoc The remaining drawing code is as follows: +graphics.asciidoc The simple use case is pretty trivial: +graphics.asciidoc To do this you can override: +graphics.asciidoc Top Left Corner: +graphics.asciidoc `ConicGradient` - following the same pattern as the `Shape` hierarchy: +graphics.asciidoc can override or augment whatever the default set: +graphics.asciidoc class is the most common option and accepts a list of color stops along a line: +graphics.asciidoc coordinates of the component. You therefore need to get the absolute clock center position to perform the rotation: +graphics.asciidoc for the images. The default is a scale adapter although you might change that to scale fill in the future: +graphics.asciidoc hook. Two ways to install one: +graphics.asciidoc https://www.codenameone.com/javadoc/com/codename1/ui/Graphics.html#isShapeSupported()[`Graphics.isShapeSupported()`] method. For example: +graphics.asciidoc it can be added to a form: +graphics.asciidoc mark at the 12 o'clock position: +graphics.asciidoc method to append curves to the drawing as follows: +graphics.asciidoc methods in the component as follows: +graphics.asciidoc name of icon to `icon_URLImage` then using this in the data: +graphics.asciidoc show this, try to place five of these components on a form inside a https://www.codenameone.com/javadoc/com/codename1/ui/layouts/BorderLayout.html[BorderLayout] and see how it looks: +graphics.asciidoc straight lines rather than curves might look like this: +graphics.asciidoc to draw each individual tick: +io.asciidoc A more advanced usage of the `FileSystemStorage` API can be a `FileSystemStorage` `Tree`: +io.asciidoc A simpler implementation could do something like this: +io.asciidoc Above, if you want to select the IDs of all players that are ranked in the top 2, you can use an expression like: +io.asciidoc Above, you globally find a lastname element with a value of ‘Hewitt’, then grab the parent node of lastname which happens to be the player node, then grab the ID attribute from the player node. Or, you could get the same result from the following simpler statement: +io.asciidoc Above, you selected the IDs of all ranked players. Conversely, you can select the non-ranked players like this: +io.asciidoc After you do that once you can write/read contacts from storage if you so want: +io.asciidoc An `Externalizable` object *must* have a *default public constructor* and must implement the following 4 methods: +io.asciidoc And delete an entry using: +io.asciidoc And vice versa: +io.asciidoc Another approach is to use the `setFailSilently(true)` method on the `ConnectionRequest`. This will prevent the `ConnectionRequest` from displaying any errors to the user. It's a powerful strategy if you use the synchronous version of the APIs for example: +io.asciidoc As part of the premium cloud features it's possible to invoke Log.sendLog() to email a log directly to the developer account. Codename One can do that seamlessly based on changes printed into the log or based on exceptions that are uncaught or logged for example: +io.asciidoc Assuming you added a new date field to the object you can do the following. Notice that a `Date` is a `long` value in Java that can be null. For completeness the full class is presented below: +io.asciidoc Binding makes this all seamless. For example: the code above can be written as: +io.asciidoc By default `GZConnectionRequest` doesn't request gzipped data ( unzips it when its received) but its pretty easy to do so add the HTTP header `Accept-Encoding: gzip` for example: +io.asciidoc Codename One provides many tools to simplify the path between networking/IO & GUI. A common task of showing a wait dialog or progress sign while fetching network data can be simplified by using the https://www.codenameone.com/javadoc/com/codename1/components/InfiniteProgress.html[InfiniteProgress] class for example: +io.asciidoc Developers need to write the data of the object in the externalize method using the methods in the data output stream and read the data of the object in the internalize method for example: +io.asciidoc For a lot of REST requests this will fail because you need to add an HTTP header indicating that you accept JSON results. You have a special case support for that: +io.asciidoc For endpoints that return a list of DTOs, use `fetchAsMappedList`: +io.asciidoc For example, if you wish to have finer grained control over the submission process for example: for making a `HEAD` request you can do this with code like: +io.asciidoc For example: to block all network errors from showing anything to the user you could do something like this: +io.asciidoc For example: you can do something like this in your `init(Object)` method: +io.asciidoc For starters all the common methods of `Object` can be implemented with almost no code: +io.asciidoc If a document is ordered, you might want to select nodes by their position, for example: +io.asciidoc If you continue the example from above to show persistence to the SQL database you can do something like this: +io.asciidoc If you continue your example from above you can do something like this: +io.asciidoc Implementing the `Externalizable` interface is important when you want to store a proprietary object. In this case you must register the object with the `com.codename1.io.Util` class so the externalization algorithm will be able to recognize it by name by invoking: +io.asciidoc In the above code you do the following: +io.asciidoc It's also possible to nest expressions, for example: +io.asciidoc It's also possible to select parent nodes, by using the `..` expression. For example: +io.asciidoc Listing the entries is more interesting: +io.asciidoc Moving on, to select a node based on the existence of an attribute: +io.asciidoc Notice that you can also implement the same thing and much more by avoiding the response listener code and instead overriding the methods of the `ConnectionRequest` class which offers many points to override for example: +io.asciidoc One of the bigger features of properties are their ability to bind UI to a property. For example: if you continue the sample above with the `Contact` class, say you have a text field on the form and you want the property (which you mapped to the database) to have the value of the text field. You could do something like this: +io.asciidoc POST requests use the same builder pattern: +io.asciidoc Server returned headers are a bit trickier to read. You need to subclass the connection request and override the `readHeaders` method for example: +io.asciidoc Since strings might be null sometimes you also included convenience methods to implement such externalization. This effectively writes a boolean before writing the UTF to show whether the string is null: +io.asciidoc Since you assume most developers reading this will be familiar with Java here is the way to implement the multipart upload in the servlet API: +io.asciidoc Some headers are built-in as direct APIs for example: content type is directly exposed within the API since it's a pretty common use case. You can set the content kind of post request using: +io.asciidoc Some objects make sense as global objects, you can use the `Preferences` API to store that data directly but then you don't have the type safety that property objects bring to the table. That's where the binding of property objects to preferences makes sense. For example: say you have a global `Settings` property object you can bind it to preferences using: +io.asciidoc That's what the new method of `URLImage` does: +io.asciidoc The JSON ("JavaScript Object Notation") format is popular on the web for passing values to/from webservices since it works so well with JavaScript. Parsing JSON is as easy but has two different variations. You can use the https://www.codenameone.com/javadoc/com/codename1/io/JSONParser.html[JSONParser] class to build a tree of the JSON data as such: +io.asciidoc The XML processor handles global selections by using a double slash anywhere within the expression, for example: +io.asciidoc The `CachedDataService` will fetch data if it isn't cached locally and cache it. When you "refresh" it will send a special HTTP request that will send back the data if it has been updated since the last refresh: +io.asciidoc The `Rest` API makes it easy to invoke a RESTful webservice without many of the nuances of `ConnectionRequest`. You can use it to define the HTTP method and start building based on that. To get a parsed JSON result from a URL you could do: +io.asciidoc The cool thing is that this works with many component types and property types almost magically. Binding works by using an adapter class to convert the data to/from the component. The adapter itself works with a generic converter for example: this code: +io.asciidoc The sample code below demonstrates listing storage content, adding and viewing entries, and deleting entries: +io.asciidoc The simplest usage of `XMLParser` looks a bit like this: +io.asciidoc There are many methods of interest to keep an eye for: +io.asciidoc This can be tedious to do if you want all requests from your app to use this header. For this use case you can use: +io.asciidoc This means that this code won't compile: +io.asciidoc This should display an error message to the user if there was a problem sending the SMS: +io.asciidoc This simple example allows you to create a server and a client assuming the device supports both: +io.asciidoc This still carries most of the flexibilities of the regular binding for example: you can still get a binding object using: +io.asciidoc To parse a CSV use the https://www.codenameone.com/javadoc/com/codename1/io/CSVParser.html[CSVParser] class as such: +io.asciidoc Up until now this was pretty cool but if you looked at the UI construction code above you would see that it's pretty full of boilerplate code. The thing about boilerplate is that it shows where automation can be applied, that's the exact idea behind the magical "InstantUI" class. This means that the UI above can be generated using this code: +io.asciidoc When you used a POJO you did this: +io.asciidoc With properties you do this: +io.asciidoc Without this feature the code would look like this: +io.asciidoc Working with a database is pretty trivial, the application logic below can send arbitrary queries to the database and present the results in a `Table`. You can probably integrate this code into your app as a debugging tool: +io.asciidoc You added a new syntax: +io.asciidoc You can also add any arbitrary header type you want, for example: a common use case is basic authorization where the authorization header includes the Base64 encoded user/password combination as such: +io.asciidoc You can also override the error callbacks of the various types in the request for example: for a server error code you can do: +io.asciidoc You can always submit data in the `buildRequestBody` but this is flaky and has some limitations in devices/size allowed. HTTP standardized file upload capabilities through the multipart request protocol, this is implemented by countless servers and is well documented. Codename One supports this out of the box: +io.asciidoc You can build a UI that would allow you to edit the `Contact` property in memory: +io.asciidoc You can do some more elaborate bindings such as: +io.asciidoc You can now send hello world as an SMS to the end user. Once this is in place sending an SMS through REST is a matter of using the `Rest` API: +io.asciidoc You can pick either one of these approaches based on your personal preferences. Here you show both uses with the server API: +io.asciidoc You can send a get request to a URL using something like: +io.asciidoc You can then add entries to the contact table using: +io.asciidoc You can update an entry using: +io.asciidoc You might not have noticed this but in the previous verbose code you had lines like: +io.asciidoc You want to extract some data above into simpler string results. You can do this using: +io.asciidoc You've some special case defaults for some common property names, so if your property is named email it will use an email constraint by default. If it's named url or password etc. It will do the "right thing" unless you explicitly state otherwise. You can customize the constraint for a specific property using something like: +io.asciidoc `NetworkManager` also supports synchronous requests which work in a similar way to `Dialog` through the `invokeAndBlock` call and thus don't block the EDT footnote:[Event Dispatch Thread] illegally. For example: you can do something like this: +io.asciidoc `Storage` also offers a simple API in the form of the https://www.codenameone.com/javadoc/com/codename1/io/Preferences.html[Preferences] class. The `Preferences` class lets developers store simple variables, strings, numbers, booleans, and similar values without writing storage code. This is a common use case in applications. For example, you might need to store a server token: +io.asciidoc `com.codename1.io.JSONWriter` class is the complement of `JSONParser`: +io.asciidoc database by name: +io.asciidoc directly: +io.asciidoc download a `ConnectionRequest` to `Storage` using code like this: +io.asciidoc out any change made to the property: +io.asciidoc than a `Map` literal: +io.asciidoc that prevents setting the property to null and defaults it to an empty string: +io.asciidoc the array directly: +io.asciidoc values from parsed JSON: +io.asciidoc you will notice the `final` keyword: +performance.asciidoc All you need to do now is update the `lastScroll` variable whenever user interaction is in place. This works for user touches: +performance.asciidoc For example, Codename One annotates: +performance.asciidoc In the new Contacts demo you have a share button for each contact, the code for constructing a `ShareButton` looks like this: +performance.asciidoc Then you did this within the background loading thread: +performance.asciidoc These icons are in a shared resource file that you load and don't cache. The initial workaround was to cache this resource but a better solution was to convert this code: +performance.asciidoc This works for general scrolling: +performance.asciidoc `Simd` is accessed as a singleton: +performance.asciidoc method returns. This is what the `alloca*` family exposes: +performance.asciidoc the allocation helpers on `Simd`: +security.asciidoc Baking an API key (for example a Google Maps key) into your app is unsafe: anyone can extract it from the binary, and rotating it means shipping a new build. `com.codename1.security.Secrets` solves this. You define the secret once in the Codename One Cloud vault, and the app fetches it at runtime and caches it in `SecureStorage`: +security.asciidoc For HOTP, supply the counter explicitly and increment it after every successful authentication: +security.asciidoc For RSA-signed tokens (RS256/384/512) and ECDSA-signed tokens (ES256/384/512), pass the key to the dynamic overloads: +security.asciidoc Shared secrets are commonly distributed as Base32 strings (the format embedded in QR codes by authenticator apps). `com.codename1.security.Base32` handles the encoding: +security.asciidoc The runtime API is identical on both platforms -- request a token bound to a fresh server nonce, then POST it to your backend for verification: +security.asciidoc There are two simple methods in the `Util` class: +security.asciidoc This works through a new mechanism in storage where you can replace the storage instance with another instance using: +security.asciidoc To block copy & paste on a specific field do: +security.asciidoc To use a key that was generated outside the app, feed the DER bytes to the static factory methods: +security.asciidoc You can leverage that knowledge to change the encryption password on the encryption storage using pseudo-code like this: +security.asciidoc `com.codename1.components.OtpField` is a segmented input -- one box per character, that advances to the next box as the user types and steps back on backspace. This is the standard pattern for SMS confirmation and authenticator-app entry screens: +security.asciidoc `com.codename1.security.Cipher` also covers RSA encryption, and `com.codename1.security.Signature` covers digital signatures (RSA and ECDSA). Keys are represented by `com.codename1.security.PublicKey` and `com.codename1.security.PrivateKey`, which wrap X.509 SubjectPublicKeyInfo and PKCS#8 DER blobs respectively -- the same encodings used by OpenSSL: +security.asciidoc `com.codename1.security.Cipher` exposes AES through the platform's native AES implementation. AES-GCM is the recommended default because it's authenticated -- a single tag-mismatch failure detects any tampering of the ciphertext or the associated data: +security.asciidoc `com.codename1.security.Hash` implements MD5, SHA-1, SHA-224, SHA-256, SHA-384, and SHA-512. SHA-256 is the recommended default for new code; MD5 and SHA-1 are exposed only for compatibility with older protocols since both are broken for collision resistance: +security.asciidoc `com.codename1.security.Hmac` implements HMAC (RFC 2104) on top of any of the hash algorithms above. Use HMAC whenever a message authentication code is needed -- API request signing, session cookie tamper detection, JWT signing with the `HS` family, or TOTP token generation: +security.asciidoc `com.codename1.security.SecureStorage` keeps small string values in the operating system's secure keychain (iOS Keychain Services and the Android Keystore) so they're protected by the platform's secure enclave instead of being written to app storage. The quiet, no-prompt API is three calls: