Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Converting to Mojolicious::Template for outputFormat #14

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -9,8 +9,12 @@ private/
tmp/*
!tmp/.gitkeep
logs/*.log

node_modules
node_modules/*
public/**/*.min.js
public/**/*.min.css
public/static-assets.json

*.o
*.pm.tdy
*.bs
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -55,7 +55,7 @@ RUN cp render_app.conf.dist render_app.conf

RUN cp conf/pg_config.yml lib/PG/conf/pg_config.yml

RUN npm install
RUN cd public/ && npm install && cd ..

RUN cd lib/PG/htdocs && npm install && cd ../../..

166 changes: 112 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
@@ -51,7 +51,9 @@ If using a local install instead of docker:
* copy `render_app.conf.dist` to `render_app.conf` and make any desired modifications
* copy `conf/pg_config.yml` to `lib/PG/pg_config.yml` and make any desired modifications
* install third party JavaScript dependencies
* `cd private/`
* `npm install`
* `cd ..`
* install PG JavaScript dependencies
* `cd lib/PG/htdocs`
* `npm install`
@@ -70,59 +72,115 @@ If using a local install instead of docker:

![image](https://user-images.githubusercontent.com/3385756/129100124-72270558-376d-4265-afe2-73b5c9a829af.png)

## Server Configuration

Modification of `baseURL` may be necessary to separate multiple services running on `SITE_HOST`, and will be used to extend `SITE_HOST`. The result of this extension will serve as the root URL for accessing the renderer (and any supplementary assets it may need to provide in support of a rendered problem). If `baseURL` is an absolute URL, it will be used verbatim -- userful if the renderer is running behind a load balancer.

By default, `formURL` will further extend `baseURL`, and serve as the form-data target for user interactions with problems rendered by this service. If `formURL` is an absolute URL, it will be used verbatim -- useful if your implementation intends to sit in between the user and the renderer.

## Renderer API

Can be interfaced through `/render-api`

## Parameters

| Key | Type | Default Value | Required | Description | Notes |
| --- | ---- | ------------- | -------- | ----------- | ----- |
| problemSourceURL | string | null | true if `sourceFilePath` and `problemSource` are null | The URL from which to fetch the problem source code | Takes precedence over `problemSource` and `sourceFilePath`. A request to this URL is expected to return valid pg source code in base64 encoding. |
| problemSource | string (base64 encoded) | null | true if `problemSourceURL` and `sourceFilePath` are null | The source code of a problem to be rendered | Takes precedence over `sourceFilePath`. |
| sourceFilePath | string | null | true if `problemSource` and `problemSourceURL` are null | The path to the file that contains the problem source code | Can begin with Library/ or Contrib/, in which case the renderer will automatically adjust the path relative to the webwork-open-problem-library root. Path may also begin with `private/` for local, non-OPL content. |
| problemSeed | number | NA | true | The seed to determine the randomization of a problem | |
| psvn | number | 123 | false | used for consistent randomization between problems | |
| formURL | string | /render-api | false | the URL for form submission | |
| baseURL | string | / | false | the URL for relative paths | |
| format | string | '' | false | Determine how the response is formatted ('html' or 'json') ||
| outputFormat | string (enum) | static | false | Determines how the problem should render, see below descriptions below | |
| language | string | en | false | Language to render the problem in (if supported) | |
| showHints | number (boolean) | 1 | false | Whether or not to show hints | |
| showSolutions | number (boolean) | 0 | false | Whether or not to show the solutions | |
| permissionLevel | number | 0 | false | Deprecated. See below. |
| isInstructor | number (boolean) | 0 | false | Is the user viewing the problem an instructor or not. | Used by PG to determine if scaffolds can be allowed to be open among other things |
| problemNumber | number | 1 | false | We don't use this | |
| numCorrect | number | 0 | false | The number of correct attempts on a problem | |
| numIncorrect | number | 1000 | false | The number of incorrect attempts on this problem | |
| processAnswers | number (boolean) | 1 | false | Determines whether or not answer json is populated, and whether or not problem_result and problem_state are non-empty | |
| answersSubmitted | number (boolean) | ? | false? | Determines whether to process form-data associated to the available input fields | |
| showSummary | number (boolean) | ? | false? | Determines whether or not to show the summary result of processing the form-data associated with `answersSubmitted` above ||
| showComments | number (boolean) | 0 | false | Renders author comment field at the end of the problem ||
| includeTags | number (boolean) | 0 | false | Includes problem tags in the returned JSON | Only relevant when requesting `format: 'json'` |

## Output Format

| Key | Description |
| ----- | ----- |
| static | zero buttons, locked form fields (read-only) |
| nosubmit | zero buttons, editable (for exams, save problem state and submit all together) |
| single | one submit button (intended for graded content) |
| classic | preview + submit buttons |
| simple | preview + submit + show answers buttons |
| practice | check answers + show answers buttons |

## Permission level

| Key | Value |
| --- | ----- |
| student | 0 |
| prof | 10 |
| admin | 20 |

## Permission logic summary

* `permissionLevel` is ignored if `isInstructor` is directly set.
* If `permissionLevel >= 10`, then `isInstructor` will be set to true.
* If `permissionLevel < 10`, then `isInstructor` will be set to false.
* `permissionLevel` is not used to determine if hints or solutions are shown.
Can be accessed by POST to `{SITE_HOST}{baseURL}{formURL}`.

By default, `localhost:3000/render-api`.

### **REQUIRED PARAMETERS**

The bare minimum of parameters that must be included are:
* the code for the problem, so, **ONE** of the following (in order of precedence):
* `problemSource` (raw pg source code, _can_ be base64 encoded)
* `sourceFilePath` (relative to OPL `Library/`, `Contrib/`; or in `private/`)
* `problemSourceURL` (fetch the pg source from remote server)
* a "seed" value for consistent randomization
* `problemSeed` (integer)

| Key | Type | Description | Notes |
| --- | ---- | ----------- | ----- |
| problemSource | string (possibly base64 encoded) | The source code of a problem to be rendered | Takes precedence over `sourceFilePath`. |
| sourceFilePath | string | The path to the file that contains the problem source code | Renderer will automatically adjust `Library/` and `Contrib/` relative to the webwork-open-problem-library root. Path may also begin with `private/` for local, non-OPL content. |
| problemSourceURL | string | The URL from which to fetch the problem source code | Takes precedence over `problemSource` and `sourceFilePath`. A request to this URL is expected to return valid pg source code in base64 encoding. |
| problemSeed | number | The seed that determines the randomization of a problem | |

**ALL** other request parameters are optional.

### Infrastructure Parameters

The defaults for these parameters are set in `render_app.conf`, but these can be overridden on a per-request basis.

| Key | Type | Default Value | Description | Notes |
| --- | ---- | ------------- | ----------- | ----- |
| baseURL | string | '/' (as set in `render_app.conf`) | the URL for relative paths | |
| formURL | string | '/render-api' (as set in `render_app.conf`) | the URL for form submission | |

### Display Parameters

#### Formatting

Parameters that control the structure and templating of the response.

| Key | Type | Default Value | Description | Notes |
| --- | ---- | ------------- | ----------- | ----- |
| language | string | en | Language to render the problem in (if supported) | affects the translation of template strings, _not_ actual problem content |
| _format | string | 'html' | Determine how the response is _structured_ ('html' or 'json') | usually 'html' if the user is directly interacting with the renderer, 'json' if your CMS sits between user and renderer |
| outputFormat | string | 'default' | Determines how the problem should be formatted | 'default', 'static', 'PTX', 'raw', or |
| displayMode | string | 'MathJax' | How to prepare math content for display | 'MathJax' or 'ptx' |

#### User Interactions

Control how the user is allowed to interact with the rendered problem.

Requesting `outputFormat: 'static'` will prevent any buttons from being included in the rendered output, regardless of the following options.

| Key | Type | Default Value | Description | Notes |
| --- | ---- | ------------- | ----------- | ----- |
| hidePreviewButton | number (boolean) | false | "Preview My Answers" is enabled by default | |
| hideCheckAnswersButton | number (boolean) | false | "Submit Answers" is enabled by default | |
| showCorrectAnswersButton | number (boolean) | `isInstructor` | "Show Correct Answers" is disabled by default, enabled if `isInstructor` is true (see below) | |

#### Content

Control what is shown to the user: hints, solutions, attempt results, scores, etc.

| Key | Type | Default Value | Description | Notes |
| --- | ---- | ------------- | ----------- | ----- |
| permissionLevel | number | 0 | **DEPRECATED.** Use `isInstructor` instead. |
| isInstructor | number (boolean) | 0 | Is the user viewing the problem an instructor or not. | Used by PG to determine if scaffolds can be allowed to be open among other things |
| showHints | number (boolean) | 1 | Whether or not to show hints | |
| showSolutions | number (boolean) | `isInstructor` | Whether or not to show the solutions | |
| hideAttemptsTable | number (boolean) | 0 | Hide the table of answer previews/results/messages | If you have a replacement for flagging the submitted entries as correct/incorrect |
| showSummary | number (boolean) | 1 | Determines whether or not to show a summary of the attempt underneath the table | Only relevant if the Attempts Table is shown `hideAttemptsTable: false` (default) |
| showComments | number (boolean) | 0 | Renders author comment field at the end of the problem | |
| showFooter | number (boolean) | 0 | Show version information and WeBWorK copyright footer | |
| includeTags | number (boolean) | 0 | Includes problem tags in the returned JSON | Only relevant when requesting `_format: 'json'` |

## Using JWTs

There are three JWT structures that the Renderer uses, each containing its predecessor:
* problemJWT
* sessionJWT
* answerJWT

### ProblemJWT

This JWT encapsulates the request parameters described above, under the API heading. Any value set in the JWT cannot be overridden by form-data. For example, if the problemJWT includes `isInstructor: 0`, then any subsequent interaction with the problem rendered by this JWT cannot override this setting by including `isInstructor: 1` in the form-data.

### SessionJWT

This JWT encapsulates a user's attempt on a problem, including:
* the text and LaTeX versions of each answer entry
* count of incorrect attempts (stopping after a correct attempt, or after `showCorrectAnswers` is used)
* the problemJWT

If stored (see next), this JWT can be submitted as the sole request parameter, and the response will effectively restore the users current state of interaction with the problem (as of their last submission).

### AnswerJWT

If the initial problemJWT contains a value for `JWTanswerURL`, this JWT will be generated and sent to the specified URL. The answerJWT is the only content provided to the URL. The renderer is intended to to be user-agnostic. It is recommended that the JWTanswerURL specify the unique identifier for the user/problem combination. (e.g. `JWTanswerURL: 'https://db.yoursite.org/grades-api/:user_problem_id'`)

For security purposes, this parameter is only accepted when included as part of a JWT.

This JWT encapsulates the status of the user's interaction with the problem.
* score
* sessionJWT

The goal here is to update the `JWTanswerURL` with the score and "state" for the user. If you have uses for additional information, please feel free to suggest as a GitHub Issue.
4 changes: 1 addition & 3 deletions conf/pg_config.yml
Original file line number Diff line number Diff line change
@@ -40,6 +40,7 @@ directories:
# (in this order) by loadMacros when it looks for a .pl macro file.
macrosPath:
- .
- $render_root/private/macros
- $pg_root/macros
- $pg_root/macros/answers
- $pg_root/macros/capa
@@ -182,9 +183,6 @@ options:
# This is the operations file to use for mathview, each contains a different locale.
mathViewLocale: mv_locale_us.js

# Set to 1 to show the WirisEditor preview system.
useWirisEditor: 0

# Catch translation warnings internally.
catchWarnings: 1

26 changes: 10 additions & 16 deletions lib/RenderApp.pm
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
use strict;
use warnings;
# use feature 'signatures';
# no warnings qw(experimental::signatures);

package RenderApp;
use Mojo::Base 'Mojolicious';

@@ -15,7 +20,7 @@ BEGIN {
$ENV{PG_ROOT} = $main::dirname . '/PG';

# Used for reconstructing library paths from sym-links.
$ENV{OPL_DIRECTORY} = "webwork-open-problem-library";
$ENV{OPL_DIRECTORY} = "$ENV{RENDER_ROOT}/webwork-open-problem-library";

$ENV{MOJO_CONFIG} = (-r "$ENV{RENDER_ROOT}/render_app.conf") ? "$ENV{RENDER_ROOT}/render_app.conf" : "$ENV{RENDER_ROOT}/render_app.conf.dist";
# $ENV{MOJO_MODE} = 'production';
@@ -26,8 +31,9 @@ use lib "$main::dirname";
print "home directory " . $main::dirname . "\n";

use RenderApp::Model::Problem;
use RenderApp::Controller::RenderProblem;
use RenderApp::Controller::IO;
use WeBWorK::RenderProblem;
use WeBWorK::FormatRenderedProblem;

sub startup {
my $self = shift;
@@ -66,6 +72,7 @@ sub startup {
$self->helper(newProblem => sub { shift; RenderApp::Model::Problem->new(@_) });

# Helpers
$self->helper(format => sub { WeBWorK::FormatRenderedProblem::formatRenderedProblem(@_) });
$self->helper(validateRequest => sub { RenderApp::Controller::IO::validate(@_) });
$self->helper(parseRequest => sub { RenderApp::Controller::Render::parseRequest(@_) });
$self->helper(croak => sub { RenderApp::Controller::Render::croak(@_) });
@@ -107,20 +114,7 @@ sub startup {
$r->any('/pg_files/CAPA_Graphics/*static')->to('StaticFiles#CAPA_graphics_file');
$r->any('/pg_files/tmp/*static')->to('StaticFiles#temp_file');
$r->any('/pg_files/*static')->to('StaticFiles#pg_file');

# any other requests fall through
$r->any('/*fail' => sub {
my $c = shift;
my $report = $c->stash('fail')."\nCOOKIE:";
for my $cookie (@{$c->req->cookies}) {
$report .= "\n".$cookie->to_string;
}
$report .= "\nFORM DATA:";
foreach my $k (@{$c->req->params->names}) {
$report .= "\n$k = ".join ', ', @{$c->req->params->every_param($k)};
}
$c->log->fatal($report);
$c->rendered(404)});
$r->any('/*static')->to('StaticFiles#public_file');
}

1;
357 changes: 0 additions & 357 deletions lib/RenderApp/Controller/FormatRenderedProblem.pm

This file was deleted.

138 changes: 72 additions & 66 deletions lib/RenderApp/Controller/Render.pm
Original file line number Diff line number Diff line change
@@ -37,7 +37,6 @@ sub parseRequest {
foreach my $key (keys %$claims) {
$params{$key} //= $claims->{$key};
}
# @params{ keys %$claims } = values %$claims;
}

# problemJWT sets basic problem request configuration and rendering options
@@ -57,9 +56,19 @@ sub parseRequest {
return undef;
};
$claims = $claims->{webwork} if defined $claims->{webwork};
# $claims->{problemJWT} = $problemJWT; # because we're merging claims, this is unnecessary?
# override key-values in params with those provided in the JWT
@params{ keys %$claims } = values %$claims;
} else {
# if no JWT is provided, create one
$params{aud} = $ENV{SITE_HOST};
my $req_jwt = encode_jwt(
payload => \%params,
key => $ENV{problemJWTsecret},
alg => 'PBES2-HS512+A256KW',
enc => 'A256GCM',
auto_iat => 1
);
$params{problemJWT} = $req_jwt;
}
return \%params;
}
@@ -81,7 +90,7 @@ sub fetchRemoteSource_p {
then(
sub {
my $tx = shift;
return encode_base64($tx->result->body);
return $tx->result->body;
})->
catch(
sub {
@@ -97,6 +106,7 @@ async sub problem {
my $c = shift;
my $inputs_ref = $c->parseRequest;
return unless $inputs_ref;

$inputs_ref->{problemSource} = fetchRemoteSource_p($c, $inputs_ref->{problemSourceURL}) if $inputs_ref->{problemSourceURL};

my $file_path = $inputs_ref->{sourceFilePath};
@@ -120,71 +130,67 @@ async sub problem {
return $c->exception($problem->{_message}, $problem->{status})
unless $problem->success();

$inputs_ref->{sourceFilePath} = $problem->{read_path}; # in case the path was updated...

my $input_errs = checkInputs($inputs_ref);

$c->render_later; # tell Mojo that this might take a while
my $ww_return_json = await $problem->render($inputs_ref);

return $c->exception( $problem->{_message}, $problem->{status} )
unless $problem->success();

my $ww_return_hash = decode_json($ww_return_json);
my $output_errs = checkOutputs($ww_return_hash);

$ww_return_hash->{debug}->{render_warn} = [$input_errs, $output_errs];

# if answers are submitted and there is a provided answerURL...
if ($inputs_ref->{JWTanswerURL} && $ww_return_hash->{JWT}{answer} && $inputs_ref->{submitAnswers}) {
my $answerJWTresponse = {
iss => $ENV{SITE_HOST},
subject => 'webwork.result',
status => 502,
message => 'initial message'
};
my $reqBody = {
Origin => $ENV{SITE_HOST},
'Content-Type' => 'text/plain',
};

$c->log->info("sending answerJWT to $inputs_ref->{JWTanswerURL}");
await $c->ua->max_redirects(5)->request_timeout(7)->post_p($inputs_ref->{JWTanswerURL}, $reqBody, $ww_return_hash->{JWT}{answer})->
then(sub {
my $response = shift->result;

$answerJWTresponse->{status} = int($response->code);
# answerURL responses are expected to be JSON
if ($response->json) {
# munge data with default response object
$answerJWTresponse = { %$answerJWTresponse, %{$response->json} };
} else {
# otherwise throw the whole body as the message
$answerJWTresponse->{message} = $response->body;
}
})->
catch(sub {
my $err = shift;
$c->log->error($err);
my $return_object = decode_json($ww_return_json);

$answerJWTresponse->{status} = 500;
$answerJWTresponse->{message} = '[' . $c->logID . '] ' . $err;
});
# if answerURL provided and this is a submit, then send the answerJWT
if ($inputs_ref->{JWTanswerURL} && $inputs_ref->{submitAnswers} && !$inputs_ref->{showCorrectAnswers}) {
$return_object->{JWTanswerURLstatus} = await sendAnswerJWT($c, $inputs_ref->{JWTanswerURL}, $return_object->{answerJWT});
}

$answerJWTresponse = encode_json($answerJWTresponse);
# this will become a string literal, so single-quote characters must be escaped
$answerJWTresponse =~ s/'/\\'/g;
$c->log->info("answerJWT response ".$answerJWTresponse);
# format the response
$c->format($return_object);
}

$ww_return_hash->{renderedHTML} =~ s/JWTanswerURLstatus/$answerJWTresponse/g;
} else {
$ww_return_hash->{renderedHTML} =~ s/JWTanswerURLstatus//;
}
async sub sendAnswerJWT {
my $c = shift;
my $JWTanswerURL = shift;
my $answerJWT = shift;

my $answerJWTresponse = {
iss => $ENV{SITE_HOST},
subject => 'webwork.result',
status => 502,
message => 'initial message'
};
my $reqBody = {
Origin => $ENV{SITE_HOST},
'Content-Type' => 'text/plain',
};

$c->respond_to(
html => { text => $ww_return_hash->{renderedHTML} },
json => { json => $ww_return_hash }
);
$c->log->info("sending answerJWT to $JWTanswerURL");
await $c->ua->max_redirects(5)->request_timeout(7)->post_p($JWTanswerURL, $reqBody, $answerJWT)->
then(sub {
my $response = shift->result;

$answerJWTresponse->{status} = int($response->code);
# answerURL responses are expected to be JSON
if ($response->json) {
# munge data with default response object
$answerJWTresponse = { %$answerJWTresponse, %{$response->json} };
} else {
# otherwise throw the whole body as the message
$answerJWTresponse->{message} = $response->body;
}
})->
catch(sub {
my $err = shift;
$c->log->error($err);

$answerJWTresponse->{status} = 500;
$answerJWTresponse->{message} = '[' . $c->logID . '] ' . $err;
});

$answerJWTresponse = encode_json($answerJWTresponse);
# this will become a string literal, so single-quote characters must be escaped
$answerJWTresponse =~ s/'/\\'/g;
$c->log->info("answerJWT response ".$answerJWTresponse);
return $answerJWTresponse;
}

sub checkInputs {
@@ -205,11 +211,12 @@ sub checkInputs {
push @errs, $err;
}
}
return "Form data submitted for "
return @errs ? "Form data submitted for "
. $inputs_ref->{sourceFilePath}
. " contained errors: {"
. join "}, {", @errs
. "}";
. "}"
: undef;
}

sub checkOutputs {
@@ -237,11 +244,12 @@ sub checkOutputs {
}
}
}
return
return @errs ?
"Output from rendering "
. ($outputs_ref->{sourceFilePath} // '')
. " contained errors: {"
. join "}, {", @errs . "}";
. ($outputs_ref->{sourceFilePath} // '')
. " contained errors: {"
. join "}, {", @errs . "}"
: undef;
}

sub exception {
@@ -280,7 +288,6 @@ sub jweFromRequest {
my $inputs_ref = $c->parseRequest;
return unless $inputs_ref;
$inputs_ref->{aud} = $ENV{SITE_HOST};
$inputs_ref->{key} = $ENV{problemJWTsecret};
my $req_jwt = encode_jwt(
payload => $inputs_ref,
key => $ENV{problemJWTsecret},
@@ -296,7 +303,6 @@ sub jwtFromRequest {
my $inputs_ref = $c->parseRequest;
return unless $inputs_ref;
$inputs_ref->{aud} = $ENV{SITE_HOST};
$inputs_ref->{key} = $ENV{problemJWTsecret};
my $req_jwt = encode_jwt(
payload => $inputs_ref,
key => $ENV{problemJWTsecret},
4 changes: 4 additions & 0 deletions lib/RenderApp/Controller/StaticFiles.pm
Original file line number Diff line number Diff line change
@@ -28,4 +28,8 @@ sub pg_file ($c) {
$c->reply_with_file_if_readable(path($ENV{PG_ROOT}, 'htdocs', $c->stash('static')));
}

sub public_file($c) {
$c->reply_with_file_if_readable($c->app->home->child('public', $c->stash('static')));
}

1;
17 changes: 11 additions & 6 deletions lib/RenderApp/Model/Problem.pm
Original file line number Diff line number Diff line change
@@ -8,7 +8,8 @@ use Mojo::IOLoop;
use Mojo::JSON qw( encode_json );
use Mojo::Base -async_await;
use Time::HiRes qw( time );
use RenderApp::Controller::RenderProblem;
use MIME::Base64 qw( decode_base64 );
use WeBWorK::RenderProblem;

##### Problem params: #####
# = random_seed (set randomization for rendering)
@@ -67,7 +68,7 @@ sub _init {
# sourcecode takes precedence over reading from file path
if ( $problem_contents =~ /\S/ ) {
$self->source($problem_contents);
$self->{code_origin} = 'pg source (' . $self->path( $read_path, 'force' ) .')';
$self->{code_origin} = 'pg source (' . ($self->path( $read_path, 'force' ) || 'no path provided') .')';
# set read_path without failing for !-e
# this supports images in problems via editor
} else {
@@ -88,9 +89,12 @@ sub source {
if ( scalar(@_) == 1 ) {
my $contents = shift;

# recognize and decode base64 if necessary
$contents = Encode::decode( "UTF-8", decode_base64($contents) )
if ( $contents =~ m!^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$!);

# UNIX style line-endings are required
$contents =~ s/\r\n/\n/g;
$contents =~ s/\r/\n/g;
$contents =~ s!\r\n?!\n!g;
$self->{problem_contents} = $contents;
}
return $self->{problem_contents};
@@ -131,7 +135,8 @@ sub path {
}
$self->{_error} = "404 I cannot find a problem with that file path."
unless ( -e $read_path || $force );
$self->{read_path} = Mojo::File->new($read_path);
# if we objectify an empty string, it becomes truth-y -- AVOID!
$self->{read_path} = Mojo::File->new($read_path) if $read_path;
}
return $self->{read_path};
}
@@ -217,7 +222,7 @@ sub render {
my $inputs_ref = shift;
$self->{action} = 'render';
my $renderPromise = Mojo::IOLoop->subprocess->run_p( sub {
return RenderApp::Controller::RenderProblem::process_pg_file( $self, $inputs_ref );
return WeBWorK::RenderProblem::process_pg_file( $self, $inputs_ref );
})->catch(sub {
$self->{exception} = Mojo::Exception->new(shift)->trace;
$self->{_error} = "500 Render failed: " . $self->{exception}->message;
467 changes: 467 additions & 0 deletions lib/WeBWorK/AttemptsTable.pm

Large diffs are not rendered by default.

313 changes: 313 additions & 0 deletions lib/WeBWorK/FormatRenderedProblem.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
################################################################################
# WeBWorK Online Homework Delivery System
# Copyright &copy; 2000-2022 The WeBWorK Project, https://github.com/openwebwork
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of either: (a) the GNU General Public License as published by the
# Free Software Foundation; either version 2, or (at your option) any later
# version, or (b) the "Artistic License" which comes with this package.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the
# Artistic License for more details.
################################################################################

=head1 NAME
FormatRenderedProblem.pm
=cut

package WeBWorK::FormatRenderedProblem;

use strict;
use warnings;

use JSON;
use Digest::SHA qw(sha1_base64);
use Mojo::Util qw(xml_escape);
use Mojo::DOM;

use WeBWorK::Localize;
use WeBWorK::AttemptsTable;
use WeBWorK::Utils qw(getAssetURL);
use WeBWorK::Utils::LanguageAndDirection;

sub formatRenderedProblem {
my $c = shift;
my $rh_result = shift;
my $inputs_ref = $rh_result->{inputs_ref};

my $renderErrorOccurred = 0;

my $problemText = $rh_result->{text} // '';
$problemText .= $rh_result->{flags}{comment} if ( $rh_result->{flags}{comment} && $inputs_ref->{showComments} );

if ($rh_result->{flags}{error_flag}) {
$rh_result->{problem_result}{score} = 0; # force score to 0 for such errors.
$renderErrorOccurred = 1;
}

my $SITE_URL = $inputs_ref->{baseURL};
my $FORM_ACTION_URL = $inputs_ref->{formURL};

my $displayMode = $inputs_ref->{displayMode} // 'MathJax';

# HTML document language setting
my $formLanguage = $inputs_ref->{language} // 'en';

# Third party CSS
# The second element of each array in the following is whether or not the file is a theme file.
# customize source for bootstrap.css
my @third_party_css = map { getAssetURL($formLanguage, $_->[0]) } (
[ 'css/bootstrap.css', ],
[ 'node_modules/jquery-ui-dist/jquery-ui.min.css', ],
[ 'node_modules/@fortawesome/fontawesome-free/css/all.min.css' ],
);

# Add CSS files requested by problems via ADD_CSS_FILE() in the PG file
# or via a setting of $ce->{pg}{specialPGEnvironmentVars}{extra_css_files}
# which can be set in course.conf (the value should be an anonomous array).
my @cssFiles;
# if (ref($ce->{pg}{specialPGEnvironmentVars}{extra_css_files}) eq 'ARRAY') {
# push(@cssFiles, { file => $_, external => 0 }) for @{ $ce->{pg}{specialPGEnvironmentVars}{extra_css_files} };
# }
if (ref($rh_result->{flags}{extra_css_files}) eq 'ARRAY') {
push @cssFiles, @{ $rh_result->{flags}{extra_css_files} };
}
my %cssFilesAdded; # Used to avoid duplicates
my @extra_css_files;
for (@cssFiles) {
next if $cssFilesAdded{ $_->{file} };
$cssFilesAdded{ $_->{file} } = 1;
if ($_->{external}) {
push(@extra_css_files, $_);
} else {
push(@extra_css_files, { file => getAssetURL($formLanguage, $_->{file}), external => 0 });
}
}

# Third party JavaScript
# The second element of each array in the following is whether or not the file is a theme file.
# The third element is a hash containing the necessary attributes for the script tag.
my @third_party_js = map { [ getAssetURL($formLanguage, $_->[0]), $_->[1] ] } (
[ 'node_modules/jquery/dist/jquery.min.js', {} ],
[ 'node_modules/jquery-ui-dist/jquery-ui.min.js', {} ],
[ 'node_modules/iframe-resizer/js/iframeResizer.contentWindow.min.js', {} ],
[ "js/apps/MathJaxConfig/mathjax-config.js", { defer => undef } ],
[ 'node_modules/mathjax/es5/tex-svg.js', { defer => undef, id => 'MathJax-script' } ],
[ 'node_modules/bootstrap/dist/js/bootstrap.bundle.min.js', { defer => undef } ],
[ "js/apps/Problem/problem.js", { defer => undef } ],
[ "js/apps/Problem/submithelper.js", { defer => undef } ],
[ "js/apps/CSSMessage/css-message.js", { defer => undef } ],
);

# Get the requested format. (outputFormat or outputformat)
# override to static mode if showCorrectAnswers has been set
my $formatName = $inputs_ref->{showCorrectAnswers} && !$inputs_ref->{isInstructor}
? 'static' : $inputs_ref->{outputFormat};

# Add JS files requested by problems via ADD_JS_FILE() in the PG file.
my @extra_js_files;
if (ref($rh_result->{flags}{extra_js_files}) eq 'ARRAY') {
my %jsFiles;
for (@{ $rh_result->{flags}{extra_js_files} }) {
next if $jsFiles{ $_->{file} };
$jsFiles{ $_->{file} } = 1;
my %attributes = ref($_->{attributes}) eq 'HASH' ? %{ $_->{attributes} } : ();
if ($_->{external}) {
push(@extra_js_files, $_);
} else {
push(@extra_js_files,
{ file => getAssetURL($formLanguage, $_->{file}), external => 0, attributes => $_->{attributes} });
}
}
}

# Set up the problem language and direction
# PG files can request their language and text direction be set. If we do not have access to a default course
# language, fall back to the $formLanguage instead.
# TODO: support for right-to-left languages
my %PROBLEM_LANG_AND_DIR =
get_problem_lang_and_dir($rh_result->{flags}, 'auto:en:ltr', $formLanguage);
my $PROBLEM_LANG_AND_DIR = join(' ', map {qq{$_="$PROBLEM_LANG_AND_DIR{$_}"}} keys %PROBLEM_LANG_AND_DIR);

# is there a reason this doesn't use the same button IDs?
my $previewMode = defined($inputs_ref->{previewAnswers}) || 0;
my $submitMode = defined($inputs_ref->{submitAnswers}) || $inputs_ref->{answersSubmitted} || 0;
my $showCorrectMode = defined($inputs_ref->{showCorrectAnswers}) || 0;
# A problemUUID should be added to the request as a parameter. It is used by PG to create a proper UUID for use in
# aliases for resources. It should be unique for a course, user, set, problem, and version.
my $problemUUID = $inputs_ref->{problemUUID} // '';
my $problemResult = $rh_result->{problem_result} // {};
my $showSummary = $inputs_ref->{showSummary} // 1;
my $showAnswerNumbers = $inputs_ref->{showAnswerNumbers} // 0; # default no
# allow the request to hide the results table or messages
my $showTable = $inputs_ref->{hideAttemptsTable} ? 0 : 1;
my $showMessages = $inputs_ref->{hideMessages} ? 0 : 1;
# allow the request to override the display of partial correct answers
my $showPartialCorrectAnswers = $inputs_ref->{showPartialCorrectAnswers}
// $rh_result->{flags}{showPartialCorrectAnswers};

# Attempts table
my $answerTemplate = '';

# Do not produce an AttemptsTable when we had a rendering error.
if (!$renderErrorOccurred && $submitMode && $showTable) {
my $tbl = WeBWorK::AttemptsTable->new(
$rh_result->{answers} // {}, $c,
answersSubmitted => 1,
answerOrder => $rh_result->{flags}{ANSWER_ENTRY_ORDER} // [],
displayMode => $displayMode,
showAnswerNumbers => $showAnswerNumbers,
showAttemptAnswers => 0,
showAttemptPreviews => 1,
showAttemptResults => $showPartialCorrectAnswers && !$previewMode,
showCorrectAnswers => $showCorrectMode,
showMessages => $showMessages,
showSummary => $showSummary && !$previewMode,
mtRef => WeBWorK::Localize::getLoc($formLanguage),
summary => $problemResult->{summary} // '', # can be set by problem grader
);
$answerTemplate = $tbl->answerTemplate;
# $tbl->imgGen->render(refresh => 1) if $tbl->displayMode eq 'images';
}

# Answer hash in XML format used by the PTX format.
my $answerhashXML = '';
if ($formatName eq 'ptx') {
my $dom = Mojo::DOM->new->xml(1);
for my $answer (sort keys %{ $rh_result->{answers} }) {
$dom->append_content($dom->new_tag(
$answer,
map { $_ => ($rh_result->{answers}{$answer}{$_} // '') } keys %{ $rh_result->{answers}{$answer} }
));
}
$dom->wrap_content('<answerhashes></answerhashes>');
$answerhashXML = $dom->to_string;
}

# Make sure this is defined and is an array reference as saveGradeToLTI might add to it.
$rh_result->{debug_messages} = [] unless defined $rh_result && ref $rh_result->{debug_messages} eq 'ARRAY';

# Execute and return the interpolated problem template

# Raw format
# This format returns javascript object notation corresponding to the perl hash
# with everything that a client-side application could use to work with the problem.
# There is no wrapping HTML "_format" template.
if ($formatName eq 'raw') {
my $output = {};

# Everything that ships out with other formats can be constructed from these
$output->{rh_result} = $rh_result;
$output->{inputs_ref} = $inputs_ref;
# $output->{input} = $ws->{input};

# The following could be constructed from the above, but this is a convenience
$output->{answerTemplate} = $answerTemplate if ($answerTemplate);
$output->{lang} = $PROBLEM_LANG_AND_DIR{lang};
$output->{dir} = $PROBLEM_LANG_AND_DIR{dir};
$output->{extra_css_files} = \@extra_css_files;
$output->{extra_js_files} = \@extra_js_files;

# Include third party css and javascript files. Only jquery, jquery-ui, mathjax, and bootstrap are needed for
# PG. See the comments before the subroutine definitions for load_css and load_js in pg/macros/PG.pl.
# The other files included are only needed to make themes work in the webwork2 formats.
$output->{third_party_css} = \@third_party_css;
$output->{third_party_js} = \@third_party_js;

# Say what version of WeBWorK this is
# $output->{ww_version} = $ce->{WW_VERSION};
# $output->{pg_version} = $ce->{PG_VERSION};

# Convert to JSON and render.
return $c->render(data => JSON->new->utf8(1)->encode($output));
}

# Setup and render the appropriate template in the templates/RPCRenderFormats folder depending on the outputformat.
# "ptx" has a special template. "json" uses the default json template. All others use the default html template.
my %template_params = (
template => $formatName eq 'ptx' ? 'RPCRenderFormats/ptx' : 'RPCRenderFormats/default',
$formatName eq 'json' ? (format => 'json') : (),
formatName => $formatName,
lh => WeBWorK::Localize::getLangHandle($inputs_ref->{language} // 'en'),
rh_result => $rh_result,
SITE_URL => $SITE_URL,
FORM_ACTION_URL => $FORM_ACTION_URL,
COURSE_LANG_AND_DIR => get_lang_and_dir($formLanguage),
PROBLEM_LANG_AND_DIR => $PROBLEM_LANG_AND_DIR,
third_party_css => \@third_party_css,
extra_css_files => \@extra_css_files,
third_party_js => \@third_party_js,
extra_js_files => \@extra_js_files,
problemText => $problemText,
extra_header_text => $inputs_ref->{extra_header_text} // '',
answerTemplate => $answerTemplate,
showScoreSummary => $submitMode && !$renderErrorOccurred && !$previewMode && $problemResult,
answerhashXML => $answerhashXML,
showPreviewButton => $inputs_ref->{hidePreviewButton} ? '0' : '',
showCheckAnswersButton => $inputs_ref->{hideCheckAnswersButton} ? '0' : '',
showCorrectAnswersButton => $inputs_ref->{showCorrectAnswersButton} // $inputs_ref->{isInstructor} ? '' : '0',
showFooter => $inputs_ref->{showFooter} // '0',
pretty_print => \&pretty_print,
);

return $c->render(%template_params) if $formatName eq 'json' && !$inputs_ref->{send_pg_flags};
$rh_result->{renderedHTML} = $c->render_to_string(%template_params)->to_string;
return $c->respond_to(
html => { text => $rh_result->{renderedHTML} },
json => { json => $rh_result });
}

# Nice output for debugging
sub pretty_print {
my ($r_input, $level) = @_;
$level //= 4;
$level--;
return '' unless $level > 0; # Only print three levels of hashes (safety feature)
my $out = '';
if (!ref $r_input) {
$out = $r_input if defined $r_input;
$out =~ s/</&lt;/g; # protect for HTML output
} elsif (eval { %$r_input && 1 }) {
# eval { %$r_input && 1 } will pick up all objectes that can be accessed like a hash and so works better than
# "ref $r_input". Do not use "$r_input" =~ /hash/i" because that will pick up strings containing the word hash,
# and that will cause an error below.
local $^W = 0;
$out .= qq{$r_input <table border="2" cellpadding="3" bgcolor="#FFFFFF">};

for my $key (sort keys %$r_input) {
# Safety feature - we do not want to display the contents of %seed_ce which
# contains the database password and lots of other things, and explicitly hide
# certain internals of the CourseEnvironment in case one slips in.
next
if (($key =~ /database/)
|| ($key =~ /dbLayout/)
|| ($key eq "ConfigValues")
|| ($key eq "ENV")
|| ($key eq "externalPrograms")
|| ($key eq "permissionLevels")
|| ($key eq "seed_ce"));
$out .= "<tr><td>$key</td><td>=&gt;</td><td>&nbsp;" . pretty_print($r_input->{$key}, $level) . "</td></tr>";
}
$out .= '</table>';
} elsif (ref $r_input eq 'ARRAY') {
my @array = @$r_input;
$out .= '( ';
while (@array) {
$out .= pretty_print(shift @array, $level) . ' , ';
}
$out .= ' )';
} elsif (ref $r_input eq 'CODE') {
$out = "$r_input";
} else {
$out = $r_input;
$out =~ s/</&lt;/g; # Protect for HTML output
}

return $out . ' ';
}

1;

Large diffs are not rendered by default.

120 changes: 95 additions & 25 deletions lib/WeBWorK/Utils.pm
Original file line number Diff line number Diff line change
@@ -35,50 +35,120 @@ sub wwRound(@) {
return int($float * $factor + 0.5) / $factor;
}

my $staticWWAssets;
my $staticPGAssets;
my $thirdPartyWWDependencies;
my $thirdPartyPGDependencies;

sub readJSON {
my $fileName = shift;

return unless -r $fileName;

open(my $fh, "<:encoding(UTF-8)", $fileName) or die "FATAL: Unable to open '$fileName'!";
local $/;
my $data = <$fh>;
close $fh;

return JSON->new->decode($data);
}

sub getThirdPartyAssetURL {
my ($file, $dependencies, $baseURL, $useCDN) = @_;

for (keys %$dependencies) {
if ($file =~ /^node_modules\/$_\/(.*)$/) {
if ($useCDN && $1 !~ /mathquill/) {
return
"https://cdn.jsdelivr.net/npm/$_\@"
. substr($dependencies->{$_}, 1) . '/'
. ($1 =~ s/(?:\.min)?\.(js|css)$/.min.$1/gr);
} else {
return "$baseURL/$file?version=" . ($dependencies->{$_} =~ s/#/@/gr);
}
}
}
return;
}

# Get the url for static assets.
sub getAssetURL {
my ($language, $file, $isThemeFile) = @_;
my ($language, $file) = @_;

# Load the static files list generated by `npm install` the first time this method is called.
if (!$staticPGAssets) {
unless ($staticWWAssets) {
my $staticAssetsList = "$ENV{RENDER_ROOT}/public/static-assets.json";
$staticWWAssets = readJSON($staticAssetsList);
unless ($staticWWAssets) {
warn "ERROR: '$staticAssetsList' not found or not readable!\n"
. "You may need to run 'npm install' from '$ENV{RENDER_ROOT}/public'.";
$staticWWAssets = {};
}
}

unless ($staticPGAssets) {
my $staticAssetsList = "$ENV{PG_ROOT}/htdocs/static-assets.json";
if (-r $staticAssetsList) {
my $data = do {
open(my $fh, "<:encoding(UTF-8)", $staticAssetsList)
or die "FATAL: Unable to open '$staticAssetsList'!";
local $/;
<$fh>;
};

$staticPGAssets = JSON->new->decode($data);
} else {
warn "ERROR: '$staticAssetsList' not found!\n"
$staticPGAssets = readJSON($staticAssetsList);
unless ($staticPGAssets) {
warn "ERROR: '$staticAssetsList' not found or not readable!\n"
. "You may need to run 'npm install' from '$ENV{PG_ROOT}/htdocs'.";
$staticPGAssets = {};
}
}

unless ($thirdPartyWWDependencies) {
my $packageJSON = "$ENV{RENDER_ROOT}/public/package.json";
my $data = readJSON($packageJSON);
warn "ERROR: '$packageJSON' not found or not readable!\n" unless $data && defined $data->{dependencies};
$thirdPartyWWDependencies = $data->{dependencies} // {};
}

unless ($thirdPartyPGDependencies) {
my $packageJSON = "$ENV{PG_ROOT}/htdocs/package.json";
my $data = readJSON($packageJSON);
warn "ERROR: '$packageJSON' not found or not readable!\n" unless $data && defined $data->{dependencies};
$thirdPartyPGDependencies = $data->{dependencies} // {};
}

# Check to see if this is a third party asset file in node_modules (either in webwork2/htdocs or pg/htdocs).
# If so, then either serve it from a CDN if requested, or serve it directly with the library version
# appended as a URL parameter.
if ($file =~ /^node_modules/) {
my $wwFile = getThirdPartyAssetURL(
$file, $thirdPartyWWDependencies,
'',
0
);
return $wwFile if $wwFile;

my $pgFile =
getThirdPartyAssetURL($file, $thirdPartyPGDependencies, '/pg_files', 1);
return $pgFile if $pgFile;
}

# If a right-to-left language is enabled (Hebrew or Arabic) and this is a css file that is not a third party asset,
# then determine the rtl varaint file name. This will be looked for first in the asset lists.
my $rtlfile = $file =~ s/\.css$/.rtl.css/r
if ($language =~ /^(he|ar)/ && $file !~ /node_modules/ && $file =~ /\.css$/);
my $rtlfile =
($language =~ /^(he|ar)/ && $file !~ /node_modules/ && $file =~ /\.css$/)
? $file =~ s/\.css$/.rtl.css/r
: undef;

# First check to see if this is a file in the webwork htdocs location with a rtl variant.
return "/$staticWWAssets->{$rtlfile}"
if defined $rtlfile && defined $staticWWAssets->{$rtlfile};

# Next check to see if this is a file in the webwork htdocs location.
return "/$staticWWAssets->{$file}" if defined $staticWWAssets->{$file};

# Now check to see if this is a file in the pg htdocs location with a rtl variant.
# These also can only be local files.
return "/pg_files/$staticPGAssets->{$rtlfile}" if defined $rtlfile && defined $staticPGAssets->{$rtlfile};

# Next check to see if this is a file in the pg htdocs location.
if (defined $staticPGAssets->{$file}) {
# File served by cdn.
return $staticPGAssets->{$file} if $staticPGAssets->{$file} =~ /^https?:\/\//;
# File served locally.
return "/pg_files/$staticPGAssets->{$file}";
}
return "/pg_files/$staticPGAssets->{$file}" if defined $staticPGAssets->{$file};

# If the file was not found in the lists, then just use the given file and assume its path is relative to the pg
# htdocs location.
return "/pg_files/$file";
# If the file was not found in the lists, then just use the given file and assume its path is relative to the
# render app public folder.
return "/$file";
}

1;
455 changes: 0 additions & 455 deletions lib/WeBWorK/Utils/AttemptsTable.pm

This file was deleted.

66 changes: 0 additions & 66 deletions lib/WebworkClient/classic_format.pl

This file was deleted.

145 changes: 0 additions & 145 deletions lib/WebworkClient/json_format.pl

This file was deleted.

106 changes: 0 additions & 106 deletions lib/WebworkClient/jwe_secure_format.pl

This file was deleted.

57 changes: 0 additions & 57 deletions lib/WebworkClient/nosubmit_format.pl

This file was deleted.

65 changes: 0 additions & 65 deletions lib/WebworkClient/practice_format.pl

This file was deleted.

67 changes: 0 additions & 67 deletions lib/WebworkClient/simple_format.pl

This file was deleted.

64 changes: 0 additions & 64 deletions lib/WebworkClient/single_format.pl

This file was deleted.

93 changes: 0 additions & 93 deletions lib/WebworkClient/standard_format.pl

This file was deleted.

59 changes: 0 additions & 59 deletions lib/WebworkClient/static_format.pl

This file was deleted.

21 changes: 0 additions & 21 deletions lib/WebworkClient/ww3_format.pl

This file was deleted.

20 changes: 1 addition & 19 deletions package-lock.json
12 changes: 0 additions & 12 deletions package.json

This file was deleted.

1,025 changes: 0 additions & 1,025 deletions public/PGCodeMirror/PG.js

This file was deleted.

1,085 changes: 0 additions & 1,085 deletions public/PGCodeMirror/PGaddons.js

This file was deleted.

89 changes: 89 additions & 0 deletions public/css/bootstrap.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/* WeBWorK Online Homework Delivery System
* Copyright &copy; 2000-2021 The WeBWorK Project, https://github.com/openwebwork
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of either: (a) the GNU General Public License as published by the
* Free Software Foundation; either version 2, or (at your option) any later
* version, or (b) the "Artistic License" which comes with this package.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the
* Artistic License for more details.
*/

// Include functions first (so you can manipulate colors, SVGs, calc, etc)
@import "../node_modules/bootstrap/scss/functions";

// Variable overrides

// Enable shadows and gradients. These are disabled by default.
$enable-shadows: true;

// Use a smaller grid gutter width. The default is 1.5rem.
$grid-gutter-width: 1rem;

// Fonts
$font-size-base: 0.85rem;
$headings-font-weight: 600;

// Links
$link-decoration: none;
$link-hover-decoration: underline;

// Make breadcrumb dividers and active items a bit darker.
$breadcrumb-divider-color: #495057;
$breadcrumb-active-color: #495057;

// Include the remainder of bootstrap's scss configuration
@import "../node_modules/bootstrap/scss/variables";
@import "../node_modules/bootstrap/scss/maps";
@import "../node_modules/bootstrap/scss/mixins";
@import "../node_modules/bootstrap/scss/utilities";

// Layout & components
@import "../node_modules/bootstrap/scss/root";
@import "../node_modules/bootstrap/scss/reboot";
@import "../node_modules/bootstrap/scss/type";
@import "../node_modules/bootstrap/scss/images";
@import "../node_modules/bootstrap/scss/containers";
@import "../node_modules/bootstrap/scss/grid";
@import "../node_modules/bootstrap/scss/tables";
@import "../node_modules/bootstrap/scss/forms";
@import "../node_modules/bootstrap/scss/buttons";
@import "../node_modules/bootstrap/scss/transitions";
@import "../node_modules/bootstrap/scss/dropdown";
@import "../node_modules/bootstrap/scss/button-group";
@import "../node_modules/bootstrap/scss/nav";
@import "../node_modules/bootstrap/scss/navbar";
@import "../node_modules/bootstrap/scss/card";
@import "../node_modules/bootstrap/scss/accordion";
@import "../node_modules/bootstrap/scss/breadcrumb";
@import "../node_modules/bootstrap/scss/pagination";
@import "../node_modules/bootstrap/scss/badge";
@import "../node_modules/bootstrap/scss/alert";
@import "../node_modules/bootstrap/scss/placeholders";
@import "../node_modules/bootstrap/scss/progress";
@import "../node_modules/bootstrap/scss/list-group";
@import "../node_modules/bootstrap/scss/close";
@import "../node_modules/bootstrap/scss/toasts";
@import "../node_modules/bootstrap/scss/modal";
@import "../node_modules/bootstrap/scss/tooltip";
@import "../node_modules/bootstrap/scss/popover";
@import "../node_modules/bootstrap/scss/carousel";
@import "../node_modules/bootstrap/scss/spinners";
@import "../node_modules/bootstrap/scss/offcanvas";

// Helpers
@import "../node_modules/bootstrap/scss/helpers";

// Utilities
@import "../node_modules/bootstrap/scss/utilities/api";

// Overrides
a:not(.btn):focus {
color: $link-hover-color;
outline-style: solid;
outline-color: $link-hover-color;
outline-width: 1px;
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
20 changes: 20 additions & 0 deletions public/css/rtl.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/* WeBWorK Online Homework Delivery System
* Copyright &copy 2000-2022 The WeBWorK Project, https://github.com/openwebwork
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of either: (a) the GNU General Public License as published by the
* Free Software Foundation; either version 2, or (at your option) any later
* version, or (b) the "Artistic License" which comes with this package.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the
* Artistic License for more details.
*/

/* --- Modify some CSS for Right to left courses/problems --- */

/* The changes which were needed here in WeBWorK 2.16 are no
* longer needed in WeBWorK 2.17. The file is being retained
* for potential future use. */

File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file removed public/favicon.ico
Binary file not shown.
219 changes: 219 additions & 0 deletions public/generate-assets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
#!/usr/bin/env node

/* eslint-env node */

const yargs = require('yargs');
const chokidar = require('chokidar');
const path = require('path');
const { minify } = require('terser');
const fs = require('fs');
const crypto = require('crypto');
const sass = require('sass');
const autoprefixer = require('autoprefixer');
const postcss = require('postcss');
const rtlcss = require('rtlcss');
const cssMinify = require('cssnano');

const argv = yargs
.usage('$0 Options').version(false).alias('help', 'h').wrap(100)
.option('enable-sourcemaps', {
alias: 's',
description: 'Generate source maps. (Not for use in production!)',
type: 'boolean'
})
.option('watch-files', {
alias: 'w',
description: 'Continue to watch files for changes. (Developer tool)',
type: 'boolean'
})
.option('clean', {
alias: 'd',
description: 'Delete all generated files.',
type: 'boolean'
})
.argv;

const assetFile = path.resolve(__dirname, 'static-assets.json');
const assets = {};

const cleanDir = (dir) => {
for (const file of fs.readdirSync(dir, { withFileTypes: true })) {
if (file.isDirectory()) {
cleanDir(path.resolve(dir, file.name));
} else {
if (/.[a-z0-9]{8}.min.(css|js)$/.test(file.name)) {
const fullPath = path.resolve(dir, file.name);
console.log(`\x1b[34mRemoving ${fullPath} from previous build.\x1b[0m`);
fs.unlinkSync(fullPath);
}
}
}
}

// The is set to true after all files are processed for the first time.
let ready = false;

const processFile = async (file, _details) => {
if (file) {
const baseName = path.basename(file);

if (/(?<!\.min)\.js$/.test(baseName)) {
// Process javascript
if (!ready) console.log(`\x1b[32mProcessing ${file}\x1b[0m`);

const filePath = path.resolve(__dirname, file);

const contents = fs.readFileSync(filePath, 'utf8');

let result;
try {
result = await minify({ [baseName]: contents }, { sourceMap: argv.enableSourcemaps });
} catch (error) {
const { name, message, line, col, pos } = error;
console.log(`\x1b[31m${name} in ${file}:`);
console.log(`${message} at line ${line} column ${col} position ${pos}.\x1b[0m`);
return;
}

const minJS = result.code + (
argv.enableSourcemaps && result.map
? `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${
Buffer.from(result.map).toString('base64')}`
: ''
);

const contentHash = crypto.createHash('sha256');
contentHash.update(minJS);

const newVersion = file.replace(/\.js$/, `.${contentHash.digest('hex').substring(0, 8)}.min.js`);
fs.writeFileSync(path.resolve(__dirname, newVersion), minJS);

// Remove a previous version if the content hash is different.
if (assets[file] && assets[file] !== newVersion) {
console.log(`\x1b[32mUpdated ${file}.\x1b[0m`);
const oldFileFullPath = path.resolve(__dirname, assets[file]);
if (fs.existsSync(oldFileFullPath)) fs.unlinkSync(oldFileFullPath);
} else if (ready) {
console.log(`\x1b[32mProcessed ${file}.\x1b[0m`);
}

assets[file] = newVersion;
} else if (/^(?!_).*(?<!\.min)\.s?css$/.test(baseName)) {
// Process scss or css.
if (!ready) console.log(`\x1b[32mProcessing ${file}\x1b[0m`);

const filePath = path.resolve(__dirname, file);

// This works for both sass/scss files and css files.
let result;
try {
result = sass.compile(filePath, { sourceMap: argv.enableSourcemaps });
} catch (e) {
console.log(`\x1b[31mIn ${file}:`);
console.log(`${e.message}\x1b[0m`);
return;
}

if (result.sourceMap) result.sourceMap.sources = [ baseName ];

// Pass the compiled css through the autoprefixer.
// This is really only needed for the bootstrap.css files, but doesn't hurt for the rest.
let prefixedResult = await postcss([autoprefixer, cssMinify]).process(result.css, { from: baseName });

const minCSS = prefixedResult.css + (
argv.enableSourcemaps && result.sourceMap
? `/*# sourceMappingURL=data:application/json;charset=utf-8;base64,${
Buffer.from(JSON.stringify(result.sourceMap)).toString('base64')}*/`
: ''
);

const contentHash = crypto.createHash('sha256');
contentHash.update(minCSS);

const newVersion = file.replace(/\.s?css$/, `.${contentHash.digest('hex').substring(0, 8)}.min.css`);
fs.writeFileSync(path.resolve(__dirname, newVersion), minCSS);

const assetName = file.replace(/\.scss$/, '.css');

// Remove a previous version if the content hash is different.
if (assets[assetName] && assets[assetName] !== newVersion) {
console.log(`\x1b[32mUpdated ${file}.\x1b[0m`);
const oldFileFullPath = path.resolve(__dirname, assets[assetName]);
if (fs.existsSync(oldFileFullPath)) fs.unlinkSync(oldFileFullPath);
} else if (ready) {
console.log(`\x1b[32mProcessed ${file}.\x1b[0m`);
}

assets[assetName] = newVersion;

// Pass the compiled css through rtlcss and autoprefixer to generate css for right-to-left languages.
let rtlResult = await postcss([rtlcss, autoprefixer, cssMinify]).process(result.css, { from: baseName });

const rtlCSS = rtlResult.css + (
argv.enableSourcemaps && result.sourceMap
? `/*# sourceMappingURL=data:application/json;charset=utf-8;base64,${
Buffer.from(JSON.stringify(result.sourceMap)).toString('base64')}*/`
: ''
);

const rtlContentHash = crypto.createHash('sha256');
rtlContentHash.update(rtlCSS);

const newRTLVersion = file.replace(/\.s?css$/,
`.rtl.${rtlContentHash.digest('hex').substring(0, 8)}.min.css`);
fs.writeFileSync(path.resolve(__dirname, newRTLVersion), rtlCSS);

const rtlAssetName = file.replace(/\.s?css$/, '.rtl.css');

// Remove a previous version if the content hash is different.
if (assets[rtlAssetName] && assets[rtlAssetName] !== newRTLVersion) {
console.log(`\x1b[32mUpdated RTL css for ${file}.\x1b[0m`);
const oldFileFullPath = path.resolve(__dirname, assets[rtlAssetName]);
if (fs.existsSync(oldFileFullPath)) fs.unlinkSync(oldFileFullPath);
} else if (ready) {
console.log(`\x1b[32mProcessed RTL css for ${file}.\x1b[0m`);
}

assets[rtlAssetName] = newRTLVersion;
} else {
return;
}
} else {
if (argv.watchFiles)
console.log('\x1b[33mWatches established, and initial build complete.\n'
+ 'Press Control-C to stop.\x1b[0m');
ready = true;
}

if (ready) fs.writeFileSync(assetFile, JSON.stringify(assets));
};

const jsDir = path.resolve(__dirname, 'js/apps');
const cssDir = path.resolve(__dirname, 'css');

// Remove generated files from previous builds.
cleanDir(jsDir);
cleanDir(cssDir);

if (argv.clean) process.exit();

// Set up the watcher.
if (argv.watchFiles) console.log('\x1b[32mEstablishing watches and performing initial build.\x1b[0m');
chokidar.watch(['js/apps', 'css'], {
ignored: /layouts|\.min\.(js|css)$/,
cwd: __dirname, // Make sure all paths are given relative to the htdocs directory.
usePolling: true, // Needed to get changes to symlinks.
interval: 500,
awaitWriteFinish: { stabilityThreshold: 500 },
persistent: argv.watchFiles ? true : false
})
.on('add', processFile).on('change', processFile).on('ready', processFile)
.on('unlink', (file) => {
// If a file is deleted, then also delete the corresponding generated file.
if (assets[file]) {
console.log(`\x1b[34mDeleting minified file for ${file}.\x1b[0m`);
fs.unlinkSync(path.resolve(__dirname, assets[file]));
delete assets[file];
}
})
.on('error', (error) => console.log(`\x1b[32m${error}\x1b[0m`));
1 change: 0 additions & 1 deletion public/iframeResizer.contentWindow.map

This file was deleted.

Loading