├── .gitignore ├── Smol.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcuserdata │ └── jason.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── Smol ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ └── crab.imageset │ │ ├── A_hermit_crab_emerges_from_its_shell.jpg │ │ └── Contents.json ├── Info.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Smol.entitlements ├── SmolApp.swift ├── WebDocumentView.swift ├── browser.html ├── index.html ├── index.md └── output.html ├── SmolTests └── SmolTests.swift └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.xcbkptlist 3 | -------------------------------------------------------------------------------- /Smol.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 960E8C8D299545A80086123C /* SmolApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960E8C8C299545A80086123C /* SmolApp.swift */; }; 11 | 960E8C8F299545A80086123C /* WebDocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960E8C8E299545A80086123C /* WebDocumentView.swift */; }; 12 | 960E8C91299545A80086123C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 960E8C90299545A80086123C /* Assets.xcassets */; }; 13 | 960E8C94299545A80086123C /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 960E8C93299545A80086123C /* Preview Assets.xcassets */; }; 14 | 960E8C9F299545A80086123C /* SmolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960E8C9E299545A80086123C /* SmolTests.swift */; }; 15 | 960E8CBA2995468D0086123C /* index.html in Resources */ = {isa = PBXBuildFile; fileRef = 960E8CB92995468D0086123C /* index.html */; }; 16 | /* End PBXBuildFile section */ 17 | 18 | /* Begin PBXContainerItemProxy section */ 19 | 960E8C9B299545A80086123C /* PBXContainerItemProxy */ = { 20 | isa = PBXContainerItemProxy; 21 | containerPortal = 960E8C81299545A80086123C /* Project object */; 22 | proxyType = 1; 23 | remoteGlobalIDString = 960E8C88299545A80086123C; 24 | remoteInfo = Smol; 25 | }; 26 | /* End PBXContainerItemProxy section */ 27 | 28 | /* Begin PBXFileReference section */ 29 | 960E8C89299545A80086123C /* Smol.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Smol.app; sourceTree = BUILT_PRODUCTS_DIR; }; 30 | 960E8C8C299545A80086123C /* SmolApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmolApp.swift; sourceTree = ""; }; 31 | 960E8C8E299545A80086123C /* WebDocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDocumentView.swift; sourceTree = ""; }; 32 | 960E8C90299545A80086123C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 33 | 960E8C93299545A80086123C /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 34 | 960E8C95299545A80086123C /* Smol.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Smol.entitlements; sourceTree = ""; }; 35 | 960E8C9A299545A80086123C /* SmolTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SmolTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 36 | 960E8C9E299545A80086123C /* SmolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmolTests.swift; sourceTree = ""; }; 37 | 960E8CB92995468D0086123C /* index.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = index.html; sourceTree = ""; }; 38 | 969A82E029BCCB2A0095D29D /* index.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = index.md; sourceTree = ""; }; 39 | 96A66A7E29B3D69B00796244 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 40 | /* End PBXFileReference section */ 41 | 42 | /* Begin PBXFrameworksBuildPhase section */ 43 | 960E8C86299545A80086123C /* Frameworks */ = { 44 | isa = PBXFrameworksBuildPhase; 45 | buildActionMask = 2147483647; 46 | files = ( 47 | ); 48 | runOnlyForDeploymentPostprocessing = 0; 49 | }; 50 | 960E8C97299545A80086123C /* Frameworks */ = { 51 | isa = PBXFrameworksBuildPhase; 52 | buildActionMask = 2147483647; 53 | files = ( 54 | ); 55 | runOnlyForDeploymentPostprocessing = 0; 56 | }; 57 | /* End PBXFrameworksBuildPhase section */ 58 | 59 | /* Begin PBXGroup section */ 60 | 960E8C80299545A70086123C = { 61 | isa = PBXGroup; 62 | children = ( 63 | 960E8C8B299545A80086123C /* Smol */, 64 | 960E8C9D299545A80086123C /* SmolTests */, 65 | 960E8C8A299545A80086123C /* Products */, 66 | ); 67 | sourceTree = ""; 68 | }; 69 | 960E8C8A299545A80086123C /* Products */ = { 70 | isa = PBXGroup; 71 | children = ( 72 | 960E8C89299545A80086123C /* Smol.app */, 73 | 960E8C9A299545A80086123C /* SmolTests.xctest */, 74 | ); 75 | name = Products; 76 | sourceTree = ""; 77 | }; 78 | 960E8C8B299545A80086123C /* Smol */ = { 79 | isa = PBXGroup; 80 | children = ( 81 | 96A66A7E29B3D69B00796244 /* Info.plist */, 82 | 960E8C95299545A80086123C /* Smol.entitlements */, 83 | 960E8C8C299545A80086123C /* SmolApp.swift */, 84 | 960E8C8E299545A80086123C /* WebDocumentView.swift */, 85 | 969A82E029BCCB2A0095D29D /* index.md */, 86 | 960E8C90299545A80086123C /* Assets.xcassets */, 87 | 960E8CB92995468D0086123C /* index.html */, 88 | 960E8C92299545A80086123C /* Preview Content */, 89 | ); 90 | path = Smol; 91 | sourceTree = ""; 92 | }; 93 | 960E8C92299545A80086123C /* Preview Content */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | 960E8C93299545A80086123C /* Preview Assets.xcassets */, 97 | ); 98 | path = "Preview Content"; 99 | sourceTree = ""; 100 | }; 101 | 960E8C9D299545A80086123C /* SmolTests */ = { 102 | isa = PBXGroup; 103 | children = ( 104 | 960E8C9E299545A80086123C /* SmolTests.swift */, 105 | ); 106 | path = SmolTests; 107 | sourceTree = ""; 108 | }; 109 | /* End PBXGroup section */ 110 | 111 | /* Begin PBXNativeTarget section */ 112 | 960E8C88299545A80086123C /* Smol */ = { 113 | isa = PBXNativeTarget; 114 | buildConfigurationList = 960E8CAE299545A80086123C /* Build configuration list for PBXNativeTarget "Smol" */; 115 | buildPhases = ( 116 | 960E8C85299545A80086123C /* Sources */, 117 | 960E8C86299545A80086123C /* Frameworks */, 118 | 960E8C87299545A80086123C /* Resources */, 119 | ); 120 | buildRules = ( 121 | ); 122 | dependencies = ( 123 | ); 124 | name = Smol; 125 | productName = Smol; 126 | productReference = 960E8C89299545A80086123C /* Smol.app */; 127 | productType = "com.apple.product-type.application"; 128 | }; 129 | 960E8C99299545A80086123C /* SmolTests */ = { 130 | isa = PBXNativeTarget; 131 | buildConfigurationList = 960E8CB1299545A80086123C /* Build configuration list for PBXNativeTarget "SmolTests" */; 132 | buildPhases = ( 133 | 960E8C96299545A80086123C /* Sources */, 134 | 960E8C97299545A80086123C /* Frameworks */, 135 | 960E8C98299545A80086123C /* Resources */, 136 | ); 137 | buildRules = ( 138 | ); 139 | dependencies = ( 140 | 960E8C9C299545A80086123C /* PBXTargetDependency */, 141 | ); 142 | name = SmolTests; 143 | productName = SmolTests; 144 | productReference = 960E8C9A299545A80086123C /* SmolTests.xctest */; 145 | productType = "com.apple.product-type.bundle.unit-test"; 146 | }; 147 | /* End PBXNativeTarget section */ 148 | 149 | /* Begin PBXProject section */ 150 | 960E8C81299545A80086123C /* Project object */ = { 151 | isa = PBXProject; 152 | attributes = { 153 | BuildIndependentTargetsInParallel = 1; 154 | LastSwiftUpdateCheck = 1400; 155 | LastUpgradeCheck = 1400; 156 | TargetAttributes = { 157 | 960E8C88299545A80086123C = { 158 | CreatedOnToolsVersion = 14.0.1; 159 | }; 160 | 960E8C99299545A80086123C = { 161 | CreatedOnToolsVersion = 14.0.1; 162 | TestTargetID = 960E8C88299545A80086123C; 163 | }; 164 | }; 165 | }; 166 | buildConfigurationList = 960E8C84299545A80086123C /* Build configuration list for PBXProject "Smol" */; 167 | compatibilityVersion = "Xcode 14.0"; 168 | developmentRegion = en; 169 | hasScannedForEncodings = 0; 170 | knownRegions = ( 171 | en, 172 | Base, 173 | ); 174 | mainGroup = 960E8C80299545A70086123C; 175 | productRefGroup = 960E8C8A299545A80086123C /* Products */; 176 | projectDirPath = ""; 177 | projectRoot = ""; 178 | targets = ( 179 | 960E8C88299545A80086123C /* Smol */, 180 | 960E8C99299545A80086123C /* SmolTests */, 181 | ); 182 | }; 183 | /* End PBXProject section */ 184 | 185 | /* Begin PBXResourcesBuildPhase section */ 186 | 960E8C87299545A80086123C /* Resources */ = { 187 | isa = PBXResourcesBuildPhase; 188 | buildActionMask = 2147483647; 189 | files = ( 190 | 960E8CBA2995468D0086123C /* index.html in Resources */, 191 | 960E8C94299545A80086123C /* Preview Assets.xcassets in Resources */, 192 | 960E8C91299545A80086123C /* Assets.xcassets in Resources */, 193 | ); 194 | runOnlyForDeploymentPostprocessing = 0; 195 | }; 196 | 960E8C98299545A80086123C /* Resources */ = { 197 | isa = PBXResourcesBuildPhase; 198 | buildActionMask = 2147483647; 199 | files = ( 200 | ); 201 | runOnlyForDeploymentPostprocessing = 0; 202 | }; 203 | /* End PBXResourcesBuildPhase section */ 204 | 205 | /* Begin PBXSourcesBuildPhase section */ 206 | 960E8C85299545A80086123C /* Sources */ = { 207 | isa = PBXSourcesBuildPhase; 208 | buildActionMask = 2147483647; 209 | files = ( 210 | 960E8C8F299545A80086123C /* WebDocumentView.swift in Sources */, 211 | 960E8C8D299545A80086123C /* SmolApp.swift in Sources */, 212 | ); 213 | runOnlyForDeploymentPostprocessing = 0; 214 | }; 215 | 960E8C96299545A80086123C /* Sources */ = { 216 | isa = PBXSourcesBuildPhase; 217 | buildActionMask = 2147483647; 218 | files = ( 219 | 960E8C9F299545A80086123C /* SmolTests.swift in Sources */, 220 | ); 221 | runOnlyForDeploymentPostprocessing = 0; 222 | }; 223 | /* End PBXSourcesBuildPhase section */ 224 | 225 | /* Begin PBXTargetDependency section */ 226 | 960E8C9C299545A80086123C /* PBXTargetDependency */ = { 227 | isa = PBXTargetDependency; 228 | target = 960E8C88299545A80086123C /* Smol */; 229 | targetProxy = 960E8C9B299545A80086123C /* PBXContainerItemProxy */; 230 | }; 231 | /* End PBXTargetDependency section */ 232 | 233 | /* Begin XCBuildConfiguration section */ 234 | 960E8CAC299545A80086123C /* Debug */ = { 235 | isa = XCBuildConfiguration; 236 | buildSettings = { 237 | ALWAYS_SEARCH_USER_PATHS = NO; 238 | CLANG_ANALYZER_NONNULL = YES; 239 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 240 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 241 | CLANG_ENABLE_MODULES = YES; 242 | CLANG_ENABLE_OBJC_ARC = YES; 243 | CLANG_ENABLE_OBJC_WEAK = YES; 244 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 245 | CLANG_WARN_BOOL_CONVERSION = YES; 246 | CLANG_WARN_COMMA = YES; 247 | CLANG_WARN_CONSTANT_CONVERSION = YES; 248 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 249 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 250 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 251 | CLANG_WARN_EMPTY_BODY = YES; 252 | CLANG_WARN_ENUM_CONVERSION = YES; 253 | CLANG_WARN_INFINITE_RECURSION = YES; 254 | CLANG_WARN_INT_CONVERSION = YES; 255 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 256 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 257 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 258 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 259 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 260 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 261 | CLANG_WARN_STRICT_PROTOTYPES = YES; 262 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 263 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 264 | CLANG_WARN_UNREACHABLE_CODE = YES; 265 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 266 | COPY_PHASE_STRIP = NO; 267 | DEBUG_INFORMATION_FORMAT = dwarf; 268 | ENABLE_STRICT_OBJC_MSGSEND = YES; 269 | ENABLE_TESTABILITY = YES; 270 | GCC_C_LANGUAGE_STANDARD = gnu11; 271 | GCC_DYNAMIC_NO_PIC = NO; 272 | GCC_NO_COMMON_BLOCKS = YES; 273 | GCC_OPTIMIZATION_LEVEL = 0; 274 | GCC_PREPROCESSOR_DEFINITIONS = ( 275 | "DEBUG=1", 276 | "$(inherited)", 277 | ); 278 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 279 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 280 | GCC_WARN_UNDECLARED_SELECTOR = YES; 281 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 282 | GCC_WARN_UNUSED_FUNCTION = YES; 283 | GCC_WARN_UNUSED_VARIABLE = YES; 284 | MACOSX_DEPLOYMENT_TARGET = 12.3; 285 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 286 | MTL_FAST_MATH = YES; 287 | ONLY_ACTIVE_ARCH = YES; 288 | SDKROOT = macosx; 289 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 290 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 291 | }; 292 | name = Debug; 293 | }; 294 | 960E8CAD299545A80086123C /* Release */ = { 295 | isa = XCBuildConfiguration; 296 | buildSettings = { 297 | ALWAYS_SEARCH_USER_PATHS = NO; 298 | CLANG_ANALYZER_NONNULL = YES; 299 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 300 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 301 | CLANG_ENABLE_MODULES = YES; 302 | CLANG_ENABLE_OBJC_ARC = YES; 303 | CLANG_ENABLE_OBJC_WEAK = YES; 304 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 305 | CLANG_WARN_BOOL_CONVERSION = YES; 306 | CLANG_WARN_COMMA = YES; 307 | CLANG_WARN_CONSTANT_CONVERSION = YES; 308 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 309 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 310 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 311 | CLANG_WARN_EMPTY_BODY = YES; 312 | CLANG_WARN_ENUM_CONVERSION = YES; 313 | CLANG_WARN_INFINITE_RECURSION = YES; 314 | CLANG_WARN_INT_CONVERSION = YES; 315 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 316 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 317 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 318 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 319 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 320 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 321 | CLANG_WARN_STRICT_PROTOTYPES = YES; 322 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 323 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 324 | CLANG_WARN_UNREACHABLE_CODE = YES; 325 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 326 | COPY_PHASE_STRIP = NO; 327 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 328 | ENABLE_NS_ASSERTIONS = NO; 329 | ENABLE_STRICT_OBJC_MSGSEND = YES; 330 | GCC_C_LANGUAGE_STANDARD = gnu11; 331 | GCC_NO_COMMON_BLOCKS = YES; 332 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 333 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 334 | GCC_WARN_UNDECLARED_SELECTOR = YES; 335 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 336 | GCC_WARN_UNUSED_FUNCTION = YES; 337 | GCC_WARN_UNUSED_VARIABLE = YES; 338 | MACOSX_DEPLOYMENT_TARGET = 12.3; 339 | MTL_ENABLE_DEBUG_INFO = NO; 340 | MTL_FAST_MATH = YES; 341 | SDKROOT = macosx; 342 | SWIFT_COMPILATION_MODE = wholemodule; 343 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 344 | }; 345 | name = Release; 346 | }; 347 | 960E8CAF299545A80086123C /* Debug */ = { 348 | isa = XCBuildConfiguration; 349 | buildSettings = { 350 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 351 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 352 | CODE_SIGN_ENTITLEMENTS = Smol/Smol.entitlements; 353 | CODE_SIGN_STYLE = Automatic; 354 | COMBINE_HIDPI_IMAGES = YES; 355 | CURRENT_PROJECT_VERSION = 1; 356 | DEVELOPMENT_ASSET_PATHS = "\"Smol/Preview Content\""; 357 | DEVELOPMENT_TEAM = FMPA3MXEW7; 358 | ENABLE_HARDENED_RUNTIME = YES; 359 | ENABLE_PREVIEWS = YES; 360 | GENERATE_INFOPLIST_FILE = YES; 361 | INFOPLIST_FILE = Smol/Info.plist; 362 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 363 | LD_RUNPATH_SEARCH_PATHS = ( 364 | "$(inherited)", 365 | "@executable_path/../Frameworks", 366 | ); 367 | MARKETING_VERSION = 1.0; 368 | PRODUCT_BUNDLE_IDENTIFIER = com.nearthespeedoflight.Smol; 369 | PRODUCT_NAME = "$(TARGET_NAME)"; 370 | SWIFT_EMIT_LOC_STRINGS = YES; 371 | SWIFT_VERSION = 5.0; 372 | }; 373 | name = Debug; 374 | }; 375 | 960E8CB0299545A80086123C /* Release */ = { 376 | isa = XCBuildConfiguration; 377 | buildSettings = { 378 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 379 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 380 | CODE_SIGN_ENTITLEMENTS = Smol/Smol.entitlements; 381 | CODE_SIGN_STYLE = Automatic; 382 | COMBINE_HIDPI_IMAGES = YES; 383 | CURRENT_PROJECT_VERSION = 1; 384 | DEVELOPMENT_ASSET_PATHS = "\"Smol/Preview Content\""; 385 | DEVELOPMENT_TEAM = FMPA3MXEW7; 386 | ENABLE_HARDENED_RUNTIME = YES; 387 | ENABLE_PREVIEWS = YES; 388 | GENERATE_INFOPLIST_FILE = YES; 389 | INFOPLIST_FILE = Smol/Info.plist; 390 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 391 | LD_RUNPATH_SEARCH_PATHS = ( 392 | "$(inherited)", 393 | "@executable_path/../Frameworks", 394 | ); 395 | MARKETING_VERSION = 1.0; 396 | PRODUCT_BUNDLE_IDENTIFIER = com.nearthespeedoflight.Smol; 397 | PRODUCT_NAME = "$(TARGET_NAME)"; 398 | SWIFT_EMIT_LOC_STRINGS = YES; 399 | SWIFT_VERSION = 5.0; 400 | }; 401 | name = Release; 402 | }; 403 | 960E8CB2299545A80086123C /* Debug */ = { 404 | isa = XCBuildConfiguration; 405 | buildSettings = { 406 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 407 | BUNDLE_LOADER = "$(TEST_HOST)"; 408 | CODE_SIGN_STYLE = Automatic; 409 | CURRENT_PROJECT_VERSION = 1; 410 | DEVELOPMENT_TEAM = FMPA3MXEW7; 411 | GENERATE_INFOPLIST_FILE = YES; 412 | MACOSX_DEPLOYMENT_TARGET = 12.3; 413 | MARKETING_VERSION = 1.0; 414 | PRODUCT_BUNDLE_IDENTIFIER = com.nearthespeedoflight.SmolTests; 415 | PRODUCT_NAME = "$(TARGET_NAME)"; 416 | SWIFT_EMIT_LOC_STRINGS = NO; 417 | SWIFT_VERSION = 5.0; 418 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Smol.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Smol"; 419 | }; 420 | name = Debug; 421 | }; 422 | 960E8CB3299545A80086123C /* Release */ = { 423 | isa = XCBuildConfiguration; 424 | buildSettings = { 425 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 426 | BUNDLE_LOADER = "$(TEST_HOST)"; 427 | CODE_SIGN_STYLE = Automatic; 428 | CURRENT_PROJECT_VERSION = 1; 429 | DEVELOPMENT_TEAM = FMPA3MXEW7; 430 | GENERATE_INFOPLIST_FILE = YES; 431 | MACOSX_DEPLOYMENT_TARGET = 12.3; 432 | MARKETING_VERSION = 1.0; 433 | PRODUCT_BUNDLE_IDENTIFIER = com.nearthespeedoflight.SmolTests; 434 | PRODUCT_NAME = "$(TARGET_NAME)"; 435 | SWIFT_EMIT_LOC_STRINGS = NO; 436 | SWIFT_VERSION = 5.0; 437 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Smol.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Smol"; 438 | }; 439 | name = Release; 440 | }; 441 | /* End XCBuildConfiguration section */ 442 | 443 | /* Begin XCConfigurationList section */ 444 | 960E8C84299545A80086123C /* Build configuration list for PBXProject "Smol" */ = { 445 | isa = XCConfigurationList; 446 | buildConfigurations = ( 447 | 960E8CAC299545A80086123C /* Debug */, 448 | 960E8CAD299545A80086123C /* Release */, 449 | ); 450 | defaultConfigurationIsVisible = 0; 451 | defaultConfigurationName = Release; 452 | }; 453 | 960E8CAE299545A80086123C /* Build configuration list for PBXNativeTarget "Smol" */ = { 454 | isa = XCConfigurationList; 455 | buildConfigurations = ( 456 | 960E8CAF299545A80086123C /* Debug */, 457 | 960E8CB0299545A80086123C /* Release */, 458 | ); 459 | defaultConfigurationIsVisible = 0; 460 | defaultConfigurationName = Release; 461 | }; 462 | 960E8CB1299545A80086123C /* Build configuration list for PBXNativeTarget "SmolTests" */ = { 463 | isa = XCConfigurationList; 464 | buildConfigurations = ( 465 | 960E8CB2299545A80086123C /* Debug */, 466 | 960E8CB3299545A80086123C /* Release */, 467 | ); 468 | defaultConfigurationIsVisible = 0; 469 | defaultConfigurationName = Release; 470 | }; 471 | /* End XCConfigurationList section */ 472 | }; 473 | rootObject = 960E8C81299545A80086123C /* Project object */; 474 | } 475 | -------------------------------------------------------------------------------- /Smol.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Smol.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Smol.xcodeproj/xcuserdata/jason.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Smol.xcodeproj/xcuserdata/jason.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Smol.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Smol/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Smol/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Smol/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Smol/Assets.xcassets/crab.imageset/A_hermit_crab_emerges_from_its_shell.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbrennan/SmolHTML/adb44e544769f7adbe150cd649a486da675b2731/Smol/Assets.xcassets/crab.imageset/A_hermit_crab_emerges_from_its_shell.jpg -------------------------------------------------------------------------------- /Smol/Assets.xcassets/crab.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "A_hermit_crab_emerges_from_its_shell.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Smol/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Smol/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Smol/Smol.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Smol/SmolApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SmolApp.swift 3 | // Smol 4 | // 5 | // Created by Jason Brennan on 2/9/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct SmolApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | BrowserView(controller: PageController()) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Smol/WebDocumentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Smol 4 | // 5 | // Created by Jason Brennan on 2/9/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BrowserView: View { 11 | @ObservedObject var controller: PageController 12 | @FocusState private var addressIsFocused: Bool 13 | 14 | var body: some View { 15 | VStack(spacing: 0) { 16 | HStack { 17 | HStack(spacing: 0) { 18 | Button(action: { controller.goBack() }) { 19 | Image(systemName: "arrowtriangle.left.fill") 20 | }.disabled(controller.canGoBack == false) 21 | Button(action: { controller.goForward() }) { 22 | Image(systemName: "arrowtriangle.right.fill") 23 | }.disabled(controller.canGoForward == false) 24 | } 25 | TextField("Address", text: $controller.address) 26 | .onSubmit { 27 | addressIsFocused = false 28 | guard let url = URL(string: controller.address) else { return } 29 | controller.loadPage(at: fullURL(forURLToLoad: url)) 30 | } 31 | .textFieldStyle(RoundedBorderTextFieldStyle()) 32 | .focused($addressIsFocused) 33 | } 34 | .padding() 35 | Divider() 36 | WebDocumentView(controller: controller) 37 | .background(.white) 38 | .environment(\.openURL, .init(handler: { url in 39 | if let scheme = url.scheme, scheme != "http" && scheme != "https" { 40 | return .systemAction 41 | } 42 | controller.loadPage(at: fullURL(forURLToLoad: url)) 43 | addressIsFocused = false 44 | return .handled 45 | })) 46 | } 47 | .environment(\.urlBuilder, fullURL(forURLToLoad:)) 48 | } 49 | 50 | private func fullURL(forURLToLoad urlToLoad: URL) -> URL { 51 | if urlToLoad.host != nil { return urlToLoad } 52 | 53 | switch controller.state { 54 | case .failed, .notLoaded: return urlToLoad 55 | case .loaded(_, let loadedURL): 56 | return URL(string: urlToLoad.path, relativeTo: loadedURL.deletingLastPathComponent()) ?? urlToLoad 57 | } 58 | } 59 | } 60 | 61 | struct WebDocumentView: View { 62 | @ObservedObject var controller: PageController 63 | 64 | var body: some View { 65 | switch controller.state { 66 | case .notLoaded: 67 | Text("Let's load a web page!") 68 | .frame(maxWidth: .infinity, maxHeight: .infinity) 69 | case .failed(let error): 70 | Text(verbatim: "Failed to load page. Error: \(error)") 71 | .frame(maxWidth: .infinity, maxHeight: .infinity) 72 | case .loaded(let document, _): 73 | BodyView(bodyNode: document.htmlNode.firstDirectChild(named: "body")!) 74 | .navigationTitle( 75 | document 76 | .htmlNode 77 | .firstDirectChild(named: "head")? 78 | .firstDirectChild(named: "title")? 79 | .firstDirectChild(named: Node.InternalElement.textRun)? 80 | .textContent ?? "Smol" 81 | ) 82 | .font(Font.custom("Times", size: 16)) 83 | } 84 | } 85 | } 86 | 87 | private struct URLBuilderKey: EnvironmentKey { 88 | static let defaultValue: (URL) -> URL = { $0 } 89 | } 90 | 91 | extension EnvironmentValues { 92 | /// A function that takes a (potentially "relative") web url to load, and fleshes it out to a full url that includes a host. 93 | var urlBuilder: (URL) -> URL { 94 | get { self[URLBuilderKey.self] } 95 | set { self[URLBuilderKey.self] = newValue } 96 | } 97 | } 98 | 99 | class PageController: ObservableObject { 100 | 101 | enum State { 102 | case notLoaded 103 | case loaded(Document, URL) 104 | case failed(Error) 105 | } 106 | 107 | private enum LoadingError: Error { 108 | case failedToLoad(URL) 109 | } 110 | 111 | @Published var state = State.notLoaded { 112 | didSet { 113 | if let currentlyLoadedDocument { 114 | address = currentlyLoadedDocument.1.absoluteString 115 | } 116 | } 117 | } 118 | var address = "https://nearthespeedoflight.com/browser.html" 119 | 120 | private var backStack: [(Document, URL)] = [] 121 | private var forwardStack: [(Document, URL)] = [] 122 | 123 | var canGoBack: Bool { backStack.isEmpty == false } 124 | var canGoForward: Bool { forwardStack.isEmpty == false } 125 | 126 | func loadPage(at url: URL) { 127 | Task { 128 | let newState: State 129 | do { 130 | let (data, response) = try await URLSession.shared.data(from: url) 131 | 132 | if let currentlyLoadedDocument { 133 | backStack.append(currentlyLoadedDocument) 134 | forwardStack = [] 135 | } 136 | 137 | let htmlString = String(data: data, encoding: .utf8) ?? "" 138 | let tokenizer = Tokenizer(programText: htmlString) 139 | let context = try ParsingContext(tokens: tokenizer.scanAllTokens()) 140 | 141 | newState = .loaded(try Document.parse(context: context, options: nil), response.url ?? url) 142 | } catch { 143 | print("error loading page: \(error)") 144 | newState = .failed(error) 145 | } 146 | 147 | await MainActor.run { 148 | state = newState 149 | } 150 | } 151 | } 152 | 153 | private var currentlyLoadedDocument: (Document, URL)? { 154 | switch state { 155 | case .notLoaded, .failed: return nil 156 | case let .loaded(document, url): return (document, url) 157 | } 158 | } 159 | 160 | func goBack() { 161 | guard let (previousDocument, previousURL) = backStack.popLast() else { return } 162 | if let currentlyLoadedDocument { 163 | forwardStack.append(currentlyLoadedDocument) 164 | } 165 | state = .loaded(previousDocument, previousURL) 166 | } 167 | 168 | func goForward() { 169 | guard let (nextDocument, nextURL) = forwardStack.popLast() else { return } 170 | if let currentlyLoadedDocument { 171 | backStack.append(currentlyLoadedDocument) 172 | } 173 | state = .loaded(nextDocument, nextURL) 174 | } 175 | } 176 | 177 | /// This view works as a generic "block" / "box" container for inline content. 178 | /// 179 | /// You might use it for paragraph contents, or h1/2/3/etc contents, or just inline content not in one of those elements. 180 | struct InlineContentWrappingBlockView: View { 181 | let node: Node 182 | @Environment(\.font) var font 183 | 184 | var body: some View { 185 | Text( 186 | node 187 | .childNodes 188 | .map { $0.attributedText(defaultFont: font ?? Font.custom("Times", size: 16)) } 189 | .reduce(AttributedString(), +) 190 | ) 191 | .lineSpacing(4) 192 | .fixedSize(horizontal: false, vertical: true) 193 | } 194 | } 195 | 196 | 197 | struct ImageView: View { 198 | let node: Node 199 | @Environment(\.urlBuilder) var urlBuilder 200 | 201 | var body: some View { 202 | AsyncImage(url: urlBuilder(URL.init(string: node.attributeDictionary["src"] ?? "")!), content: { image in 203 | image 204 | .resizable() 205 | .aspectRatio(contentMode: .fit) 206 | .frame( 207 | width: node.attributeDictionary["width"].flatMap(WebSize.init(rawValue:))?.dimension, 208 | height: node.attributeDictionary["height"].flatMap(WebSize.init(rawValue:))?.dimension 209 | ) 210 | }, placeholder: { 211 | Color(white: 0.9).cornerRadius(4) 212 | }) 213 | } 214 | } 215 | 216 | struct WebSize { 217 | let rawValue: String 218 | 219 | var dimension: CGFloat { 220 | // trim anything that isn't a digit, then try to parse that into an int. this ignores things like "px" 221 | CGFloat(Int(rawValue.prefix(while: \.isWholeNumber)) ?? 0) 222 | } 223 | } 224 | 225 | struct BlocksView: View { 226 | let children: [Node] 227 | @Environment(\.font) var font 228 | 229 | var body: some View { 230 | VStack(alignment: .leading, spacing: 20) { 231 | ForEach(children, id: \.self) { childNode in 232 | switch childNode.element { 233 | case "h1": 234 | InlineContentWrappingBlockView(node: childNode) 235 | .font(Font.custom("Times", size: 32).bold()) 236 | case "h2": 237 | InlineContentWrappingBlockView(node: childNode) 238 | .font(Font.custom("Times", size: 28).bold()) 239 | case "h3": 240 | InlineContentWrappingBlockView(node: childNode) 241 | .font(Font.custom("Times", size: 24).bold()) 242 | case "p": 243 | InlineContentWrappingBlockView(node: childNode) 244 | case "img": 245 | ImageView(node: childNode) 246 | case "div", "section", "main", "footer", "article", "header", "nav", "aside": 247 | BlocksView(children: childNode.childNodesSortedIntoBlocks) 248 | case "pre": 249 | BlocksView(children: childNode.childNodesSortedIntoBlocks) 250 | .font(Font.system(size: 13, design: .monospaced)) 251 | case "blockquote": 252 | BlocksView(children: childNode.childNodesSortedIntoBlocks) 253 | .padding(.leading, 20) 254 | case "ul": ListNodeView(node: childNode, style: .unordered) 255 | case "ol": ListNodeView(node: childNode, style: .ordered) 256 | case "hr": Divider() 257 | case "script": EmptyView() 258 | case "br": Color.clear.padding(20) 259 | default: Text("unknown block element: <\(childNode.element)>") 260 | } 261 | } 262 | } 263 | } 264 | } 265 | 266 | struct ListNodeView: View { 267 | enum Style { 268 | case ordered, unordered 269 | 270 | func listMarker(for index: Int) -> String { 271 | switch self { 272 | case .ordered: return "\(index + 1)." 273 | case .unordered: return "•" 274 | } 275 | } 276 | } 277 | 278 | let node: Node 279 | let style: Style 280 | 281 | var body: some View { 282 | VStack(alignment: .leading, spacing: 8) { 283 | ForEach(Array(zip(node.childNodes.indices, node.childNodes)), id: \.1) { (index, childNode) in 284 | HStack(alignment: .firstTextBaseline, spacing: 8) { 285 | Text(verbatim: style.listMarker(for: index)) 286 | BlocksView(children: childNode.childNodesSortedIntoBlocks) 287 | } 288 | } 289 | } 290 | } 291 | } 292 | 293 | struct BodyView: View { 294 | let bodyNode: Node 295 | var body: some View { 296 | ScrollView { 297 | HStack(spacing: 0) { 298 | BlocksView(children: bodyNode.childNodesSortedIntoBlocks) 299 | .frame(maxWidth: bodyNode.styleFromAttributes?.maxWidth) 300 | Spacer() 301 | } 302 | .padding(20) 303 | } 304 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 305 | .background(Color.white) 306 | } 307 | } 308 | 309 | extension Node { 310 | func attributedText(defaultFont: Font) -> AttributedString { 311 | switch element { 312 | case InternalElement.textRun: 313 | var attributes = AttributeContainer() 314 | attributes.font = defaultFont 315 | 316 | return AttributedString(textContent ?? "", attributes: attributes) 317 | case "em", "i": 318 | var attributes = AttributeContainer() 319 | attributes.font = defaultFont.italic() 320 | 321 | return childNodes 322 | .map { $0.attributedText(defaultFont: defaultFont.italic()) } 323 | .reduce(AttributedString(), +) 324 | .mergingAttributes(attributes, mergePolicy: .keepCurrent) 325 | case "strong", "b": 326 | var attributes = AttributeContainer() 327 | attributes.font = defaultFont.bold() 328 | 329 | return childNodes 330 | .map { $0.attributedText(defaultFont: defaultFont.bold()) } 331 | .reduce(AttributedString(), +) 332 | .mergingAttributes(attributes, mergePolicy: .keepCurrent) 333 | case "code": 334 | var attributes = AttributeContainer() 335 | let monospaced = Font.system(size: 13, design: .monospaced) 336 | attributes.font = monospaced 337 | 338 | return childNodes 339 | .map { $0.attributedText(defaultFont: monospaced) } 340 | .reduce(AttributedString(), +) 341 | .mergingAttributes(attributes, mergePolicy: .keepCurrent) 342 | case "a": 343 | var attributes = AttributeContainer() 344 | attributes.link = URL(string: attributeDictionary["href"] ?? "") 345 | attributes.underlineStyle = .single 346 | 347 | return childNodes 348 | .map { $0.attributedText(defaultFont: defaultFont) } 349 | .reduce(AttributedString(), +) 350 | .mergingAttributes(attributes, mergePolicy: .keepCurrent) 351 | default: 352 | var attributes = AttributeContainer() 353 | attributes.font = defaultFont 354 | 355 | return childNodes 356 | .map { $0.attributedText(defaultFont: defaultFont) } 357 | .reduce(AttributedString(), +) 358 | .mergingAttributes(attributes, mergePolicy: .keepCurrent) 359 | } 360 | } 361 | } 362 | 363 | struct Token: Equatable, CustomDebugStringConvertible { 364 | 365 | enum Kind: Equatable { 366 | case text, openAngleBracket, closeAngleBracket, forwardSlash, equals, hyphen, singleQuote, doubleQuote, whitespace, bang 367 | } 368 | 369 | let kind: Kind 370 | let body: String 371 | 372 | init(kind: Kind, body: String) { 373 | self.kind = kind 374 | self.body = body 375 | } 376 | 377 | init?(symbol: Character) { 378 | switch symbol { 379 | case "<": self.init(kind: .openAngleBracket, body: "<") 380 | case ">": self.init(kind: .closeAngleBracket, body: ">") 381 | case "/": self.init(kind: .forwardSlash, body: "/") 382 | case "=": self.init(kind: .equals, body: "=") 383 | case "-": self.init(kind: .hyphen, body: "-") 384 | case "'": self.init(kind: .singleQuote, body: "'") 385 | case "\"": self.init(kind: .doubleQuote, body: "\"") 386 | case "!": self.init(kind: .bang, body: "!") 387 | default: return nil 388 | } 389 | } 390 | 391 | var debugDescription: String { body } 392 | } 393 | 394 | class ScanningCursor { 395 | private let programText: String 396 | var currentIndex: String.Index 397 | 398 | var isNotAtEnd: Bool { currentIndex < programText.endIndex } 399 | 400 | init(programText: String) { 401 | self.programText = programText 402 | self.currentIndex = programText.startIndex 403 | } 404 | 405 | @discardableResult 406 | func advance() -> Character { 407 | guard isNotAtEnd else { fatalError() } 408 | 409 | let currentCharacter = currentCharacter() 410 | currentIndex = programText.index(after: currentIndex) 411 | 412 | return currentCharacter 413 | } 414 | 415 | func currentCharacter() -> Character { 416 | programText[currentIndex] 417 | } 418 | 419 | func previousCharacter() -> Character { 420 | programText[programText.index(before: currentIndex)] 421 | } 422 | } 423 | 424 | class Tokenizer { 425 | private let cursor: ScanningCursor 426 | var scannedTokens = [Token]() 427 | 428 | init(programText: String) { 429 | cursor = ScanningCursor(programText: programText) 430 | } 431 | 432 | func scanAllTokens() throws -> [Token] { 433 | while cursor.isNotAtEnd { 434 | try scanNextToken() 435 | } 436 | 437 | return scannedTokens 438 | } 439 | 440 | private func scanNextToken() throws { 441 | let next = cursor.advance() 442 | 443 | if let token = Token(symbol: next) { 444 | return scannedTokens.append(token) 445 | } else if next.isWhitespace { 446 | return scannedTokens.append(Token(kind: .whitespace, body: String(next))) 447 | } else { 448 | scanText() 449 | } 450 | } 451 | 452 | private func scanText() { 453 | var body = String(cursor.previousCharacter()) 454 | while cursor.isNotAtEnd { 455 | let next = cursor.currentCharacter() 456 | if Token(symbol: next) != nil { 457 | break 458 | } 459 | if next.isWhitespace { break } 460 | 461 | body.append(next) 462 | cursor.advance() 463 | } 464 | scannedTokens.append(Token(kind: .text, body: body)) 465 | } 466 | } 467 | 468 | class ParsingContext { 469 | 470 | private let tokens: [Token] 471 | private var tokenIndexStack = [0] 472 | private var currentTokenIndex: Int { 473 | get { tokenIndexStack.last! } 474 | set { tokenIndexStack[tokenIndexStack.endIndex - 1] = newValue } 475 | } 476 | 477 | var isNotAtEnd: Bool { 478 | currentTokenIndex < tokens.count 479 | } 480 | 481 | /// Might be a whitespace token. 482 | var currentToken: Token { isNotAtEnd == false ? tokens.last! : tokens[currentTokenIndex] } 483 | 484 | /// Might be a whitespace token. 485 | var nextToken: Token { tokens[currentTokenIndex + 1] } 486 | 487 | /// Might be a whitespace token. 488 | var nextNextToken: Token { tokens[currentTokenIndex + 2] } 489 | var previousToken: Token { tokens[currentTokenIndex - 1] } 490 | 491 | init(tokens: [Token]) { 492 | self.tokens = tokens 493 | } 494 | 495 | enum ParseError: Error { 496 | case unexpectedToken(Token, feedback: String) 497 | case failedToParse 498 | } 499 | 500 | @discardableResult 501 | func consume(tokenKind kind: Token.Kind, feedback: String) throws -> Token { 502 | try consume(where: { $0.kind == kind }, feedback: feedback) 503 | } 504 | 505 | @discardableResult 506 | func consume(where predicate: (Token) -> Bool, skipWhitespaceTokens: Bool = true, feedback: String) throws -> Token { 507 | let oldCurrentToken = self.currentToken 508 | guard advance(when: predicate, skipWhitespaceTokens: skipWhitespaceTokens) else { 509 | throw ParseError.unexpectedToken(oldCurrentToken, feedback: feedback) 510 | } 511 | return previousToken 512 | } 513 | 514 | /// Similar to `whileNotAtEnd()`, except this call does not `throw`. If the given `perform` closure throws, the results accumulated thus far are returned, vs just propagating up the error like `whileNotAtEnd()` does. 515 | /// 516 | /// Use this method when you want to accumulate results until parsing fails, but you want to keep what you've found so far. 517 | func untilThrowOrEndOfTokensReached(perform: () throws -> ConsumedType) -> [ConsumedType] { 518 | untilErrorThrownOrEndOfTokensReached(perform: perform).0 519 | } 520 | 521 | /// Similar to `untilThrowOrEndOfTokensReached` but this one includes the error, if any, that was thrown that ended iterating. 522 | /// 523 | /// You probably want to use the error-less variant of this method most of the time, but this one is useful if you're trying to debug or want fine grained control. 524 | func untilErrorThrownOrEndOfTokensReached(perform: () throws -> ConsumedType) -> ([ConsumedType], Error?) { 525 | var results = [ConsumedType]() 526 | 527 | do { 528 | while isNotAtEnd { 529 | results.append(try perform()) 530 | } 531 | } catch { 532 | return (results, error) 533 | } 534 | return (results, nil) 535 | } 536 | 537 | func attempt(action: () throws -> ContentType) throws -> ContentType { 538 | tokenIndexStack.append(currentTokenIndex) 539 | var shouldRevertIndexStack = true 540 | 541 | defer { 542 | // Pop the stack if `try action()` fails. 543 | // doing it this way, instead of catching + rethrowing 544 | // so that the error chain continues to the original error, not our rethrow 545 | if shouldRevertIndexStack { 546 | _ = tokenIndexStack.popLast() 547 | } 548 | } 549 | 550 | let result = try action() 551 | 552 | // we succeeded, so pop the token index stack, and use THAT value as the new current index 553 | currentTokenIndex = tokenIndexStack.popLast()! 554 | shouldRevertIndexStack = false 555 | return result 556 | } 557 | 558 | func choose(from choices: [() throws -> ContentType]) throws -> ContentType { 559 | try attempt(action: { 560 | var mostRecentError: Error = ParseError.failedToParse 561 | 562 | for choice in choices { 563 | 564 | do { 565 | return try attempt(action: { 566 | try choice() 567 | }) 568 | } catch { 569 | mostRecentError = error 570 | } 571 | } 572 | 573 | throw mostRecentError 574 | }) 575 | } 576 | 577 | private func advance(when predicate: (Token) -> Bool, skipWhitespaceTokens: Bool = true) -> Bool { 578 | 579 | var skippedWhitespaceCount = 0 580 | if skipWhitespaceTokens { 581 | while isNotAtEnd && currentToken.kind == .whitespace { 582 | currentTokenIndex += 1 583 | skippedWhitespaceCount += 1 584 | } 585 | } 586 | 587 | guard isNotAtEnd else { return false } 588 | 589 | if predicate(tokens[currentTokenIndex]) { 590 | currentTokenIndex += 1 591 | return true 592 | } else { 593 | currentTokenIndex -= skippedWhitespaceCount 594 | return false 595 | } 596 | } 597 | } 598 | 599 | protocol Parsable { 600 | static func parse(context: ParsingContext, options: ParsingOptions?) throws -> Self 601 | } 602 | 603 | struct ParsingOptions { 604 | let preservesWhitespace: Bool 605 | } 606 | 607 | /// This type mostly exists right now to handle parsing pages that have a `` node at their root, along with an `` node. 608 | /// For now, we're discarding the doctype. 609 | struct Document: Hashable, Parsable { 610 | 611 | enum DocumentError: Error { 612 | case unableToFindHTMLNode 613 | } 614 | 615 | let htmlNode: Node 616 | 617 | static func parse(context: ParsingContext, options: ParsingOptions?) throws -> Document { 618 | let (nodes, error) = context.untilErrorThrownOrEndOfTokensReached { 619 | try Node.parse(context: context, options: nil) 620 | } 621 | 622 | if let error { 623 | print("Document finished parsing with an error: \(error)") 624 | } 625 | 626 | guard let htmlNode = nodes.first(where: { $0.element.lowercased() == "html" }) else { 627 | throw DocumentError.unableToFindHTMLNode 628 | } 629 | 630 | return Document(htmlNode: htmlNode) 631 | } 632 | } 633 | 634 | struct Node: Hashable, Parsable, Identifiable { 635 | 636 | struct InternalElement { 637 | static let textRun = "__textRun" 638 | static let comment = "__comment" 639 | } 640 | 641 | enum Content: Hashable { 642 | case text(String) 643 | case childNodes([Node]) 644 | case voidNode 645 | } 646 | 647 | let element: String 648 | let content: Content 649 | let attributes: [Attribute] 650 | 651 | // todo: this id is breaking all the tests 652 | let id = UUID() 653 | 654 | enum NodeParseError: Error { 655 | case closingTagDidNotMatchOpeningTag(opening: String, closing: String) 656 | 657 | /// This error will probably get thrown a lot, just to signify that parsing a child failed. 658 | /// todo: It's probably wasteful to do it this way! 659 | case openingTagWasActuallyClosing(tagName: String) 660 | case closingTagWasActuallyOpening(tagName: String) 661 | case didNotFindAnyText 662 | } 663 | 664 | static func parse(context: ParsingContext, options: ParsingOptions?) throws -> Node { 665 | 666 | let startTag = try Tag.parse(context: context, options: options) 667 | 668 | guard startTag.isEnd == false else { 669 | throw NodeParseError.openingTagWasActuallyClosing(tagName: startTag.element) 670 | } 671 | 672 | 673 | if startTag.element.lowercased() == "doctype" { 674 | return Node(element: "doctype", content: .voidNode, attributes: startTag.attributes) 675 | } 676 | 677 | if startTag.isVoidElement { 678 | return Node(element: startTag.element, content: .voidNode, attributes: startTag.attributes) 679 | } 680 | 681 | let shouldPreserveWhitespace = startTag.element == "pre" || options?.preservesWhitespace ?? false 682 | 683 | let children = context.untilThrowOrEndOfTokensReached(perform: { 684 | try context.choose(from: [ 685 | { try Node.parse(context: context, options: .init(preservesWhitespace: shouldPreserveWhitespace)) }, 686 | { 687 | let textContents = context.untilThrowOrEndOfTokensReached { 688 | try context.consume(where: { $0.kind != .openAngleBracket }, skipWhitespaceTokens: false, feedback: "Expected a non `<` token") 689 | } 690 | guard textContents.isEmpty == false else { 691 | throw NodeParseError.didNotFindAnyText 692 | } 693 | 694 | // todo: this does not follow the exact html rules, but good enough for now 695 | let entityDecodedContents = textContents 696 | .map(\.body) 697 | .joined() 698 | .replacingOccurrences(of: " ", with: "") 699 | .replacingOccurrences(of: "<", with: "<") 700 | .replacingOccurrences(of: ">", with: ">") 701 | .replacingOccurrences(of: "'", with: "'") 702 | .replacingOccurrences(of: """, with: "\"") 703 | .replacingOccurrences(of: "’", with: "’") 704 | .replacingOccurrences(of: "‘", with: "’") 705 | .replacingOccurrences(of: "”", with: "”") 706 | .replacingOccurrences(of: "“", with: "“") 707 | .replacingOccurrences(of: "&", with: "&") 708 | let contentRun = shouldPreserveWhitespace ? entityDecodedContents : entityDecodedContents 709 | .replacingOccurrences(of: "\n", with: " ") 710 | .replacingOccurrences(of: "\t", with: " ") 711 | 712 | return Node(element: InternalElement.textRun, content: .text(contentRun), attributes: []) 713 | }, 714 | { 715 | try context.consumeBetween(leftToken: .openAngleBracket, rightToken: .closeAngleBracket) { 716 | try context.consume(tokenKind: .bang, feedback: "Expected comment to begin with a bang") 717 | try context.consume(tokenKind: .hyphen, feedback: "Expected comment to have a hyphen after the bang") 718 | try context.consume(tokenKind: .hyphen, feedback: "Expected comment to have two hyphens after the bang") 719 | 720 | var done = false 721 | while done == false { 722 | 723 | if context.currentToken.kind == .hyphen && context.nextToken.kind == .hyphen && context.nextNextToken.kind == .closeAngleBracket { 724 | 725 | try context.consume(tokenKind: .hyphen, feedback: "-") 726 | try context.consume(tokenKind: .hyphen, feedback: "-") 727 | done = true 728 | } else { 729 | try context.consume(where: { _ in true }, skipWhitespaceTokens: false, feedback: "munch munch") 730 | } 731 | } 732 | 733 | return Node(element: InternalElement.comment, content: .voidNode, attributes: []) 734 | } 735 | } 736 | ]) 737 | }) 738 | .filter { 739 | if $0.element == InternalElement.comment { return false } 740 | if $0.element != InternalElement.textRun { return true } 741 | 742 | // filter out empty text run nodes 743 | return $0.textContent?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false 744 | } 745 | 746 | let endTag = try Tag.parse(context: context, options: options) 747 | guard endTag.isEnd else { 748 | throw NodeParseError.closingTagWasActuallyOpening(tagName: endTag.element) 749 | } 750 | 751 | guard startTag.element == endTag.element else { 752 | throw NodeParseError.closingTagDidNotMatchOpeningTag(opening: startTag.element, closing: endTag.element) 753 | } 754 | 755 | return .init( 756 | element: startTag.element, 757 | content: .childNodes(children), 758 | attributes: startTag.attributes 759 | ) 760 | } 761 | } 762 | 763 | struct Tag: Parsable { 764 | 765 | let element: String 766 | let isEnd: Bool 767 | let attributes: [Attribute] 768 | 769 | /// A "void element" is one that has no end tag and no children. 770 | var isVoidElement: Bool { 771 | ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"].contains(element) 772 | } 773 | 774 | static func parse(context: ParsingContext, options: ParsingOptions?) throws -> Tag { 775 | try context.consumeBetween(leftToken: .openAngleBracket, rightToken: .closeAngleBracket) { 776 | let slashToken = try? context.consume(tokenKind: .forwardSlash, feedback: "Expected a `/`") 777 | let _ = try? context.consume(tokenKind: .bang, feedback: "Expected a `!`") 778 | let identifier = try context.consume(tokenKind: .text, feedback: "Expected a tag name") 779 | 780 | let attributes = context.untilThrowOrEndOfTokensReached(perform: { 781 | try context.attempt(action: { 782 | try Attribute.parse(context: context, options: options) 783 | }) 784 | }) 785 | 786 | // If there's a trailing slash (eg ), consume it but ignore it. this is invalid html 787 | _ = try? context.consume(tokenKind: .forwardSlash, feedback: "Expected a trailing `/`") 788 | return Tag(element: identifier.body, isEnd: slashToken != nil, attributes: attributes) 789 | } 790 | } 791 | } 792 | 793 | struct Attribute: Hashable, Parsable { 794 | let key: String 795 | let value: String 796 | 797 | enum AttributeParseError: Error { 798 | case emptyAttributeValue(key: String) 799 | } 800 | 801 | static func parse(context: ParsingContext, options: ParsingOptions?) throws -> Attribute { 802 | // todo: attribute keys can be hyphenated 803 | let key = try context.consume(tokenKind: .text, feedback: "Expected an attribute name") 804 | 805 | guard let _ = try? context.consume(tokenKind: .equals, feedback: "Expected an equals sign") else { 806 | return Attribute(key: key.body, value: key.body) 807 | } 808 | 809 | let value = try context.choose(from: [ 810 | { 811 | try context.consumeBetween(leftToken: .doubleQuote, rightToken: .doubleQuote) { 812 | let textContents = context.untilThrowOrEndOfTokensReached { 813 | try context.consume(where: { $0.kind != .doubleQuote }, skipWhitespaceTokens: false, feedback: "Expected a non `\"` token") 814 | } 815 | 816 | return textContents 817 | .map(\.body) 818 | .joined() 819 | } 820 | }, 821 | { 822 | try context.consumeBetween(leftToken: .singleQuote, rightToken: .singleQuote) { 823 | let textContents = context.untilThrowOrEndOfTokensReached { 824 | try context.consume(where: { $0.kind != .singleQuote }, skipWhitespaceTokens: false, feedback: "Expected a non `'` token") 825 | } 826 | 827 | return textContents 828 | .map(\.body) 829 | .joined() 830 | } 831 | }, 832 | { 833 | let textContents = context.untilThrowOrEndOfTokensReached { 834 | try context.consume( 835 | where: { 836 | $0.kind != .singleQuote && $0.kind != .doubleQuote && $0.kind != .whitespace && $0.kind != .closeAngleBracket 837 | }, 838 | skipWhitespaceTokens: false, 839 | feedback: "Expected non-whitespace, non-quote characters") 840 | } 841 | 842 | guard textContents.isEmpty == false else { 843 | throw AttributeParseError.emptyAttributeValue(key: key.body) 844 | } 845 | 846 | return textContents 847 | .map(\.body) 848 | .joined() 849 | } 850 | ]) 851 | 852 | return Attribute(key: key.body, value: value) 853 | } 854 | } 855 | 856 | extension ParsingContext { 857 | @discardableResult 858 | func consumeBetween(leftToken: Token.Kind, rightToken: Token.Kind, content: () throws -> ContentType) throws -> ContentType { 859 | try consume(tokenKind: leftToken, feedback: "Expected a \(leftToken)") 860 | let consumedContent = try content() 861 | try consume(tokenKind: rightToken, feedback: "Expected a \(rightToken)") 862 | 863 | return consumedContent 864 | } 865 | } 866 | 867 | extension Node { 868 | var attributeDictionary: [String: String] { 869 | Dictionary(uniqueKeysWithValues: attributes.map({ ($0.key, $0.value) })) 870 | } 871 | 872 | var childNodes: [Node] { 873 | switch content { 874 | case .voidNode, .text: return [] 875 | case .childNodes(let nodes): return nodes 876 | } 877 | } 878 | 879 | var childNodesSortedIntoBlocks: [Node] { 880 | var nodesToReturn = [Node]() 881 | var inlineElements = [Node]() 882 | 883 | func addInlineElementsAsGroupIfNeeded() { 884 | guard inlineElements.isEmpty == false else { return } 885 | // make a fake block element that has all these as children 886 | let wrapper = Node(element: "p", content: .childNodes(inlineElements), attributes: []) 887 | // and append it to our list to return 888 | nodesToReturn.append(wrapper) 889 | // then, empty the inlineElements list 890 | inlineElements = [] 891 | } 892 | 893 | for node in childNodes { 894 | let display = node.styleFromAttributes?.display ?? node.defaultDisplayStyle 895 | if display == .inline { 896 | inlineElements.append(node) 897 | } else { 898 | addInlineElementsAsGroupIfNeeded() 899 | nodesToReturn.append(node) 900 | } 901 | } 902 | addInlineElementsAsGroupIfNeeded() 903 | return nodesToReturn 904 | } 905 | 906 | var defaultDisplayStyle: Style.DisplayStyle { 907 | isInlineNode ? .inline : .block 908 | } 909 | 910 | var isInlineNode: Bool { 911 | [InternalElement.textRun, "a", "abbr", "acronym", "audio", "b", "bdi", "bdo", "big", "br", "button", "canvas", "cite", "code", "data", "datalist", "del", "dfn", "em", "embed", "i", "iframe", "img", "input", "ins", "kbd", "label", "map", "mark", "meter", "noscript", "object", "output", "picture", "progress", "q", "ruby", "s", "samp", "script", "select", "slot", "small", "span", "strong", "sub", "sup", "svg", "template", "textarea", "time", "u", "tt", "var", "video", "wbr"].contains(element) 912 | } 913 | 914 | var textContent: String? { 915 | switch content { 916 | case .childNodes, .voidNode: return nil 917 | case .text(let text): return text 918 | } 919 | } 920 | 921 | func firstDirectChild(named element: String) -> Node? { 922 | childNodes.first(where: { $0.element == element }) 923 | } 924 | 925 | var styleFromAttributes: Style? { 926 | guard let styleAttribute = attributeDictionary["style"] else { return nil } 927 | let stylePairs = styleAttribute.components(separatedBy: ";") 928 | return Style( 929 | rawPairs: .init( 930 | uniqueKeysWithValues: stylePairs 931 | .map { $0.components(separatedBy: ":") } 932 | .map { ($0.first?.trimmingCharacters(in: .whitespacesAndNewlines), $0.last?.trimmingCharacters(in: .whitespacesAndNewlines)) } 933 | .compactMap { 934 | guard let key = $0, let value = $1 else { return nil } 935 | return (key, value) 936 | } 937 | ) 938 | ) 939 | } 940 | } 941 | 942 | struct Style { 943 | enum DisplayStyle { case inline, block } 944 | 945 | var display: DisplayStyle? { 946 | switch rawValue["display"] { 947 | case "inline": return .inline 948 | case "block": return .block 949 | default: 950 | return nil 951 | } 952 | } 953 | 954 | var maxWidth: CGFloat? { 955 | rawValue["max-width"].map(WebSize.init(rawValue:)).map(\.dimension) 956 | } 957 | 958 | private let rawValue: [String: String] 959 | 960 | init(rawPairs: [String: String]) { 961 | self.rawValue = rawPairs 962 | } 963 | } 964 | -------------------------------------------------------------------------------- /Smol/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Jason's homepage 4 | 5 | 6 |

Welcome to Jason's homepage

7 |

On this page you'll find lots of boring old html, that I'll use to test the renderer. And maybe even a link to a website. 8 |

9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Smol/index.md: -------------------------------------------------------------------------------- 1 | # Let's Write a Web Browser from Scratch in Swift! 2 | 3 | *April 2023* 4 | 5 | There's a rumour that Apple's going to start allowing custom, non-WebKit based browser engines on iOS starting later this year. While that most likely means Chrome, Firefox, and the other big browsers could start using custom engines, it also means you could write your own too. So why not try it? 6 | 7 | In this 2 part series ([part 2 is here](https://nearthespeedoflight.com/browser-2.html)), I'll take you through how to write a basic web browser from scratch, from parsing HTML in Swift to rendering the pages with SwiftUI, displaying them with a simple, but familiar interface. 8 | 9 | 10 | 11 | You might be thinking "Aren't web browsers huge, incredibly complicated pieces of software?" and yes, the big ones we use every day are huge and complicated. But even huge and complicated pieces of software are still "just software" at their core, written by normal programmers just doing their job or following their passion. You can write one too. 12 | 13 | What we're attempting in this series is a very simple browser, and the end result is actually a little under 1000 lines of fairly straightforward Swift code. We'll focus solely on rendering a subset of HTML, leaving CSS and Javascript as exercises for the reader :). We'll take many shortcuts and liberties, but in the end you should have an app that can render unstyled, standard HTML pages. And you'll also have some tools for writing programming language parsers by hand, which you could use to write your own custom language. 14 | 15 | The feature set of our browser is going to be small, but the goal is this: **you should be able to render this very browser tutorial web page in the browser itself**. Fun, right? 16 | 17 | (by the way: I'm looking for work, so if you're looking to hire someone to work on browsers, programming languages, dev tools, or Swift apps, I'm your guy! [Please reach out](mailto:i.jasonbrennan@gmail.com), I'd love to hear from you) 18 | 19 | ## The Architecture 20 | 21 | Before we dive in to code, let's look at the overall archicture of what we're building, to make the challenge ahead more managable. Since HTML is a programming language, we'll follow a similar architecture to that of most compilers / interpreters, which is a sort of pipeline, where each part of the pipeline takes input and spits something else out for the next component to work with. So what are our inputs and components? 22 | 23 | 1. We start with the **raw html**, as a `String`. This either comes from the network or a local file, but it doesn't really matter. 24 | 2. We then digest the html string into an array of **tokens**, in a process known as *tokenizing* or *lexing*. This essentially chews up the raw string into common pieces that are easier to digest, such as punctuation characters (`<, >, ", etc`), whitespaces (newlines, tabs, spaces), digits, or just regular letter characters. 25 | 3. The tokens are then passed to the **Parsing Context**, a class whose core purpose is letting other types *consume* tokens they recognize, while also keeping track of which tokens have already been consumed. 26 | 4. Next we have structs representing **the data we're parsing**, things like, the `Document`, a tree of `Node`s, which consist of `Tag`s and `Attribute`s. We'll write little parsers for each of these things, that will call into the `ParsingContext` to consume the tokens they need for their construction. 27 | 5. Finally, when parsing is complete, we have data we can then use to **display our SwiftUI pages** with. In a traditional programming language, this might be the point where you output compiled code into an executable or evaluate your data with an interpreter, but here our "interpreter" will simply display a UI. 28 | 29 | With that general archictecture in mind, let's fire up Xcode and get started. 30 | 31 | ### Quick Tips 32 | 33 | If you're coding along as you read this tutorial, I highly recommend typing out all the code yourself, instead of copying and pasting. In my experience, I find this forces you to slow down and work more deliberately, and I think it'll help you understand things better in the process. You can also find [the complete source code on Github](https://github.com/jbrennan/SmolHTML). 34 | 35 | I'd also recommend changing things as you move along. None of what I've written is the definitive way to write this code, and you could probably put your own spin on it. Or better, extend it to do even more! 36 | 37 | The code I'll be showing in this tutorial is more or less "finished" as is (we won't be building too much of it iteratively, because that would take up a whole book!), but please know my browser *was* built iteratively (you can check out the git history if you'd like to see my stumbles as I went!). Some bits of code in the tutorial will depend upon code we haven't written yet, so please use your imagination if things don't compile at every stage. 38 | 39 | Finally, I won't be providing any unit tests in the tutorial, but you may very well like to include some, especially if you decide to extend your browser after you're done. I find programming languages lend themselves very well to unit testing, as they have well defined inputs and outputs. 40 | 41 | ## Starting the project 42 | 43 | Create a new Xcode project using a SwiftUI template. I called my browser `Smol` because it's very tiny, but feel free to let your creativity shine here. I made my browser be a Mac app just for ease of playing around with, but you could make yours an iOS app if you wanted, everything will work more or less the same. 44 | 45 | In your project settings Info tab, add key for "App Transport Security Settings," and inside that add a key for "Allow arbitrary loads," setting its value to Yes. This will let us load http and https content from anywhere on the internet and it's not enabled by default. 46 | 47 | ## Tokenizing 48 | 49 | Tokenizing is the process of breaking down our program from a `String` into an array of `Token` elements, by scanning through the program character by character to build up different tokens. We'll make 3 types: `Token`, `ScanningCursor`, and `Tokenizer`. 50 | 51 | `Token` will be a small data struct that combines a token `Kind` with the text that makes it up. You could also include other data like where in the program this token is located (which would be helpful in showing errors to someone writing html), but it's not strictly necessary here. 52 | 53 | We define the type along with some initializers that'll help us as we're tokenizing. 54 | 55 | ``` 56 | struct Token: Equatable { 57 | 58 | enum Kind: Equatable { 59 | case text, openAngleBracket, closeAngleBracket, forwardSlash, equals, hyphen, singleQuote, doubleQuote, whitespace, bang 60 | } 61 | 62 | let kind: Kind 63 | let body: String 64 | 65 | init(kind: Kind, body: String) { 66 | self.kind = kind 67 | self.body = body 68 | } 69 | 70 | init?(symbol: Character) { 71 | switch symbol { 72 | case "<": self.init(kind: .openAngleBracket, body: "<") 73 | case ">": self.init(kind: .closeAngleBracket, body: ">") 74 | case "/": self.init(kind: .forwardSlash, body: "/") 75 | case "=": self.init(kind: .equals, body: "=") 76 | case "-": self.init(kind: .hyphen, body: "-") 77 | case "'": self.init(kind: .singleQuote, body: "'") 78 | case "\"": self.init(kind: .doubleQuote, body: "\"") 79 | case "!": self.init(kind: .bang, body: "!") 80 | default: return nil 81 | } 82 | } 83 | } 84 | ``` 85 | 86 | Next, the `ScanningCursor` class will help us keep track of what character we're looking at at any given moment. This could theoretically just be a part of `Tokenizer`, but I've pulled it out into its own type for possible testability and to keep the tokenizer simple. 87 | 88 | ``` 89 | class ScanningCursor { 90 | private let programText: String 91 | var currentIndex: String.Index 92 | 93 | var isNotAtEnd: Bool { currentIndex < programText.endIndex } 94 | 95 | init(programText: String) { 96 | self.programText = programText 97 | self.currentIndex = programText.startIndex 98 | } 99 | 100 | @discardableResult 101 | func advance() -> Character { 102 | guard isNotAtEnd else { fatalError() } 103 | 104 | let currentCharacter = currentCharacter() 105 | currentIndex = programText.index(after: currentIndex) 106 | 107 | return currentCharacter 108 | } 109 | 110 | func currentCharacter() -> Character { 111 | programText[currentIndex] 112 | } 113 | 114 | func previousCharacter() -> Character { 115 | programText[programText.index(before: currentIndex)] 116 | } 117 | } 118 | ``` 119 | 120 | Finally, the `Tokenizer` itself: 121 | 122 | ``` 123 | class Tokenizer { 124 | private let cursor: ScanningCursor 125 | var scannedTokens = [Token]() 126 | 127 | init(programText: String) { 128 | cursor = ScanningCursor(programText: programText) 129 | } 130 | 131 | func scanAllTokens() -> [Token] { 132 | while cursor.isNotAtEnd { 133 | scanNextToken() 134 | } 135 | 136 | return scannedTokens 137 | } 138 | 139 | private func scanNextToken() { 140 | let next = cursor.advance() 141 | 142 | if let token = Token(symbol: next) { 143 | return scannedTokens.append(token) 144 | } else if next.isWhitespace { 145 | return scannedTokens.append(Token(kind: .whitespace, body: String(next))) 146 | } else { 147 | scanText() 148 | } 149 | } 150 | 151 | private func scanText() { 152 | var body = String(cursor.previousCharacter()) 153 | while cursor.isNotAtEnd { 154 | let next = cursor.currentCharacter() 155 | if Token(symbol: next) != nil { 156 | break 157 | } 158 | if next.isWhitespace { break } 159 | 160 | body.append(next) 161 | cursor.advance() 162 | } 163 | scannedTokens.append(Token(kind: .text, body: body)) 164 | } 165 | } 166 | ``` 167 | 168 | The tokenizer's primary public function runs a loop, attempting to parse tokens until the cursor says we've reached the end of the program string. 169 | 170 | The `scanNextToken()` method tells the cursor to pop off its next character and advance its internal position. With that `next` character, it then tries to decide what kind of token to make: 171 | 172 | - if the character matches one of the punctuation token types, we append that token to our list and return 173 | - if the character is whitespace, we add a single whitespace token 174 | - otherwise, we assume the token will be any other text, so we start scanning that. 175 | 176 | `scanText()` grabs the most recently popped-off character and starts its own loop, accumulating text characters into a single string. Here we're considering "text" to be "anything that's neither whitespace nor one of our recognized punctuation tokens." This is a kind of strange way to tokenize text, but html is a strange kind of programming language! and we break things up this way to make parsing easier for us later on. 177 | 178 | ## The Parsing Context 179 | 180 | As mentioned earlier, the **Parsing Context**, is a class whose core purpose is letting other types *consume* tokens they recognize, while also keeping track of which tokens have already been consumed. It's similar to the scanning cursor from earlier, but a little more tailored moving forward (and at times, backward) through a list of tokens. 181 | 182 | You can think of this type as similar to a graphics context, like an OpenGL or Core Graphics context. A graphics context is kind of like a canvas, where you call drawing methods on it (stroke this path, fill this rectangle) or set properties (the current font, the current transform matrix, etc). These calls manipulate the internal state of the context, until you're ready for it to spit out a final rendered image. 183 | 184 | The parsing context is kind of like that, but instead of *adding to an eventual image*, we're subtracting bits of the internal token state while we parse out types. When we're all done parsing, the context should ideally be at the end of its list of tokens and we should have all our parsed data. 185 | 186 | ``` 187 | class ParsingContext { 188 | 189 | private let tokens: [Token] 190 | private var tokenIndexStack = [0] 191 | private var currentTokenIndex: Int { 192 | get { tokenIndexStack.last! } 193 | set { tokenIndexStack[tokenIndexStack.endIndex - 1] = newValue } 194 | } 195 | 196 | var isNotAtEnd: Bool { 197 | currentTokenIndex < tokens.count 198 | } 199 | 200 | // These might be whitespace tokens. 201 | var currentToken: Token { isNotAtEnd == false ? tokens.last! : tokens[currentTokenIndex] } 202 | var nextToken: Token { tokens[currentTokenIndex + 1] } 203 | var nextNextToken: Token { tokens[currentTokenIndex + 2] } 204 | var previousToken: Token { tokens[currentTokenIndex - 1] } 205 | 206 | init(tokens: [Token]) { 207 | self.tokens = tokens 208 | } 209 | 210 | enum ParseError: Error { 211 | case unexpectedToken(Token, feedback: String) 212 | case failedToParse 213 | } 214 | ``` 215 | 216 | We start with some properties around accessing the tokens. We store a list of all tokens and access them by an index, which is our current parsing location. Instead of storing a single index, we instead have a stack of indexes, with the *current* index being the top of this index stack. We'll look into this more below, but it allows us to move forward *and backward* through the list of tokens as we're parsing. 217 | 218 | ``` 219 | private func advance(when predicate: (Token) -> Bool, skipWhitespaceTokens: Bool = true) -> Bool { 220 | 221 | var skippedWhitespaceCount = 0 222 | if skipWhitespaceTokens { 223 | while isNotAtEnd && currentToken.kind == .whitespace { 224 | currentTokenIndex += 1 225 | skippedWhitespaceCount += 1 226 | } 227 | } 228 | 229 | guard isNotAtEnd else { return false } 230 | 231 | if predicate(tokens[currentTokenIndex]) { 232 | currentTokenIndex += 1 233 | return true 234 | } else { 235 | currentTokenIndex -= skippedWhitespaceCount 236 | return false 237 | } 238 | } 239 | 240 | @discardableResult 241 | func consume(tokenKind kind: Token.Kind, feedback: String) throws -> Token { 242 | try consume(where: { $0.kind == kind }, feedback: feedback) 243 | } 244 | 245 | @discardableResult 246 | func consume(where predicate: (Token) -> Bool, skipWhitespaceTokens: Bool = true, feedback: String) throws -> Token { 247 | let oldCurrentToken = self.currentToken 248 | guard advance(when: predicate, skipWhitespaceTokens: skipWhitespaceTokens) else { 249 | throw ParseError.unexpectedToken(oldCurrentToken, feedback: feedback) 250 | } 251 | return previousToken 252 | } 253 | ``` 254 | 255 | Next, we have the primary methods used for updating the token state, by advancing the cursor when we finding (`advance(when:...)`) and consuming matching tokens. The `advance()` method more or less just checks to see if the given `predicate` closure matches the current token. Most of the time in programming languages, we ignore whitespace tokens, so this method does that by default, but it has a flag to not skip, since we'll need that later on for some of our parsing. 256 | 257 | The `consume(...)` methods build upon `advance(...)`, but will `throw` an error if matching fails. From this point onward in the parser architecture, we use Swift errors as a means of control flow to indicate more or less that parsing a certain token or syntax node was unsucessful. This doesn't necessarily mean there is an error, only that we weren't able to interpret a specific part a certain way (it might mean it should be interpreted another way). 258 | 259 | The consume method takes a feedback string to make parsing failures a little clearer, and to make bugs in the parser a little easier to track down. 260 | 261 | ``` 262 | // MARK: - Helpers 263 | 264 | /// Use this method when you want to accumulate results until parsing fails, but you want to keep what you've found so far. 265 | func untilThrowOrEndOfTokensReached(perform: () throws -> ConsumedType) -> [ConsumedType] { 266 | 267 | var results = [ConsumedType]() 268 | 269 | do { 270 | while isNotAtEnd { 271 | results.append(try perform()) 272 | } 273 | } catch { 274 | return results 275 | } 276 | return results 277 | } 278 | 279 | func attempt(action: () throws -> ContentType) throws -> ContentType { 280 | tokenIndexStack.append(currentTokenIndex) 281 | var shouldRevertIndexStack = true 282 | 283 | defer { 284 | // Pop the stack if `try action()` fails. 285 | // doing it this way, instead of catching + rethrowing 286 | // so that the error chain continues to the original error, not our rethrow 287 | if shouldRevertIndexStack { 288 | _ = tokenIndexStack.popLast() 289 | } 290 | } 291 | 292 | let result = try action() 293 | 294 | // we succeeded, so pop the token index stack, and use THAT value as the new current index 295 | currentTokenIndex = tokenIndexStack.popLast()! 296 | shouldRevertIndexStack = false 297 | return result 298 | } 299 | 300 | func choose(from choices: [() throws -> ContentType]) throws -> ContentType { 301 | try attempt(action: { 302 | var mostRecentError: Error = ParseError.failedToParse 303 | 304 | for choice in choices { 305 | 306 | do { 307 | return try attempt(action: { 308 | try choice() 309 | }) 310 | } catch { 311 | mostRecentError = error 312 | } 313 | } 314 | 315 | throw mostRecentError 316 | }) 317 | } 318 | 319 | @discardableResult 320 | func consumeBetween(leftToken: Token.Kind, rightToken: Token.Kind, content: () throws -> ContentType) throws -> ContentType { 321 | try consume(tokenKind: leftToken, feedback: "Expected a \(leftToken)") 322 | let consumedContent = try content() 323 | try consume(tokenKind: rightToken, feedback: "Expected a \(rightToken)") 324 | 325 | return consumedContent 326 | } 327 | } 328 | ``` 329 | 330 | Finally, we have 4 helper methods that we'll use while parsing. 331 | 332 | `untilThrowOrEndOfTokensReached(perform:)` calls its `perform` closure in a loop, accumulating values returned from it in an array, which it eventually returns either when the end of tokens is reached or (more likely) when the closure throws an error. In practice, we'll be calling other methods of the parsing context inside that closure while parsing syntax nodes. The point of this method is to essentially say "after a while, parsing failed, so I'm gonna give you what I've successfully parsed until that point." 333 | 334 | `attempt(action:)` is extremely useful, as it allows us to try multiple parsing actions in the hopes they succeed (and thus, move the token cursor forward), but if the action `throws`, we're able to revert back to the previous cursor position. If we didn't use `attempt(action:)` when parsing and e.g., called `consume()` twice successfully, and then a third time unsucessfully, we would have failed to parse a whole thing, but we would have also moved the cursor along with us, now in a spot unable to try finding something else. `attempt(:)` solves this for us, and is why we use a stack of token indexes instead of just a single index (this also works recursively). 335 | 336 | `choose(from:)` takes an array of closures with parsing calls in them, each returning a value. It then runs through the array, calling each closure in order. If a closure successfully returns a value, `choose` will return that value. If a closure `throws`, then we move on to the next closure to try that. All of this is wrapped in an `attempt(action:)` call so that if parsing in one closure fails, the next one gets a fresh start before it parses. This method is useful when parsing could result in multiple possibilities in the same place in the program, and you frequently (but not always) would want your return type to be an `enum` with a choice for each of its cases. 337 | 338 | Finally, `consumeBetween(leftToken:, rightToken:, content:)` helps us in the case when things are wrapped in certain tokens, for example quotes, parentheses, or angle brackets. It tries to consume the left token, then tries the `content` closure, and finally tries to consume the right token. If all of that succeeded, it returns whatever was returned by the closure. 339 | 340 | And that completes the `ParsingContext`, which models common operations used throughout the HTML parsing process (and which could easily be reused with parsers for your own programming language too). 341 | 342 | ## Parsing HTML 343 | 344 | Now that we've built ourselves parsing tools, lets use them to parse out HTML into our own data types (in programming language theory, these are known as "abstract syntax trees / nodes," which is a fancy way of saying a set of types that are usually arranged in some sort of hierarchy or graph). We'll only make use of a few types, as most of HTML is fairly generic and has a similar structure all the way down. 345 | 346 | To identify these syntax tree nodes, let's make a protocol for anything that is `Parsable`: 347 | 348 | ``` 349 | protocol Parsable { 350 | static func parse(context: ParsingContext) throws -> Self 351 | } 352 | ``` 353 | 354 | Types that conform to this protocol will have to implement the above static method and return a parsed version of themselves, or throw an error if they couldn't be parsed out of the given `ParsingContext`. You could alternatively make this an initializer method instead, but that will shadow the auto-generated `struct` initializers, which is kind of annoying. 355 | 356 | ### Document 357 | 358 | Let's start at the top, with the `Document`. An HTML document is our model that more or less lines up with the html "file" as a whole. We'll keep ours very simple: 359 | 360 | ``` 361 | struct Document: Hashable, Parsable { 362 | 363 | enum DocumentError: Error { 364 | case unableToFindHTMLNode 365 | } 366 | 367 | let htmlNode: Node 368 | 369 | static func parse(context: ParsingContext) throws -> Document { 370 | let nodes = context.untilThrowOrEndOfTokensReached { 371 | try Node.parse(context: context, options: nil) 372 | } 373 | 374 | guard let htmlNode = nodes.first(where: { $0.element.lowercased() == "html" }) else { 375 | throw DocumentError.unableToFindHTMLNode 376 | } 377 | 378 | return Document(htmlNode: htmlNode) 379 | } 380 | } 381 | ``` 382 | 383 | An html document has 0 or more "nodes" (tags) at the top level. It might have a `` tag, and it ideally should have an `` tag too. Our `Document.parse(:)` implementation asks the given parsing context to parse out `Node`s until an error is thrown or we've reached the end of the tokens. Then, we search through that array of nodes, looking for the `html` node, and finally, we return the document initialized with that found node (and we ignore any doctype or other nodes we might find). If we can't find any html node, we throw an error indicating such. It might be that the program really didn't contain an html tag, or more likely, that our `Node` parser failed to handle something inside the html node and errored out. 384 | 385 | Our parser system is going to be kind of strict in what it accepts, which is contrary to how the Big Browsers tend to work, where they'll accept pretty much anything you throw at them. Our approach favours simplicity of implementation to get concepts across, at the cost of compatibility with lots of websites. As you build out your browser, feel free to expand what your parser can handle :) 386 | 387 | ### Node 388 | 389 | Now it's time for the real meat and potatoes of our syntax tree, the `Node`, which represents a "node" in the html document. It's more or less the data model equivalent of a ``, any attributes inside of the tag itself, and any children nested between the tags (the distinction between a node, an element, and a tag is subtle, and you may be used to using the terms interchangeably, but I'll try to keep them separate as best I can). 390 | 391 | Let's start the `Node` type with some internal types and properties: 392 | 393 | ``` 394 | struct Node: Hashable, Parsable { 395 | 396 | struct InternalElement { 397 | static let textRun = "__textRun" 398 | static let comment = "__comment" 399 | } 400 | 401 | enum Content: Hashable { 402 | case text(String) 403 | case childNodes([Node]) 404 | case voidNode 405 | } 406 | 407 | enum NodeParseError: Error { 408 | case closingTagDidNotMatchOpeningTag(opening: String, closing: String) 409 | case openingTagWasActuallyClosing(tagName: String) 410 | case closingTagWasActuallyOpening(tagName: String) 411 | case didNotFindAnyText 412 | } 413 | 414 | let element: String 415 | let content: Content 416 | let attributes: [Attribute] 417 | ``` 418 | 419 | `InternalElement` lists some private element names we'll use for bits of the html file that don't fall under normal html tag rules (we'll see more of them later). 420 | 421 | Then we have the `Content` enum, which models the stuff inside of our node. This says, a node can either contain text, child nodes, or be a "void" node (that is, a node that only has a start tag, no end tag and no children. `` is an example of a void node). 422 | 423 | Next, we have an error type defined to list the things that can go wrong during parsing and which act as control flow. 424 | 425 | Finally, we have `Node`'s properties: its element (or tag name), the aforementioned content, and any attributes that were in the start tag. 426 | 427 | Now it's on to parsing the node itself, which we'll break down into some chunks: 428 | 429 | ``` 430 | static func parse(context: ParsingContext) throws -> Node { 431 | let startTag = try Tag.parse(context: context) 432 | 433 | guard startTag.isEnd == false else { 434 | throw NodeParseError.openingTagWasActuallyClosing(tagName: startTag.element) 435 | } 436 | 437 | if startTag.element.lowercased() == "doctype" { 438 | return Node(element: "doctype", content: .voidNode, attributes: startTag.attributes) 439 | } 440 | 441 | if startTag.isVoidElement { 442 | return Node(element: startTag.element, content: .voidNode, attributes: startTag.attributes) 443 | } 444 | ``` 445 | 446 | We begin by trying to parse a start tag (which we'll get to in a bit). Then, we check some conditions to see if we can bail early: 447 | 448 | - If the tag that got parsed was an end tag (eg ``), then we throw an error. Alternatively, we could break `Tag` into 2 types, `StartTag` and `EndTag`, and let start tags fail to parse end tags. 449 | - Then we check to see if our start tag is a `doctype` tag, in which case we return immediately. 450 | - Finally, we check to see if the start tag represents a void element, and if so we also return immediately. 451 | 452 | If none of those conditions are met, we keep parsing. At this point, we have a start tag and we need to look for 0 or more children we might have, before reaching an end tag. 453 | 454 | To parse child nodes, we're going to ask the context to parse nodes until we hit an error. This way, we'll get 0 or more child nodes. Inside that loop, we're going to ask the context to choose from a few possibilities: 455 | 456 | ``` 457 | let children = context.untilThrowOrEndOfTokensReached(perform: { 458 | try context.choose(from: [ 459 | { try Node.parse(context: context) }, 460 | ``` 461 | 462 | The child might be a normal `Node` of some kind, so we recursively call `Node.parse()`. 463 | 464 | ``` 465 | { 466 | let textContents = context.untilThrowOrEndOfTokensReached { 467 | try context.consume(where: { $0.kind != .openAngleBracket }, skipWhitespaceTokens: false, feedback: "Expected a non `<` token") 468 | } 469 | guard textContents.isEmpty == false else { 470 | throw NodeParseError.didNotFindAnyText 471 | } 472 | 473 | let contentRun = textContents 474 | .map(\.body) 475 | .joined() 476 | .replacingOccurrences(of: "\n", with: " ") 477 | .replacingOccurrences(of: "\t", with: " ") 478 | .replacingOccurrences(of: " ", with: "") 479 | .replacingOccurrences(of: "<", with: "<") 480 | .replacingOccurrences(of: ">", with: ">") 481 | .replacingOccurrences(of: "'", with: "'") 482 | .replacingOccurrences(of: """, with: "\"") 483 | .replacingOccurrences(of: "’", with: "’") 484 | .replacingOccurrences(of: "‘", with: "’") 485 | .replacingOccurrences(of: "”", with: "”") 486 | .replacingOccurrences(of: "“", with: "“") 487 | .replacingOccurrences(of: "&", with: "&") 488 | 489 | return Node(element: InternalElement.textRun, content: .text(contentRun), attributes: []) 490 | }, 491 | ``` 492 | 493 | If it's not a standard node, it might be a **text run** node. Text runs in html are not real nodes like `
` or `

`, instead they're the any text content inside of other tags. So if we have a node like `

Hi there

`, this will get parsed out to a p `Node`, whose `content` is `.childNodes(children)`, and `children` will be an array with a single `Node`, whose `content` is `.text("Hi there")`. This structure *feels weird*, but it allows us to parse more complicated nodes like `

Hi there, friend

`. In short, we're wrapping otherwise un-tagged text into a pretend `` tag and then treating it as we do other nodes. 494 | 495 | To parse a text run, we first consume every token that's not an `<` character, which we assume might be the beginning of a tag. If we find any contents, we then join the contents' body together into one big string. 496 | 497 | Then, we do some quick and dirty text replacement, replacing encoded html entities with their display characters and non-space whitespaces with spaces for display (this doesn't follow the html standard for whitespaces perfectly, but it works well enough). With all the replacement done, we return the text run node. 498 | 499 | ``` 500 | { 501 | try context.consumeBetween(leftToken: .openAngleBracket, rightToken: .closeAngleBracket) { 502 | try context.consume(tokenKind: .bang, feedback: "Expected comment to begin with a bang") 503 | try context.consume(tokenKind: .hyphen, feedback: "Expected comment to have a hyphen after the bang") 504 | try context.consume(tokenKind: .hyphen, feedback: "Expected comment to have two hyphens after the bang") 505 | 506 | var done = false 507 | while done == false { 508 | 509 | if context.currentToken.kind == .hyphen && context.nextToken.kind == .hyphen && context.nextNextToken.kind == .closeAngleBracket { 510 | 511 | try context.consume(tokenKind: .hyphen, feedback: "-") 512 | try context.consume(tokenKind: .hyphen, feedback: "-") 513 | done = true 514 | } else { 515 | try context.consume(where: { _ in true }, skipWhitespaceTokens: false, feedback: "consuming comment contents") 516 | } 517 | } 518 | 519 | return Node(element: InternalElement.comment, content: .voidNode, attributes: []) 520 | } 521 | }])}) 522 | ``` 523 | 524 | Finally, if the child node wasn't a normal node, nor a text run node, we see if it was perhaps a comment node, which takes the form ``. Looking inside angle brackets, we first attempt to consume a bang, then 2 hyphen tokens. After that, we loop, peeking at the next 3 tokens looking for the ending `-->` pattern. If we don't find that pattern, we just consume and ignore whatever content was there. Once we're done munching tokens, we return the internal comment node. 525 | 526 | ``` 527 | .filter { 528 | if $0.element == InternalElement.comment { return false } 529 | if $0.element != InternalElement.textRun { return true } 530 | 531 | // filter out empty text run nodes 532 | return $0.textContent?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false 533 | } 534 | ``` 535 | 536 | As a very last step of parsing child nodes, we remove nodes that are comments or nodes that are text runs with empty text. Everything else, we keep. And now we're done parsing child nodes. 537 | 538 | ``` 539 | 540 | let endTag = try Tag.parse(context: context) 541 | guard endTag.isEnd else { 542 | throw NodeParseError.closingTagWasActuallyOpening(tagName: endTag.element) 543 | } 544 | 545 | guard startTag.element == endTag.element else { 546 | throw NodeParseError.closingTagDidNotMatchOpeningTag(opening: startTag.element, closing: endTag.element) 547 | } 548 | 549 | return .init( 550 | element: startTag.element, 551 | content: .childNodes(children), 552 | attributes: startTag.attributes 553 | ) 554 | } 555 | ``` 556 | 557 | After the child nodes are parsed, all that's left is to parse the end tag, make sure it's really an end tag, and ensure that it matches the start tag. If all of that succeeded, we return the fully constructed `Node`. Most of what we just did was bookkeeping (checking tags, make sure start / end tags match), and then parsing the node's children, if any. 558 | 559 | ### Tag 560 | 561 | We've papered over `Tag` parsing, though, so let's look at that now: 562 | 563 | ``` 564 | struct Tag: Parsable { 565 | 566 | let element: String 567 | let isEnd: Bool 568 | let attributes: [Attribute] 569 | 570 | var isVoidElement: Bool { 571 | ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"].contains(element) 572 | } 573 | ``` 574 | 575 | We start our `Tag` type with some properties, alluding to the `Attribute` type we'll see shortly as well. We also list the known void elements to determine if our element should be considered void. Now on to the parsing: 576 | 577 | ``` 578 | static func parse(context: ParsingContext, options: ParsingOptions?) throws -> Tag { 579 | try context.consumeBetween(leftToken: .openAngleBracket, rightToken: .closeAngleBracket) { 580 | let slashToken = try? context.consume(tokenKind: .forwardSlash, feedback: "Expected a `/`") 581 | let _ = try? context.consume(tokenKind: .bang, feedback: "Expected a `!`") 582 | ``` 583 | 584 | A tag is wrapped in `<` and `>` angle brackets. Within those, we first look for an initial forward slash token, and if we find it we assume we're parsing an end tag (we use `try?` to optionally parse this — if we don't find the slash, we're not considering that an error worth bailing from). We also look for an optional bang token and just completely ignore it if we find it (this is for the `` tag). 585 | 586 | ``` 587 | let identifier = try context.consume(tokenKind: .text, feedback: "Expected a tag name") 588 | 589 | let attributes = context.untilThrowOrEndOfTokensReached(perform: { 590 | try context.attempt(action: { 591 | try Attribute.parse(context: context, options: options) 592 | }) 593 | }) 594 | ``` 595 | 596 | Next, we parse an identifier that we'll use for the tag's element. Then we attempt to parse as many attributes as we can (there may be 0). 597 | 598 | ``` 599 | // If there's a trailing slash (eg ), consume it but ignore it. this is invalid html 600 | _ = try? context.consume(tokenKind: .forwardSlash, feedback: "Expected a trailing `/`") 601 | return Tag(element: identifier.body, isEnd: slashToken != nil, attributes: attributes) 602 | } 603 | } 604 | } 605 | ``` 606 | 607 | Finally, optionally look for and ignore a trailing slash at the end of the tag, as it's not actually valid html (this was news to me when I started working on the browser). However, it's extremely common, so I thought it warranted handling here to make more of the web work. With that out of the way, we return our completed tag. 608 | 609 | ### Attribute 610 | 611 | Ok, last part of the parser! the attributes inside a tag. 612 | 613 | ``` 614 | struct Attribute: Hashable, Parsable { 615 | let key: String 616 | let value: String 617 | 618 | enum AttributeParseError: Error { 619 | case emptyAttributeValue(key: String) 620 | } 621 | 622 | static func parse(context: ParsingContext, options: ParsingOptions?) throws -> Attribute { 623 | 624 | let key = try context.consume(tokenKind: .text, feedback: "Expected an attribute name") 625 | 626 | guard let _ = try? context.consume(tokenKind: .equals, feedback: "Expected an equals sign") else { 627 | return Attribute(key: key.body, value: key.body) 628 | } 629 | ``` 630 | 631 | Attributes are (usually) key-value pairs, so those are our properties (for attributes that don't have explicit values, we'll just repeat the key for the value). 632 | 633 | Then, we start parsing. First we parse the key, then we look for an equals sign token. If we don't find it, we assume this attribute is the valueless kind and return it immediately. Otherwise, we parse the value, as a choice: 634 | 635 | ``` 636 | let value = try context.choose(from: [ 637 | { 638 | try context.consumeBetween(leftToken: .doubleQuote, rightToken: .doubleQuote) { 639 | let textContents = context.untilThrowOrEndOfTokensReached { 640 | try context.consume(where: { $0.kind != .doubleQuote }, skipWhitespaceTokens: false, feedback: "Expected a non quote token") 641 | } 642 | 643 | return textContents 644 | .map(\.body) 645 | .joined() 646 | } 647 | }, 648 | ``` 649 | 650 | First choice: the value is between double quotes, and we consume everything inside that isn't a double quote (and we don't skip whitespaces either). Then we join all those tokens together and return that as the value. 651 | 652 | ``` 653 | { 654 | try context.consumeBetween(leftToken: .singleQuote, rightToken: .singleQuote) { 655 | let textContents = context.untilThrowOrEndOfTokensReached { 656 | try context.consume(where: { $0.kind != .singleQuote }, skipWhitespaceTokens: false, feedback: "Expected a non single quote token") 657 | } 658 | 659 | return textContents 660 | .map(\.body) 661 | .joined() 662 | } 663 | }, 664 | ``` 665 | 666 | Second choice: same thing as before, except between single quotes. 667 | 668 | ``` 669 | { 670 | let textContents = context.untilThrowOrEndOfTokensReached { 671 | try context.consume( 672 | where: { 673 | $0.kind != .singleQuote && $0.kind != .doubleQuote && $0.kind != .whitespace && $0.kind != .closeAngleBracket 674 | }, 675 | skipWhitespaceTokens: false, 676 | feedback: "Expected non-whitespace, non-quote characters") 677 | } 678 | 679 | guard textContents.isEmpty == false else { 680 | throw AttributeParseError.emptyAttributeValue(key: key.body) 681 | } 682 | 683 | return textContents 684 | .map(\.body) 685 | .joined() 686 | } 687 | ``` 688 | 689 | Final choice: we look for a value that's *not* wrapped in any kind of quotes. These kinds of values are delimitted by whitespace (or an angle bracket), so we consume basically everything else, make sure we actually found something non-empty, and join those tokens together into a value. 690 | 691 | ``` 692 | ]) 693 | 694 | return Attribute(key: key.body, value: value) 695 | } 696 | } 697 | ``` 698 | 699 | Last, we return the completed attribute. 700 | 701 | ## End of Part 1 702 | 703 | This completes the end of part 1! We built ourselves some tools for breaking apart a program string into tokens and parsing them. And then we built some data types that know how to parse themselves using those tools. HTML is a kind of strange language, but we saw some familiar patterns repeated in multiple places (things being wrapped inside others, for example). 704 | 705 | In the next part, we'll take the data we just parsed and render it with SwiftUI. [Onward to part 2!](https://nearthespeedoflight.com/browser-2.html) 706 | 707 | 708 | # Part 2: Rendering in SwiftUI 709 | 710 | Welcome to part 2 of "Writing a web browser engine in Swift!" In [part 1](https://nearthespeedoflight.com/browser.html), we built a basic html parser from the ground up, learning about tokenizing, parsing, and syntax trees. We now have a fairly complete set of tools that can parse html into plain old Swift structs. 711 | 712 | In this part, we'll build our rendering engine with SwiftUI views. Let's get started. 713 | 714 | ## The Architecture 715 | 716 | The architecture of our rendering engine should look pretty familiar to anyone who's worked with SwiftUI before: we're more or less just going to have views which render our node hierarchy. It's almost exclusively composed of standard SwiftUI views, plus a controller object for loading HTML pages, and a few extensions on the `Node` type to more easily work with its properties. Here are the main pieces we'll be working with. 717 | 718 | - `PageController` is responsible for loading web urls asynchronously and parsing them into `Document`s. It also maintains the back / forward stacks of documents. 719 | - Some views: 720 | - `BrowserView` is the primary view, containing our chrome (back / forward / address bar) and the document view. 721 | - `WebDocumentView` displays either a homepage, error page, or the contents of the loaded page, depending on the page controller's state. 722 | - `BodyView` is the true beginnings of our rendering engine, it nests our page's content in a scroll view. 723 | - `BlocksView` displays views for 0 or more nodes in a vertical stack. It picks a different view depending on the node's element. 724 | - `InlineContentWrappingBlockView` combines the text of all its inline elements into one big `Text` for rendering. 725 | - `ListNodeView` renders ordered or unordered lists and their items. 726 | - `ImageView` asynchronously downloads and renders img nodes. 727 | - Extensions on `Node` for accessing its content. 728 | 729 | ### The Page Controller 730 | 731 | The `PageController` is our main controller object, responsible for loading pages, parsing them, and managing the back / forward stacks: 732 | 733 | ``` 734 | class PageController: ObservableObject { 735 | 736 | enum State { 737 | case notLoaded 738 | case loaded(Document, URL) 739 | case failed(Error) 740 | } 741 | 742 | private enum LoadingError: Error { 743 | case failedToLoad(URL) 744 | } 745 | 746 | @Published var state = State.notLoaded { 747 | didSet { 748 | if let currentlyLoadedDocument { 749 | address = currentlyLoadedDocument.1.absoluteString 750 | } 751 | } 752 | } 753 | var address = "https://nearthespeedoflight.com/browser.html" 754 | 755 | private var backStack: [(Document, URL)] = [] 756 | private var forwardStack: [(Document, URL)] = [] 757 | 758 | var canGoBack: Bool { backStack.isEmpty == false } 759 | var canGoForward: Bool { forwardStack.isEmpty == false } 760 | ``` 761 | 762 | First we set up some nested types. The controller can be in one of three `State`s: an initial unloaded state (maybe you show a homepage?), a loaded state with the parsed document and the URL it came from, and the failed error state. 763 | 764 | Then we have some properties, mainly the controller's current `state`, its current `address` string, and the back / forward stacks. 765 | 766 | ``` 767 | func loadPage(at url: URL) { 768 | Task { 769 | let newState: State 770 | do { 771 | let (data, response) = try await URLSession.shared.data(from: url) 772 | 773 | if let currentlyLoadedDocument { 774 | backStack.append(currentlyLoadedDocument) 775 | forwardStack = [] 776 | } 777 | 778 | let htmlString = String(data: data, encoding: .utf8) ?? "" 779 | let tokenizer = Tokenizer(programText: htmlString) 780 | let context = try ParsingContext(tokens: tokenizer.scanAllTokens()) 781 | 782 | newState = .loaded(try Document.parse(context: context, options: nil), response.url ?? url) 783 | } catch { 784 | print("error loading page: \(error)") 785 | newState = .failed(error) 786 | } 787 | 788 | await MainActor.run { 789 | state = newState 790 | } 791 | } 792 | } 793 | ``` 794 | 795 | To load a page, we kick off an async `Task`, await the loading of the given url, then we put the data through our parser pipeline. We also set the back / forward stacks to account for the state change that's about to happen. 796 | 797 | This is all made a little awkward due to error handling, as we want to catch any errors that happen here: there could be URL related errors, there could be an error in the parsing context, or there could be an error parsing the document. If there is an error, we want to record it. This wouldn't be so bad on its own, but we don't want to do any of this parsing on the main actor, where it could freeze the UI, *but* we must update our controller's `state` property on the main actor, as our view depends on that property to draw itself. 798 | 799 | ``` 800 | private var currentlyLoadedDocument: (Document, URL)? { 801 | switch state { 802 | case .notLoaded, .failed: return nil 803 | case let .loaded(document, url): return (document, url) 804 | } 805 | } 806 | 807 | func goBack() { 808 | guard let (previousDocument, previousURL) = backStack.popLast() else { return } 809 | if let currentlyLoadedDocument { 810 | forwardStack.append(currentlyLoadedDocument) 811 | } 812 | state = .loaded(previousDocument, previousURL) 813 | } 814 | 815 | func goForward() { 816 | guard let (nextDocument, nextURL) = forwardStack.popLast() else { return } 817 | if let currentlyLoadedDocument { 818 | backStack.append(currentlyLoadedDocument) 819 | } 820 | state = .loaded(nextDocument, nextURL) 821 | } 822 | } 823 | ``` 824 | 825 | Finally, we have a helper property for accessing the currently loaded document, if any, and methods for going back and forward. That wraps up our controller. Next, we'll see how the views make use of it while displaying our nodes. 826 | 827 | ### The Browser View 828 | 829 | As mentioned above, our `BrowserView` is the primary view for our browser window: it composes the "chrome" of our UI, plus the actual rendered content in another view. Our UI is going to be very simple, but you could extend it to use tabs, or even something more imaginative if you want :) 830 | 831 | ``` 832 | struct BrowserView: View { 833 | @ObservedObject var controller: PageController 834 | @FocusState private var addressIsFocused: Bool 835 | ``` 836 | 837 | All we need are 2 properties, an observed page controller and the focus state of the address textfield, so that focus works like you'd expect as we navigate. 838 | 839 | ``` 840 | var body: some View { 841 | VStack(spacing: 0) { 842 | HStack { 843 | HStack(spacing: 0) { 844 | Button(action: { controller.goBack() }) { 845 | Image(systemName: "arrowtriangle.left.fill") 846 | }.disabled(controller.canGoBack == false) 847 | Button(action: { controller.goForward() }) { 848 | Image(systemName: "arrowtriangle.right.fill") 849 | }.disabled(controller.canGoForward == false) 850 | } 851 | TextField("Address", text: $controller.address) 852 | .onSubmit { 853 | addressIsFocused = false 854 | guard let url = URL(string: controller.address) else { return } 855 | controller.loadPage(at: fullURL(forURLToLoad: url)) 856 | } 857 | .textFieldStyle(RoundedBorderTextFieldStyle()) 858 | .focused($addressIsFocused) 859 | } 860 | .padding() 861 | Divider() 862 | ``` 863 | 864 | The body of our view until this point is all about the chrome. We create our back / forward buttons and the address bar, and we bind their actions to our controller. 865 | 866 | ``` 867 | WebDocumentView(controller: controller) 868 | .background(.white) 869 | .environment(\.openURL, .init(handler: { url in 870 | controller.loadPage(at: fullURL(forURLToLoad: url)) 871 | addressIsFocused = false 872 | return .handled 873 | })) 874 | } 875 | ``` 876 | 877 | We configure the WebDocumentView and override SwiftUI's `openURL` environment value. When the user clicks a link in our app, SwiftUI invokes this callback, giving our app a chance to handle the URL. With the given URL, we construct an absolute URL (below), adjust the text field's focus, and tell the system we handled the url (we could also tell the system to handle it instead if the URL was eg `mailto:...`, but I'll leave that to you). 878 | 879 | ``` 880 | .environment(\.urlBuilder, fullURL(forURLToLoad:)) 881 | } 882 | 883 | private func fullURL(forURLToLoad urlToLoad: URL) -> URL { 884 | if urlToLoad.host != nil { return urlToLoad } 885 | 886 | switch controller.state { 887 | case .failed, .notLoaded: return urlToLoad 888 | case .loaded(_, let loadedURL): 889 | return URL(string: urlToLoad.path, relativeTo: loadedURL.deletingLastPathComponent()) ?? urlToLoad 890 | } 891 | } 892 | } // End of BrowserView 893 | 894 | private struct URLBuilderKey: EnvironmentKey { 895 | static let defaultValue: (URL) -> URL = { $0 } 896 | } 897 | 898 | extension EnvironmentValues { 899 | /// A function that takes a (potentially "relative") web url to load, and fleshes it out to a full url that includes a host. 900 | var urlBuilder: (URL) -> URL { 901 | get { self[URLBuilderKey.self] } 902 | set { self[URLBuilderKey.self] = newValue } 903 | } 904 | } 905 | ``` 906 | 907 | Finally, we use the environment modifier for a custom environment value. The `urlBuilder` is a closure / function responsible for taking a URL (one that's possibly relative, eg just `/page.html` vs `https://example.com/page.html`) and expanding it to an absolute URL so that pages and assets like images can be loaded. 908 | 909 | We do this as an environment value so that other views in the hierarchy can access the functionality. 910 | 911 | ### Web Document View 912 | 913 | The `WebDocumentView` takes up the majority of space in our browser window. What it shows depends on the `state` of the page controller, either showing a simple home page, error screen, or the loaded content. 914 | 915 | ``` 916 | struct WebDocumentView: View { 917 | @ObservedObject var controller: PageController 918 | 919 | var body: some View { 920 | switch controller.state { 921 | case .notLoaded: 922 | Text("Let's load a web page!") 923 | .frame(maxWidth: .infinity, maxHeight: .infinity) 924 | case .failed(let error): 925 | Text(verbatim: "Failed to load page. Error: \(error)") 926 | .frame(maxWidth: .infinity, maxHeight: .infinity) 927 | case .loaded(let document, _): 928 | BodyView(bodyNode: document.htmlNode.firstDirectChild(named: "body")!) 929 | .navigationTitle( 930 | document 931 | .htmlNode 932 | .firstDirectChild(named: "head")? 933 | .firstDirectChild(named: "title")? 934 | .firstDirectChild(named: Node.InternalElement.textRun)? 935 | .textContent ?? "Smol" 936 | ) 937 | .environment(\.font, Font.custom("Times", size: 16)) 938 | } 939 | } 940 | } 941 | ``` 942 | 943 | The `BodyView` accesses some properties on `Node` which we'll write shortly for accessing child nodes more easily. We drill down to find the page's title, if it has one, and set that as our window title. Finally, we set a default font on the document's text. "Times" is the font you see in most browsers with unstylized text (but you're allowed to choose any font you'd like here). 944 | 945 | ### Node extensions 946 | 947 | Before we go any further with our views, let's write those helpers in an extension on `Node`. 948 | 949 | ``` 950 | extension Node { 951 | var childNodes: [Node] { 952 | switch content { 953 | case .voidNode, .text: return [] 954 | case .childNodes(let nodes): return nodes 955 | } 956 | } 957 | 958 | var textContent: String? { 959 | switch content { 960 | case .childNodes, .voidNode: return nil 961 | case .text(let text): return text 962 | } 963 | } 964 | 965 | func firstDirectChild(named element: String) -> Node? { 966 | childNodes.first(where: { $0.element == element }) 967 | } 968 | ``` 969 | 970 | These properties help us access child nodes and text content more easily. 971 | 972 | ``` 973 | var childNodesSortedIntoBlocks: [Node] { 974 | var nodesToReturn = [Node]() 975 | var inlineElements = [Node]() 976 | 977 | func addInlineElementsAsGroupIfNeeded() { 978 | guard inlineElements.isEmpty == false else { return } 979 | // make a fake block element that has all these as children 980 | let wrapper = Node(element: "p", content: .childNodes(inlineElements), attributes: []) 981 | // and append it to our list to return 982 | nodesToReturn.append(wrapper) 983 | // then, empty the inlineElements list 984 | inlineElements = [] 985 | } 986 | 987 | for node in childNodes { 988 | if isInlineNode { 989 | inlineElements.append(node) 990 | } else { 991 | addInlineElementsAsGroupIfNeeded() 992 | nodesToReturn.append(node) 993 | } 994 | } 995 | addInlineElementsAsGroupIfNeeded() 996 | return nodesToReturn 997 | } 998 | 999 | var isInlineNode: Bool { 1000 | [InternalElement.textRun, "a", "abbr", "acronym", "audio", "b", "bdi", "bdo", "big", "br", "button", "canvas", "cite", "code", "data", "datalist", "del", "dfn", "em", "embed", "i", "iframe", "img", "input", "ins", "kbd", "label", "map", "mark", "meter", "noscript", "object", "output", "picture", "progress", "q", "ruby", "s", "samp", "script", "select", "slot", "small", "span", "strong", "sub", "sup", "svg", "template", "textarea", "time", "u", "tt", "var", "video", "wbr"].contains(element) 1001 | } 1002 | ``` 1003 | 1004 | This next property is a little more involved. When we're rendering nodes, we want block nodes, like `

`, `

`, etc. to flow one after another, vertically down the page, while things like ``, ``, etc. flow within the same line like words in a paragraph. 1005 | 1006 | The trouble for us is, in html those inline elements don't have exist inside of block elements at all, they can exist outside of them too. For example: 1007 | 1008 | ``` 1009 | 1010 | Some bold text 1011 |

A paragraph

1012 | 1013 | ``` 1014 | 1015 | The bold text is just kinda hanging out as inline, but inline relative *to what?* I'm not entirely sure how other browsers solve this, but we've solved it by grouping any inline elements as children of a fake, inserted `

` node. 1016 | 1017 | ``` 1018 | var attributeDictionary: [String: String] { 1019 | Dictionary(uniqueKeysWithValues: attributes.map({ ($0.key, $0.value) })) 1020 | } 1021 | } 1022 | ``` 1023 | 1024 | Lastly, we offer a way to access the node's attributes as a dictionary. 1025 | 1026 | Now we have enough tools at our disposal to write the rest of the views. 1027 | 1028 | ### The BodyView 1029 | 1030 | This view hosts our browser's scroll view, which then displays child nodes in another view. 1031 | 1032 | ``` 1033 | struct BodyView: View { 1034 | let bodyNode: Node 1035 | var body: some View { 1036 | ScrollView { 1037 | BlocksView(children: bodyNode.childNodesSortedIntoBlocks) 1038 | .padding(20) 1039 | } 1040 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 1041 | .background(Color.white) 1042 | } 1043 | } 1044 | ``` 1045 | 1046 | The hierarchy here is mostly straightforward: the `BlocksView` is initialized with the child nodes of the body and is given a global padding. Then we extend the frame of the scroll view to stretch as much as possible and align the content to the top leading edge, like other browsers do. 1047 | 1048 | ### BlocksView 1049 | 1050 | This one is kind of fun: it's a reusable view that vertically stacks the child nodes it was given, rendering them with the appropriate view depending on what element they are. It even recursively uses itself in a few cases. 1051 | 1052 | ``` 1053 | struct BlocksView: View { 1054 | let children: [Node] 1055 | 1056 | var body: some View { 1057 | VStack(alignment: .leading, spacing: 20) { 1058 | ForEach(children, id: \.self) { childNode in 1059 | switch childNode.element { 1060 | case "h1": 1061 | InlineContentWrappingBlockView(node: childNode) 1062 | .font(Font.custom("Times", size: 32).bold()) 1063 | case "h2": 1064 | InlineContentWrappingBlockView(node: childNode) 1065 | .font(Font.custom("Times", size: 28).bold()) 1066 | case "h3": 1067 | InlineContentWrappingBlockView(node: childNode) 1068 | .font(Font.custom("Times", size: 24).bold()) 1069 | case "p": 1070 | InlineContentWrappingBlockView(node: childNode) 1071 | case "div", "section", "main", "footer", "article", "header", "nav", "aside": 1072 | BlocksView(children: childNode.childNodesSortedIntoBlocks) 1073 | case "pre": 1074 | BlocksView(children: childNode.childNodesSortedIntoBlocks) 1075 | .font(Font.system(size: 13, design: .monospaced)) 1076 | case "blockquote": 1077 | BlocksView(children: childNode.childNodesSortedIntoBlocks) 1078 | .padding(.leading, 20) 1079 | case "ul": ListNodeView(node: childNode, style: .unordered) 1080 | case "ol": ListNodeView(node: childNode, style: .ordered) 1081 | case "hr": Divider() 1082 | case "script": EmptyView() 1083 | case "br": Color.clear.padding(20) 1084 | default: Text("unknown block element: <\(childNode.element)>") 1085 | } 1086 | } 1087 | } 1088 | } 1089 | } 1090 | ``` 1091 | 1092 | We don't support special rendering for every element under the sun, so if we find an element we don't know about, we just render that we found an unknown block. You could default it to behaving like a `

` if you wanted, but I like calling them out like this instead because I'm more motivated to give it a proper view that way. 1093 | 1094 | ### Inline nodes 1095 | 1096 | Inline nodes are interesting, because to render them we can't just use views placed in some kind of stack. Instead, we want them to be rendered one after another like text, wrapping to the next line as needed. And indeed, that's how we're going to do it in SwiftUI, by combining (or in Swift terms, using `reduce()`) inline contents into an `AttributedString` and rendering it in a single `Text` view per inline "block." 1097 | 1098 | ``` 1099 | struct InlineContentWrappingBlockView: View { 1100 | let node: Node 1101 | @Environment(\.font) var font 1102 | 1103 | var body: some View { 1104 | Text( 1105 | node 1106 | .childNodes 1107 | .map { $0.attributedText(defaultFont: font ?? Font.custom("Times", size: 16)) } 1108 | .reduce(AttributedString(), +) 1109 | ) 1110 | .lineSpacing(4) 1111 | .fixedSize(horizontal: false, vertical: true) 1112 | } 1113 | } 1114 | ``` 1115 | 1116 | In the body of our body, we return a single `Text`, initialized with an attributed string. The attributed string is created by mapping the node's child nodes and calling the `attributedText(defaultFont:)` method on each (we'll see that property in a moment). This mapping gives us an array of attribute strings, so we `reduce()` them into a single attributed string. 1117 | 1118 | ``` 1119 | extension Node { 1120 | func attributedText(defaultFont: Font) -> AttributedString { 1121 | switch element { 1122 | case InternalElement.textRun: 1123 | var attributes = AttributeContainer() 1124 | attributes.font = defaultFont 1125 | 1126 | return AttributedString(textContent ?? "", attributes: attributes) 1127 | ``` 1128 | 1129 | To get the attributed text for a node, we switch over its `element` to see how we should format it. Here we have the base case: a text run. We create an attribute container, use the font that was passed in, and return an attributed string with the node's text content and those attributes. 1130 | 1131 | ``` 1132 | case "em", "i": 1133 | var attributes = AttributeContainer() 1134 | attributes.font = defaultFont.italic() 1135 | 1136 | return childNodes 1137 | .map { $0.attributedText(defaultFont: defaultFont.italic()) } 1138 | .reduce(AttributedString(), +) 1139 | .mergingAttributes(attributes, mergePolicy: .keepCurrent) 1140 | ``` 1141 | 1142 | The rest of the cases are similar, in that we create some attributes, modifying the passed in font as needed. But in order to create the final attributed string, we actually need to recursively call ourselves so that we can handle multiple overlapping styles (eg a link node wrapped inside an italics node). 1143 | 1144 | ``` 1145 | case "strong", "b": 1146 | var attributes = AttributeContainer() 1147 | attributes.font = defaultFont.bold() 1148 | 1149 | return childNodes 1150 | .map { $0.attributedText(defaultFont: defaultFont.bold()) } 1151 | .reduce(AttributedString(), +) 1152 | .mergingAttributes(attributes, mergePolicy: .keepCurrent) 1153 | case "code": 1154 | var attributes = AttributeContainer() 1155 | let monospaced = Font.system(size: 13, design: .monospaced) 1156 | attributes.font = monospaced 1157 | 1158 | return childNodes 1159 | .map { $0.attributedText(defaultFont: monospaced) } 1160 | .reduce(AttributedString(), +) 1161 | .mergingAttributes(attributes, mergePolicy: .keepCurrent) 1162 | case "a": 1163 | var attributes = AttributeContainer() 1164 | attributes.link = URL(string: attributeDictionary["href"] ?? "") 1165 | attributes.underlineStyle = .single 1166 | 1167 | return childNodes 1168 | .map { $0.attributedText(defaultFont: defaultFont) } 1169 | .reduce(AttributedString(), +) 1170 | .mergingAttributes(attributes, mergePolicy: .keepCurrent) 1171 | default: 1172 | var attributes = AttributeContainer() 1173 | attributes.font = defaultFont 1174 | 1175 | return childNodes 1176 | .map { $0.attributedText(defaultFont: defaultFont) } 1177 | .reduce(AttributedString(), +) 1178 | .mergingAttributes(attributes, mergePolicy: .keepCurrent) 1179 | } 1180 | } 1181 | } 1182 | ``` 1183 | 1184 | It's all a little boilerplatey but it gets the job done. 1185 | 1186 | ### ListNodeView 1187 | 1188 | Our last node view is the `ListNodeView`, which we'll use for displaying both ordered and unordered lists (`
    ` and `