From 770fa2b09248a3bae70ec35d1dfe0c02053f1afa Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Wed, 5 Feb 2025 19:55:59 -0500 Subject: [PATCH 1/3] Github Action for publishing docs to Github Pages Signed-off-by: Jordan Hollinger --- .github/workflows/docs.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/docs.yaml diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 00000000..bbb920b8 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,29 @@ +name: Docs - Github Pages +on: + push: + branches: + # - main + - release-2.0 + - jh/2.0-docs + paths: + - docs/** + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Setup mdBook + uses: peaceiris/actions-mdbook@v2 + with: + mdbook-version: "latest" + + - run: mdbook build + + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs-dist + publish_branch: docs From d7d0e7d9a7f104c03ab55f5da244849253b75747 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Tue, 4 Feb 2025 09:42:18 -0500 Subject: [PATCH 2/3] Docs with mdbook Signed-off-by: Jordan Hollinger --- .gitignore | 1 + book.toml | 16 ++ docs/SUMMARY.md | 31 +++ docs/api/context-objects.md | 97 +++++++ docs/api/extensions.md | 447 ++++++++++++++++++++++++++++++++ docs/api/extractors.md | 36 +++ docs/api/fields.md | 31 +++ docs/api/index.md | 23 ++ docs/api/reflection.md | 141 ++++++++++ docs/dsl/extensions.md | 94 +++++++ docs/dsl/fields.md | 66 +++++ docs/dsl/formatters.md | 18 ++ docs/dsl/index.md | 27 ++ docs/dsl/options.md | 357 +++++++++++++++++++++++++ docs/dsl/partials.md | 68 +++++ docs/dsl/views.md | 94 +++++++ docs/introduction.md | 39 +++ docs/rendering.md | 61 +++++ docs/upgrading/configuration.md | 27 ++ docs/upgrading/customization.md | 23 ++ docs/upgrading/extensions.md | 11 + docs/upgrading/fields.md | 67 +++++ docs/upgrading/index.md | 39 +++ docs/upgrading/reflection.md | 66 +++++ docs/upgrading/rendering.md | 37 +++ docs/v1.md | 3 + spec/v2/partials_spec.rb | 8 +- theme/favicon.svg | 17 ++ theme/highlight.js | 382 +++++++++++++++++++++++++++ theme/hljs-overrides.css | 60 +++++ 30 files changed, 2385 insertions(+), 2 deletions(-) create mode 100644 book.toml create mode 100644 docs/SUMMARY.md create mode 100644 docs/api/context-objects.md create mode 100644 docs/api/extensions.md create mode 100644 docs/api/extractors.md create mode 100644 docs/api/fields.md create mode 100644 docs/api/index.md create mode 100644 docs/api/reflection.md create mode 100644 docs/dsl/extensions.md create mode 100644 docs/dsl/fields.md create mode 100644 docs/dsl/formatters.md create mode 100644 docs/dsl/index.md create mode 100644 docs/dsl/options.md create mode 100644 docs/dsl/partials.md create mode 100644 docs/dsl/views.md create mode 100644 docs/introduction.md create mode 100644 docs/rendering.md create mode 100644 docs/upgrading/configuration.md create mode 100644 docs/upgrading/customization.md create mode 100644 docs/upgrading/extensions.md create mode 100644 docs/upgrading/fields.md create mode 100644 docs/upgrading/index.md create mode 100644 docs/upgrading/reflection.md create mode 100644 docs/upgrading/rendering.md create mode 100644 docs/v1.md create mode 100644 theme/favicon.svg create mode 100644 theme/highlight.js create mode 100644 theme/hljs-overrides.css diff --git a/.gitignore b/.gitignore index 5e5107fd..e5e6f26b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ spec/dummy/db/*.sqlite3-journal spec/dummy/log/*.log spec/dummy/tmp/ doc/ +docs-dist/ .yardoc/ Gemfile.lock .vscode diff --git a/book.toml b/book.toml new file mode 100644 index 00000000..7840b125 --- /dev/null +++ b/book.toml @@ -0,0 +1,16 @@ +[book] +authors = ["Procore Technologies, Inc."] +language = "en" +src = "docs" +title = "Blueprinter" + +[build] +build-dir = "docs-dist" + +# https://rust-lang.github.io/mdBook/format/configuration/renderers.html?highlight=top%20bar#html-renderer-options +[output.html] +additional-css = ["theme/hljs-overrides.css"] +default-theme = "light" +preferred-dark-theme = "navy" +no-section-label = true +git-repository-url = "https://github.com/procore-oss/blueprinter" diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 00000000..8aab7640 --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,31 @@ +# Summary + +- [Introduction](./introduction.md) + +- [Blueprinter DSL](./dsl/index.md) + - [Fields](./dsl/fields.md) + - [Views](./dsl/views.md) + - [Partials](./dsl/partials.md) + - [Formatters](./dsl/formatters.md) + - [Options](./dsl/options.md) + - [Extensions](./dsl/extensions.md) + +- [Rendering](./rendering.md) + +- [Blueprinter API](./api/index.md) + - [Extensions](./api/extensions.md) + - [Reflection](./api/reflection.md) + - [Extractors](./api/extractors.md) + - [Context Objects](./api/context-objects.md) + - [Fields](./api/fields.md) + +--- + +- [Upgrading](./upgrading/index.md) + - [Configuration](./upgrading/configuration.md) + - [Customization](./upgrading/customization.md) + - [Fields](./upgrading/fields.md) + - [Rendering](./upgrading/rendering.md) + - [Reflection](./upgrading/reflection.md) + - [Extensions](./upgrading/extensions.md) +- [Legacy/V1 Docs](./v1.md) diff --git a/docs/api/context-objects.md b/docs/api/context-objects.md new file mode 100644 index 00000000..014cfe3e --- /dev/null +++ b/docs/api/context-objects.md @@ -0,0 +1,97 @@ +# Context Objects + +Context objects are the arguments passed to APIs like [field blocks](../dsl/fields.md#field-blocks), [option procs](../dsl/options.md) and [extension hooks](./extensions.md). There are several kinds of context objects, each with its own set of fields. + +## Render Context + +> **blueprint**\ +> The current Blueprint instance. You can use this to access the Blueprint's name, options, reflections, and instance methods. + +> **fields**\ +> A frozen array of field definitions that will be serialized, in order. See [Fields API](./fields.md) and the [blueprint_fields](./extensions.md#blueprint_fields) hook. + +> **options**\ +> The frozen options Hash passed to `render`. An empty Hash if none was passed. + +> **depth**\ +> The current blueprint depth (1-indexed). + +## Object Context + +> **blueprint**\ +> The current Blueprint instance. You can use this to access the Blueprint's name, options, reflections, and instance methods. + +> **fields**\ +> A frozen array of field definitions that will be serialized, in order. See [Fields API](./fields.md) and the [blueprint_fields](./extensions.md#blueprint_fields) hook. + +> **options**\ +> The frozen options Hash passed to `render`. An empty Hash if none was passed. + +> **object**\ +> The object or collection currently being serialized. + +> **depth**\ +> The current blueprint depth (1-indexed). + +## Field Context + +> **blueprint**\ +> The current Blueprint instance. You can use this to access the Blueprint's name, options, reflections, and instance methods. + +> **fields**\ +> A frozen array of field definitions that will be serialized, in order. See [Fields API](./fields.md) and the [blueprint_fields](./extensions.md#blueprint_fields) hook. + +> **options**\ +> The frozen options Hash passed to `render`. An empty Hash if none was passed. + +> **object**\ +> The object currently being serialized. + +> **field**\ +> A struct of the field, object, or collection currently being rendered. You can use this to access the field's name and options. See [Fields API](./fields.md). + +> **value**\ +> The extracted field value. (In certain situations, like the extractor API and field blocks, it will always be `nil` since nothing has been extracted yet.) + +> **depth**\ +> The current blueprint depth (1-indexed). + +## Result Context + +> **blueprint**\ +> The current Blueprint instance. You can use this to access the Blueprint's name, options, reflections, and instance methods. + +> **fields**\ +> A frozen array of field definitions that were serialized, in order. See [Fields API](./fields.md) and the [blueprint_fields](./extensions.md#blueprint_fields) hook. + +> **options**\ +> The frozen options Hash passed to `render`. An empty Hash if none was passed. + +> **object**\ +> The object or collection that was just serialized. + +> **result**\ +> A serialized result. Depending on the situation this will be a Hash or an array of Hashes. + +> **depth**\ +> The current blueprint depth (1-indexed). + +## Hook Context + +> **blueprint**\ +> The current Blueprint instance. You can use this to access the Blueprint's name, options, reflections, and instance methods. + +> **fields**\ +> A frozen array of field definitions that will be serialized, in order. See [Fields API](./fields.md) and the [blueprint_fields](./extensions.md#blueprint_fields) hook. + +> **options**\ +> The frozen options Hash passed to `render`. An empty Hash if none was passed. + +> **extension**\ +> Instance of the current extension + +> **hook**\ +> Name of the current hook + +> **depth**\ +> The current blueprint depth (1-indexed). diff --git a/docs/api/extensions.md b/docs/api/extensions.md new file mode 100644 index 00000000..f3263023 --- /dev/null +++ b/docs/api/extensions.md @@ -0,0 +1,447 @@ +# Extensions + +Blueprinter has a powerful extension system with hooks for every step of the serialization lifecycle. In fact, many of Blueprinter's features are implemented as built-in extensions! + +Simply extend the `Blueprinter::Extension` class, define the hooks you need, and [add it to your configuration](../dsl/extensions.md#using-extensions). + +```ruby +class MyExtension < Blueprinter::Extension + # Use the exclude_field? hook to exclude certain fields on Tuesdays + def exclude_field?(ctx) = ctx.field.options[:tues] == false && Date.today.tuesday? +end + +class MyBlueprint < ApplicationBlueprint + extensions << MyExtension.new +end +``` + +Alternatively, you can define an extension direclty in your blueprint: + +```ruby +class MyBlueprint < ApplicationBlueprint + extension do + def exclude_field?(ctx) = ctx.field.options[:tues] == false && Date.today.tuesday? + end +end +``` + +## Hooks + +Hooks are called in the following order. They are passed a [context object](./context-objects.md) as an argument. + +- [blueprint](#blueprint) +- [blueprint_fields](#blueprint_fields) +- [blueprint_setup](#blueprint_setup) +- [around_serialize_object](#around_serialize_object) | [around_serialize_collection](#around_serialize_collection) + - [object_input](#object_input) | [collection_input](#collection_input) + - [blueprint_input](#blueprint_input) + - [extract_value](#extract_value) + - [field_value](#field_value) | [object_field_value](#object_field_value) | [collection_field_value](#collection_field_value) + - [exclude_field?](#exclude_field) | [exclude_object_field?](#exclude_object_field) | [exclude_collection_field?](#exclude_collection_field) + - *blueprint_fields …* + - [field_result](#field_result) | [object_field_result](#object_field_result) | [collection_field_result](#collection_field_result) + - [blueprint_output](#blueprint_output) + - [object_output](#object_output) | [collection_output](#collection_output) +- [json](#json) + +Additionally, the [around_hook](#around_hook) hook runs around all other hooks. + +#### Chain vs override hooks + +Most hooks are *chained*; if you have N of the same hook, they run one after the other, using the output of one as input for the next. However, a few hooks are *override* hooks: only the last one runs. Override hooks are used to replace built-in functionality, like the JSON serializer. + +## blueprint + +> *Override hook*\ +> **@param [Render Context](./context-objects.md#render-context)** NOTE `fields` will be empty\ +> **@return [Class](./fields.md)** The Blueprint class to use\ +> **@cost** Low - run once during render + +Return a different blueprint class to render with. If multiple extensions define this hook, _only the last one_ will be used. The included, optional [View Option extension](../dsl/extensions.md#viewoption) uses this hook. + +The following example looks for a `view` option passed in to `render`. If present, it attempts to return a child view. + +```ruby +def blueprint(ctx) + view = ctx.options[:view] + view ? ctx.blueprint.class[view] : ctx.blueprint.class +end +``` + +## blueprint_fields + +> *Override hook*\ +> **@param [Render Context](./context-objects.md#render-context)**\ +> **@return [Array<Field>](./fields.md)** The fields to serialize\ +> **@cost** Low - run once for _every blueprint class_ during render + +Customize the order fields are rendered in - or strip out certain fields entirely. If multiple extensions define this hook, _only the last one_ will be used. The included, optional [Field Order extension](../dsl/extensions.md#field-order) uses this hook. + +In this hook, `context.fields` will contain all of the view's fields in the order in which they were defined. (Fields from `use`d partials are appended.) The fields this hook returns are used as `context.fields` in all subsequent hooks: + +The following example removes all collection fields and sorts the rest by name: + +```ruby +def blueprint_fields(ctx) + ctx.fields. + reject { |f| f.type == :collection }. + sort_by(&:name) +end +``` + +It's run once _per blueprint class_ during a render. So if you're rendering an array of widgets with `WidgetBlueprint`, which contains `PartBlueprint`s and `CategoryBlueprint`s, this hook will be called **three** times: one for each of those blueprints. + +[↑ Back to Hooks](#hooks) + +## blueprint_setup + +> **@param [Render Context](./context-objects.md#render-context)**\ +> **@cost** Low - run once for _every blueprint class_ during render + +Allows an extension to perform setup operations for the render of the current blueprint. + +```ruby +def blueprint_setup(ctx) + # do setup for ctx.blueprint +end +``` + +It's run once _per blueprint class_ during a render. So if you're rendering an array of widgets with `WidgetBlueprint`, which contains `PartBlueprint`s and `CategoryBlueprint`s, this hook will be called **three** times: one for each of those blueprints. + +[↑ Back to Hooks](#hooks) + +## around_serialize_object + +> **@param [Object Context](./context-objects.md#object-context)** `context.object` will contain the current object being rendered\ +> **@cost** Medium - run every time any blueprint is rendered + +Wraps the rendering of every object (`context.object`). This could be the top-level object or one from an association N levels deep (check `context.depth`). + +Rendering happens during `yield`, allowing the hook to run code before and after the render. If `yield` is not called exactly one time, a `BlueprinterError` is thrown. + +```ruby +def around_serialize_object(ctx) + # do something before render + yield # render + # do something after render +end +``` + +[↑ Back to Hooks](#hooks) + +## around_serialize_collection + +> **@param [Object Context](./context-objects.md#object-context)** `context.object` will contain the current collection being rendered\ +> **@cost** Medium - run every time any blueprint is rendered + +Wraps the rendering of every collection (`context.object`). This could be the top-level collection or one from an association N levels deep (check `context.depth`). + +Rendering happens during `yield`, allowing the hook to run code before and after the render. If `yield` is not called exactly one time, a `BlueprinterError` is thrown. + +```ruby +def around_serialize_collection(ctx) + # do something before render + yield # render + # do something after render +end +``` + +[↑ Back to Hooks](#hooks) + +## object_input + +> **@param [Object Context](./context-objects.md#object-context)** `context.object` will contain the current object being rendered\ +> **@return Object** A new or modified version of `context.object`\ +> **@cost** Medium - run every time an object is rendered + +Runs before serialization of any object from `render`, `render_object`, or a blueprint's `object` field. You may modify and return `context.object` or return a different object entirely. **Whatever object is returned will be used as context.object in subsequent hooks, then rendered.** + +If you want to target only the root object, check `context.depth == 1`. + +```ruby +def object_input(ctx) + ctx.object +end +``` + +[↑ Back to Hooks](#hooks) + +## collection_input + +> **@param [Object Context](./context-objects.md#object-context)** `context.object` will contain the current collection being rendered\ +> **@return Object** A new or modified version of `context.object`, which will be array-like\ +> **@cost** Medium - run every time a collection is rendered + +Runs before serialization of any collection from `render`, `render_collection`, or a blueprint's `collection` field. You may modify and return `context.object` or return a different collection entirely. **Whatever collection is returned will be used as context.object in subsequent hooks, then rendered.** + +If you want to target only the root collection, check `context.depth == 1`. + +```ruby +def collection_input(ctx) + ctx.object +end +``` + +[↑ Back to Hooks](#hooks) + +## blueprint_input + +> **@param [Object Context](./context-objects.md#object-context)** `context.object` will contain the current object being rendered\ +> **@return Object** A new or modified version of `context.object`\ +> **@cost** Medium - run every time any blueprint is rendered + +Run each time a blueprint renders, allowing you to modify or return a new object (`context.object`) used for the render. For collections of size N, it will be called N times. **Whatever object is returned will be used as context.object in subsequent hooks, then rendered.** + +```ruby +def blueprint_input(ctx) + ctx.object +end +``` + +[↑ Back to Hooks](#hooks) + +## extract_value + +> *Override hook*\ +> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ +> **@return Object** The value for the field\ +> **@cost** High - run for every field, object, and collection + +Called on each field, object, and collection to extract a field's value from an object. The return value is used as `context.value` in subsequent hooks. If multiple extensions define this hook, _only the last one_ will be used. + +```ruby +def extract_value(ctx) + ctx.object.public_send(ctx.field.from) +end +``` + +[↑ Back to Hooks](#hooks) + +## field_value + +> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ +> **@return Object** The value to be rendered\ +> **@cost** High - run for every field (not object or collection fields) + +Run after a field value is extracted from `context.object`. The extracted value is available in `context.value`. **Whatever value you return is used as context.value in subsequent field_value hooks, then run through any formatters and rendered.** + +```ruby +def field_value(ctx) + case ctx.value + when String then ctx.value.strip + else ctx.value + end +end +``` + +[↑ Back to Hooks](#hooks) + +## object_field_value + +> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ +> **@return Object** The object to be rendered for this field\ +> **@cost** High - run for every object field + +Run after an object field value is extracted from `context.object`. The extracted value is available in `context.value`. **Whatever value you return is used as context.value in subsequent object_field_value hooks, then rendered.** + +```ruby +def object_field_value(ctx) + ctx.value +end +``` + +[↑ Back to Hooks](#hooks) + +## collection_field_value + +> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ +> **@return Object** The array-like collection to be rendered for this field\ +> **@cost** High - run for every collection field + +Run after a collection field value is extracted from `context.object`. The extracted value is available in `context.value`. **Whatever value you return is used as context.value in subsequent collection_field_value hooks, then rendered.** + +```ruby +def collection_field_value(ctx) + ctx.value.compact +end +``` + +[↑ Back to Hooks](#hooks) + +## exclude_field? + +> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ +> **@return Boolean** Truthy to exclude the field from the output\ +> **@cost** High - run for every field (not object or collection fields) + +If any extension with this hook returns truthy, the field will be excluded from the output. The formatted field value is available in `context.value`. + +```ruby +def exclude_field?(ctx) + ctx.field.options[:tuesday] == false && Date.today.tuesday? +end +``` + +[↑ Back to Hooks](#hooks) + +## exclude_object_field? + +> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ +> **@return Boolean** Truthy to exclude the field from the output\ +> **@cost** High - run for every object field + +If any extension with this hook returns truthy, the object field will be excluded from the output. The field object value is available in `context.value`. + +```ruby +def exclude_object_field?(ctx) + ctx.field.options[:tuesday] == false && Date.today.tuesday? +end +``` + +[↑ Back to Hooks](#hooks) + +## exclude_collection_field? + +> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ +> **@return Boolean** Truthy to exclude the field from the output\ +> **@cost** High - run for every collection field + +If any extension with this hook returns truthy, the collection field will be excluded from the output. The field collection value is available in `context.value`. + +```ruby +def exclude_collection_field?(ctx) + ctx.field.options[:tuesday] == false && Date.today.tuesday? +end +``` + +[↑ Back to Hooks](#hooks) + +## field_result + +> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ +> **@return Object** The value to be rendered for this field\ +> **@cost** High - run for every field + +The final value to be used for the field, available in `context.value`. You may modify or replace it. **Whatever value you return is used as context.value in subsequent hooks, then rendered.** Not called if [exclude_field?](#exclude_field) returned `true`. + +```ruby +def field_result(ctx) + ctx.value +end +``` + +[↑ Back to Hooks](#hooks) + +## object_field_result + +> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ +> **@return Object** The value to be rendered for this field\ +> **@cost** High - run for every field + +The final value to be used for the field, available in `context.value`. You may modify or replace it. **Whatever value you return is used as context.value in subsequent hooks, then rendered.** Not called if [exclude_object_field?](#exclude_object_field) returned `true`. + +```ruby +def object_field_result(ctx) + ctx.value +end +``` + +[↑ Back to Hooks](#hooks) + +## collection_field_result + +> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ +> **@return Object** The value to be rendered for this field\ +> **@cost** High - run for every field + +The final value to be used for the field, available in `context.value`. You may modify or replace it. **Whatever value you return is used as context.value in subsequent hooks, then rendered.** Not called if [exclude_collection_field?](#exclude_collection_field) returned `true`. + +```ruby +def collection_field_result(ctx) + ctx.value +end +``` + +[↑ Back to Hooks](#hooks) + +## blueprint_output + +> **@param [Result Context](./context-objects.md#result-context)** `context.result` will contain the serialized Hash from the current blueprint, and `context.object` the current object\ +> **@return Hash** The Hash to use as this blueprint's serialized output\ +> **@cost** Medium - run every time any blueprint is rendered + +Run after a blueprint serializes an object to a Hash, allowing you to modify the output. The Hash is available in `context.result`. For collections of size N, it will be called N times. **Whatever Hash is returned will be used as context.result in subsequent hooks and used as the serialized output for this blueprint.** + +```ruby +def blueprint_output(ctx) + ctx.result.merge(ctx.object.extra_fields) +end +``` + +[↑ Back to Hooks](#hooks) + +## object_output + +> **@param [Result Context](./context-objects.md#result-context)** `context.result` will contain the serialized Hash from the current blueprint, and `context.object` the current object\ +> **@return [Object]** The value to use for the fully serialized object\ +> **@cost** High - run for every object field + +Run after an object is fully serialized. This may be the root object from `render` or an `object` field from a blueprint (check `context.depth`). This example wraps the result in a metadata block: + +```ruby +def object_output(ctx) + { data: ctx.value, metadata: {...} } +end +``` + +[↑ Back to Hooks](#hooks) + +## collection_output + +> **@param [Result Context](./context-objects.md#result-context)** `context.result` will contain the array of serialized Hashes from the current blueprint, and `context.object` the current collection\ +> **@return Object** The value to use for the fully serialized collection\ +> **@cost** High - run for every collection field + +Run after a collection is fully serialized. This may be the root collection from `render` or a `collection` field from a blueprint (check `context.depth`). This example wraps the result in a metadata block: + +```ruby +def collection_output(ctx) + { data: ctx.value, metadata: {...} } +end +``` + +[↑ Back to Hooks](#hooks) + +## json + +> *Override hook*\ +> **@param [Result Context](./context-objects.md#result-context)** `context.result` will contain the serialized Hash or array from the top-level blueprint, and `context.object` the top-level object or collection\ +> **@return String** The JSON output\ +> **@cost** Low - run once per JSON render + +Serializes the final output to JSON. Only called on the top-level blueprint. If multiple extensions define this hook, _only the last one_ will be used. + +The default behavior looks like: + +```ruby +def json(ctx) + JSON.dump ctx.result +end +``` + +[↑ Back to Hooks](#hooks) + +## around_hook + +> **@param [Hook Context](./context-objects.md#hook-context)**\ +> **@cost** Variable - Depends on what hooks your extensions implement + +A special hook that runs around all other extension hooks. Useful for instrumenting. You can exclude an extension's hooks from this hook by putting `def hidden? = true` in the extension. + +```ruby +def around_hook(ext, hook) + # Do something before extension hook runs + yield # hook runs here + # Do something after extension hook runs +end +``` diff --git a/docs/api/extractors.md b/docs/api/extractors.md new file mode 100644 index 00000000..8d945b5d --- /dev/null +++ b/docs/api/extractors.md @@ -0,0 +1,36 @@ +# Extractors + +Extractors are [extensions](./extensions.md#extract_value) that pull field values from the objects you're serializing. The default extraction logic is smart enough for most use cases, but you can create custom extractors if needed. (Note that passing a block to a field completely [bypasses extractors](../dsl/fields.md#extracting-field-values).) + +## Default Extractor + +The default extractor is a built-in extension. If `context.object` is a Hash, it tries symbol, then string keys. Otherwise, it calls `public_send` on the object. + +```ruby +class Blueprinter::Extensions::Core::Extractor < Blueprinter::Extension + def extract_value(ctx) + if ctx.object.is_a? Hash + ctx.object[ctx.field.from] || ctx.object[ctx.field.from_str] + else + ctx.object.public_send(ctx.field.from) + end + end +end +``` + +## Custom Extractors + +Your [extract_value](./extensions.md#extract_value) hook will be passed a [Field context object](./context-objects.md#field-context). + +```ruby +class WeirdObjectExtractor < Blueprinter::Extension + def extract_value(ctx) + # my extraction logic + end +end +``` + +There are several ways to use your extractor: + +* Add it to your blueprint(s) or view(s) like any other [extension](../dsl/extensions.md). +* Add it to specific fields using the [extractor option](../dsl/options.md#extractor). diff --git a/docs/api/fields.md b/docs/api/fields.md new file mode 100644 index 00000000..bac3355a --- /dev/null +++ b/docs/api/fields.md @@ -0,0 +1,31 @@ +# Fields + +Extensions, reflection, and other APIs allow access to structs that describe fields. + +> **type**\ +> *Symbol* `:field | :object | :collection`\ +> The type of field. + +> **name**\ +> *Symbol*\ +> Name of the field as it will appear in the JSON or Hash output. + +> **from**\ +> *Symbol*\ +> Name of the field in the source object (usually the same as `name`). + +> **from_str**\ +> *String*\ +> Same as `from`, but as a frozen string. + +> **value_proc**\ +> *nil | Proc*\ +> The block passed to the field definition (if given). Expects a [Field Context](./context-objects.md#field-context) argument and returns the field value. + +> **options**\ +> *Hash*\ +> A frozen Hash of any additional options passed to the field. + +> **blueprint** (object and collection only)\ +> *Class*\ +> The blueprint to use for serializaing the object or collection. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 00000000..983ac4f2 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,23 @@ +# Blueprinter API + +Blueprinter has a rich API for extending the serialization process and reflecting on your blueprints. + +## Extensions + +The extensions API offers deep hooks into the serialization process. [Read more](./extensions.md). + +## Reflection + +The reflection API allows your application, or Blueprinter extensions, to introspect on your blueprints' options, fields, and views. [Read more](./reflection.md). + +## Extractors + +By creating and using custom extractors, you can change the way field values are extracted from objects. [Read more](./extractors.md). + +## Context Objects + +Context objects are the arguments you'll receive in most of the above APIs. [Read more](./context-objects.md). + +## Fields + +Several APIs provide access to structs describing field definitions. [Read more](./fields.md). diff --git a/docs/api/reflection.md b/docs/api/reflection.md new file mode 100644 index 00000000..15458fec --- /dev/null +++ b/docs/api/reflection.md @@ -0,0 +1,141 @@ +# Reflection + +Blueprints may be reflected on to inspect their views, fields, and options. This is useful for building [extensions](./extensions.md), and possibly even for some applications. + +We will use the following blueprint in the examples below: + +```ruby +class WidgetBlueprint < ApplicationBlueprint + field :name + field :description, exclude_if_empty: true + object :category, CategoryBlueprint + collection :parts, PartBlueprint + + view :extended do + object :manufacturer, CompanyBlueprint[:full] + + view :with_price do + field :price + end + end +end +``` + +## Blueprint & view names + +```ruby +WidgetBlueprint.blueprint_name +=> "WidgetBlueprint" + +WidgetBlueprint.view_name +=> :default + +WidgetBlueprint[:extended].blueprint_name +=> "WidgetBlueprint.extended" + +WidgetBlueprint[:extended].view_name +=> :extended + +WidgetBlueprint["extended.with_price"].blueprint_name +=> "WidgetBlueprint.extended.with_price" + +WidgetBlueprint["extended.with_price"].view_name +=> :"extended.with_price" +``` + +## Blueprint & view options + +```ruby +WidgetBlueprint.options +=> {exclude_if_nil: true} + +WidgetBlueprint[:extended].options +=> {exclude_if_nil: true, exclude_if_empty: true} +``` + +## Views + +Here, `:default` refers to the top level of the blueprint. + +```ruby +WidgetBlueprint.reflections.keys +=> [:default, :extended, :"extended.with_price"] +``` + +You can also reflect directly on a view. + +```ruby +WidgetBlueprint[:extended].reflections.keys +=> [:default, :with_price] +``` + +**Notice that the names are relative**: `:default` now refers to the `:extended` view, since we called `.reflections` on `:extended`. The prefix is also gone from the nested `:with_price` view. + +## Fields + +```ruby +view = WidgetBlueprint.reflections[:default] + +# Regular fields +view.fields.keys +=> [:name, :description] + +# Object fields +view.objects.keys +=> [:category] + +# Collection fields +view.collections.keys +=> [:parts] + +# All fields in the order they were defined +view.ordered +# returns an array of field objects +``` + +## Field metadata + +```ruby +view = WidgetBlueprint.reflections[:default] +field = view.fields[:description] + +field.name +=> :description + +field.from +=> :description # the :from option in the DSL + +field.value_proc +=> nil # the block you passed to the field, if any + +field.options # all other options passed to the field +=> { exclude_if_empty: true } +``` + +Object and collection fields have the same metadata as regular fields, plus a `blueprint` attribute: + +```ruby +view = WidgetBlueprint.reflections[:default] +field = view.collections[:parts] + +# it returns the Blueprint class, so you can continue reflecting +field.blueprint +=> PartBlueprint + +field.blueprint.reflections[:default].fields +=> # array of fields on the default view of PartBlueprint +``` + +If you used a view in an object or collection field, you can reflect on that view just like a blueprint: + +```ruby +view = WidgetBlueprint.reflections[:extended] +field = view.objects[:manufacturer] + +field.blueprint.to_s +=> "CompanyBlueprint.full" + +# Remember, we're reflecting ON the :full view, so the name is relative! +field.blueprint.reflections[:default].fields +=> # array of fields on the :full view of CompanyBlueprint +``` diff --git a/docs/dsl/extensions.md b/docs/dsl/extensions.md new file mode 100644 index 00000000..263fee44 --- /dev/null +++ b/docs/dsl/extensions.md @@ -0,0 +1,94 @@ +# Extensions + +Blueprinter has a powerful extension system with hooks for every step of the serialization lifecycle. Some are included with Blueprinter, others are available as gems, and you can easily write your own using the [Extension API](../api/extensions.md). + +## Using extensions + +Extensions can be added to your `ApplicationBlueprint` or any other blueprint, view, or partial. They're inherited from parent classes and views, but can be overridden. + +```ruby +class MyBlueprint < ApplicationBlueprint + # This extension instance will exist for the duration of your program + extensions << FooExtension.new + + # These extensions will be initialized once during each render + extensions << BarExtension + extensions << -> { ZorpExtension.new(some_args) } + + # Inline extensions are also initialized once per render + extension do + def blueprint_output(ctx) = ctx.result.merge({ foo: "Foo" }) + end + + view :minimal do + # extensions is a simple Array, so you can add or remove elements + extensions.select! { |ext| ext.is_a? FooExtension } + + # or simply replace the whole Array + self.extensions = [FooExtension.new] + end +end +``` + +## Included extensions + +These extensions are distributed with Blueprinter. Simply add them to your configuration. + +### Field Order + +Control the order of fields in your output. See [Fields API](../api/fields.md) for more information about the block parameters. + +```ruby +extensions << Blueprinter::Extensions::FieldOrder.new { |a, b| a.name <=> b.name } +``` + +### MultiJson + +The MultiJson extension switches Blueprinter from Ruby's built-in JSON library to the [multi_json](https://rubygems.org/gems/multi_json) gem. Just install the `multi_json` gem, your serialization library of choice, and enable the extension. + +```ruby +extensions << Blueprinter::Extensions::MultiJson.new + +# Any options you pass will be forwarded to MultiJson.dump +extensions << Blueprinter::Extensions::MultiJson.new(pretty: true) + +# You can also pass MultiJson.dump options during render +WidgetBlueprint.render(widget, multi_json: { pretty: true }).to_json +``` + +If `multi_json` doesn't support your preferred JSON library, you can use Blueprinter's [json extension hook](../api/extensions.md#json) to render JSON however you like. + +### OpenTelemetry + +Enable the OpenTelemetry extension to see what's happening while you render your blueprints. One outer `blueprinter.render` span will nest various `blueprinter.object` and `blueprinter.collection` spans. Each span will include the blueprint/view name that triggered it. + +Extension hooks will be wrapped in `blueprinter.extension` spans and annotated with the current extension and hook name. + +```ruby +extensions << Blueprinter::Extensions::OpenTelemetry.new("my-tracer-name") +``` + +### ViewOption + +The ViewOption extension uses the [blueprint](../api/extensions.md#blueprint) extension hook to add a `view` option to `render`, `render_object`, and `render_collection`. It allows V1-compatible rendering of views. + +```ruby +extensions << Blueprinter::Extensions::ViewOption.new +``` + +Now you can render a view either way: + +```ruby +# V2 style +MyBlueprint[:foo].render(obj) +# or V1 style +MyBlueprint.render(obj, view: :foo) +``` + +## Gem extensions + +_Have an extension you'd like to share? Let us know and we may add it to the list!_ + +### blueprinter-activerecord + +[blueprinter-activerecord](https://github.com/procore-oss/blueprinter-activerecord) is an official extension from the Blueprinter team providing ActiveRecord integration, including automatic preloading of associations based on your Blueprint definitions. diff --git a/docs/dsl/fields.md b/docs/dsl/fields.md new file mode 100644 index 00000000..057843e1 --- /dev/null +++ b/docs/dsl/fields.md @@ -0,0 +1,66 @@ +# Fields + +```ruby +# Use field for scalar values, arrays of scalar values, or even a Hash +field :name +field :tags + +# Add multiple fields at once +fields :description, :price + +# Use object to render an object or Hash using another blueprint +object :category, CategoryBlueprint + +# Use collection to render an array-like collection of objects +collection :parts, PartBlueprint +``` + +## Options + +Fields accept a wide array of built-in options, and [extensions](./extensions.md) can define even more. [Find all built-in options here.](./options.md) + +```ruby +field :description, default: "No description" +collection :parts, PartBlueprint, exclude_if_empty: true +``` + +## Extracting field values + +Blueprinter is pretty smart about extracting field values from objects, but there are ways to customize the behavior if needed. + +### Default behavior + +- For Hashes, Blueprinter will look for a key matching the field name - first with a Symbol, then a String. +- For anything else, Blueprinter will look for a public method matching the field name. +- The [from](./options.md#from) field option can be used to specify a different method or Hash key name. + +### Field blocks + +Return whatever you want from a block. It will be passed a [Field context](../api/context-objects.md#field-context) argument containing the object being rendered, among other things. + +```ruby +field :description do |ctx| + ctx.object.description.upcase +end + +# Blocks can call instance methods defined on your Blueprint +collection :parts, PartBlueprint do |ctx| + active_parts ctx.object +end + +def active_parts(object) + object.parts.select(&:active?) +end +``` + +### Custom extractors + +Define your own extraction behavior with a [custom extractor](../api/extractors.md). + +```ruby +# For an entire Blueprint or view +extensions << MyCustomExtractor.new + +# For a single field +object :bar, extractor: MyCustomExtractor +``` diff --git a/docs/dsl/formatters.md b/docs/dsl/formatters.md new file mode 100644 index 00000000..c97121e2 --- /dev/null +++ b/docs/dsl/formatters.md @@ -0,0 +1,18 @@ +# Formatters + +Declaratively format field values by class. You can define formatters anywhere in your blueprints: top level, [views](./views.md), and [partials](./partials.md). + +```ruby +class WidgetBlueprint < ApplicationBlueprint + # Strip whitespace from all strings + format(String) { |val| val.strip } + + # Format all dates and times using ISO-8601 + format Date, :iso8601 + format Time, :iso8601 + + def iso8601(val) + val.iso8601 + end +end +``` diff --git a/docs/dsl/index.md b/docs/dsl/index.md new file mode 100644 index 00000000..c9fdde1d --- /dev/null +++ b/docs/dsl/index.md @@ -0,0 +1,27 @@ +# Blueprinter DSL + +## Define your base class + +Define an `ApplicationBlueprint` for your blueprints to inherit from. Any global configuration goes here: common [fields](./fields.md), [views](./views.md), [partials](./partials.md), [formatters](./formatters.md), [extensions](./extensions.md), and [options](./options.md). + +```ruby +class ApplicationBlueprint < Blueprinter::Blueprint + extensions << MyExtension.new + options[:exclude_if_nil] = true + field :id +end +``` + +## Define blueprints for your models + +This blueprint inherits everything from `ApplicationBlueprint`, then adds a `name` field and two associations that will render using other blueprints. + +```ruby +class WidgetBlueprint < ApplicationBlueprint + field :name + object :category, CategoryBlueprint + collection :parts, PartBlueprint +end +``` + +There's a lot more you can do with the Blueprinter DSL. [Fields](./fields.md) are a good place to start! diff --git a/docs/dsl/options.md b/docs/dsl/options.md new file mode 100644 index 00000000..7f1945c7 --- /dev/null +++ b/docs/dsl/options.md @@ -0,0 +1,357 @@ +# Options + +Numerous options can be defined on Blueprints, views, partials, or individual fields. Some can also be passed to `render`. + +```ruby +class WidgetBlueprint < ApplicationBlueprint + # Blueprint options apply to all fields, associations, views, and partials in + # the Blueprint. They are inherited from the parent class but can be overridden. + options[:exclude_if_empty] = true + + # Field options apply to individual fields or associations. They can override + # Blueprint options. + field :name, exclude_if_empty: false + + # Options in views apply to all fields, associations, partials and nested views + # in the view. They inherit options from the Blueprint, or from parent views, + # and can override them. + view :foo do + options[:exclude_if_empty] = false + end + + # Options in partials apply to all fields, associations, views, and partials in + # the partial. All of these are applied to the views that use the partial. + partial :bar do + options[:exclude_if_empty] = false + end + + # Some options accept Procs/labmdas. These can call instance methods defined on + # your Blueprint. Or you can pass a method name as a symbol. + field :foo, if: ->(ctx) { long_complex_check? ctx } + field :bar, if: :long_complex_check? + + def long_complex_check?(ctx) + # ... + end +end + +# Passing a supported option to render will override what's in the blueprint +WidgetBlueprint.render(widget, exclude_if_empty: false).to_json +``` + +For easier reference, options are grouped into the following categories: + +- [Default values](#default-values): Provide defaults for empty fields +- [Conditional fields](#conditional-fields): Exclude fields based on conditions +- [Field mapping](#field-mapping): Change how field values are extracted from objects +- [Metadata](#other): Wrap or add metadata to the output + +#### A note about context objects + +Options that accept Procs, lambdas, or method names are usually passed a [Field context](../api/context-objects.md#field-context) argument. It contains the object being rendered as well as other useful information. + +## Default Values + +These options allow you to set default values for fields and associations, and customize when they're used. + +#### default + +A default value used when the field or assocation is nil. + +> *Available in field, object, collection*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +field :foo, default: "Foo" +field :foo, default: ->(ctx) { "Foo" } +field :foo, default: :foo + +def foo(ctx) = "Foo" +``` + +#### field_default + +Default value for any nil non-association field in its scope. + +> *Available in blueprint, view, partial, render*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +options[:field_default] = "Foo" +options[:field_default] = ->(ctx) { "Foo" } +options[:field_default] = :foo + +def foo(ctx) = "Foo" + +WidgetBluerpint.render(widget, field_default: "Foo").to_json +``` + +#### object_default + +Default value for any nil object field in its scope. + +> *Available in blueprint, view, partial, render*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +options[:object_default] = { name: "Foo" } +options[:object_default] = ->(ctx) { { name: "Foo" } } +options[:object_default] = :foo + +def foo(ctx) = { name: "Foo" } + +WidgetBluerpint.render(widget, object_default: { name: "Foo" }).to_json +``` + +#### collection_default + +Default value for any nil collection field. + +> *Available in blueprint, view, partial, render*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +options[:collection_default] = [{ name: "Foo" }] +options[:collection_default] = ->(ctx) { [{ name: "Foo" }] } +options[:collection_default] = :foo + +def foo(ctx) = [{ name: "Foo" }] + +WidgetBluerpint.render(widget, collection_default: [{ name: "Foo" }]).to_json +``` + +#### default_if + +Use the default value if the given Proc or method name returns truthy. + +> *Available in field, object, collection*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +field :foo, default: "Foo", default_if: ->(ctx) { ctx.object.disabled? } +field :foo, default: "Foo", default_if: :disabled? + +def disabled?(ctx) = ctx.object.disabled? +``` + +#### field_default_if + +Same as [default_if](#default_if), but applies to any non-association field in scope. + +> *Available in blueprint, view, partial, render*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +options[:field_default_if] = ->(ctx) { ctx.object.disabled? } +options[:field_default_if] = :disabled? + +def disabled?(ctx) = ctx.object.disabled? + +WidgetBluerpint.render(widget, field_default_if: :disabled?).to_json +``` + +#### object_default_if + +Same as [default_if](#default_if), but applies to any object field in scope. + +> *Available in blueprint, view, partial, render*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +options[:object_default_if] = ->(ctx) { ctx.object.disabled? } +options[:object_default_if] = :disabled? + +def disabled?(ctx) = ctx.object.disabled? + +WidgetBluerpint.render(widget, object_default_if: :disabled?).to_json +``` + +#### collection_default_if + +Same as [default_if](#default_if), but applies to any collection field in scope. + +> *Available in blueprint, view, partial, render*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +options[:collection_default_if] = ->(ctx) { ctx.object.disabled? } +options[:collection_default_if] = :disabled? + +def disabled?(ctx) = ctx.object.disabled? + +WidgetBluerpint.render(widget, collection_default_if: :disabled?).to_json +``` + +## Conditional Fields + +These options allow you to exclude fields from the output. + +#### exclude_if_nil + +Exclude fields if they're nil. + +> *Available in blueprint, view, partial, field, object, collection, render* + +```ruby +options[:exclude_if_nil] = true + +field :description, exclude_if_nil: true + +WidgetBluerpint.render(widget, exclude_if_nil: true).to_json +``` + +#### exclude_if_empty + +Exclude fields if they're nil, or if they respond to `empty?` and it returns true. + +> *Available in blueprint, view, partial, field, object, collection, render* + +```ruby +options[:exclude_if_empty] = true + +field :description, exclude_if_empty: true + +WidgetBluerpint.render(widget, exclude_if_empty: true).to_json +``` + +#### if + +Only include the field if the given Proc or method name returns truthy. + +> *Available in field, object, collection*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +field :foo, if: ->(ctx) { ctx.object.enabled? } +field :foo, if: :enabled? + +def enabled?(ctx) = ctx.object.enabled? +``` + +#### field_if + +Only include non-association fields if the given Proc or method name returns truthy. + +> *Available in blueprint, view, partial, render*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +options[:field_if] = ->(ctx) { ctx.object.enabled? } +options[:field_if] = :enabled? + +def enabled?(ctx) = ctx.object.enabled? + +WidgetBluerpint.render(widget, field_if: :enabled?).to_json +``` + +#### object_if + +Only include object fields if the given Proc or method name returns truthy. + +> *Available in blueprint, view, partial, render*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +options[:object_if] = ->(ctx) { ctx.object.enabled? } +options[:object_if] = :enabled? + +def enabled?(ctx) = ctx.object.enabled? + +WidgetBluerpint.render(widget, object_if: :enabled?).to_json +``` + +#### collection_if + +Only include collection fields if the given Proc or method name returns truthy. + +> *Available in blueprint, view, partial, render*\ +> **@param** [Field context](../api/context-objects.md#field-context) + +```ruby +options[:collection_if] = ->(ctx) { ctx.object.enabled? } +options[:collection_if] = :enabled? + +def enabled?(ctx) = ctx.object.enabled? + +WidgetBluerpint.render(widget, collection_if: :enabled?).to_json +``` + +#### unless + +Inverse of [if](#if). + +#### field_unless + +Inverse of [field_if](#field_if). + +#### object_unless + +Inverse of [object_if](#object_if). + +#### collection_unless + +Inverse of [collection_if](#collection_if). + +## Field mapping + +These options let you change how fields values are extracted from your objects. + +#### from + +Populate the field using a method/Hash key other than the field name. + +> *Available in field, object, collection* + +```ruby +field :desc, from: :description +``` + +#### extractor + +Pass a [custom extractor](../api/extractors.md) class or instance. + +> *Available in field, object, collection* + +```ruby +# Pass as a class +object :category, CategoryBlueprint, extractor: MyCategoryExtractor +# or an instance +object :category, CategoryBlueprint, extractor: MyCategoryExtractor.new(args) +``` + +Note that when you pass a class, it will be initialized _once per render_. + +## Metadata + +These options allow you to add metadata to the rendered output. + +#### root + +Pass a root key to wrap the output. + +> *Available in blueprint, view, partial, render* + +```ruby +options[:root] = :data + +WidgetBlueprint.render(widget, root: :data).to_json +``` + +#### meta + +Add a `meta` key and data to the wrapped output (requires the `root` option). + +> *Available in blueprint, view, partial, render*\ +> **@param** [Result context](../api/context-objects.md#result-context) + +```ruby +options[:root] = :data +options[:meta] = { page: 1 } + +# If you pass a Proc/lambda, it can call instance methods defined on the Blueprint +options[:meta] = ->(ctx) { { page: page_num(ctx) } } + +WidgetBlueprint + .render(widget, root: :data, meta: { page: params[:page] }) + .to_json +``` diff --git a/docs/dsl/partials.md b/docs/dsl/partials.md new file mode 100644 index 00000000..82024faf --- /dev/null +++ b/docs/dsl/partials.md @@ -0,0 +1,68 @@ +# Partials + +Partials allow you to compose views from reusable components. Just like views, partials can define [fields](./fields.md), [options](./options.md), [views](./views.md), other [partials](./partials.md), [formatters](./formatters.md), and [extensions](./extensions.md). + +```ruby +class WidgetBlueprint < ApplicationBlueprint + field :name + + view :foo do + use :associations + field :foo + end + + view :bar do + use :associations, :description + field :bar + end + + partial :associations do + object :category, CategoryBlueprint + collection :parts, PartBlueprint + end + + partial :description do + field :description + end +end +``` + +There are two ways of including partials: [appending with 'use'](#append-with-use) and [inserting with 'use!'](#inserting-with-use) (see [examples](#examples-of-use-and-use)). + +### Append with 'use' + +Partials are _appended_ to your view, giving them the opportunity to override your view's fields, options, etc. Precedence (highest to lowest) is: + +1. Definitions in the partial +2. Definitions in the view +3. Definitions inherited from the blueprint/parent views + +### Insert with 'use!' + +Partials are embedded immediately, _on that line_, allowing subsequent lines to override the partial. Precedence (highest to lowest) is: + +1. Definitions in the view _after_ `use!` +2. Definitions in the partial +3. Definitions in the view _before_ `use!` +4. Definitions inherited from the blueprint/parent views + +### Examples of 'use' and 'use!' + +```ruby +partial :no_empty_fields do + options[:field_if] = :og_field_logic + # other stuff +end + +# :foo appends the partial, so it overrides the view's field_if +view :foo do + use :no_empty_fields + options[:field_if] = :other_field_logic +end + +# :bar inserts the partial, but the next line overrides the partial's field_if +view :bar do + use! :no_empty_fields + options[:field_if] = :other_field_logic +end +``` diff --git a/docs/dsl/views.md b/docs/dsl/views.md new file mode 100644 index 00000000..78397eff --- /dev/null +++ b/docs/dsl/views.md @@ -0,0 +1,94 @@ +# Views + +Blueprints can define views to provide different representations of the data. A view inherits everything from its parent but is free to override as needed. In addition to [fields](./fields.md), views can define [options](./options.md), [partials](./partials.md), [formatters](./formatters.md), [extensions](./extensions.md), and [nested views](#nesting-views). + +```ruby +class WidgetBlueprint < ApplicationBlueprint + field :name + object :category, CategoryBlueprint + + # The "with_parts" view inherits from "default" and adds a collection of parts + view :with_parts do + collection :parts, PartBlueprint + end + + # Views can include other views + view :full do + use :with_parts + field :description + end +end +``` + +At the top level of every Blueprint is an implicit view called `default`. The default view is used when no other is specified. All other views in the Blueprint inherit from it. + +### Nesting views + +You can nest views within views, allowing for a hierarchy of inheritance. + +```ruby +class WidgetBlueprint < ApplicationBlueprint + field :name + object :category, CategoryBlueprint + + view :extended do + field :description + collection :parts, PartBlueprint + + # The "extended.with_price" view adds a price field + view :with_price do + field :price + end + end +``` + +### Excluding fields + +Views can exclude select fields from parents, views they've included, or from [partials](./partials.md). + +```ruby +class WidgetBlueprint < ApplicationBlueprint + fields :name, :description, :price + + view :minimal do + exclude :description, :price + end +end +``` + +You can exclude and and all parent fields by creating an empty view: + +```ruby +class WidgetBlueprint < ApplicationBlueprint + fields :name, :description, :price + + view :minimal, empty: true do + field :the_only_field + end +end +``` + +### Referencing views + +When defining an association, you can choose a view from its blueprint: + +```ruby +object :widget, WidgetBlueprint[:extended] +``` + +Nested views can be accessed with a dot syntax or a nested Hash syntax. + +```ruby +collection :widgets, WidgetBlueprint["extended.with_price"] +collection :widgets, WidgetBlueprint[:extended][:with_price] +``` + +### Inheriting from views + +You can inherit from another blueprint, or from one of its views: + +```ruby +class WidgetBlueprint < ApplicationBlueprint[:with_timestamps] + # ... +end +``` diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 00000000..afbf11fd --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,39 @@ +# Blueprinter + +### NOTE This is a WIP for API V2! + +Blueprinter is a JSON serializer for your business objects. It is designed to be simple, flexible, and performant. + +Upgrading from 1.x? [Read the upgrade guide!](./upgrading/index.md) + +### Installation + +```bash +bundle add blueprinter +``` + +See [rubydoc.info/gems/blueprinter](https://www.rubydoc.info/gems/blueprinter) for generated API documentation. + +### Basic Usage + +```ruby +class WidgetBlueprint < ApplicationBlueprint + field :name + object :category, CategoryBlueprint + collection :parts, PartBlueprint + + view :extended do + field :description + object :manufacturer, CompanyBlueprint + collection :vendors, CompanyBlueprint + end +end + +# Render the default view to JSON +WidgetBlueprint.render(widget).to_json + +# Render the extended view to a Hash +WidgetBlueprint[:extended].render(widget).to_hash +``` + +Look interesting? [Learn the DSL!](./dsl/index.md) diff --git a/docs/rendering.md b/docs/rendering.md new file mode 100644 index 00000000..3a203759 --- /dev/null +++ b/docs/rendering.md @@ -0,0 +1,61 @@ +# Rendering + +### Rendering to JSON + +```ruby +WidgetBlueprint.render(widget).to_json +``` + +If you're using Rails, you may omit `.to_json` when calling `render json:` + +```ruby +render json: WidgetBlueprint.render(widget) +``` + +Ruby's built-in `JSON` library is used by default. Alternatively, you can use the built-in [MultiJson extension](./dsl/extensions.md#multijson). Or for total control, implement the [json extension hook](./api/extensions.md#json) and call any serializer you like. + +### Rendering to a Hash + +```ruby +WidgetBlueprint.render(widget).to_hash +``` + +### Rendering a view + +```ruby +# Render a view +WidgetBlueprint[:extended].render(widget).to_json + +# Render a nested view +WidgetBlueprint["extended.price"].render(widget).to_json + +# These two both render the default view +WidgetBlueprint.render(widget).to_json +WidgetBlueprint[:default].render(widget).to_json +``` + +### Passing options + +An options hash can be passed to `render`. Read more about [options](./dsl/options.md). + +```ruby +WidgetBlueprint.render(Widget.all, exclude_if_nil: true).to_json +``` + +### Rendering collections + +`render` will treat any `Enumerable`, except `Hash`, as an array of objects: + +```ruby +WidgetBlueprint.render(Widget.all).to_json +``` + +If you wish to be explicit you may use `render_object` and `render_collection`: + +```ruby +WidgetBlueprint.render_object(widget).to_json + +WidgetBlueprint.render_collection(Widget.all).to_json +``` + +Whatever you pass to `render_collection` must respond to `map`, yielding zero or more serializable objects, and returning an `Enumerable` with the mapped results. diff --git a/docs/upgrading/configuration.md b/docs/upgrading/configuration.md new file mode 100644 index 00000000..6d133a5d --- /dev/null +++ b/docs/upgrading/configuration.md @@ -0,0 +1,27 @@ +# Configuration + +Blueprinter V2 has no concept of global configruation like V1's `Blueprinter.configure`. Instead, blueprints and views inherit configuration from their parent classes. By putting your "global" configuration into `ApplicationBlueprint`, all your application's blueprints and views will inherit it. + +```ruby +class ApplicationBlueprint < Blueprinter::Blueprint + options[:exclude_if_nil] = true + extensions << MyExtension.new +end +``` + +Read more about [options](../dsl/options.md) and [extensions](../dsl/extensions.md). + +## Overrides + +Child classes, [views](../dsl/views.md), and [partials](../dsl/partials.md) can override their inherited configuration. + +```ruby +class MyBlueprint < ApplicationBlueprint + options[:exclude_if_nil] = false + + view :foo do + options.clear + extensions.clear + end +end +``` diff --git a/docs/upgrading/customization.md b/docs/upgrading/customization.md new file mode 100644 index 00000000..58ea2b97 --- /dev/null +++ b/docs/upgrading/customization.md @@ -0,0 +1,23 @@ +# Customization + +## Formatting + +Blueprinter V2 has a more generic approach to formatting, allowing any type of value to have formatting applied. [Learn more](../dsl/formatters.md). + +```ruby +format(Date) { |date| date.iso8601 } +``` + +The [field_value](../api/extensions.md#field_value), [object_field_value](../api/extensions.md#object_field_value), and [collection_field_value](../api/extensions.md#collection_field_value) extension hooks can also be used. + +## Custom extractors + +Custom extraction in V2 is accomplished using the [extract_value](../api/extensions.md#extract_value) extension hook. + +Fields, objects, and collections continue to have an [extractor](../dsl/options.md#extractor) option. Simply pass your extension class to it. [Learn more](../api/extractors.md). + +Unlike Legacy/V1, custom extractors *do not override blocks* passed to fields, objects, and collections. If a field has a block, that's how it's extracted. + +## Transformers + +Blueprinter V2's [extension hooks](../api/extensions.md) offer many ways to transform your inputs and outputs. The [blueprint_output](../api/extensions.md#blueprint_output) hook offers equivalent functionality to Legacy/V1 transformers. diff --git a/docs/upgrading/extensions.md b/docs/upgrading/extensions.md new file mode 100644 index 00000000..05ed2d05 --- /dev/null +++ b/docs/upgrading/extensions.md @@ -0,0 +1,11 @@ +# Extensions + +The [V2 Extension API](../api/extensions.md), as well as the DSL for [enabling V2 extensions](../dsl/extensions.md), are vastly different and more powerful than V1. Legacy/V1 had only one extension hook: `pre_render`. V2 has [over a dozen](../api/extensions.md#hooks). + +## Porting pre_render + +Legacy/V1's `pre_render` hook does not exist in V2, but it has three possible replacements: + +* [object_input](../api/extensions.md#object_input) intercept an object before it's serialized +* [collection_input](../api/extensions.md#collection_input) intercept a collection before it's serialized +* [blueprint_input](../api/extensions.md#blueprint_input) runs each time a blueprint serializes an object diff --git a/docs/upgrading/fields.md b/docs/upgrading/fields.md new file mode 100644 index 00000000..27205a2c --- /dev/null +++ b/docs/upgrading/fields.md @@ -0,0 +1,67 @@ +# Fields + +## Identifier field and view + +Blueprinter Legacy/V1 had a special feature for an `id` field and `identifier` view. Blueprinter V2 does not have this concept, but you can simulate it in your `ApplicationBlueprint`. + +```ruby +class ApplicationBlueprint < Blueprinter::Blueprint + # Every Blueprint that inherits from ApplicationBlueprint will have this field + field :id + + # Every Blueprint that inherits from ApplicationBlueprint will have this view, + # and it will only have the `id` field + view :identifier, empty: true do + field :id + end +end +``` + +## Renaming fields + +In Blueprinter Legacy/V1, you could rename fields using the `name` option. Blueprinter V2 swaps the order and uses `from`. We believe this makes your blueprints more readable. + +In the following examples, both blueprints are populating the output field *description* from a source attribute named *desc*. + +```ruby +# Legacy/V1 +field :desc, name: :description + +# V2 +field :description, from: :desc +``` + +## Associations + +Blueprinter Legacy/V1 figured out if associations were single items or arrays at runtime. Blueprinter V2 accounts for this in the DSL. Also, the `:blueprint` and `:view` options are gone. + +```ruby +class WidgetBlueprint < ApplicationBlueprint + field :name + object :category, CategoryBlueprint + collection :parts, PartBlueprint + + # specify a view + object :manufacturer, CompanyBlueprint[:extended] +end +``` + +## Field order + +Blueprinter Legacy/V1 offered two options for ordering fields: `:name_asc` (default), and `:definition` (order they were defined in). Blueprinter V2 defaults to the order of definition. You can define a different order using the [blueprint_fields](../api/extensions.md#blueprint_fields) extension hook or the built-in [FieldOrder](../dsl/extensions.md#field-order) extension. + +The following replicates Legacy/V1's default field order using the built-in [FieldOrder](../dsl/extensions.md#field-order) extension. + +```ruby +class ApplicationBlueprint < Blueprinter::Blueprint + extensions << Blueprinter::Extensions::FieldOrder.new do |a, b| + if a.name == :id + -1 + elsif b.name == :id + 1 + else + a.name <=> b.name + end + end +end +``` diff --git a/docs/upgrading/index.md b/docs/upgrading/index.md new file mode 100644 index 00000000..676b65c4 --- /dev/null +++ b/docs/upgrading/index.md @@ -0,0 +1,39 @@ +# Upgrading to API V2 + +You have two options when updating from the legacy/V1 API: [full update](#full-update) or [incremental update](#incremental-update). + +Regardless which you choose, you'll need to familiarize yourself with the [new DSL](../dsl/index.md) and [API](../api/index.md). The rest of this section will focus on the differences between V1 and V2. + +## Full update + +Update `blueprinter` to 2.x. All of your blueprints will need updated to use the [new DSL](../dsl/index.md). If you're making use of extensions, custom extractors, or transformers, they'll also need updated to the [new API](../api/index.md). + +## Incremental update + +Larger applications may find it easier to update incrementally. Update `blueprinter` to 1.2.x, which contains both the legacy/V1 and V2 APIs. They can be used side-by-side. + +```ruby +# A legacy/V1 blueprint +class WidgetBlueprint < Blueprinter::Blueprint + field :name + + view :with_desc do + field :description + end + + view :with_category do + # Using a V2 blueprint in a legacy/V1 blueprint + association :category, blueprint: CategoryBlueprint, view: :extended + end +end + +# A V2 blueprint +class CategoryBlueprint < ApplicationBlueprint + field :name + + view :extended do + # Using a legacy/V1 blueprint in a V2 blueprint + collection :widgets, WidgetBlueprint[:with_desc] + end +end +``` diff --git a/docs/upgrading/reflection.md b/docs/upgrading/reflection.md new file mode 100644 index 00000000..18800070 --- /dev/null +++ b/docs/upgrading/reflection.md @@ -0,0 +1,66 @@ +# Reflection + +The [V2 Reflection API](../api/reflection.md) has very few changes from Legacy/V1. + +## Reflecting on fields + +Regular fields (no change): + +```ruby +MyBlueprint.reflections[:default].fields +``` + +Objects and collections: + +```ruby +# Legacy/V1 does not differentiate between objects and collections +MyV1Blueprint.reflections[:default].associations + +# V2 does +MyV2Blueprint.reflections[:default].objects +MyV2Blueprint.reflections[:default].collections +``` + +## Field names + +[V2's field metadata](../api/reflection.md#field-metadata) is similar, but there's an important different in `name`. + +#### Legacy/V1 + +In Legacy/V1, `name` refers to what the field is called in the *input*. + +```ruby +class MyV1Blueprint < Blueprinter::Base + field :foo, name: :bar +end + +ref = MyV1Blueprint.reflections[:default] + +# What the field is called in the source object +ref.fields[:foo].name +=> :foo + +# What the field will be called in the output +ref.fields[:foo].display_name +=> :bar +``` + +#### V2 + +In V2, `name` refers to what the field is called in the *output*. Note that this change is also reflected in the Hash key. + +```ruby +class MyV2Blueprint < Blueprinter::Blueprint + field :bar, from: :foo +end + +ref = MyV1Blueprint.reflections[:default] + +# What the field will be called in the output +ref.fields[:bar].name +=> :bar + +# What the field is called in the source object +ref.fields[:bar].from +=> :foo +``` diff --git a/docs/upgrading/rendering.md b/docs/upgrading/rendering.md new file mode 100644 index 00000000..420aea47 --- /dev/null +++ b/docs/upgrading/rendering.md @@ -0,0 +1,37 @@ +# Rendering + +You can read the full [rendering documentation here](../rendering.md). This page highlights the main differences between V1 and V2. + +### Rendering to JSON + +If you're using Rails's `render json:`, V2 blueprints should continue to work like Legacy/V1: + +```ruby +render json: WidgetBlueprint.render(widget) +``` + +Otherwise, it now looks like this: + +```ruby +WidgetBlueprint.render(widget).to_json +``` + +### Rendering to Hash + +```ruby +WidgetBlueprint.render(widget).to_hash +``` + +### Views + +V2's preferred method of rendering views is: + +```ruby +WidgetBlueprint[:extended].render(widget).to_json +``` + +However, the [ViewOption](../dsl/extensions.md#viewoption) extension can be enabled to allow V1-style view rendering: + +```ruby +WidgetBlueprint.render(widget, view: :extended).to_json +``` diff --git a/docs/v1.md b/docs/v1.md new file mode 100644 index 00000000..7c5b7cb1 --- /dev/null +++ b/docs/v1.md @@ -0,0 +1,3 @@ +# Legacy/V1 Docs + +TODO copy from old README diff --git a/spec/v2/partials_spec.rb b/spec/v2/partials_spec.rb index 10af3405..7b509b4a 100644 --- a/spec/v2/partials_spec.rb +++ b/spec/v2/partials_spec.rb @@ -105,6 +105,10 @@ blueprint = Class.new(Blueprinter::V2::Base) do view :foo do field :name + + view :zorp do + field :zorp + end end view :bar do @@ -113,8 +117,8 @@ end end - refs = blueprint.reflections - expect(refs[:bar].fields.keys).to eq %i(name description).sort + expect(blueprint.reflections[:bar].fields.keys.sort).to eq %i(name description).sort + expect(blueprint.reflections[:"bar.zorp"].fields.keys.sort).to eq %i(name zorp description).sort end context 'precedence' do diff --git a/theme/favicon.svg b/theme/favicon.svg new file mode 100644 index 00000000..c5b65fd7 --- /dev/null +++ b/theme/favicon.svg @@ -0,0 +1,17 @@ + + + + Blueprinter_option3 + Created with Sketch. + + + + + + + + + + + + \ No newline at end of file diff --git a/theme/highlight.js b/theme/highlight.js new file mode 100644 index 00000000..aa42ae09 --- /dev/null +++ b/theme/highlight.js @@ -0,0 +1,382 @@ +/*! + Highlight.js v11.10.0 (git: 366a8bd012) + (c) 2006-2024 Josh Goebel and other contributors + License: BSD-3-Clause + */ +var hljs=function(){"use strict";function e(t){ +return t instanceof Map?t.clear=t.delete=t.set=()=>{ +throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{ +const i=t[n],s=typeof i;"object"!==s&&"function"!==s||Object.isFrozen(i)||e(i) +})),t}class t{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function n(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function i(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t] +;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const s=e=>!!e.scope +;class o{constructor(e,t){ +this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){ +this.buffer+=n(e)}openNode(e){if(!s(e))return;const t=((e,{prefix:t})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const n=e.split(".") +;return[`${t}${n.shift()}`,...n.map(((e,t)=>`${e}${"_".repeat(t+1)}`))].join(" ") +}return`${t}${e}`})(e.scope,{prefix:this.classPrefix});this.span(t)} +closeNode(e){s(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const r=(e={})=>{const t={children:[]} +;return Object.assign(t,e),t};class a{constructor(){ +this.rootNode=r(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const t=r({scope:e}) +;this.add(t),this.stack.push(t)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){ +return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t), +t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +a._collapse(e)})))}}class c extends a{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,t){const n=e.root +;t&&(n.scope="language:"+t),this.add(n)}toHTML(){ +return new o(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function l(e){ +return e?"string"==typeof e?e:e.source:null}function g(e){return h("(?=",e,")")} +function u(e){return h("(?:",e,")*")}function d(e){return h("(?:",e,")?")} +function h(...e){return e.map((e=>l(e))).join("")}function f(...e){const t=(e=>{ +const t=e[e.length-1] +;return"object"==typeof t&&t.constructor===Object?(e.splice(e.length-1,1),t):{} +})(e);return"("+(t.capture?"":"?:")+e.map((e=>l(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const b=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function m(e,{joinWith:t}){let n=0;return e.map((e=>{n+=1;const t=n +;let i=l(e),s="";for(;i.length>0;){const e=b.exec(i);if(!e){s+=i;break} +s+=i.substring(0,e.index), +i=i.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0], +"("===e[0]&&n++)}return s})).map((e=>`(${e})`)).join(t)} +const E="[a-zA-Z]\\w*",x="[a-zA-Z_]\\w*",w="\\b\\d+(\\.\\d+)?",y="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",_="\\b(0b[01]+)",O={ +begin:"\\\\[\\s\\S]",relevance:0},v={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[O]},k={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[O]},N=(e,t,n={})=>{const s=i({scope:"comment",begin:e,end:t, +contains:[]},n);s.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const o=f("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return s.contains.push({begin:h(/[ ]+/,"(",o,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),s +},S=N("//","$"),M=N("/\\*","\\*/"),R=N("#","$");var j=Object.freeze({ +__proto__:null,APOS_STRING_MODE:v,BACKSLASH_ESCAPE:O,BINARY_NUMBER_MODE:{ +scope:"number",begin:_,relevance:0},BINARY_NUMBER_RE:_,COMMENT:N, +C_BLOCK_COMMENT_MODE:M,C_LINE_COMMENT_MODE:S,C_NUMBER_MODE:{scope:"number", +begin:y,relevance:0},C_NUMBER_RE:y,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{ +t.data._beginMatch!==e[1]&&t.ignoreMatch()}}),HASH_COMMENT_MODE:R,IDENT_RE:E, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+x,relevance:0}, +NUMBER_MODE:{scope:"number",begin:w,relevance:0},NUMBER_RE:w, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:k,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[O,{begin:/\[/,end:/\]/,relevance:0,contains:[O]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const t=/^#![ ]*\// +;return e.binary&&(e.begin=h(t,/.*\b/,e.binary,/\b.*/)),i({scope:"meta",begin:t, +end:/$/,relevance:0,"on:begin":(e,t)=>{0!==e.index&&t.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:E,relevance:0},UNDERSCORE_IDENT_RE:x, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:x,relevance:0}});function A(e,t){ +"."===e.input[e.index-1]&&t.ignoreMatch()}function I(e,t){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function T(e,t){ +t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=A,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function L(e,t){ +Array.isArray(e.illegal)&&(e.illegal=f(...e.illegal))}function B(e,t){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function P(e,t){ +void 0===e.relevance&&(e.relevance=1)}const D=(e,t)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const n=Object.assign({},e);Object.keys(e).forEach((t=>{delete e[t] +})),e.keywords=n.keywords,e.begin=h(n.beforeMatch,g(n.begin)),e.starts={ +relevance:0,contains:[Object.assign(n,{endsParent:!0})] +},e.relevance=0,delete n.beforeMatch +},H=["of","and","for","in","not","or","if","then","parent","list","value"],C="keyword" +;function $(e,t,n=C){const i=Object.create(null) +;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{ +Object.assign(i,$(e[n],t,n))})),i;function s(e,n){ +t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|") +;i[n[0]]=[e,U(n[0],n[1])]}))}}function U(e,t){ +return t?Number(t):(e=>H.includes(e.toLowerCase()))(e)?0:1}const z={},W=e=>{ +console.error(e)},X=(e,...t)=>{console.log("WARN: "+e,...t)},G=(e,t)=>{ +z[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),z[`${e}/${t}`]=!0) +},K=Error();function F(e,t,{key:n}){let i=0;const s=e[n],o={},r={} +;for(let e=1;e<=t.length;e++)r[e+i]=s[e],o[e+i]=!0,i+=p(t[e-1]) +;e[n]=r,e[n]._emit=o,e[n]._multi=!0}function Z(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw W("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +K +;if("object"!=typeof e.beginScope||null===e.beginScope)throw W("beginScope must be object"), +K;F(e,e.begin,{key:"beginScope"}),e.begin=m(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw W("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +K +;if("object"!=typeof e.endScope||null===e.endScope)throw W("endScope must be object"), +K;F(e,e.end,{key:"endScope"}),e.end=m(e.end,{joinWith:""})}})(e)}function V(e){ +function t(t,n){ +return RegExp(l(t),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(n?"g":"")) +}class n{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,t){ +t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=t(m(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const t=this.matcherRe.exec(e);if(!t)return null +;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n] +;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const t=new n +;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))), +t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){ +this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){ +const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex +;let n=t.exec(e) +;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{ +const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)} +return n&&(this.regexIndex+=n.position+1, +this.regexIndex===this.count&&this.considerAll()),n}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=i(e.classNameAliases||{}),function n(o,r){const a=o +;if(o.isCompiled)return a +;[I,B,Z,D].forEach((e=>e(o,r))),e.compilerExtensions.forEach((e=>e(o,r))), +o.__beforeBegin=null,[T,L,P].forEach((e=>e(o,r))),o.isCompiled=!0;let c=null +;return"object"==typeof o.keywords&&o.keywords.$pattern&&(o.keywords=Object.assign({},o.keywords), +c=o.keywords.$pattern, +delete o.keywords.$pattern),c=c||/\w+/,o.keywords&&(o.keywords=$(o.keywords,e.case_insensitive)), +a.keywordPatternRe=t(c,!0), +r&&(o.begin||(o.begin=/\B|\b/),a.beginRe=t(a.begin),o.end||o.endsWithParent||(o.end=/\B|\b/), +o.end&&(a.endRe=t(a.end)), +a.terminatorEnd=l(a.end)||"",o.endsWithParent&&r.terminatorEnd&&(a.terminatorEnd+=(o.end?"|":"")+r.terminatorEnd)), +o.illegal&&(a.illegalRe=t(o.illegal)), +o.contains||(o.contains=[]),o.contains=[].concat(...o.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>i(e,{ +variants:null},t)))),e.cachedVariants?e.cachedVariants:q(e)?i(e,{ +starts:e.starts?i(e.starts):null +}):Object.isFrozen(e)?i(e):e))("self"===e?o:e)))),o.contains.forEach((e=>{n(e,a) +})),o.starts&&n(o.starts,r),a.matcher=(e=>{const t=new s +;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(a),a}(e)}function q(e){ +return!!e&&(e.endsWithParent||q(e.starts))}class J extends Error{ +constructor(e,t){super(e),this.name="HTMLInjectionError",this.html=t}} +const Y=n,Q=i,ee=Symbol("nomatch"),te=n=>{ +const i=Object.create(null),s=Object.create(null),o=[];let r=!0 +;const a="Could not find the language '{}', did you forget to load/include a language module?",l={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:c};function b(e){ +return p.noHighlightRe.test(e)}function m(e,t,n){let i="",s="" +;"object"==typeof t?(i=e, +n=t.ignoreIllegals,s=t.language):(G("10.7.0","highlight(lang, code, ...args) has been deprecated."), +G("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +s=e,i=t),void 0===n&&(n=!0);const o={code:i,language:s};N("before:highlight",o) +;const r=o.result?o.result:E(o.language,o.code,n) +;return r.code=o.code,N("after:highlight",r),r}function E(e,n,s,o){ +const c=Object.create(null);function l(){if(!N.keywords)return void M.addText(R) +;let e=0;N.keywordPatternRe.lastIndex=0;let t=N.keywordPatternRe.exec(R),n="" +;for(;t;){n+=R.substring(e,t.index) +;const s=_.case_insensitive?t[0].toLowerCase():t[0],o=(i=s,N.keywords[i]);if(o){ +const[e,i]=o +;if(M.addText(n),n="",c[s]=(c[s]||0)+1,c[s]<=7&&(j+=i),e.startsWith("_"))n+=t[0];else{ +const n=_.classNameAliases[e]||e;u(t[0],n)}}else n+=t[0] +;e=N.keywordPatternRe.lastIndex,t=N.keywordPatternRe.exec(R)}var i +;n+=R.substring(e),M.addText(n)}function g(){null!=N.subLanguage?(()=>{ +if(""===R)return;let e=null;if("string"==typeof N.subLanguage){ +if(!i[N.subLanguage])return void M.addText(R) +;e=E(N.subLanguage,R,!0,S[N.subLanguage]),S[N.subLanguage]=e._top +}else e=x(R,N.subLanguage.length?N.subLanguage:null) +;N.relevance>0&&(j+=e.relevance),M.__addSublanguage(e._emitter,e.language) +})():l(),R=""}function u(e,t){ +""!==e&&(M.startScope(t),M.addText(e),M.endScope())}function d(e,t){let n=1 +;const i=t.length-1;for(;n<=i;){if(!e._emit[n]){n++;continue} +const i=_.classNameAliases[e[n]]||e[n],s=t[n];i?u(s,i):(R=s,l(),R=""),n++}} +function h(e,t){ +return e.scope&&"string"==typeof e.scope&&M.openNode(_.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(u(R,_.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +R=""):e.beginScope._multi&&(d(e.beginScope,t),R="")),N=Object.create(e,{parent:{ +value:N}}),N}function f(e,n,i){let s=((e,t)=>{const n=e&&e.exec(t) +;return n&&0===n.index})(e.endRe,i);if(s){if(e["on:end"]){const i=new t(e) +;e["on:end"](n,i),i.isMatchIgnored&&(s=!1)}if(s){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return f(e.parent,n,i)}function b(e){ +return 0===N.matcher.regexIndex?(R+=e[0],1):(T=!0,0)}function m(e){ +const t=e[0],i=n.substring(e.index),s=f(N,e,i);if(!s)return ee;const o=N +;N.endScope&&N.endScope._wrap?(g(), +u(t,N.endScope._wrap)):N.endScope&&N.endScope._multi?(g(), +d(N.endScope,e)):o.skip?R+=t:(o.returnEnd||o.excludeEnd||(R+=t), +g(),o.excludeEnd&&(R=t));do{ +N.scope&&M.closeNode(),N.skip||N.subLanguage||(j+=N.relevance),N=N.parent +}while(N!==s.parent);return s.starts&&h(s.starts,e),o.returnEnd?0:t.length} +let w={};function y(i,o){const a=o&&o[0];if(R+=i,null==a)return g(),0 +;if("begin"===w.type&&"end"===o.type&&w.index===o.index&&""===a){ +if(R+=n.slice(o.index,o.index+1),!r){const t=Error(`0 width match regex (${e})`) +;throw t.languageName=e,t.badRule=w.rule,t}return 1} +if(w=o,"begin"===o.type)return(e=>{ +const n=e[0],i=e.rule,s=new t(i),o=[i.__beforeBegin,i["on:begin"]] +;for(const t of o)if(t&&(t(e,s),s.isMatchIgnored))return b(n) +;return i.skip?R+=n:(i.excludeBegin&&(R+=n), +g(),i.returnBegin||i.excludeBegin||(R=n)),h(i,e),i.returnBegin?0:n.length})(o) +;if("illegal"===o.type&&!s){ +const e=Error('Illegal lexeme "'+a+'" for mode "'+(N.scope||"")+'"') +;throw e.mode=N,e}if("end"===o.type){const e=m(o);if(e!==ee)return e} +if("illegal"===o.type&&""===a)return 1 +;if(I>1e5&&I>3*o.index)throw Error("potential infinite loop, way more iterations than matches") +;return R+=a,a.length}const _=O(e) +;if(!_)throw W(a.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const v=V(_);let k="",N=o||v;const S={},M=new p.__emitter(p);(()=>{const e=[] +;for(let t=N;t!==_;t=t.parent)t.scope&&e.unshift(t.scope) +;e.forEach((e=>M.openNode(e)))})();let R="",j=0,A=0,I=0,T=!1;try{ +if(_.__emitTokens)_.__emitTokens(n,M);else{for(N.matcher.considerAll();;){ +I++,T?T=!1:N.matcher.considerAll(),N.matcher.lastIndex=A +;const e=N.matcher.exec(n);if(!e)break;const t=y(n.substring(A,e.index),e) +;A=e.index+t}y(n.substring(A))}return M.finalize(),k=M.toHTML(),{language:e, +value:k,relevance:j,illegal:!1,_emitter:M,_top:N}}catch(t){ +if(t.message&&t.message.includes("Illegal"))return{language:e,value:Y(n), +illegal:!0,relevance:0,_illegalBy:{message:t.message,index:A, +context:n.slice(A-100,A+100),mode:t.mode,resultSoFar:k},_emitter:M};if(r)return{ +language:e,value:Y(n),illegal:!1,relevance:0,errorRaised:t,_emitter:M,_top:N} +;throw t}}function x(e,t){t=t||p.languages||Object.keys(i);const n=(e=>{ +const t={value:Y(e),illegal:!1,relevance:0,_top:l,_emitter:new p.__emitter(p)} +;return t._emitter.addText(e),t})(e),s=t.filter(O).filter(k).map((t=>E(t,e,!1))) +;s.unshift(n);const o=s.sort(((e,t)=>{ +if(e.relevance!==t.relevance)return t.relevance-e.relevance +;if(e.language&&t.language){if(O(e.language).supersetOf===t.language)return 1 +;if(O(t.language).supersetOf===e.language)return-1}return 0})),[r,a]=o,c=r +;return c.secondBest=a,c}function w(e){let t=null;const n=(e=>{ +let t=e.className+" ";t+=e.parentNode?e.parentNode.className:"" +;const n=p.languageDetectRe.exec(t);if(n){const t=O(n[1]) +;return t||(X(a.replace("{}",n[1])), +X("Falling back to no-highlight mode for this block.",e)),t?n[1]:"no-highlight"} +return t.split(/\s+/).find((e=>b(e)||O(e)))})(e);if(b(n))return +;if(N("before:highlightElement",{el:e,language:n +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new J("One of your code blocks includes unescaped HTML.",e.innerHTML) +;t=e;const i=t.textContent,o=n?m(i,{language:n,ignoreIllegals:!0}):x(i) +;e.innerHTML=o.value,e.dataset.highlighted="yes",((e,t,n)=>{const i=t&&s[t]||n +;e.classList.add("hljs"),e.classList.add("language-"+i) +})(e,n,o.language),e.result={language:o.language,re:o.relevance, +relevance:o.relevance},o.secondBest&&(e.secondBest={ +language:o.secondBest.language,relevance:o.secondBest.relevance +}),N("after:highlightElement",{el:e,result:o,text:i})}let y=!1;function _(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(w):y=!0 +}function O(e){return e=(e||"").toLowerCase(),i[e]||i[s[e]]} +function v(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +s[e.toLowerCase()]=t}))}function k(e){const t=O(e) +;return t&&!t.disableAutodetect}function N(e,t){const n=e;o.forEach((e=>{ +e[n]&&e[n](t)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +y&&_()}),!1),Object.assign(n,{highlight:m,highlightAuto:x,highlightAll:_, +highlightElement:w, +highlightBlock:e=>(G("10.7.0","highlightBlock will be removed entirely in v12.0"), +G("10.7.0","Please use highlightElement now."),w(e)),configure:e=>{p=Q(p,e)}, +initHighlighting:()=>{ +_(),G("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +_(),G("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,t)=>{let s=null;try{s=t(n)}catch(t){ +if(W("Language definition for '{}' could not be registered.".replace("{}",e)), +!r)throw t;W(t),s=l} +s.name||(s.name=e),i[e]=s,s.rawDefinition=t.bind(null,n),s.aliases&&v(s.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete i[e] +;for(const t of Object.keys(s))s[t]===e&&delete s[t]}, +listLanguages:()=>Object.keys(i),getLanguage:O,registerAliases:v, +autoDetection:k,inherit:Q,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{ +e["before:highlightBlock"](Object.assign({block:t.el},t)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{ +e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),o.push(e)}, +removePlugin:e=>{const t=o.indexOf(e);-1!==t&&o.splice(t,1)}}),n.debugMode=()=>{ +r=!1},n.safeMode=()=>{r=!0},n.versionString="11.10.0",n.regex={concat:h, +lookahead:g,either:f,optional:d,anyNumberOfTimes:u} +;for(const t in j)"object"==typeof j[t]&&e(j[t]);return Object.assign(n,j),n +},ne=te({});return ne.newInstance=()=>te({}),ne}() +;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);/*! `bash` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{const s=e.regex,t={},n={begin:/\$\{/, +end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]};Object.assign(t,{ +className:"variable",variants:[{ +begin:s.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},n]});const a={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE] +},i=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),c={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,a]};a.contains.push(o);const r={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),m={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[l,e.SHEBANG(),m,r,i,c,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{ +className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}})() +;hljs.registerLanguage("bash",e)})();/*! `ruby` grammar compiled for Highlight.js 11.10.0 */ +(()=>{var e=(()=>{"use strict";return e=>{ +const n=e.regex,a="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",s=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(s,/(::\w+)*/),t={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},c={className:"doctag",begin:"@[A-Za-z]+"},r={ +begin:"#<",end:">"},b=[e.COMMENT("#","$",{contains:[c] +}),e.COMMENT("^=begin","^=end",{contains:[c],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],l={className:"subst",begin:/#\{/, +end:/\}/,keywords:t},d={className:"string",contains:[e.BACKSLASH_ESCAPE,l], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,l]})]}]},o="[0-9](_?[0-9])*",g={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${o}))?([eE][+-]?(${o})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},_={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:t}]},u=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:t},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:t},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:s,scope:"title.class"},{ +match:[/def/,/\s+/,a],scope:{1:"keyword",3:"title.function"},contains:[_]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:a}],relevance:0},g,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:t},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,l], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(r,b),relevance:0}].concat(r,b) +;l.contains=u,_.contains=u;const m=[{begin:/^\s*=>/,starts:{end:"$",contains:u} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:t,contains:u}}];return b.unshift(r),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:t,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(m).concat(b).concat(u)}}})() +;hljs.registerLanguage("ruby",e)})(); \ No newline at end of file diff --git a/theme/hljs-overrides.css b/theme/hljs-overrides.css new file mode 100644 index 00000000..3bcfc9c7 --- /dev/null +++ b/theme/hljs-overrides.css @@ -0,0 +1,60 @@ +/* Override the default syntax highlighting to sorta match GitHub's */ + +code.language-ruby.hljs .hljs-params { + color: inherit; +} + +/* Assume dark theme by default */ + +code.language-ruby.hljs .hljs-keyword { + color: #f46767; +} + +code.language-ruby.hljs .hljs-symbol, +code.language-ruby.hljs .hljs-string { + color: #96d0ff; +} + +code.language-ruby.hljs .hljs-title.function_ { + color: #dcbdfb; +} + +code.language-ruby.hljs .hljs-title.class_ { + color: #f69d50; +} + +code.language-ruby.hljs .hljs-variable, +code.language-ruby.hljs .hljs-literal { + color: #6cb6ff; +} + +/* Variants for light themes */ + +html.light code.language-ruby.hljs .hljs-keyword, +html.rust code.language-ruby.hljs .hljs-keyword { + color: #cf222e; +} + +html.light code.language-ruby.hljs .hljs-title.function_, +html.rust code.language-ruby.hljs .hljs-title.function_ { + color: #6639ba; +} + +html.light code.language-ruby.hljs .hljs-title.class_, +html.rust code.language-ruby.hljs .hljs-title.class_ { + color: #953800; +} + +html.light code.language-ruby.hljs .hljs-variable, +html.light code.language-ruby.hljs .hljs-literal, +html.rust code.language-ruby.hljs .hljs-variable, +html.rust code.language-ruby.hljs .hljs-literal { + color: #0550ae; +} + +html.light code.language-ruby.hljs .hljs-symbol, +html.light code.language-ruby.hljs .hljs-string, +html.rust code.language-ruby.hljs .hljs-symbol, +html.rust code.language-ruby.hljs .hljs-string { + color: #1554b3; +} From 3f591e5c4cb7ce41488b8c596acc6f67e25bc6d3 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Thu, 30 Oct 2025 09:07:26 -0400 Subject: [PATCH 3/3] Update extension docs to middleware-based design Signed-off-by: Jordan Hollinger --- book.toml | 2 +- docs/SUMMARY.md | 1 - docs/api/context-objects.md | 83 +++--- docs/api/extensions.md | 467 ++++++++++---------------------- docs/api/extractors.md | 36 --- docs/api/index.md | 6 +- docs/dsl/extensions.md | 11 +- docs/dsl/fields.md | 38 ++- docs/dsl/options.md | 15 - docs/rendering.md | 2 +- docs/upgrading/customization.md | 10 +- docs/upgrading/extensions.md | 9 +- docs/upgrading/fields.md | 2 +- theme/custom.css | 5 + 14 files changed, 237 insertions(+), 450 deletions(-) delete mode 100644 docs/api/extractors.md create mode 100644 theme/custom.css diff --git a/book.toml b/book.toml index 7840b125..28bc0b94 100644 --- a/book.toml +++ b/book.toml @@ -9,7 +9,7 @@ build-dir = "docs-dist" # https://rust-lang.github.io/mdBook/format/configuration/renderers.html?highlight=top%20bar#html-renderer-options [output.html] -additional-css = ["theme/hljs-overrides.css"] +additional-css = ["theme/hljs-overrides.css", "theme/custom.css"] default-theme = "light" preferred-dark-theme = "navy" no-section-label = true diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 8aab7640..0629fba6 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -15,7 +15,6 @@ - [Blueprinter API](./api/index.md) - [Extensions](./api/extensions.md) - [Reflection](./api/reflection.md) - - [Extractors](./api/extractors.md) - [Context Objects](./api/context-objects.md) - [Fields](./api/fields.md) diff --git a/docs/api/context-objects.md b/docs/api/context-objects.md index 014cfe3e..ee74336d 100644 --- a/docs/api/context-objects.md +++ b/docs/api/context-objects.md @@ -4,94 +4,109 @@ Context objects are the arguments passed to APIs like [field blocks](../dsl/fiel ## Render Context -> **blueprint**\ +_* Field can be assigned._ + +> **blueprint** \ > The current Blueprint instance. You can use this to access the Blueprint's name, options, reflections, and instance methods. -> **fields**\ -> A frozen array of field definitions that will be serialized, in order. See [Fields API](./fields.md) and the [blueprint_fields](./extensions.md#blueprint_fields) hook. +> **fields** * \ +> A frozen array of field definitions that will be serialized, in order. See [Fields API](./fields.md). -> **options**\ +> **options** * \ > The frozen options Hash passed to `render`. An empty Hash if none was passed. -> **depth**\ +> **store** \ +> A Hash that can be used to store & access information by extensions and your application. + +> **depth** \ > The current blueprint depth (1-indexed). ## Object Context -> **blueprint**\ +_* Field can be assigned._ + +> **blueprint** \ > The current Blueprint instance. You can use this to access the Blueprint's name, options, reflections, and instance methods. -> **fields**\ +> **fields** \ > A frozen array of field definitions that will be serialized, in order. See [Fields API](./fields.md) and the [blueprint_fields](./extensions.md#blueprint_fields) hook. -> **options**\ +> **options** \ > The frozen options Hash passed to `render`. An empty Hash if none was passed. -> **object**\ +> **object** * \ > The object or collection currently being serialized. -> **depth**\ +> **store** \ +> A Hash that can be used to store & access information by extensions and your application. + +> **depth** \ > The current blueprint depth (1-indexed). ## Field Context -> **blueprint**\ +> **blueprint** \ > The current Blueprint instance. You can use this to access the Blueprint's name, options, reflections, and instance methods. -> **fields**\ +> **fields** \ > A frozen array of field definitions that will be serialized, in order. See [Fields API](./fields.md) and the [blueprint_fields](./extensions.md#blueprint_fields) hook. -> **options**\ +> **options** \ > The frozen options Hash passed to `render`. An empty Hash if none was passed. -> **object**\ +> **object** \ > The object currently being serialized. -> **field**\ +> **field** \ > A struct of the field, object, or collection currently being rendered. You can use this to access the field's name and options. See [Fields API](./fields.md). -> **value**\ -> The extracted field value. (In certain situations, like the extractor API and field blocks, it will always be `nil` since nothing has been extracted yet.) +> **store** \ +> A Hash that can be used to store & access information by extensions and your application. -> **depth**\ +> **depth** \ > The current blueprint depth (1-indexed). ## Result Context -> **blueprint**\ +_* Field can be assigned._ + +> **blueprint** * \ > The current Blueprint instance. You can use this to access the Blueprint's name, options, reflections, and instance methods. -> **fields**\ -> A frozen array of field definitions that were serialized, in order. See [Fields API](./fields.md) and the [blueprint_fields](./extensions.md#blueprint_fields) hook. +> **fields** \ +> A frozen array of field definitions that were serialized, in order. See [Fields API](./fields.md) and the [around_blueprint_init](./extensions.md#around_blueprint_init) hook. -> **options**\ +> **options** * \ > The frozen options Hash passed to `render`. An empty Hash if none was passed. -> **object**\ +> **object** * \ > The object or collection that was just serialized. -> **result**\ -> A serialized result. Depending on the situation this will be a Hash or an array of Hashes. +> **store** \ +> A Hash that can be used to store & access information by extensions and your application. -> **depth**\ -> The current blueprint depth (1-indexed). +> **format** * \ +> The requested serialization format (e.g. `:json`, `:hash`). ## Hook Context -> **blueprint**\ +> **blueprint** \ > The current Blueprint instance. You can use this to access the Blueprint's name, options, reflections, and instance methods. -> **fields**\ -> A frozen array of field definitions that will be serialized, in order. See [Fields API](./fields.md) and the [blueprint_fields](./extensions.md#blueprint_fields) hook. +> **fields** \ +> A frozen array of field definitions that will be serialized, in order. See [Fields API](./fields.md) and the [around_blueprint_init](./extensions.md#around_blueprint_init) hook. -> **options**\ +> **options** \ > The frozen options Hash passed to `render`. An empty Hash if none was passed. -> **extension**\ +> **extension** \ > Instance of the current extension -> **hook**\ +> **hook** \ > Name of the current hook -> **depth**\ +> **store** \ +> A Hash that can be used to store & access information by extensions and your application. + +> **depth** \ > The current blueprint depth (1-indexed). diff --git a/docs/api/extensions.md b/docs/api/extensions.md index f3263023..924b04ed 100644 --- a/docs/api/extensions.md +++ b/docs/api/extensions.md @@ -1,447 +1,258 @@ # Extensions -Blueprinter has a powerful extension system with hooks for every step of the serialization lifecycle. In fact, many of Blueprinter's features are implemented as built-in extensions! +Blueprinter has a powerful middleware-based extension system with hooks for every step of the serialization lifecycle. In fact, many of Blueprinter's features are implemented using the extension API! Simply extend the `Blueprinter::Extension` class, define the hooks you need, and [add it to your configuration](../dsl/extensions.md#using-extensions). -```ruby -class MyExtension < Blueprinter::Extension - # Use the exclude_field? hook to exclude certain fields on Tuesdays - def exclude_field?(ctx) = ctx.field.options[:tues] == false && Date.today.tuesday? -end - -class MyBlueprint < ApplicationBlueprint - extensions << MyExtension.new -end -``` - -Alternatively, you can define an extension direclty in your blueprint: - -```ruby -class MyBlueprint < ApplicationBlueprint - extension do - def exclude_field?(ctx) = ctx.field.options[:tues] == false && Date.today.tuesday? - end -end -``` - ## Hooks Hooks are called in the following order. They are passed a [context object](./context-objects.md) as an argument. -- [blueprint](#blueprint) -- [blueprint_fields](#blueprint_fields) -- [blueprint_setup](#blueprint_setup) -- [around_serialize_object](#around_serialize_object) | [around_serialize_collection](#around_serialize_collection) - - [object_input](#object_input) | [collection_input](#collection_input) - - [blueprint_input](#blueprint_input) - - [extract_value](#extract_value) - - [field_value](#field_value) | [object_field_value](#object_field_value) | [collection_field_value](#collection_field_value) - - [exclude_field?](#exclude_field) | [exclude_object_field?](#exclude_object_field) | [exclude_collection_field?](#exclude_collection_field) - - *blueprint_fields …* - - [field_result](#field_result) | [object_field_result](#object_field_result) | [collection_field_result](#collection_field_result) - - [blueprint_output](#blueprint_output) - - [object_output](#object_output) | [collection_output](#collection_output) -- [json](#json) + Additionally, the [around_hook](#around_hook) hook runs around all other hooks. -#### Chain vs override hooks - -Most hooks are *chained*; if you have N of the same hook, they run one after the other, using the output of one as input for the next. However, a few hooks are *override* hooks: only the last one runs. Override hooks are used to replace built-in functionality, like the JSON serializer. - -## blueprint - -> *Override hook*\ -> **@param [Render Context](./context-objects.md#render-context)** NOTE `fields` will be empty\ -> **@return [Class](./fields.md)** The Blueprint class to use\ -> **@cost** Low - run once during render - -Return a different blueprint class to render with. If multiple extensions define this hook, _only the last one_ will be used. The included, optional [View Option extension](../dsl/extensions.md#viewoption) uses this hook. - -The following example looks for a `view` option passed in to `render`. If present, it attempts to return a child view. - -```ruby -def blueprint(ctx) - view = ctx.options[:view] - view ? ctx.blueprint.class[view] : ctx.blueprint.class -end -``` - -## blueprint_fields - -> *Override hook*\ -> **@param [Render Context](./context-objects.md#render-context)**\ -> **@return [Array<Field>](./fields.md)** The fields to serialize\ -> **@cost** Low - run once for _every blueprint class_ during render - -Customize the order fields are rendered in - or strip out certain fields entirely. If multiple extensions define this hook, _only the last one_ will be used. The included, optional [Field Order extension](../dsl/extensions.md#field-order) uses this hook. - -In this hook, `context.fields` will contain all of the view's fields in the order in which they were defined. (Fields from `use`d partials are appended.) The fields this hook returns are used as `context.fields` in all subsequent hooks: - -The following example removes all collection fields and sorts the rest by name: - -```ruby -def blueprint_fields(ctx) - ctx.fields. - reject { |f| f.type == :collection }. - sort_by(&:name) -end -``` - -It's run once _per blueprint class_ during a render. So if you're rendering an array of widgets with `WidgetBlueprint`, which contains `PartBlueprint`s and `CategoryBlueprint`s, this hook will be called **three** times: one for each of those blueprints. +### around_result -[↑ Back to Hooks](#hooks) +> **param** [Result Context](./context-objects.md#result-context) \ +> **return** result \ +> **cost** Low - run once during render -## blueprint_setup +The `around_result` hook runs around the entire serialization process, allowing you to modify the initial input and final output. -> **@param [Render Context](./context-objects.md#render-context)**\ -> **@cost** Low - run once for _every blueprint class_ during render - -Allows an extension to perform setup operations for the render of the current blueprint. +The following example hook caches an entire result for five minutes. ```ruby -def blueprint_setup(ctx) - # do setup for ctx.blueprint +def around_result(ctx) + cache(ctx.blueprint.class, ctx.object, ctx.format, ttl: 300) do + yield ctx + end end ``` -It's run once _per blueprint class_ during a render. So if you're rendering an array of widgets with `WidgetBlueprint`, which contains `PartBlueprint`s and `CategoryBlueprint`s, this hook will be called **three** times: one for each of those blueprints. - -[↑ Back to Hooks](#hooks) +#### Finalizing -## around_serialize_object - -> **@param [Object Context](./context-objects.md#object-context)** `context.object` will contain the current object being rendered\ -> **@cost** Medium - run every time any blueprint is rendered - -Wraps the rendering of every object (`context.object`). This could be the top-level object or one from an association N levels deep (check `context.depth`). - -Rendering happens during `yield`, allowing the hook to run code before and after the render. If `yield` is not called exactly one time, a `BlueprinterError` is thrown. +The `final` and `final?` helpers allow middleware to declare, and check if, a result is "finalized" and should no longer be altered. These helpers should **only** be used in `around_result`. ```ruby -def around_serialize_object(ctx) - # do something before render - yield # render - # do something after render -end -``` - -[↑ Back to Hooks](#hooks) +def around_result(ctx) + result = yield ctx + return result if final? result -## around_serialize_collection - -> **@param [Object Context](./context-objects.md#object-context)** `context.object` will contain the current collection being rendered\ -> **@cost** Medium - run every time any blueprint is rendered - -Wraps the rendering of every collection (`context.object`). This could be the top-level collection or one from an association N levels deep (check `context.depth`). - -Rendering happens during `yield`, allowing the hook to run code before and after the render. If `yield` is not called exactly one time, a `BlueprinterError` is thrown. - -```ruby -def around_serialize_collection(ctx) - # do something before render - yield # render - # do something after render + result = somehow_modify result + final result end ``` -[↑ Back to Hooks](#hooks) - -## object_input +### around_blueprint_init -> **@param [Object Context](./context-objects.md#object-context)** `context.object` will contain the current object being rendered\ -> **@return Object** A new or modified version of `context.object`\ -> **@cost** Medium - run every time an object is rendered +> **param** [Render Context](./context-objects.md#render-context) \ +> **cost** Low - run once per used blueprint during render -Runs before serialization of any object from `render`, `render_object`, or a blueprint's `object` field. You may modify and return `context.object` or return a different object entirely. **Whatever object is returned will be used as context.object in subsequent hooks, then rendered.** - -If you want to target only the root object, check `context.depth == 1`. +The `around_blueprint_init` hook runs the first time a new Blueprint is used during a render cycle. It can be used by extensions to perform time-saving setup before a render. ```ruby -def object_input(ctx) - ctx.object +def around_blueprint_init(ctx) + perform_setup ctx.blueprint, ctx.options + yield ctx end ``` -[↑ Back to Hooks](#hooks) - -## collection_input +`around_blueprint_init` MUST yield, otherwise a `Blueprinter::Errors::ExtensionHook` will be raised. -> **@param [Object Context](./context-objects.md#object-context)** `context.object` will contain the current collection being rendered\ -> **@return Object** A new or modified version of `context.object`, which will be array-like\ -> **@cost** Medium - run every time a collection is rendered +### around_serialize_object -Runs before serialization of any collection from `render`, `render_collection`, or a blueprint's `collection` field. You may modify and return `context.object` or return a different collection entirely. **Whatever collection is returned will be used as context.object in subsequent hooks, then rendered.** +> **param** [Object Context](./context-objects.md#object-context) \ +> **return** result \ +> **cost** Medium - run every time any blueprint is rendered -If you want to target only the root collection, check `context.depth == 1`. +The `around_serialize_object` hook runs around every object (as opposed to collection) that's serialized. The following example would see it called **four** times: once for the category itself and once for each item. ```ruby -def collection_input(ctx) - ctx.object -end +CategoryBlueprint.render({ + name: "Foo", + items: [item1, item2, item3], +}).to_json ``` -[↑ Back to Hooks](#hooks) - -## blueprint_input - -> **@param [Object Context](./context-objects.md#object-context)** `context.object` will contain the current object being rendered\ -> **@return Object** A new or modified version of `context.object`\ -> **@cost** Medium - run every time any blueprint is rendered - -Run each time a blueprint renders, allowing you to modify or return a new object (`context.object`) used for the render. For collections of size N, it will be called N times. **Whatever object is returned will be used as context.object in subsequent hooks, then rendered.** +The following example hook modifies both the input object and the output result. ```ruby -def blueprint_input(ctx) - ctx.object -end -``` - -[↑ Back to Hooks](#hooks) - -## extract_value - -> *Override hook*\ -> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ -> **@return Object** The value for the field\ -> **@cost** High - run for every field, object, and collection +def around_serialize_object(ctx) + # modify the object before it's serialized + ctx.object = modify ctx.object -Called on each field, object, and collection to extract a field's value from an object. The return value is used as `context.value` in subsequent hooks. If multiple extensions define this hook, _only the last one_ will be used. + result = yield ctx -```ruby -def extract_value(ctx) - ctx.object.public_send(ctx.field.from) + # modify the result + result.merge({ foo: "Bar" }) end ``` -[↑ Back to Hooks](#hooks) +### around_serialize_collection -## field_value +> **param** [Object Context](./context-objects.md#object-context) \ +> **return** result \ +> **cost** Medium - run every time any blueprint is rendered -> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ -> **@return Object** The value to be rendered\ -> **@cost** High - run for every field (not object or collection fields) - -Run after a field value is extracted from `context.object`. The extracted value is available in `context.value`. **Whatever value you return is used as context.value in subsequent field_value hooks, then run through any formatters and rendered.** +The `around_serialize_collection` hook runs around every collection that's serialized. The following example would see it called three times: once for the array of categories and once for each set of items. ```ruby -def field_value(ctx) - case ctx.value - when String then ctx.value.strip - else ctx.value - end -end +CategoryBlueprint.render([ + { name: "Foo", items: [item1] }, + { name: "Bar", items: [item2, item3] }, +]).to_json ``` -[↑ Back to Hooks](#hooks) - -## object_field_value - -> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ -> **@return Object** The object to be rendered for this field\ -> **@cost** High - run for every object field - -Run after an object field value is extracted from `context.object`. The extracted value is available in `context.value`. **Whatever value you return is used as context.value in subsequent object_field_value hooks, then rendered.** +The following example hook modifies both the input collection and the output results. ```ruby -def object_field_value(ctx) - ctx.value -end -``` - -[↑ Back to Hooks](#hooks) - -## collection_field_value - -> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ -> **@return Object** The array-like collection to be rendered for this field\ -> **@cost** High - run for every collection field +def around_serialize_collection(ctx) + # modify the collection before it's serialized + ctx.object = modify ctx.object -Run after a collection field value is extracted from `context.object`. The extracted value is available in `context.value`. **Whatever value you return is used as context.value in subsequent collection_field_value hooks, then rendered.** + result = yield ctx -```ruby -def collection_field_value(ctx) - ctx.value.compact + # modify the result + result.reject { |obj| some_logic obj } end ``` -[↑ Back to Hooks](#hooks) - -## exclude_field? +### around_blueprint -> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ -> **@return Boolean** Truthy to exclude the field from the output\ -> **@cost** High - run for every field (not object or collection fields) +> **param** [Object Context](./context-objects.md#object-context) \ +> **return** result \ +> **cost** Medium - run every time any blueprint is rendered -If any extension with this hook returns truthy, the field will be excluded from the output. The formatted field value is available in `context.value`. +The `around_blueprint` hook runs every time an object, including members of collections, are serialized. The following example would see it called three times: once for the category and once for each item. ```ruby -def exclude_field?(ctx) - ctx.field.options[:tuesday] == false && Date.today.tuesday? -end +CategoryBlueprint.render({ name: "Foo", items: [item1, item] }).to_json ``` -[↑ Back to Hooks](#hooks) - -## exclude_object_field? - -> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ -> **@return Boolean** Truthy to exclude the field from the output\ -> **@cost** High - run for every object field - -If any extension with this hook returns truthy, the object field will be excluded from the output. The field object value is available in `context.value`. +The following example hook modifies both the input object and the output result. ```ruby -def exclude_object_field?(ctx) - ctx.field.options[:tuesday] == false && Date.today.tuesday? -end -``` +def around_blueprint(ctx) + # modify the object before it's serialized + ctx.object = modify ctx.object -[↑ Back to Hooks](#hooks) + result = yield ctx -## exclude_collection_field? - -> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ -> **@return Boolean** Truthy to exclude the field from the output\ -> **@cost** High - run for every collection field - -If any extension with this hook returns truthy, the collection field will be excluded from the output. The field collection value is available in `context.value`. - -```ruby -def exclude_collection_field?(ctx) - ctx.field.options[:tuesday] == false && Date.today.tuesday? + # modify the result + result.merge({ foo: "Bar" }) end ``` -[↑ Back to Hooks](#hooks) - -## field_result - -> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ -> **@return Object** The value to be rendered for this field\ -> **@cost** High - run for every field - -The final value to be used for the field, available in `context.value`. You may modify or replace it. **Whatever value you return is used as context.value in subsequent hooks, then rendered.** Not called if [exclude_field?](#exclude_field) returned `true`. - -```ruby -def field_result(ctx) - ctx.value -end -``` - -[↑ Back to Hooks](#hooks) - -## object_field_result +### around_field_value -> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ -> **@return Object** The value to be rendered for this field\ -> **@cost** High - run for every field +> **param** [Field Context](./context-objects.md#field-context) \ +> **return** result \ +> **cost** High - run for every (non-object, non-collection) field -The final value to be used for the field, available in `context.value`. You may modify or replace it. **Whatever value you return is used as context.value in subsequent hooks, then rendered.** Not called if [exclude_object_field?](#exclude_object_field) returned `true`. +The `around_field_value` hook runs around every non-object, non-collection field that's extracted. The following example trims leading and trailing whitespace from string values. ```ruby -def object_field_result(ctx) - ctx.value +def around_field_value(ctx) + val = yield ctx + case val + when String then val.strip + else val + end end ``` -[↑ Back to Hooks](#hooks) - -## collection_field_result +#### Skipping fields -> **@param [Field Context](./context-objects.md#field-context)** `context.field` will contain the current field being serialized, and `context.object` the current object\ -> **@return Object** The value to be rendered for this field\ -> **@cost** High - run for every field - -The final value to be used for the field, available in `context.value`. You may modify or replace it. **Whatever value you return is used as context.value in subsequent hooks, then rendered.** Not called if [exclude_collection_field?](#exclude_collection_field) returned `true`. +You can tell blueprinter to completely skip a field using `skip`. It will bail out of the hook and any previous hooks that yielded. ```ruby -def collection_field_result(ctx) - ctx.value +def around_field_value(ctx) + val = yield ctx + skip if ctx.field.options[:skip_on] == val + val end ``` -[↑ Back to Hooks](#hooks) - -## blueprint_output +### around_object_value -> **@param [Result Context](./context-objects.md#result-context)** `context.result` will contain the serialized Hash from the current blueprint, and `context.object` the current object\ -> **@return Hash** The Hash to use as this blueprint's serialized output\ -> **@cost** Medium - run every time any blueprint is rendered +> **param** [Field Context](./context-objects.md#field-context) \ +> **return** result \ +> **cost** High - run for every object field -Run after a blueprint serializes an object to a Hash, allowing you to modify the output. The Hash is available in `context.result`. For collections of size N, it will be called N times. **Whatever Hash is returned will be used as context.result in subsequent hooks and used as the serialized output for this blueprint.** +The `around_object_value` hook runs around every object field that's extracted (before it's serialized with a Blueprint). The following example adds a `foo` attribute to each object Hash. ```ruby -def blueprint_output(ctx) - ctx.result.merge(ctx.object.extra_fields) +def around_object_value(ctx) + val = yield ctx + case val + when Hash then val.merge({ foo: "bar" }) + else val + end end ``` -[↑ Back to Hooks](#hooks) - -## object_output - -> **@param [Result Context](./context-objects.md#result-context)** `context.result` will contain the serialized Hash from the current blueprint, and `context.object` the current object\ -> **@return [Object]** The value to use for the fully serialized object\ -> **@cost** High - run for every object field +#### Skipping fields -Run after an object is fully serialized. This may be the root object from `render` or an `object` field from a blueprint (check `context.depth`). This example wraps the result in a metadata block: +You can tell blueprinter to completely skip a field using `skip`. It will bail out of the hook and any previous hooks that yielded. ```ruby -def object_output(ctx) - { data: ctx.value, metadata: {...} } +def around_object_value(ctx) + val = yield ctx + skip if ctx.field.options[:skip_on] == val + val end ``` -[↑ Back to Hooks](#hooks) +### around_collection_value -## collection_output +> **param** [Field Context](./context-objects.md#field-context) \ +> **return** result \ +> **cost** High - run for every collection field -> **@param [Result Context](./context-objects.md#result-context)** `context.result` will contain the array of serialized Hashes from the current blueprint, and `context.object` the current collection\ -> **@return Object** The value to use for the fully serialized collection\ -> **@cost** High - run for every collection field - -Run after a collection is fully serialized. This may be the root collection from `render` or a `collection` field from a blueprint (check `context.depth`). This example wraps the result in a metadata block: +The `around_collection_value` hook runs around every collection field that's extracted (before it's serialized with a Blueprint). The following example removes deleted widgets from a collection. ```ruby -def collection_output(ctx) - { data: ctx.value, metadata: {...} } +def around_collection_value(ctx) + val = yield ctx + case ctx.field.blueprint + when WidgetBlueprint + val.reject { |widget| widget.deleted? } + else + val + end end ``` -[↑ Back to Hooks](#hooks) - -## json +#### Skipping fields -> *Override hook*\ -> **@param [Result Context](./context-objects.md#result-context)** `context.result` will contain the serialized Hash or array from the top-level blueprint, and `context.object` the top-level object or collection\ -> **@return String** The JSON output\ -> **@cost** Low - run once per JSON render - -Serializes the final output to JSON. Only called on the top-level blueprint. If multiple extensions define this hook, _only the last one_ will be used. - -The default behavior looks like: +You can tell blueprinter to completely skip a field using `skip`. It will bail out of the hook and any previous hooks that yielded. ```ruby -def json(ctx) - JSON.dump ctx.result +def around_collection_value(ctx) + val = yield ctx + skip if ctx.field.options[:skip_on] == val + val end ``` -[↑ Back to Hooks](#hooks) - -## around_hook +### around_hook -> **@param [Hook Context](./context-objects.md#hook-context)**\ -> **@cost** Variable - Depends on what hooks your extensions implement +> **param** [Hook Context](./context-objects.md#hook-context) \ +> **cost** Variable - runs around all your extensions -A special hook that runs around all other extension hooks. Useful for instrumenting. You can exclude an extension's hooks from this hook by putting `def hidden? = true` in the extension. +The `around_hook` hook runs around all other extension hooks. It **must** yield, otherwise a `Blueprinter::Errors::ExtensionHook` will be raised. The return value from `yield` is **not** used, nor is the return value of `around_hook`. ```ruby -def around_hook(ext, hook) - # Do something before extension hook runs - yield # hook runs here - # Do something after extension hook runs +def around_hook(ctx) + # do something + yield + # do something else end ``` diff --git a/docs/api/extractors.md b/docs/api/extractors.md deleted file mode 100644 index 8d945b5d..00000000 --- a/docs/api/extractors.md +++ /dev/null @@ -1,36 +0,0 @@ -# Extractors - -Extractors are [extensions](./extensions.md#extract_value) that pull field values from the objects you're serializing. The default extraction logic is smart enough for most use cases, but you can create custom extractors if needed. (Note that passing a block to a field completely [bypasses extractors](../dsl/fields.md#extracting-field-values).) - -## Default Extractor - -The default extractor is a built-in extension. If `context.object` is a Hash, it tries symbol, then string keys. Otherwise, it calls `public_send` on the object. - -```ruby -class Blueprinter::Extensions::Core::Extractor < Blueprinter::Extension - def extract_value(ctx) - if ctx.object.is_a? Hash - ctx.object[ctx.field.from] || ctx.object[ctx.field.from_str] - else - ctx.object.public_send(ctx.field.from) - end - end -end -``` - -## Custom Extractors - -Your [extract_value](./extensions.md#extract_value) hook will be passed a [Field context object](./context-objects.md#field-context). - -```ruby -class WeirdObjectExtractor < Blueprinter::Extension - def extract_value(ctx) - # my extraction logic - end -end -``` - -There are several ways to use your extractor: - -* Add it to your blueprint(s) or view(s) like any other [extension](../dsl/extensions.md). -* Add it to specific fields using the [extractor option](../dsl/options.md#extractor). diff --git a/docs/api/index.md b/docs/api/index.md index 983ac4f2..d03e7341 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -4,16 +4,12 @@ Blueprinter has a rich API for extending the serialization process and reflectin ## Extensions -The extensions API offers deep hooks into the serialization process. [Read more](./extensions.md). +The extensions API offers middleware for various parts of the serialization process. [Read more](./extensions.md). ## Reflection The reflection API allows your application, or Blueprinter extensions, to introspect on your blueprints' options, fields, and views. [Read more](./reflection.md). -## Extractors - -By creating and using custom extractors, you can change the way field values are extracted from objects. [Read more](./extractors.md). - ## Context Objects Context objects are the arguments you'll receive in most of the above APIs. [Read more](./context-objects.md). diff --git a/docs/dsl/extensions.md b/docs/dsl/extensions.md index 263fee44..9d647d8c 100644 --- a/docs/dsl/extensions.md +++ b/docs/dsl/extensions.md @@ -1,6 +1,6 @@ # Extensions -Blueprinter has a powerful extension system with hooks for every step of the serialization lifecycle. Some are included with Blueprinter, others are available as gems, and you can easily write your own using the [Extension API](../api/extensions.md). +Blueprinter has a powerful extension system that permits middleware throughout the entire serialization lifecycle. Some extensions are included with Blueprinter, others are available as gems, and you can easily write your own using the [Extension API](../api/extensions.md). ## Using extensions @@ -17,7 +17,10 @@ class MyBlueprint < ApplicationBlueprint # Inline extensions are also initialized once per render extension do - def blueprint_output(ctx) = ctx.result.merge({ foo: "Foo" }) + def around_blueprint(ctx) + result = yield ctx + result.merge({ foo: "Foo" }) + end end view :minimal do @@ -56,7 +59,7 @@ extensions << Blueprinter::Extensions::MultiJson.new(pretty: true) WidgetBlueprint.render(widget, multi_json: { pretty: true }).to_json ``` -If `multi_json` doesn't support your preferred JSON library, you can use Blueprinter's [json extension hook](../api/extensions.md#json) to render JSON however you like. +If `multi_json` doesn't support your preferred JSON library, you can use Blueprinter's [around_result](../api/extensions.md#around_result) extension hook to render JSON however you like. ### OpenTelemetry @@ -70,7 +73,7 @@ extensions << Blueprinter::Extensions::OpenTelemetry.new("my-tracer-name") ### ViewOption -The ViewOption extension uses the [blueprint](../api/extensions.md#blueprint) extension hook to add a `view` option to `render`, `render_object`, and `render_collection`. It allows V1-compatible rendering of views. +The ViewOption extension uses the [around_result](../api/extensions.md#around_result) extension hook to add a `view` option to `render`, `render_object`, and `render_collection`. It allows V1-compatible rendering of views. ```ruby extensions << Blueprinter::Extensions::ViewOption.new diff --git a/docs/dsl/fields.md b/docs/dsl/fields.md index 057843e1..af29e4ad 100644 --- a/docs/dsl/fields.md +++ b/docs/dsl/fields.md @@ -26,7 +26,7 @@ collection :parts, PartBlueprint, exclude_if_empty: true ## Extracting field values -Blueprinter is pretty smart about extracting field values from objects, but there are ways to customize the behavior if needed. +Blueprinter is pretty smart about extracting field values from objects and Hashes, but there are ways to customize the behavior if needed. ### Default behavior @@ -36,16 +36,16 @@ Blueprinter is pretty smart about extracting field values from objects, but ther ### Field blocks -Return whatever you want from a block. It will be passed a [Field context](../api/context-objects.md#field-context) argument containing the object being rendered, among other things. +If you pass a block to your field, the default behavior will be bypassed and the block's return value will be used. It will be passed a [Field context](../api/context-objects.md#field-context) argument containing the object being rendered, among other things. ```ruby -field :description do |ctx| - ctx.object.description.upcase +field :description do |object, ctx| + object.description.upcase end # Blocks can call instance methods defined on your Blueprint -collection :parts, PartBlueprint do |ctx| - active_parts ctx.object +collection :parts, PartBlueprint do |object, ctx| + active_parts object end def active_parts(object) @@ -53,14 +53,26 @@ def active_parts(object) end ``` -### Custom extractors +### Extracting with extensions -Define your own extraction behavior with a [custom extractor](../api/extractors.md). +The [around_field_value](../api/extensions.md#around_field_value), [around_object_value](../api/extensions.md#around_object_value), and [around_collection_value](../api/extensions.md#around_collection_value) middleware hooks can intercept extraction and return whatever values they want. [Learn more about using extensions](./extensions.md). ```ruby -# For an entire Blueprint or view -extensions << MyCustomExtractor.new - -# For a single field -object :bar, extractor: MyCustomExtractor +class MyExtractor < Blueprinter::Extension + def around_field_value(ctx) + if ctx.field.options[:my_extractor] || ctx.blueprint.class.options[:my_extractor] + # If the field or blueprint has the "my_extractor" option, use custom extraction + my_custom_extraction(ctx.object, ctx.field) + else + # Otherwise use the default behavior + yield ctx + end + end + + private + + def my_custom_extraction(object, field) + # ... + end +end ``` diff --git a/docs/dsl/options.md b/docs/dsl/options.md index 7f1945c7..8f5fa43e 100644 --- a/docs/dsl/options.md +++ b/docs/dsl/options.md @@ -306,21 +306,6 @@ Populate the field using a method/Hash key other than the field name. field :desc, from: :description ``` -#### extractor - -Pass a [custom extractor](../api/extractors.md) class or instance. - -> *Available in field, object, collection* - -```ruby -# Pass as a class -object :category, CategoryBlueprint, extractor: MyCategoryExtractor -# or an instance -object :category, CategoryBlueprint, extractor: MyCategoryExtractor.new(args) -``` - -Note that when you pass a class, it will be initialized _once per render_. - ## Metadata These options allow you to add metadata to the rendered output. diff --git a/docs/rendering.md b/docs/rendering.md index 3a203759..d60a5013 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -12,7 +12,7 @@ If you're using Rails, you may omit `.to_json` when calling `render json:` render json: WidgetBlueprint.render(widget) ``` -Ruby's built-in `JSON` library is used by default. Alternatively, you can use the built-in [MultiJson extension](./dsl/extensions.md#multijson). Or for total control, implement the [json extension hook](./api/extensions.md#json) and call any serializer you like. +Ruby's built-in `JSON` library is used by default. Alternatively, you can use the built-in [MultiJson extension](./dsl/extensions.md#multijson). Or for total control, implement the [around_result](./api/extensions.md#around_result) and call any serializer you like. ### Rendering to a Hash diff --git a/docs/upgrading/customization.md b/docs/upgrading/customization.md index 58ea2b97..2b727d4e 100644 --- a/docs/upgrading/customization.md +++ b/docs/upgrading/customization.md @@ -8,16 +8,12 @@ Blueprinter V2 has a more generic approach to formatting, allowing any type of v format(Date) { |date| date.iso8601 } ``` -The [field_value](../api/extensions.md#field_value), [object_field_value](../api/extensions.md#object_field_value), and [collection_field_value](../api/extensions.md#collection_field_value) extension hooks can also be used. +The [around_field_value](../api/extensions.md#around_field_value), [around_object_value](../api/extensions.md#around_object_value), and [around_collection_value](../api/extensions.md#around_collection_value) extension hooks can also be used. ## Custom extractors -Custom extraction in V2 is accomplished using the [extract_value](../api/extensions.md#extract_value) extension hook. - -Fields, objects, and collections continue to have an [extractor](../dsl/options.md#extractor) option. Simply pass your extension class to it. [Learn more](../api/extractors.md). - -Unlike Legacy/V1, custom extractors *do not override blocks* passed to fields, objects, and collections. If a field has a block, that's how it's extracted. +Custom extraction in V2 can also be accomplished with the [around_field_value](../api/extensions.md#around_field_value), [around_object_value](../api/extensions.md#around_object_value), and [around_collection_value](../api/extensions.md#around_collection_value) hooks. [Read more](../dsl/fields.md#extracting-with-extensions). ## Transformers -Blueprinter V2's [extension hooks](../api/extensions.md) offer many ways to transform your inputs and outputs. The [blueprint_output](../api/extensions.md#blueprint_output) hook offers equivalent functionality to Legacy/V1 transformers. +Blueprinter V2's [extension hooks](../api/extensions.md) offer many ways to transform your inputs and outputs. The [around_blueprint](../api/extensions.md#around_blueprint) hook offers equivalent functionality to Legacy/V1 transformers. diff --git a/docs/upgrading/extensions.md b/docs/upgrading/extensions.md index 05ed2d05..2c98285f 100644 --- a/docs/upgrading/extensions.md +++ b/docs/upgrading/extensions.md @@ -4,8 +4,9 @@ The [V2 Extension API](../api/extensions.md), as well as the DSL for [enabling V ## Porting pre_render -Legacy/V1's `pre_render` hook does not exist in V2, but it has three possible replacements: +Legacy/V1's `pre_render` hook does not exist in V2, but it has several possible replacements: -* [object_input](../api/extensions.md#object_input) intercept an object before it's serialized -* [collection_input](../api/extensions.md#collection_input) intercept a collection before it's serialized -* [blueprint_input](../api/extensions.md#blueprint_input) runs each time a blueprint serializes an object +* [around_result](../api/extensions.md#around_result) runs once around each entire result +* [around_serialize_object](../api/extensions.md#around_serialize_object) runs around each object's serialization +* [around_serialize_collection](../api/extensions.md#around_serialize_collection) runs around each collection's serialization +* [around_blueprint](../api/extensions.md#around_blueprint) runs each time a blueprint serializes an object diff --git a/docs/upgrading/fields.md b/docs/upgrading/fields.md index 27205a2c..069db27e 100644 --- a/docs/upgrading/fields.md +++ b/docs/upgrading/fields.md @@ -48,7 +48,7 @@ end ## Field order -Blueprinter Legacy/V1 offered two options for ordering fields: `:name_asc` (default), and `:definition` (order they were defined in). Blueprinter V2 defaults to the order of definition. You can define a different order using the [blueprint_fields](../api/extensions.md#blueprint_fields) extension hook or the built-in [FieldOrder](../dsl/extensions.md#field-order) extension. +Blueprinter Legacy/V1 offered two options for ordering fields: `:name_asc` (default), and `:definition` (order they were defined in). Blueprinter V2 defaults to the order of definition. You can define a different order using the [around_blueprint_init](../api/extensions.md#around_blueprint_init) extension hook or the built-in [FieldOrder](../dsl/extensions.md#field-order) extension. The following replicates Legacy/V1's default field order using the built-in [FieldOrder](../dsl/extensions.md#field-order) extension. diff --git a/theme/custom.css b/theme/custom.css new file mode 100644 index 00000000..5505d4fb --- /dev/null +++ b/theme/custom.css @@ -0,0 +1,5 @@ +.hooks { + font-family: var(--mono-font) !important; + font-size: var(--code-font-size); + white-space: pre-wrap; +}