diff --git a/.env.sample b/.env.sample index 2376572..1f599ba 100644 --- a/.env.sample +++ b/.env.sample @@ -33,4 +33,3 @@ REPO_AWS_SECRET_ACCESS_KEY_PRODUCTION= REPO_AWS_SECRET_ACCESS_KEY_STAGING= REPO_SLACK_BOT_TOKEN= REPO_SLACK_WEBHOOK_URL= - diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 0f25eae..1512058 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3.6.0 + - uses: actions/checkout@v4.1.0 - name: Run tests run: | @@ -62,14 +62,14 @@ jobs: status: STARTING color: warning - - uses: actions/checkout@v3.6.0 + - uses: actions/checkout@v4.1.0 - name: Set up QEMU - uses: docker/setup-qemu-action@v2.2.0 + uses: docker/setup-qemu-action@v3.0.0 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2.10.0 + uses: docker/setup-buildx-action@v3.0.0 with: buildkitd-flags: --debug @@ -108,7 +108,7 @@ jobs: echo "::set-output name=tag_list::${TAG_LIST}" - name: Build and push - uses: docker/build-push-action@v4.1.1 + uses: docker/build-push-action@v5.0.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 725f703..8dd8b06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3.6.0 + - uses: actions/checkout@v4.1.0 - name: Run tests run: | diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..944880f --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.2.0 diff --git a/Gemfile b/Gemfile index c2e7e2c..272f28c 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,8 @@ source 'https://rubygems.org' git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } # Stealing some bits from rails -gem 'activemodel', '~> 7.0' -gem 'activesupport', '~> 7.0' +gem 'activemodel', '< 7.2' +gem 'activesupport', '< 7.2' # Autoloading explictly will use zeitwerk gem 'zeitwerk', '~> 2.3' @@ -32,7 +32,7 @@ gem 'aws-sdk-s3', '~> 1.64' gem 'concurrent-ruby', '~> 1.1' # Interacting with github -gem 'octokit', '~> 6' +gem 'octokit', '~> 7' # Interface with libsodium gem 'rbnacl' @@ -45,6 +45,9 @@ gem 'sass-embedded', '~> 1.58' gem 'sinatra', '~> 3' gem 'thin' +# Pinning google-protobuf so that it continues to build on ARM devices +gem 'google-protobuf', '=3.22.0' + # Controlling Chrome to create snapshots gem 'ferrum' diff --git a/Gemfile.lock b/Gemfile.lock index 317f7c0..ad4060b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,63 +14,74 @@ GIT GEM remote: https://rubygems.org/ specs: - activemodel (7.0.7.1) - activesupport (= 7.0.7.1) - activesupport (7.0.7.1) + activemodel (7.1.1) + activesupport (= 7.1.1) + activesupport (7.1.1) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) - addressable (2.8.4) + addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) aws-eventstream (1.2.0) - aws-partitions (1.747.0) - aws-sdk-core (3.171.0) + aws-partitions (1.835.0) + aws-sdk-core (3.185.1) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.63.0) - aws-sdk-core (~> 3, >= 3.165.0) + aws-sdk-kms (1.72.0) + aws-sdk-core (~> 3, >= 3.184.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.120.1) - aws-sdk-core (~> 3, >= 3.165.0) + aws-sdk-s3 (1.136.0) + aws-sdk-core (~> 3, >= 3.181.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.5.2) + aws-sigv4 (~> 1.6) + aws-sigv4 (1.6.0) aws-eventstream (~> 1, >= 1.0.2) backport (1.2.0) + base64 (0.1.1) benchmark (0.2.1) - cli-ui (2.1.0) + bigdecimal (3.1.4) + cli-ui (2.2.3) coderay (1.1.3) commonmarker (0.23.10) concurrent-ruby (1.2.2) + connection_pool (2.4.1) daemons (1.4.1) diff-lcs (1.5.0) + drb (2.1.1) + ruby2_keywords e2mmap (0.1.0) em-websocket (0.5.3) eventmachine (>= 0.12.9) http_parser.rb (~> 0) eventmachine (1.2.7) - faraday (2.7.4) + faraday (2.7.11) + base64 faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) - faraday-retry (2.1.0) + faraday-retry (2.2.0) faraday (~> 2.0) - ferrum (0.13) + ferrum (0.14) addressable (~> 2.5) concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (>= 0.6, < 0.8) - ffi (1.15.5) + ffi (1.16.3) formatador (1.1.0) git (1.18.0) addressable (~> 2.8) rchardet (~> 1.8) google-protobuf (3.22.0) - guard (2.18.0) + guard (2.18.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) lumberjack (>= 1.0.12, < 2.0) @@ -88,47 +99,50 @@ GEM http_parser.rb (0.8.0) i18n (1.14.1) concurrent-ruby (~> 1.0) - jaro_winkler (1.5.4) + jaro_winkler (1.5.6) jmespath (1.6.2) json (2.6.3) kramdown (2.4.0) rexml kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) + language_server-protocol (3.17.0.3) listen (3.8.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - lumberjack (1.2.8) + lumberjack (1.2.9) method_source (1.0.0) mini_magick (4.12.0) - mini_portile2 (2.8.1) - minitest (5.19.0) + mini_portile2 (2.8.4) + minitest (5.20.0) multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) + mutex_m (0.1.2) nenv (0.3.0) - nokogiri (1.14.3) - mini_portile2 (~> 2.8.0) + nokogiri (1.15.4) + mini_portile2 (~> 2.8.2) racc (~> 1.4) notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) - octokit (6.1.1) + octokit (7.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) - parallel (1.22.1) - parser (3.2.2.0) + parallel (1.23.0) + parser (3.2.2.4) ast (~> 2.4.1) + racc pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) - public_suffix (5.0.1) - racc (1.6.2) - rack (2.2.6.4) + public_suffix (5.0.3) + racc (1.7.1) + rack (2.2.8) rack-livereload (0.5.1) rack - rack-protection (3.0.6) - rack + rack-protection (3.1.0) + rack (~> 2.2, >= 2.2.4) rack-test (2.1.0) rack (>= 1.3) rainbow (3.1.1) @@ -140,35 +154,37 @@ GEM ffi rbs (2.8.4) rchardet (1.8.0) - regexp_parser (2.7.0) + regexp_parser (2.8.2) reverse_markdown (2.1.1) nokogiri - rexml (3.2.5) - rubocop (1.50.1) + rexml (3.2.6) + rubocop (1.57.1) + base64 (~> 0.1.1) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.2.2.4) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + rubocop-ast (>= 1.28.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.28.0) + rubocop-ast (1.29.0) parser (>= 3.2.1.0) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - sass-embedded (1.62.0) + sass-embedded (1.62.1) google-protobuf (~> 3.21) rake (>= 10.0.0) sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) shellany (0.0.1) - sinatra (3.0.6) + sinatra (3.1.0) mustermann (~> 3.0) rack (~> 2.2, >= 2.2.4) - rack-protection (= 3.0.6) + rack-protection (= 3.1.0) tilt (~> 2.0) slack-notifier (2.4.0) solargraph (0.49.0) @@ -191,24 +207,24 @@ GEM daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) - thor (1.2.1) - tilt (2.1.0) + thor (1.2.2) + tilt (2.3.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.4.2) + unicode-display_width (2.5.0) webrick (1.8.1) - websocket-driver (0.7.5) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) yard (0.9.34) - zeitwerk (2.6.7) + zeitwerk (2.6.12) PLATFORMS ruby DEPENDENCIES - activemodel (~> 7.0) - activesupport (~> 7.0) + activemodel (< 7.2) + activesupport (< 7.2) aws-sdk-s3 (~> 1.64) cli-ui (~> 2) commonmarker @@ -217,11 +233,12 @@ DEPENDENCIES faraday-retry ferrum git + google-protobuf (= 3.22.0) guard (~> 2, >= 2.16.2) guard-livereload levenshtein-ffi! mini_magick - octokit (~> 6) + octokit (~> 7) rack-livereload rack-test rbnacl @@ -236,4 +253,4 @@ DEPENDENCIES zeitwerk (~> 2.3) BUNDLED WITH - 2.4.7 + 2.4.20 diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb new file mode 100644 index 0000000..75926e8 --- /dev/null +++ b/app/commands/content_module_cli.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# CLI for managing content module of a multi model learning path + +class ContentModuleCli < Thor + desc 'render', 'renders content modules' + option :module_file, type: :string, desc: 'Location of the module.yaml file' + option :local, type: :boolean + def render + runner.render_content_module(module_file: options['module_file'], local: options['local']) + end + + desc 'serve', 'starts local preview server' + option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not module files)' + def serve + fork do + if options[:dev] + Guard.start(no_interactions: true) + else + Guard.start(guardfile_contents: content_module_guardfile, watchdir: '/data/src', no_interactions: true) + end + end + RoblesContentModuleServer.run! + end + + desc 'lint [MODULE_FILE]', 'runs a selection of linters on the module' + option :module_file, type: :string, desc: 'Location of the module.yaml file' + method_options 'without-version': :boolean, aliases: '-e', default: false, desc: 'Run linting without git branch naming check' + method_options silent: :boolean, aliases: '-s', default: false, desc: 'Hide all output' + def lint + output = runner.lint_content_module(module_file: options['module_file'], options:) + exit 1 unless output.validated || ENVIRONMENT == 'staging' + end + + desc 'circulate [MODULE_FILE]', 'renders and circulates a content module' + option :module_file, type: :string, desc: 'Location of the module.yaml file' + def circulate + runner.circulate_content_module(module_file: options['module_file']) + end + + desc 'slides [MODULE_FILE]', 'generates slides to be inserted at beginning of video' + option :module_file, type: :string, desc: 'Location of the module.yaml file' + option :app_host, type: :string, default: 'app', desc: 'Hostname of host running robles app server' + option :app_port, type: :string, default: '4567', desc: 'Port of host running robles app server' + option :snapshot_host, type: :string, default: 'snapshot', desc: 'Hostname of host running headless chrome' + option :snapshot_port, type: :string, default: '3000', desc: 'Port of host running headless chrome' + option :out_dir, type: :string, default: '/data/src/artwork/video-title-slides', desc: 'Location to save the output slides' + def slides + module_file = options.fetch('module_file', runner.default_module_file) + parser = Parser::Circulate.new(file: module_file) + content_module = parser.parse + options.delete('module_file') + args = options.merge(data: content_module.lessons).symbolize_keys + snapshotter = Snapshotter::ContentModuleSlides.new(**args) + snapshotter.generate + end + + + desc 'serve', 'starts local preview server' + option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not module files)' + def serve + fork do + if options[:dev] + Guard.start(no_interactions: true) + else + Guard.start(guardfile_contents: content_module_guardfile, watchdir: './', no_interactions: true) + end + end + RoblesContentModuleServer.run! + end + + desc 'secrets [REPO]', 'configures a module repo with the necessary secrets' + long_desc <<-LONGDESC + `robles module secrets [REPO]` will upload the secrets requires to run robles on a + git repository containing a module. + + You must ensure that the required secrets are provided as environment variables + before running this command: + + GITHUB_TOKEN= + REPO_ALEXANDRIA_SERVICE_API_TOKEN_PRODUCTION= + REPO_ALEXANDRIA_SERVICE_API_TOKEN_STAGING= + REPO_AWS_ACCESS_KEY_ID_PRODUCTION= + REPO_AWS_ACCESS_KEY_ID_STAGING= + REPO_AWS_SECRET_ACCESS_KEY_PRODUCTION= + REPO_AWS_SECRET_ACCESS_KEY_STAGING= + REPO_SLACK_BOT_TOKEN= + REPO_SLACK_WEBHOOK_URL= + LONGDESC + def secrets(repo) + secrets_manager = RepoManagement::Secrets.new(repo:, mode: :content_module) + secrets_manager.apply_secrets + end + + private + + def runner + Runner::Base.runner + end + + def content_module_guardfile + <<~GUARDFILE + guard 'livereload' do + watch(%r{[a-zA-Z0-9-_]+.yaml$}) + end + GUARDFILE + end +end diff --git a/app/commands/robles_cli.rb b/app/commands/robles_cli.rb index db79ee1..86863ac 100644 --- a/app/commands/robles_cli.rb +++ b/app/commands/robles_cli.rb @@ -7,37 +7,17 @@ def self.exit_on_failure? true end - desc 'book SUBCOMMAND ...ARGS', 'manage publication of books' + desc 'book [SUBCOMMAND] ...ARGS', 'manage publication of books' subcommand 'book', BookCli - desc 'video SUBCOMMAND ...ARGS', 'manage publication of videos' + desc 'video [SUBCOMMAND] ...ARGS', 'manage publication of videos' subcommand 'video', VideoCli - desc 'pablo SUBCOMMAND ...ARGS', 'manage publication of pablo' - subcommand 'pablo', PabloCli - - ## We leave these in for now--they're deprecated. They should be removed later - desc 'serve', '[DEPRECATED: use `robles book serve` instead] starts local preview server' - option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not book files)' - def serve - fork do - if options[:dev] - Guard.start(no_interactions: true) - else - Guard.start(guardfile_contents: book_guardfile, watchdir: '/data/src', no_interactions: true) - end - end - RoblesBookServer.run! - end + desc 'module [SUBCOMMAND] ...ARGS', 'manage publication of content modules' + subcommand 'module', ContentModuleCli - desc 'lint [PUBLISH_FILE]', '[DEPRECATED: use `robles book lint` instead] runs a selection of linters on the book' - option :'publish-file', type: :string, desc: 'Location of the publish.yaml file' - method_options 'without-edition': :boolean, aliases: '-e', default: false, desc: 'Run linting without git branch naming check' - method_options silent: :boolean, aliases: '-s', default: false, desc: 'Hide all output' - def lint - output = runner.lint_book(publish_file: options['publish_file'], options:) - exit 1 unless output.validated || ENVIRONMENT == 'staging' - end + desc 'pablo [SUBCOMMAND] ...ARGS', 'manage publication of pablo' + subcommand 'pablo', PabloCli private diff --git a/app/commands/video_cli.rb b/app/commands/video_cli.rb index bec04f0..495e6d1 100644 --- a/app/commands/video_cli.rb +++ b/app/commands/video_cli.rb @@ -7,7 +7,6 @@ class VideoCli < Thor option :local, type: :boolean def render video_course = runner.render_video_course(release_file: options['release_file'], local: options['local']) - puts video_course.to_json end desc 'serve', 'starts local preview server' @@ -58,8 +57,8 @@ def slides release_file = options.fetch('release_file', runner.default_release_file) parser = Parser::Release.new(file: release_file) video_course = parser.parse - args = options.merge(video_course:).symbolize_keys - snapshotter = Snapshotter::Slides.new(**args) + args = options.merge(data: video_course.parts.flat_map(&:episodes)).symbolize_keys + snapshotter = Snapshotter::VideoCourseSlides.new(**args) snapshotter.generate end diff --git a/app/lib/api/alexandria/content_module_uploader.rb b/app/lib/api/alexandria/content_module_uploader.rb new file mode 100644 index 0000000..3ebf085 --- /dev/null +++ b/app/lib/api/alexandria/content_module_uploader.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Api + module Alexandria + # Allow uploading of content_modules to alexandria + class ContentModuleUploader + include Util::Logging + + attr_reader :content_module + + PUBLISH_URL = '/api/content_modules/publish' + + def self.upload(content_module) + new(content_module).upload + end + + def initialize(content_module) + @content_module = content_module + end + + def upload + conn.post do |req| + req.url api_uri + req.body = payload + end + end + + private + + def api_uri + path = [ + ALEXANDRIA_BASE_URL, + PUBLISH_URL + ].join + + URI(path) + end + + def conn + @conn ||= Faraday.new(headers: { 'Content-Type' => 'application/json' }) do |faraday| + faraday.response(:logger, logger) do |logger| + logger.filter(/(Token token=\\")(\w+)/, '\1[REMOVED]') + end + faraday.response(:raise_error) + faraday.adapter(Faraday.default_adapter) + faraday.request(:authorization, 'Bearer', ALEXANDRIA_SERVICE_API_TOKEN) + faraday.request(:retry) + end + end + + def payload + { content_module: }.to_json + end + end + end +end diff --git a/app/lib/image_provider/content_module_extractor.rb b/app/lib/image_provider/content_module_extractor.rb new file mode 100644 index 0000000..b2cc19a --- /dev/null +++ b/app/lib/image_provider/content_module_extractor.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module ImageProvider + # Extract all images from a content module + class ContentModuleExtractor + attr_reader :content_module, :images + + def initialize(content_module) + @content_module = content_module + @images = [] + end + + def extract + @images = image_paths.map do |path| + Image.with_representations({ local_url: path[:absolute_path], uploaded_image_root_path: }, variants: path[:variants]) + end + end + + def extract_images_from_markdown(file) + MarkdownImageExtractor.images_from(file) if file && File.exist?(file) + end + + def uploaded_image_root_path + "content_module/#{Digest::SHA2.hexdigest(content_module.shortcode)}/images" + end + + def image_paths + content_module.lessons.map do |lesson| + from_segments = lesson.segments.map do |segment| + next unless segment.respond_to?(:markdown_file) + + extract_images_from_markdown(segment.markdown_file) + segment.image_attachment_paths + end + + from_segments + lesson.image_attachment_paths + end.flatten.compact.uniq + content_module.image_attachment_paths + end + end +end diff --git a/app/lib/image_provider/provider.rb b/app/lib/image_provider/provider.rb index 7eefc88..7d16680 100644 --- a/app/lib/image_provider/provider.rb +++ b/app/lib/image_provider/provider.rb @@ -5,10 +5,11 @@ module ImageProvider class Provider include Util::Logging - attr_reader :extractor + attr_reader :extractor, :width_required - def initialize(extractor:) + def initialize(extractor:, width_required: true) @extractor = extractor + @width_required = width_required end def process diff --git a/app/lib/linting/content_module_linter.rb b/app/lib/linting/content_module_linter.rb new file mode 100644 index 0000000..8c96176 --- /dev/null +++ b/app/lib/linting/content_module_linter.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Linting + # Overall linter that combines all other linters + class ContentModuleLinter # rubocop:disable Metrics/ClassLength + include Util::PathExtraction + include Util::Logging + include Linting::FileExistenceChecker + include Linting::YamlSyntaxChecker + + attr_reader :annotations, :output_details + + def initialize(file:) + super + Linting::Annotation.root_directory = Pathname.new(file).dirname + end + + def lint(options: {}) + @annotations = [] + lint_with_ui(options:, show_ui: !options['silent']) + output + end + + def lint_with_ui(options:, show_ui: true) # rubocop:disable Metrics/AbcSize + with_spinner(title: 'Checking {{bold:module.yaml}} exists', show: show_ui) do + check_module_file_exists + end + return if output_details.present? + + with_spinner(title: 'Checking {{bold:module.yaml}} is valid YAML', show: show_ui) do + check_module_file_valid_yaml + end + return if output_details.present? + + with_spinner(title: 'Validating metadata in {{bold:module.yaml}}', show: show_ui) do + annotations.concat(Linting::ContentModuleMetadataLinter.new(file:).lint(options:)) + end + return unless annotations.blank? + + with_spinner(title: 'Attempting to parse content module', show: show_ui) do + content_module + end + return unless annotations.blank? + + with_spinner(title: 'Validating data models', show: show_ui) do + annotations.concat(Linting::Validations::ContentModule.new(content_module:, file:).lint) + end + end + + def with_spinner(title:, show: true, &block) + if show + CLI::UI::Spinner.spin(title, &block) + else + yield + end + end + + def output + return Linting::Output.new(output_details.merge(annotations:)) if output_details.present? + + if annotations.present? + Linting::Output.new( + title: 'robles Linting Failure', + summary: 'There was a problem with your content module repository', + text: 'Please check the individual file annotations for details', + annotations:, + validated: false + ) + else + Linting::Output.new( + title: 'robles Linting Success', + summary: 'Your content module repo looks great', + text: 'I have nothing else to say here...', + validated: true + ) + end + end + + def check_module_file_exists + logger.debug "Checking for existence of #{file}" + return true if file_exists?(file) + + @output_details = { + title: 'robles Linting Failure', + summary: 'Unable to locate the `module.yaml` file', + text: "There should be a `module.yaml` file in the root of your book repository. Looking here: #{file}", + validated: false + } + false + end + + def check_module_file_valid_yaml + logger.debug "Checking for validity of #{file}" + error = valid_yaml?(file) + return true unless error + + @output_details = { + title: 'robles Linting Failure', + summary: 'Unable to parse `module.yaml`', + text: "There was a problem parsing the `module.yaml` file. Check the indentation. Details:\n\n > #{error}", + validated: false + } + false + end + + def content_module + @content_module ||= begin + parser = Parser::Circulate.new(file:) + parser.parse + end + rescue Parser::Error => e + line_number = (e.message.match(/at line (\d+)/)&.captures&.first&.to_i || 0) + 1 + annotations.push( + Annotation.new( + absolute_path: e.file, + annotation_level: 'failure', + start_line: line_number, + end_line: line_number, + message: e.message, + title: 'Unable to parse content module.' + ) + ) + end + end +end diff --git a/app/lib/linting/content_module_metadata_linter.rb b/app/lib/linting/content_module_metadata_linter.rb new file mode 100644 index 0000000..57deee2 --- /dev/null +++ b/app/lib/linting/content_module_metadata_linter.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Linting + # Run through various metadata checks + class ContentModuleMetadataLinter + include Util::PathExtraction + + def lint(options: {}) # rubocop:disable Metrics/AbcSize + [].tap do |annotations| + annotations.concat Linting::Metadata::CirculateAttributes.lint(file:, attributes: module_attributes) + annotations.concat Linting::Metadata::ModuleFile.lint(file:, attributes: module_attributes) + annotations.concat Linting::Metadata::CaptionsFile.lint(file:, attributes: module_attributes) + annotations.concat Linting::Metadata::AssessmentFile.lint(file:, attributes: module_attributes) + annotations.concat Linting::Metadata::BranchName.lint(file:, attributes: module_attributes, version_attribute: :version) unless options['without-version'] + end + end + + def module_attributes + @module_attributes ||= Psych.load_file(file, permitted_classes: [Date]).deep_symbolize_keys + end + end +end diff --git a/app/lib/linting/metadata/assessment_file.rb b/app/lib/linting/metadata/assessment_file.rb index a66753f..ce8137f 100644 --- a/app/lib/linting/metadata/assessment_file.rb +++ b/app/lib/linting/metadata/assessment_file.rb @@ -23,12 +23,31 @@ def lint # Find the script file references in each episode in the release.yaml def file_path_list - @file_path_list ||= - attributes[:parts].flat_map do |part| - part[:episodes].map do |episode| - episode[:assessment_file] - end - end.compact + @file_path_list ||= content_module? ? module_assessments : video_course_assessments + end + + def video_course_assessments + attributes[:parts].flat_map do |part| + part[:episodes].map do |episode| + episode[:assessment_file] + end + end.compact + end + + def module_assessments + attributes[:lessons].flat_map do |lesson| + ModuleFile.new(file:, attributes:).segments(lesson).flat_map do |segment| + segment[:relative_path] if assessment?(segment) + end + end.compact + end + + def content_module? + attributes.key?(:lessons) + end + + def assessment?(segment) + segment[:type] == 'assessment' end # What kind of files are we looking for? diff --git a/app/lib/linting/metadata/captions_file.rb b/app/lib/linting/metadata/captions_file.rb index 4b9acb0..a527aa2 100644 --- a/app/lib/linting/metadata/captions_file.rb +++ b/app/lib/linting/metadata/captions_file.rb @@ -5,6 +5,7 @@ module Metadata # Check that captions file references point to actual files class CaptionsFile include Linting::Metadata::FileAttributeExistenceChecker + include Parser::MarkdownMetadata attr_reader :file, :attributes @@ -23,18 +24,49 @@ def lint # Find the script file references in each episode in the release.yaml def file_path_list - @file_path_list ||= - attributes[:parts].flat_map do |part| - part[:episodes].map do |episode| - episode[:captions_file] - end - end.compact + @file_path_list ||= content_module? ? module_captions : video_course_captions + end + + def video_course_captions + attributes[:parts].flat_map do |part| + part[:episodes].map do |episode| + episode[:captions_file] + end + end.compact + end + + # Caption file is part of markdown metadata for video and text + # And quiz being a yaml file is handled + def module_captions + caption_files = ModuleFile.new(file:, attributes:).file_path_list + caption_files.map do |file| + captions_file = if yaml?(file) + load_yaml(File.read(file)).deep_symbolize_keys[:captions_file] + else + @markdown_metadata = nil + @path = file + markdown_metadata[:captions_file] + end + next unless captions_file.present? + + [captions_file, file] + end.compact end # What kind of files are we looking for? def file_description 'captions' end + + private + + def yaml?(file) + ['.yaml', '.yml'].include?(file.extname) + end + + def content_module? + attributes[:lessons].present? + end end end end diff --git a/app/lib/linting/metadata/circulate_attributes.rb b/app/lib/linting/metadata/circulate_attributes.rb new file mode 100644 index 0000000..7b594c4 --- /dev/null +++ b/app/lib/linting/metadata/circulate_attributes.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Linting + module Metadata + # Check for the required attributes in the publish.yaml file + class CirculateAttributes + REQUIRED_ATTRIBUTES = %i[shortcode version title description_md released_at authors lessons materials_url + version_description difficulty platform language editor professional + short_description domains categories ].freeze + + attr_reader :file, :attributes + + def self.lint(file:, attributes:) + new(file:, attributes:).lint + end + + def initialize(file:, attributes:) + @file = file + @attributes = attributes + end + + def lint + (REQUIRED_ATTRIBUTES - attributes.keys).map do |missing_key| + Annotation.new( + start_line: 1, + end_line: 1, + absolute_path: file, + annotation_level: 'failure', + message: "`module.yaml` should include a top-level `#{missing_key}` attribute.`", + title: 'Missing required attribute' + ) + end + end + end + end +end diff --git a/app/lib/linting/metadata/file_attribute_existence_checker.rb b/app/lib/linting/metadata/file_attribute_existence_checker.rb index 8e469b6..ae66738 100644 --- a/app/lib/linting/metadata/file_attribute_existence_checker.rb +++ b/app/lib/linting/metadata/file_attribute_existence_checker.rb @@ -8,15 +8,21 @@ module FileAttributeExistenceChecker def file_attribute_annotations file_path_list.map do |path| - next if relative_file_exists?(path) + if path.is_a?(Array) + relative_to = path.second + path = path.first + else + relative_to = file + end + next if relative_file_exists?(path, relative_to:) line = missing_file_line(path) Annotation.new( start_line: line, end_line: line, - absolute_path: file, + absolute_path: relative_to, annotation_level: 'failure', - message: "`release.yaml` includes references to unknown #{file_description} file: `#{path}", + message: "`#{relative_to}` includes references to unknown #{file_description} file: `#{path}", title: "Missing #{file_description} file" ) end.compact @@ -33,9 +39,9 @@ def file_description private # Check whether script file exists - def relative_file_exists?(path) + def relative_file_exists?(path, relative_to: nil) # Find path relative to the release.yaml file - file_path = Pathname.new(file).dirname.join(path) + file_path = Pathname.new(relative_to || file).dirname.join(path) # Check whether this exists file_exists?(file_path) end @@ -43,7 +49,7 @@ def relative_file_exists?(path) # Search release.yaml line by line to find the file references def missing_file_line(path) File.readlines(file).each_with_index do |line, index| - return index + 1 if line.include?(path) + return index + 1 if line.include?(path.to_s) end end end diff --git a/app/lib/linting/metadata/module_file.rb b/app/lib/linting/metadata/module_file.rb new file mode 100644 index 0000000..298eb4f --- /dev/null +++ b/app/lib/linting/metadata/module_file.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Linting + module Metadata + # Check that script file references point to actual files + class ModuleFile + include Linting::Metadata::FileAttributeExistenceChecker + + attr_reader :file, :attributes + + def self.lint(file:, attributes:) + new(file:, attributes:).lint + end + + def initialize(file:, attributes:) + @file = file + @attributes = attributes + end + + def lint + file_attribute_annotations + end + + # Find the script file references in each episode in the release.yaml + def file_path_list + @file_path_list ||= + attributes[:lessons].flat_map do |lesson| + segment_files(lesson) + end.compact + end + + def segments(lesson) + @file_path = Pathname.new(file).dirname.join(lesson[:segments_path]) + lesson_data = Psych.load_file(@file_path, permitted_classes: [Date]).deep_symbolize_keys + lesson_data[:segments].map do |segment| + segment[:relative_path] = Pathname.new(@file_path).dirname.join(segment[:path]) + segment + end + end + + def segment_files(lesson) + segments(lesson).map { |segment| segment[:relative_path] } + end + + # What kind of files are we looking for? + def file_description + 'script' + end + end + end +end diff --git a/app/lib/linting/validations/content_module.rb b/app/lib/linting/validations/content_module.rb new file mode 100644 index 0000000..a5b2d20 --- /dev/null +++ b/app/lib/linting/validations/content_module.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Linting + module Validations + # Run the model validations for a VideoCourse and child objects + class ContentModule + attr_accessor :content_module, :annotations, :file + + def initialize(content_module:, file:) + @content_module = content_module + @file = file + end + + def lint # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + [].tap do |annotations| + content_module.lessons.each do |lesson| + lesson.segments.each do |segment| + if segment.is_a?(Video) + annotations.concat(validate_children(segment, :authors)) + elsif segment.is_a?(Assessment::Quiz) + segment.questions.each do |question| + context = "Assessment::Quiz (#{segment&.validation_name || 'unknown'})" + annotations.concat(validate_children(question, :choices, context:)) + end + + annotations.concat(validate_children(segment, :questions)) + end + end + + annotations.concat(validate_children(lesson, :segments)) + end + + annotations.concat(validate_children(content_module, :authors)) + annotations.concat(validate_children(content_module, :lessons)) + + annotations.concat(annotations_from_errors(content_module)) unless content_module.valid? + end.compact.reverse + end + + def validate_children(object, attribute, context: nil) + Array.wrap(object.send(attribute)).flat_map do |child| + next if child.valid? + + context = "#{context}\n#{object.class} (#{object&.validation_name || 'unknown'})" + annotations_from_errors(child, context:) + end.compact + end + + def annotations_from_errors(object, context: nil) + title = "#{object.class} (#{object&.validation_name || 'unknown'})" + object.errors.full_messages.map do |error| + Linting::Annotation.new( + start_line: 0, + end_line: 0, + absolute_path: file, + annotation_level: 'failure', + message: "#{context}\n#{title}: #{error}", + title: "#{title} Validation Error" + ) + end + end + end + end +end diff --git a/app/lib/parser/circulate.rb b/app/lib/parser/circulate.rb new file mode 100644 index 0000000..6828017 --- /dev/null +++ b/app/lib/parser/circulate.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Parser + # Parses a release.yaml file, and returns a VideoCourse model object + class Circulate + include Util::PathExtraction + include Util::GitHashable + + attr_reader :video_course + + def parse + load_course + apply_additional_metadata + video_course + end + + def load_course + @video_course = Parser::ContentModule.new(file:).parse + end + + def apply_additional_metadata + video_course.root_path = root_directory + end + end +end diff --git a/app/lib/parser/content_module.rb b/app/lib/parser/content_module.rb new file mode 100644 index 0000000..c9166df --- /dev/null +++ b/app/lib/parser/content_module.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Parser + # Parse a symbolised hash into a ContentModule + class ContentModule + include Parser::SimpleAttributes + include Util::PathExtraction + include Util::GitHashable + + VALID_SIMPLE_ATTRIBUTES = %i[shortcode version version_description title + description_md short_description released_at materials_url + professional difficulty platform language editor domains + categories who_is_this_for_md module_outcomes_md covered_concepts_md git_commit_hash + featured_banner_image twitter_card_image + access_personal access_team].freeze + + attr_accessor :content_module + + def parse + lessons = metadata[:lessons].map.with_index do |lesson, idx| + parse_lesson(lesson, idx) + end + + @content_module = ::ContentModule.new(lessons:) + apply_segment_ordinals + load_authors + apply_additional_metadata + content_module + end + + def metadata + @metadata = load_yaml_file(file).merge(git_commit_hash: git_hash) + end + + def parse_lesson(metadata, index) + segments = parse_segments(metadata[:segments_path]) + + Lesson.new(ordinal: index + 1, segments:).tap do |lesson| + lesson_metadata = load_yaml_file(apply_path((metadata[:segments_path]))) + LessonMetadata.new(lesson, lesson_metadata).apply! + end + end + + def parse_segments(segment_yaml_file) + lesson_path = segment_yaml_file.split('/').first + lesson = load_yaml_file(apply_path(segment_yaml_file)) + + lesson[:segments].map do |segment| + segment_path = "#{lesson_path}/#{segment[:path]}" + send("parse_#{segment[:type]}".to_sym, segment_path) + end + end + + def parse_text(file) + markdown_file = apply_path(file) + + Text.new(markdown_file:, root_path: Pathname.new(markdown_file).dirname.to_s).tap do |text| + TextMetadata.new(text).apply! + end + end + + def parse_video(file) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + script_file = apply_path(file) if file.present? + raise Parser::Error.new(file:, msg: "Script file (#{file}}) not found") if script_file.present? && !File.file?(script_file) + + root_path = Pathname.new(script_file).dirname.to_s if script_file.present? + captions_file = apply_path(metadata[:captions_file]) if metadata[:captions_file].present? + raise Parser::Error.new(file:, msg: "Captions file (#{metadata[:captions_file]}) not found") if captions_file.present? && !File.file?(captions_file) + + metadata[:captions_file] = captions_file if captions_file.present? + Video.new(script_file:, root_path:).tap do |video| + VideoMetadata.new(video, metadata).apply! + end + end + + def parse_assessment(file) + assessment_file = apply_path(file) if file.present? + raise Parser::Error.new(file:, msg: "Assessment file (#{file}}) not found") if assessment_file.present? && !File.file?(assessment_file) + + assessment_metadata = load_yaml_file(assessment_file) + + Assessment.create(assessment_metadata).tap do |assessment| + AssessmentMetadata.new(assessment, assessment_metadata).apply! + end + end + + def apply_segment_ordinals + content_module.lessons.flat_map(&:segments).each_with_index do |segment, index| + segment.ordinal = index + 1 + end + + # ref is reset for each lesson + content_module.lessons.each do |lesson| + lesson.segments.each_with_index do |segment, index| + segment.ref ||= (index + 1).to_s.rjust(2, '0') + end + end + end + + def load_authors + content_module.authors = Array.wrap(metadata[:authors]).map do |author| + Author.new(author) + end + end + + def apply_additional_metadata + content_module.assign_attributes(simple_attributes) + end + + private + + def load_yaml_file(file) + Psych.load_file(file, permitted_classes: [Date]).deep_symbolize_keys + end + end +end diff --git a/app/lib/parser/lesson_metadata.rb b/app/lib/parser/lesson_metadata.rb new file mode 100644 index 0000000..76f8550 --- /dev/null +++ b/app/lib/parser/lesson_metadata.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Parser + # Parse a symbolised hash into a Part, with nested Episodes + class LessonMetadata + include SimpleAttributes + + VALID_SIMPLE_ATTRIBUTES = %i[title description_md learning_objectives_md ordinal ref].freeze + + attr_accessor :lesson, :metadata + + def initialize(lesson, metadata) + @lesson = lesson + @metadata = metadata + end + + def apply! + lesson.assign_attributes(simple_attributes) + end + end +end diff --git a/app/lib/parser/markdown_metadata.rb b/app/lib/parser/markdown_metadata.rb index dc6bb3b..588b486 100644 --- a/app/lib/parser/markdown_metadata.rb +++ b/app/lib/parser/markdown_metadata.rb @@ -20,12 +20,16 @@ def metadata def markdown_metadata return {} if path.blank? - @markdown_metadata ||= Psych.load(extract_metadata, symbolize_names: true, permitted_classes: [Date]).tap do |header_metadata| + @markdown_metadata ||= load_yaml(extract_metadata).tap do |header_metadata| # If can't metadata, and also don't have a provided hash, then raise error raise Parser::Error.new(file: path, msg: 'Unable to locate metadata at the top of the markdown') if header_metadata.blank? && @metadata.blank? end || {} rescue Psych::SyntaxError => e raise Parser::Error.new(file: path, error: e) end + + def load_yaml(file) + Psych.load(file, symbolize_names: true, permitted_classes: [Date]) + end end end diff --git a/app/lib/parser/text_metadata.rb b/app/lib/parser/text_metadata.rb new file mode 100644 index 0000000..5462a5f --- /dev/null +++ b/app/lib/parser/text_metadata.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Parser + # Parses the metadata at the top of a text markdown file + class TextMetadata + include MarkdownMetadata + + VALID_SIMPLE_ATTRIBUTES = %i[title description short_description free ref authors_notes_md].freeze + + attr_reader :text + + def initialize(text) + @text = text + @path = text.markdown_file + end + + def apply! + text.assign_attributes(simple_attributes) + text.cleanse_title! + text.authors += authors if authors.present? + end + + def authors + @authors ||= metadata[:authors]&.map do |author| + Author.new(author) + end + end + end +end diff --git a/app/lib/parser/video_metadata.rb b/app/lib/parser/video_metadata.rb index 929d24c..9fedd50 100644 --- a/app/lib/parser/video_metadata.rb +++ b/app/lib/parser/video_metadata.rb @@ -18,6 +18,8 @@ def initialize(video, metadata) def apply! check_captions_path video.assign_attributes(simple_attributes) + # Need to extract the ID from the URL, if one has been provided + video.vimeo_id = metadata[:vimeo_id]&.to_s&.match(/(\d+)$/)&.values_at(1)&.first video.authors += authors if authors.present? end @@ -29,7 +31,7 @@ def authors # If we've read the captions path from the script file, it needs adjusting to be absolute def check_captions_path - return unless markdown_metadata&.include?(:captions_file) + return unless markdown_metadata&.include?(:captions_file) && markdown_metadata[:captions_file].present? @markdown_metadata[:captions_file] = apply_path(@markdown_metadata[:captions_file]) end diff --git a/app/lib/renderer/content_module.rb b/app/lib/renderer/content_module.rb new file mode 100644 index 0000000..1ee3e2c --- /dev/null +++ b/app/lib/renderer/content_module.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Renderer + # Takes a sparse ContentModule object (i.e. parsed) and renders markdown / attaches images + class ContentModule + include ImageAttachable + include MarkdownRenderable + include Util::Logging + + attr_accessor :disable_transcripts + + def render + logger.info "Beginning module render: #{object.title}" + attach_images + render_markdown + object.lessons.each do |lesson| + lesson_renderer = Renderer::Lesson.new(lesson, image_provider:) + lesson_renderer.disable_transcripts = disable_transcripts + lesson_renderer.render + end + logger.info 'Completed module render' + end + end +end diff --git a/app/lib/renderer/lesson.rb b/app/lib/renderer/lesson.rb new file mode 100644 index 0000000..07bb394 --- /dev/null +++ b/app/lib/renderer/lesson.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Renderer + # Takes a Lesson model, and updates it with markdown + class Lesson + include MarkdownRenderable + include ImageAttachable + include Util::Logging + + attr_accessor :disable_transcripts + + def render + logger.info "Beginning lesson render: #{object.ref} #{object.title}" + attach_images + render_markdown + object.segments.each do |segment| + segment_renderer = Renderer::Segment.create(segment, image_provider:) + segment_renderer.disable_transcripts = disable_transcripts if segment_renderer.respond_to?(:disable_transcripts=) + segment_renderer.render + end + end + end +end diff --git a/app/lib/renderer/rw_markdown_renderer.rb b/app/lib/renderer/rw_markdown_renderer.rb index 426289f..9e147a0 100644 --- a/app/lib/renderer/rw_markdown_renderer.rb +++ b/app/lib/renderer/rw_markdown_renderer.rb @@ -22,7 +22,7 @@ def image(node) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics alt_text = node.each.select { |child| child.type == :text }.map { |child| escape_html(child.string_content) }.join(' ') classes = class_list(alt_text) - if width_class?(alt_text) + if width_class?(alt_text) || @image_provider.width_required == false out('
') out(' ') if svg?(alt_text, node.url) diff --git a/app/lib/renderer/segment.rb b/app/lib/renderer/segment.rb new file mode 100644 index 0000000..9278ba7 --- /dev/null +++ b/app/lib/renderer/segment.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Renderer + # Base renderer for episodes + class Segment + include MarkdownRenderable + include Util::Logging + + def self.create(model, image_provider:) + case model + when ::Assessment + Renderer::Assessment.new(model, image_provider:) + when ::Video + Renderer::Video.new(model, image_provider:) + when ::Text + Renderer::Chapter.new(model, image_provider:) + else + raise "Unknown model type: #{model.class}" + end + end + + def render + logger.info "Beginning segment render: #{object.ref}: #{object.title}" + render_markdown + end + end +end diff --git a/app/lib/repo_management/secrets.rb b/app/lib/repo_management/secrets.rb index b236c4d..8127738 100644 --- a/app/lib/repo_management/secrets.rb +++ b/app/lib/repo_management/secrets.rb @@ -44,7 +44,7 @@ def apply_secrets end def apply_secret(name, value) - client.create_or_update_secret(repo, name.upcase, options_for_secret(value)) + client.create_or_update_actions_secret(repo, name.upcase, options_for_secret(value)) end def options_for_secret(value) @@ -65,7 +65,7 @@ def key end def public_key - @public_key ||= client.get_public_key(repo) + @public_key ||= client.get_actions_public_key(repo) end def client diff --git a/app/lib/runner/base.rb b/app/lib/runner/base.rb index 36a757f..c5398d3 100644 --- a/app/lib/runner/base.rb +++ b/app/lib/runner/base.rb @@ -19,7 +19,7 @@ def render_book(publish_file:, local: false) parser = Parser::Publish.new(file: publish_file) book = parser.parse image_extractor = ImageProvider::BookExtractor.new(book) - image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor) + image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor, width_required: true) image_provider&.process renderer = Renderer::Book.new(book, image_provider:) renderer.render @@ -31,7 +31,7 @@ def publish_book(publish_file:) parser = Parser::Publish.new(file: publish_file) book = parser.parse image_extractor = ImageProvider::BookExtractor.new(book) - image_provider = ImageProvider::Provider.new(extractor: image_extractor) + image_provider = ImageProvider::Provider.new(extractor: image_extractor, width_required: true) image_provider.process Renderer::Book.new(book, image_provider:).render Api::Alexandria::BookUploader.upload(book) @@ -60,7 +60,7 @@ def render_video_course(release_file:, local: false) parser = Parser::Release.new(file: release_file) video_course = parser.parse image_extractor = ImageProvider::VideoCourseExtractor.new(video_course) - image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor) + image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor, width_required: false) image_provider&.process renderer = Renderer::VideoCourse.new(video_course, image_provider:) renderer.render @@ -74,7 +74,7 @@ def upload_video_course(release_file:) video_course = parser.parse image_extractor = ImageProvider::VideoCourseExtractor.new(video_course) - image_provider = ImageProvider::Provider.new(extractor: image_extractor) + image_provider = ImageProvider::Provider.new(extractor: image_extractor, width_required: false) image_provider.process Renderer::VideoCourse.new(video_course, image_provider:).render Api::Betamax::VideoCourseUploader.upload(video_course) @@ -112,7 +112,7 @@ def publish_pablo(source:, output:) output ||= default_pablo_output image_extractor = ImageProvider::DirectoryExtractor.new(source) - image_provider = ImageProvider::Provider.new(extractor: image_extractor) + image_provider = ImageProvider::Provider.new(extractor: image_extractor, width_required: false) image_provider.process paths = image_extractor.categories.map { "/#{_1}" }.push('/', '/license', '/instructions', '/styles.css', '/javascript/search.js') @@ -123,10 +123,57 @@ def publish_pablo(source:, output:) }) end + # module + def render_content_module(module_file:, local: false) + module_file ||= default_module_file + + parser = Parser::Circulate.new(file: module_file) + content_module = parser.parse + image_extractor = ImageProvider::ContentModuleExtractor.new(content_module) + image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor, width_required: false) + image_provider&.process + renderer = Renderer::ContentModule.new(content_module, image_provider:) + renderer.render + content_module + end + + def lint_content_module(module_file:, options: {}) + module_file ||= default_module_file + logger.info("Attempting to lint using release file at #{module_file}") + + CLI::UI::StdoutRouter.enable unless options['silent'] + + linter = Linting::ContentModuleLinter.new(file: module_file) + output = linter.lint(options:) + Cli::OutputFormatter.render(output) unless options['silent'] + output + end + + def circulate_content_module(module_file:) + module_file ||= default_module_file + + parser = Parser::Circulate.new(file: module_file) + content_module = parser.parse + + image_extractor = ImageProvider::ContentModuleExtractor.new(content_module) + image_provider = ImageProvider::Provider.new(extractor: image_extractor, width_required: false) + image_provider.process + Renderer::ContentModule.new(content_module, image_provider:).render + Api::Alexandria::ContentModuleUploader.upload(content_module) + notify_content_module_success(content_module:) + rescue StandardError => e + notify_content_module_failure(content_module: defined?(content_module) ? content_module : nil, details: e.full_message) + raise e + end + def default_publish_file raise 'Override this in a subclass please' end + def default_module_file + raise 'Override this in a subclass please' + end + def default_release_file raise 'Override this in a subclass please' end diff --git a/app/lib/runner/ci.rb b/app/lib/runner/ci.rb index e4fe0c2..89bd559 100644 --- a/app/lib/runner/ci.rb +++ b/app/lib/runner/ci.rb @@ -19,6 +19,10 @@ def default_release_file Pathname.new(GITHUB_WORKSPACE).join('release.yaml').to_s end + def default_module_file + Pathname.new(GITHUB_WORKSPACE).join('module.yaml').to_s + end + def default_pablo_source Pathname.new(GITHUB_WORKSPACE).join('images').to_s end diff --git a/app/lib/runner/interactive.rb b/app/lib/runner/interactive.rb index ed6f316..50d9095 100644 --- a/app/lib/runner/interactive.rb +++ b/app/lib/runner/interactive.rb @@ -18,5 +18,9 @@ def default_pablo_source def default_pablo_output '/data/src/dist' end + + def default_module_file + '/data/src/module.yaml' + end end end diff --git a/app/lib/snapshotter/content_module_slides.rb b/app/lib/snapshotter/content_module_slides.rb new file mode 100644 index 0000000..3eead9d --- /dev/null +++ b/app/lib/snapshotter/content_module_slides.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Snapshotter + # Creates snapshots of all the slides in a content module + class ContentModuleSlides < Slides + def generate + data.each do |lesson| + lesson.segments.each do |segment| + next unless segment.is_a?(Video) + + browser.goto("#{app_base}/slides/#{lesson.slug}/#{segment.slug}") + browser.screenshot(path: "#{out_dir}/#{lesson.ref}-#{segment.slug}.png", selector: '#slide-to-snapshot') + end + end + end + end +end diff --git a/app/lib/snapshotter/slides.rb b/app/lib/snapshotter/slides.rb index d695ddb..84bd7ad 100644 --- a/app/lib/snapshotter/slides.rb +++ b/app/lib/snapshotter/slides.rb @@ -5,10 +5,10 @@ module Snapshotter # Creates snapshots of all the slides in a video course class Slides - attr_reader :video_course, :app_host, :app_port, :snapshot_host, :snapshot_port, :out_dir + attr_reader :data, :app_host, :app_port, :snapshot_host, :snapshot_port, :out_dir - def initialize(video_course:, app_host: 'app', app_port: 4567, snapshot_host: 'snapshot', snapshot_port: 3000, out_dir: '/data/src/slides') # rubocop:disable Metrics/ParameterLists - @video_course = video_course + def initialize(data:, app_host: 'app', app_port: 4567, snapshot_host: 'snapshot', snapshot_port: 3000, out_dir: '/data/src/slides') # rubocop:disable Metrics/ParameterLists + @data = data @app_host = app_host @app_port = app_port @snapshot_host = snapshot_host @@ -17,13 +17,11 @@ def initialize(video_course:, app_host: 'app', app_port: 4567, snapshot_host: 's end def generate - browser = Ferrum::Browser.new(url: "http://#{snapshot_host}:#{snapshot_port}", window_size: [1920, 1080], timeout: 15) - video_course.parts.flat_map(&:episodes).each do |episode| - next unless episode.is_a?(Video) + raise 'Override this in a subclass please' + end - browser.goto("#{app_base}/slides/#{episode.slug}") - browser.screenshot(path: "#{out_dir}/#{episode.slug}.png", selector: '#slide-to-snapshot') - end + def browser + @browser ||= Ferrum::Browser.new(url: "http://#{snapshot_host}:#{snapshot_port}", window_size: [1920, 1080], timeout: 15) end def app_base diff --git a/app/lib/snapshotter/video_course_slides.rb b/app/lib/snapshotter/video_course_slides.rb new file mode 100644 index 0000000..0227f68 --- /dev/null +++ b/app/lib/snapshotter/video_course_slides.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Snapshotter + # Creates snapshots of all the slides in a video course + class VideoCourseSlides < Slides + def generate + data.each do |episode| + next unless episode.is_a?(Video) + + browser.goto("#{app_base}/slides/#{episode.slug}") + browser.screenshot(path: "#{out_dir}/#{episode.slug}.png", selector: '#slide-to-snapshot') + end + end + end +end diff --git a/app/lib/util/slack_notifiable.rb b/app/lib/util/slack_notifiable.rb index 58fd6cd..88b8f87 100644 --- a/app/lib/util/slack_notifiable.rb +++ b/app/lib/util/slack_notifiable.rb @@ -10,6 +10,7 @@ module SlackNotifiable # rubocop:disable Metrics/ModuleLength FAILURE_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object_errors.png' BOOK_ROBLES_CONTEXT_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object_box-of-books.png' VIDEO_COURSE_ROBLES_CONTEXT_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object-box-videos.png' + CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object-box-content-module.png' def notify_book_success(book:) return unless notifiable? @@ -35,6 +36,18 @@ def notify_video_course_failure(video_course:, details: nil) notifier.post(blocks: video_course_failure_blocks(video_course:, details: details || 'N/A')) end + def notify_content_module_success(content_module:) + return unless notifiable? + + notifier.post(blocks: content_module_success_blocks(content_module:)) + end + + def notify_content_module_failure(content_module:, details: nil) + return unless notifiable? + + notifier.post(blocks: content_module_failure_blocks(content_module:, details: details || 'N/A')) + end + def notifiable? SLACK_WEBHOOK_URL.present? end @@ -109,6 +122,39 @@ def video_course_failure_blocks(video_course:, details:) ] end + def content_module_success_blocks(content_module:) + [ + intro_section(fields: standard_content_module_fields(content_module:), + message: ':white_check_mark: Content module upload successful!', + image_url: CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL, + alt_text: 'Upload successful'), + { + type: 'divider' + }, + context(CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL) + ] + end + + def content_module_failure_blocks(content_module:, details:) + [ + intro_section(fields: standard_content_module_fields(content_module:), + message: ':x: Content module upload failed!', + image_url: FAILURE_IMAGE_URL, + alt_text: 'Upload failed'), + { + type: 'section', + text: { + type: 'mrkdwn', + text: "```#{details}```" + } + }, + { + type: 'divider' + }, + context(CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL) + ] + end + def standard_book_fields(book:) [ { @@ -151,6 +197,27 @@ def standard_video_course_fields(video_course:) ] end + def standard_content_module_fields(content_module:) + [ + { + type: 'mrkdwn', + text: "*Content Module*\n#{content_module&.title || '_unknown_'}" + }, + { + type: 'mrkdwn', + text: "*Short Code*\n`#{content_module&.shortcode || 'unknown'}`" + }, + { + type: 'mrkdwn', + text: "*Version*\n#{content_module&.version || '_unknown_'}" + }, + { + type: 'mrkdwn', + text: "*Environment*\n`#{ENVIRONMENT}`" + } + ] + end + def intro_section(fields:, message:, image_url:, alt_text:) { type: 'section', diff --git a/app/models/assessment.rb b/app/models/assessment.rb index b64a95f..02acee8 100644 --- a/app/models/assessment.rb +++ b/app/models/assessment.rb @@ -35,9 +35,13 @@ def episode_type 'assessment' end + def segment_type + 'assessment' + end + # Used for serialisation def attributes - { title: nil, ordinal: nil, description: nil, short_description: nil, ref: nil, episode_type: nil, assessment_type: nil }.stringify_keys + { title: nil, ordinal: nil, description: nil, short_description: nil, ref: nil, episode_type:, segment_type:, assessment_type: nil }.stringify_keys end # Used for linting diff --git a/app/models/content_module.rb b/app/models/content_module.rb new file mode 100644 index 0000000..f0635c6 --- /dev/null +++ b/app/models/content_module.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# The top-level model object for a MMLP module +class ContentModule + include ActiveModel::Model + include ActiveModel::Serializers::JSON + include Concerns::ImageAttachable + include Concerns::MarkdownRenderable + + attr_accessor :shortcode, :version, :version_description, :title, :description_md, + :short_description, :released_at, :materials_url, :professional, :difficulty, + :platform, :language, :editor, :domains, :categories, :who_is_this_for_md, :module_outcomes_md, + :covered_concepts_md, :authors, :lessons, :git_commit_hash, + :featured_banner_image, :twitter_card_image, :root_path, :access_personal, + :access_team + + attr_markdown :who_is_this_for, source: :who_is_this_for_md, file: false + attr_markdown :covered_concepts, source: :covered_concepts_md, file: false + attr_markdown :outcomes, source: :module_outcomes_md, file: false + attr_markdown :description, source: :description_md, file: false + attr_image :featured_banner_image_url, source: :featured_banner_image, variants: %i[original w750 w225 w90] + attr_image :twitter_card_image_url, source: :twitter_card_image, variants: %i[original w1800] + + validates :shortcode, :version, :title, :version_description, :description_md, :domains, + :categories, presence: true + validates_inclusion_of :difficulty, in: %w[beginner intermediate advanced] + validates_inclusion_of :professional, :access_personal, :access_team, in: [true, false] + validates :lessons, length: { minimum: 1 }, allow_blank: false, lessons: true + validates_each :domains do |record, attr, value| + value.each do |domain| + record.errors.add(attr, "(#{domain}) not included in the list") unless %w[ios android flutter server-side-swift unity macos professional-growth].include?(domain) + end + end + + def initialize(attributes = {}) + super + @lessons ||= [] + end + + # Used for serialisation + def attributes + { shortcode: nil, version: nil, version_description: nil, title: nil, + description: nil, short_description: nil, released_at: nil, materials_url: nil, + professional: nil, difficulty: nil, platform: nil, language: nil, editor: nil, domains: [], + categories: [], who_is_this_for: nil, covered_concepts: nil, outcomes: nil, authors: [], lessons: [], + git_commit_hash: nil, featured_banner_image_url: [], + twitter_card_image_url: [], access_personal: nil, access_team: nil }.stringify_keys + end + + # Used for linting + def validation_name + title + end +end diff --git a/app/models/lesson.rb b/app/models/lesson.rb new file mode 100644 index 0000000..323838c --- /dev/null +++ b/app/models/lesson.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# A MMLP module has multiple lesson +class Lesson + include ActiveModel::Model + include ActiveModel::Serializers::JSON + include Concerns::ImageAttachable + include Concerns::MarkdownRenderable + + attr_accessor :title, :description_md, :ordinal, :ref, :segments, :learning_objectives_md + + attr_markdown :description, source: :description_md, file: false + attr_markdown :learning_objectives, source: :learning_objectives_md, file: false + validates :title, :ordinal, :ref, presence: true + + def initialize(attributes = {}) + super + @segments ||= [] + end + + def slug + "#{ref}-#{title.parameterize}" + end + + # Used for serialisation + def attributes + { title: nil, description: nil, learning_objectives: nil, ordinal: nil, segments: [], ref: nil }.stringify_keys + end + + # Used for linting + def validation_name + title + end +end diff --git a/app/models/lessons_validator.rb b/app/models/lessons_validator.rb new file mode 100644 index 0000000..aca3586 --- /dev/null +++ b/app/models/lessons_validator.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# A validator that will check an array of choices for uniqueness of ref +class LessonsValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return unless value.is_a?(Array) + + check_correct_class(record, attribute, value) + check_unique_refs(record, attribute, value) + check_unique_title(record, attribute, value) + end + + def check_correct_class(record, attribute, value) + value.each do |choice| + record.errors.add(attribute, "lesson #{choice} is not a Lesson") unless choice.is_a?(Lesson) + end + end + + def check_unique_refs(record, attribute, value) + return unless value.is_a?(Array) + + value.each do |lesson| + ref_counts = Hash.new(0) + lesson.segments.each { |segment| ref_counts[segment.ref] += 1 } + non_unique_refs = ref_counts.select { |_, count| count > 1 }.keys + + non_unique_refs.each { |ref| record.errors.add(attribute, "(#{lesson.title}) segment ref #{ref} is not unique") } + end + end + + def check_unique_title(record, attribute, value) + return unless value.is_a?(Array) + + value.each do |lesson| + title_counts = Hash.new(0) + lesson.segments.each { |segment| title_counts[segment.title] += 1 } + non_unique_titles = title_counts.select { |_, count| count > 1 }.keys + + non_unique_titles.each { |title| record.errors.add(attribute, "(#{lesson.title}) segment title #{title} is not unique") } + end + end +end diff --git a/app/models/text.rb b/app/models/text.rb new file mode 100644 index 0000000..edc9e98 --- /dev/null +++ b/app/models/text.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# The smallest quantity of content +class Text + include ActiveModel::Model + include ActiveModel::Serializers::JSON + include Concerns::AutoNumberable + include Concerns::ImageAttachable + include Concerns::MarkdownRenderable + include Concerns::TitleCleanser + + attr_accessor :title, :ordinal, :ref, :description, :authors, :markdown_file, :root_path, :free, :kind + + attr_markdown :body, source: :markdown_file, file: true, wrapper_class: :wrapper_class + validates :title, :ordinal, :ref, :markdown_file, presence: true + validate do |text| + # Check the ref is a string + errors.add(:ref, 'must be a string') unless text.ref.is_a?(String) + end + + def initialize(attributes = {}) + super + @authors ||= [] + @free ||= false + @kind ||= 'chapter' + end + + def slug + "#{ref}-#{title.parameterize}" + end + + # Used for serialisation + def attributes + { title: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false, ref: nil, episode_type:, segment_type: }.stringify_keys + end + + # Used for linting + def validation_name + title + end + + # For wrapping content + def wrapper_class + { + chapter: nil, + dedications: 'c-book-chapter__dedications', + 'team-bios': 'c-book-chapter__team' + }[kind&.to_sym] + end + + def episode_type + 'text' + end + + def segment_type + 'text' + end +end diff --git a/app/models/video.rb b/app/models/video.rb index c61f1a0..e9c8600 100644 --- a/app/models/video.rb +++ b/app/models/video.rb @@ -8,7 +8,7 @@ class Video include Concerns::MarkdownRenderable attr_accessor :title, :ordinal, :free, :description_md, :short_description, :authors_notes_md, - :authors, :script_file, :root_path, :captions_file, :ref + :authors, :script_file, :root_path, :captions_file, :ref, :vimeo_id attr_markdown :description, source: :description_md, file: false attr_markdown :authors_notes, source: :authors_notes_md, file: false @@ -33,14 +33,23 @@ def episode_type 'video' end + def segment_type + 'video' + end + # Used for serialisation def attributes { title: nil, ordinal: nil, free: false, description: nil, short_description: nil, authors_notes: nil, - authors: [], transcript: nil, ref: nil, episode_type: }.stringify_keys + authors: [], transcript: nil, ref: nil, vimeo_id: nil, episode_type:, segment_type: }.stringify_keys end # Used for linting def validation_name title end + + # Content module videos are markdown files containing metadata + def markdown_file + script_file + end end diff --git a/app/server/robles_content_module_server.rb b/app/server/robles_content_module_server.rb new file mode 100644 index 0000000..df9c8e3 --- /dev/null +++ b/app/server/robles_content_module_server.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'rack-livereload' + +# A local preview server for robles +class RoblesContentModuleServer < Sinatra::Application + set :bind, '0.0.0.0' + set :views, "#{__dir__}/views" + set :public_folder, "#{__dir__}/public" + set :static_cache_control, [max_age: 0] + + use Rack::LiveReload, host: 'localhost', source: :vendored + + helpers do + def slide_path(lesson, segment) + "/slides/#{lesson.slug}/#{segment.slug}" + end + + def transcript_path(lesson, segment) + "/transcripts/#{lesson.slug}/#{segment.slug}" + end + + def assessment_path(lesson, segment) + "/assessments/#{lesson.slug}/#{segment.slug}" + end + + def text_path(lesson, segment) + "/texts/#{lesson.slug}/#{segment.slug}" + end + + def class_for_domain(course) + if course.domains.count > 1 + 'multi-domain' + else + course.domains.first + end + end + + # scss is no longer a built-in helper in sinatra + # However, we can almost just proxy it to Tilt + def scss(template, options = {}, locals = {}) + options.merge!(layout: false, exclude_outvar: true) + # Set the content type to css + render(:scss, template, options, locals).dup.tap do |css| + css.extend(ContentTyped).content_type = :css + end + end + end + + before do + cache_control max_age: 0 + end + + get '/' do + @content_module = content_module(with_transcript: false) + erb :'content_modules/index.html', locals: { content_module: @content_module, title: "robles Preview: #{@content_module.title}" }, layout: :'content_modules/layout.html' + end + + get '/slides/:lesson_slug/:slug' do + @content_module = content_module(with_transcript: false) + lesson = lesson_for_slug(params[:lesson_slug]) + segment = segment_for_slug(lesson, params[:slug]) + raise Sinatra::NotFound unless segment.present? + + erb :'content_modules/segment_slide.html', + locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" }, + layout: :'content_modules/layout.html' + end + + get '/transcripts/:lesson_slug/:slug' do + @content_module = content_module(with_transcript: true) + lesson = lesson_for_slug(params[:lesson_slug]) + segment = segment_for_slug(lesson, params[:slug]) + raise Sinatra::NotFound unless segment.present? + + erb :'content_modules/segment_transcript.html', + locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" }, + layout: :'content_modules/layout.html' + end + + get '/assessments/:lesson_slug/:slug' do + @content_module = content_module(with_transcript: false) + lesson = lesson_for_slug(params[:lesson_slug]) + segment = segment_for_slug(lesson, params[:slug]) + raise Sinatra::NotFound unless segment.present? + + erb :'content_modules/assessment.html', + locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" }, + layout: :'content_modules/layout.html' + end + + get '/texts/:lesson_slug/:slug' do + @content_module = content_module(with_transcript: false) + lesson = lesson_for_slug(params[:lesson_slug]) + segment = segment_for_slug(lesson, params[:slug]) + raise Sinatra::NotFound unless segment.present? + + erb :'content_modules/text.html', + locals: { + segment:, + content_module: @content_module, + title: "robles Preview: #{segment.title}", + word_counter: word_counter_for_segment(segment) + }, + layout: :'content_modules/layout.html' + end + + get '/assets/*' do + local_url = File.join('/data/src/', params[:splat]) + raise Sinatra::NotFound unless acceptable_image_extension(File.extname(local_url)) && File.exist?(local_url) + + send_file(local_url) + end + + get '/styles.css' do + scss :'styles/application', style: :expanded + end + + def content_module(with_transcript: true) + parser = Parser::Circulate.new(file: module_file) + content_module = parser.parse + renderer = Renderer::ContentModule.new(content_module, image_provider: nil) + renderer.disable_transcripts = !with_transcript + renderer.render + content_module.image_attachment_loop { |local_url| servable_image_url(local_url) } + content_module + end + + def render_string(content) + Renderer::MarkdownStringRenderer.new(content:).render + end + + def word_counter_for_segment(segment) + markdown = File.read(segment.markdown_file) + Linting::Markdown::WordCounter.new(markdown) + end + + def lesson_for_slug(slug) + @content_module.lessons.find { |lesson| lesson.slug == slug } + end + + def segment_for_slug(lesson, slug) + lesson.segments.find { |segment| segment.slug == slug } + end + + def module_file + '/data/src/module.yaml' + end + + def servable_image_url(local_url) + [OpenStruct.new(url: local_url&.gsub(%r{/data/src}, '/assets'), variant: :original)] # rubocop:disable Style/OpenStructUse + end + + def acceptable_image_extension(extension) + %w[jpg jpeg png gif].include?(extension.sub('.', '').downcase) + end +end diff --git a/app/server/views/content_modules/_index_table_of_contents.html.erb b/app/server/views/content_modules/_index_table_of_contents.html.erb new file mode 100644 index 0000000..e8bf9d3 --- /dev/null +++ b/app/server/views/content_modules/_index_table_of_contents.html.erb @@ -0,0 +1,31 @@ +
+ <% content_module.lessons.each do |lesson| %> +

Lesson <%= lesson.ordinal %>: <%= lesson.title %>

+
+

+ <%= lesson.description %> +

+
+ + + <% lesson.segments.each do |segment| %> +
+
+ <% if segment.is_a?(Video) %> +

<%= segment.title %>

+ <% if segment.free %>Free<% end %> + slide + <% elsif segment.is_a?(Assessment) %> +

<%= segment.title %>

+ <% elsif segment.is_a?(Text) %> +

<%= segment.title %>

+ <% end %> +
+

<%= segment.description %>

+ + <%= segment.ref %> + +
+ <% end %> + <% end %> +
diff --git a/app/server/views/content_modules/_table_of_contents.html.erb b/app/server/views/content_modules/_table_of_contents.html.erb new file mode 100644 index 0000000..a9d59ae --- /dev/null +++ b/app/server/views/content_modules/_table_of_contents.html.erb @@ -0,0 +1,41 @@ +
+
+
+ +
+
+
diff --git a/app/server/views/content_modules/assessment.html.erb b/app/server/views/content_modules/assessment.html.erb new file mode 100644 index 0000000..2afdcd0 --- /dev/null +++ b/app/server/views/content_modules/assessment.html.erb @@ -0,0 +1,75 @@ + + +
+ <%= erb :'content_modules/_table_of_contents.html', locals: { content_module: content_module, current_segment: segment } %> + +
+
+
+
+ + <%= content_module.title %> + + + + <%= segment.title %> + + +

+ <%= segment.ref %> +
+ <%= segment.title %> + + It's a quiz! + +
+

+
+
+ +
+ <% segment.questions.each do |question| %> +
+

<%= question.question %>

+
    + <% question.choices.each do |choice| %> +
  • + <%= choice.ref %> + <%= choice.option %> + <% if choice.correct %>correct<% end %> +
  • + <% end %> +
+ <%= question.explanation %> +
+ <% end %> +
+ +
+
+
diff --git a/app/server/views/content_modules/index.html.erb b/app/server/views/content_modules/index.html.erb new file mode 100644 index 0000000..c9f7365 --- /dev/null +++ b/app/server/views/content_modules/index.html.erb @@ -0,0 +1,78 @@ +
+ +
+ +
+
+ +
+
+ Version + <%= content_module.version_description %> +
+
+ Platform + <%= content_module.platform %> +
+
+ Language + <%= content_module.language %> +
+
+ Editor + <%= content_module.editor %> +
+
+
+
+ +
+
+
+ +
+ <%= content_module.description %> +
+ + <%= erb :'content_modules/_index_table_of_contents.html', locals: { content_module: content_module } %> + +
+
+

Who is this course for

+
+ <%= content_module.who_is_this_for %> +
+
+
+

Concepts covered in this course

+
+ <%= content_module.covered_concepts %> +
+
+
+
+ +
+
+ +
+ + <% if content_module.professional %> +
+ Pro +
+ <% end %> +
+ +

<%= content_module.title %>

+

+ By* <%= content_module.authors.map(&:username).to_sentence(two_words_connector: ' & ', last_word_connector: ' and ') %> +

+ +

+ <%= content_module.short_description %> +

+
+
+
+
diff --git a/app/server/views/content_modules/layout.html.erb b/app/server/views/content_modules/layout.html.erb new file mode 100644 index 0000000..30fc796 --- /dev/null +++ b/app/server/views/content_modules/layout.html.erb @@ -0,0 +1,23 @@ + + + + + + + <%= title || 'robles Preview' %> + + + + + + + + + <%= erb :'shared/_svg_icons.html' %> + + +
+ <%= yield %> +
+ + diff --git a/app/server/views/content_modules/segment_slide.html.erb b/app/server/views/content_modules/segment_slide.html.erb new file mode 100644 index 0000000..7268182 --- /dev/null +++ b/app/server/views/content_modules/segment_slide.html.erb @@ -0,0 +1,19 @@ +
+

+ <%= content_module.title %> +

+
+ <% if content_module.lessons.count> 1 %> +

Lesson <%= lesson.ref %>: <%= lesson.title %>

+ <% end %> +

<%= segment.title %>

+
+ +
+ + <% if content_module.featured_banner_image_url&.first&.url.present? %> +
+ +
+ <% end %> +
diff --git a/app/server/views/content_modules/segment_transcript.html.erb b/app/server/views/content_modules/segment_transcript.html.erb new file mode 100644 index 0000000..e5e29a8 --- /dev/null +++ b/app/server/views/content_modules/segment_transcript.html.erb @@ -0,0 +1,67 @@ + + +
+ <%= erb :'content_modules/_table_of_contents.html', locals: { content_module: content_module, current_segment: segment } %> + +
+
+
+
+ + <%= content_module.title %> + + + + <%= segment.title %> + + + + +

+ <%= segment.ref %> +
+ <%= segment.title %> + <% if segment.respond_to?(:authors) %> + + <% if segment.authors.filter { |author| author.role == 'author' }.present? %> + Written by <%= segment.authors.filter { |author| author.role == 'author' }.map(&:username).to_sentence %> + <% end %> + + <% end %> +
+

+
+
+ +
+ <%= segment.transcript if segment.respond_to?(:transcript) %> +
+ +
+
+
diff --git a/app/server/views/content_modules/text.html.erb b/app/server/views/content_modules/text.html.erb new file mode 100644 index 0000000..96dc17b --- /dev/null +++ b/app/server/views/content_modules/text.html.erb @@ -0,0 +1,85 @@ +
+ Word count: <%= "#{word_counter.count} / #{word_counter.word_limit}" %>. + <%= "This chapter exceeds the word limit." if word_counter.exceeds_word_limit? %> +
+ + + + +
+ +
+
+
+
+ + <%= content_module.title %> + + + + <%= segment.title %> + + + + +

+
+ <%= segment.title %> + + <% if segment.authors.filter { |author| author.role == 'author' }.present? %> + Written by <%= segment.authors.filter { |author| author.role == 'author' }.map(&:username).to_sentence %> + <% end %> + +
+

+
+
+ +
+ <%= segment.body %> +
+ +
+
+
diff --git a/app/server/views/styles/components/slide.scss b/app/server/views/styles/components/slide.scss index b3ebedc..2a94d1d 100644 --- a/app/server/views/styles/components/slide.scss +++ b/app/server/views/styles/components/slide.scss @@ -113,7 +113,7 @@ ========================================================================== */ /* Title slide is a bigger size than video to go to a video file. So we need to scale all the sizes up */ -/* 2022 Oct sizing for carolus is height: 602px, width: 1072px.*/ +/* 2022 Oct sizing for carolus is height: 588px, width: 1036px.*/ $slide-ratio: 1.8; .title-slide { @@ -145,14 +145,16 @@ $slide-ratio: 1.8; bottom: 32px * $slide-ratio; h2 { - font-size: 16px * $slide-ratio; - line-height: 1.25; + font-size: 12px * $slide-ratio; + line-height: 1; + letter-spacing: 2px !important; + text-transform: uppercase; font-family: 'Relative', sans-serif; - font-weight: 400; + font-weight: 500; text-align: left; - padding-bottom: 16px * $slide-ratio; - /* Max-width is roughly 55%, but percentage doesn't work cos absolute positioning... */ - max-width: 587px * $slide-ratio; + padding-bottom: 8px * $slide-ratio; + /* Max-width is roughly 75%, but percentage doesn't work cos absolute positioning... */ + max-width: 713px * $slide-ratio; } h3 { @@ -160,14 +162,13 @@ $slide-ratio: 1.8; font-family: 'Relative', sans-serif; font-weight: 400; line-height: 1.25; - /* Max-width is roughly 55%, but percentage doesn't work cos absolute positioning... */ - max-width: 587px * $slide-ratio; + /* Max-width is roughly 75%, but percentage doesn't work cos absolute positioning... and -64px to match carolus */ + max-width: 713px * $slide-ratio; } } figure { - width: 252px * $slide-ratio; - height: 252px * $slide-ratio; + width: 21%; position: absolute; bottom: 32px * $slide-ratio; right: 32px * $slide-ratio; @@ -230,3 +231,21 @@ $slide-ratio: 1.8; } } } + +.segment-slide { + h1 { + max-width: 100%; + padding-right: 32px * $slide-ratio; + } + .slide-episode { + h2 { + text-transform: unset; + letter-spacing: normal !important; + font-weight: 400; + } + + h3 { + font-size: 24px * $slide-ratio; + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 549ec93..eddded9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,15 @@ services: # When working on videos #- ../videos/video-aiar:/data/src # When working on books - - ../../books/alg:/data/src + #- ../../books/alg:/data/src # When working on pablo #- ../../sites/pablo:/data/src - command: bin/robles video serve + # When working on m3 + - ../../m3/m3-devtest:/data/src + command: bin/robles module serve env_file: .env environment: - - IMAGES_CDN_HOST=assets.robles.raywenderlich.com + - IMAGES_CDN_HOST=assets.robles.kodeco.com ports: - "4567:4567" - "35729:35729"